
Dev
- 21 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
dev is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- dev
- AI & Agent Building
- AI-coding skill
Dev by the numbers
- 21 all-time installs (skills.sh)
- Ranked #10,307 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 21 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
/ork:dev — Lab-Stack Boot
One command boots the four moving parts of a Vercel-Labs-flavored dev loop:
1. portless → named HTTPS https://<branch>.localhost (no port collisions across worktrees) 2. emulate → stateful API emulators on the same origin via @emulators/adapter-next 3. dev server → pnpm dev / npm run dev / yarn dev (auto-detected) 4. agent-browser → pre-warmed session named after the branch
State lives in .claude/state/dev-stack.json. Teardown via /ork:dev stop reads the PIDs and signals SIGTERM in reverse boot order.
Paired with `/ork:expect`: the agent-browser session that/ork:devwarms is the same one/ork:expect(and the M125 #2 auto-trigger) attach to — no second startup latency on the first UI test.
When to invoke
| Situation | Command |
|---|---|
| Start work on a new branch | /ork:dev |
| Resume after a session break | /ork:dev (idempotent — skips already-live processes) |
| Tear down before deleting branch | /ork:dev stop |
| Inspect state | /ork:dev status |
| Share preview with stakeholder | /ork:dev --share (tailnet) or /ork:dev --funnel (public) |
| Time-boxed live demo | /ork:dev --live 4 (public funnel, 4-hour expiry) |
Resuming a backgrounded dev session (CC 2.1.144+): Sessions started viaclaude --bgnow appear in/resumealongside interactive ones, markedbg— use/resumeas the direct recovery path after a crash or session end instead of navigating the agent view.
>
Background shell sessions (CC 2.1.154+): Inclaude agents, type! <command>to run a shell command as a backgrounded session you can attach to and detach from — also available asclaude --bg --exec '<command>'. Useful for long dev-loop processes (watchers, builds, servers) you want to monitor without holding a terminal.
Modes (M127)
| Flag | Wraps | Reach | Tailscale CLI |
|---|---|---|---|
| (none) | portless <slug> <pkg-mgr> run dev | https://<branch>.localhost only | not required |
--share | portless --tailscale ... | tailnet members on https://*.ts.net | required |
--funnel | portless --funnel ... | public on the internet | required |
--live N | portless --funnel ... + N-hour expiry | public, tracked in live-demos.jsonl | required |
Tailscale is optional — required only behind --share/--funnel/--live. Default /ork:dev is unchanged for users who don't share.
When turbo.json or package.json workspaces is detected (#1562), the boot uses bare `portless` (zero-config) which auto-discovers each workspace's dev script and assigns subdomains via the task graph. State file shows mode: "monorepo"; list subdomains via portless list or /ork:dev status.
Boot sequence
portless is a wrapper, not a sidecar — portless <slug> <pkg-mgr> run dev is one fused command that owns the dev server's lifecycle. boot.sh tracks the wrapper PID; stop.sh walks its process tree to clean up children.
0. Detect package manager pnpm > yarn > bun > npm (lockfile-based)
1. Resolve subdomain slug <branch> → lower → / to - → DNS-safe → ≤63 chars
2. portless proxy start (idempotent — skipped if `portless list` already responds)
3. emulate --seed <yaml> (sidecar, optional — only if emulate.config.yaml exists)
4. portless <slug> <pkg-mgr> run dev (FUSED — wrapper owns dev server's lifecycle)
5. portless get <slug> (poll up to 30s for the route to register)
6. wait-on <baseUrl> (poll up to 30s for the dev server through the proxy)
7. AGENT_BROWSER_SESSION=<slug> agent-browser open <baseUrl> (warm + register session)
8. atomic state write (.claude/state/dev-stack.json via jq + temp + mv)
9. print summaryThe full annotated walkthrough: references/boot-sequence.md.
State file shape
{
"bootedAt": "2026-04-27T12:34:56Z",
"branch": "feat/m125-lane-b",
"subdomain": "feat-m125-lane-b.localhost",
"baseUrl": "https://feat-m125-lane-b.localhost:1355",
"mode": "single",
"processes": {
"portlessWrapper": {
"pid": 86104,
"command": "portless feat-m125-lane-b pnpm run dev"
},
"agentBrowser": { "sessionName": "feat-m125-lane-b" },
"emulate": { "pid": 86200, "command": "emulate --seed emulate.config.yaml" }
},
"emulators": ["github", "stripe"],
"share": null,
"notes": "portless proxy daemon is shared and not tracked here — stop.sh leaves it running."
}When --share / --funnel / --live is used (M127 #1561 / #1565), share becomes:
"share": {
"mode": "tailscale",
"tailscaleUrl": "https://app.your-tailnet.ts.net",
"expiresAt": "2026-05-03T20:00:00Z"
}mode is "single" (default) or "monorepo" (when turbo.json/workspaces detected). Note portlessWrapper (not portless + devServer) — portless owns the dev server. Full schema: references/state-schema.md.
Auto-surfaced hints (M127)
When /ork:dev boots, it inspects package.json and emits hints:
- *`@json-render/` detected** (#1560) → prints the devtools adapter import line so the inspector panel (Spec / State / Actions / Stream / Catalog / Pick) can be enabled in dev. Tree-shakes from production builds.
- *`@clerk/
detected** (#1563) → ifclerkis inemulate.config.yaml, prints the mock login URL (http://localhost:4012); otherwise warns to run/ork:emulate-seed --auto`.
Prerequisites + graceful no-op
$ /ork:dev
✓ portless found
✓ agent-browser found
✓ jq found
[1] slug feat-m125-lane-b
# OR with a missing prereq:
✗ portless not found. Install: npm i -g portless
Skipping boot — install missing tools and re-run.portless, agent-browser, and jq are required. emulate is optional — required only if emulate.config.yaml exists. The boot is all-or-nothing on the required set; with no emulate config the boot proceeds without emulators.
CI=1 short-circuits the boot (exits 0 immediately).
Status + teardown
$ /ork:dev status
ork:dev — feat/m125-lane-b
✓ portlessWrapper portless feat-m125-lane-b pnpm run dev
✓ agentBrowser feat-m125-lane-b
base url: https://feat-m125-lane-b.localhost:1355
booted: 2026-04-27T19:36:54Z
portless: route registered ✓
$ /ork:dev stop
ork:dev — sending SIGTERM in reverse boot order…
✓ agent-browser session "feat-m125-lane-b" closed
✓ portless wrapper (pid 86104) + 21 descendant(s) stopped
Cleared .claude/state/dev-stack.json
Note: portless proxy daemon left running (shared). Run `portless proxy stop` if you really mean to stop the daemon.Stop walks the wrapper's process tree (pgrep -P recursively) and SIGTERMs descendants leaves-first because portless doesn't always propagate signals cleanly. The portless proxy daemon itself is shared infrastructure and is never killed by /ork:dev stop.
Worktree behavior
Each git worktree gets its own subdomain — feat-foo.localhost and feat-bar.localhost coexist on the same machine. The state file lives under each worktree's .claude/state/, so /ork:dev from one worktree doesn't see the other's processes.
Idempotency
Re-running /ork:dev while the stack is already live is a no-op:
$ /ork:dev
ork:dev — feat/m125-lane-b already running.
https://feat-m125-lane-b.localhost (uptime 2h 14m)
Run /ork:dev stop to tear down, or /ork:dev status for detail.Liveness probe: process.kill(pid, 0) against each tracked PID. If any are dead, the skill prints which ones and offers to clean up state and reboot.
How agent-browser composes
The session name equals the subdomain — agent-browser commands targeting that session don't need a --session flag if it's the only one connected:
agent-browser open "https://feat-m125-lane-b.localhost/dashboard"
# implicit session = "feat-m125-lane-b" because it's the only one/ork:expect (M125 #2) reads the dev-stack state file and reuses this same session — no second handshake.
Integration with /ork:expect (M125 #2)
When auto-expect fires after a .tsx edit, it:
1. Reads .claude/state/dev-stack.json to find the agent-browser session and base URL. 2. Computes the affected route from the file path (app/dashboard/page.tsx → /dashboard). 3. Drives agent-browser against <baseUrl><route> using the live session. 4. Records the ARIA snapshot to memory keyed by (route, parentCommit) (M125 #6).
If the dev stack isn't live, auto-expect skips silently — /ork:dev is the prerequisite, not a hard dep.
When NOT to use
- CI — set
CI=1; the skill exits 0 without booting. - Production deploys — never; this is dev-loop only.
- Non-Vercel-Labs stacks — falls back to install hints; you can still run the underlying tools manually.
- Inside a `tmux -CC` session — agent-browser dashboard incompatible with iTerm2 tmux integration.
Scripts
| Script | What it does |
|---|---|
scripts/boot.sh | All-or-nothing prereq check, then 9-step boot. Idempotent (no-ops if already live). Honors CI=1 to skip in CI. |
scripts/stop.sh | SIGTERM in reverse boot order with 5-second SIGKILL fallback. Removes state file last. |
scripts/status.sh | Pretty status. --quiet for liveness-only (exit 0 live, 1 down). Used by boot for idempotency. |
/ork:dev invokes scripts/boot.sh; stop → stop.sh; status → status.sh. The shell scripts are the source of truth.
References
| File | Purpose |
|---|---|
references/boot-sequence.md | Step-by-step boot annotated with commands |
references/state-schema.md | Full JSON shape + field semantics |
Rules
| Rule | Impact | When it applies |
|---|---|---|
rules/lab-stack-prerequisites.md | CRITICAL | Every boot |
rules/branch-named-subdomain.md | HIGH | Subdomain resolution |
rules/idempotent-boot.md | HIGH | Re-running while live |
rules/teardown-order.md | MEDIUM | stop invocations |
Running unattended with /goal
Set a completion condition with /goal (CC 2.1.139+) and this skill will keep working across turns until the condition is met. Works in interactive, -p, and Remote Control. The overlay panel shows live elapsed / turns / tokens.
Example completion condition for this skill:
/goal until services.running == 4Stops when: all 4 dev-loop services (portless + emulate + dev-server + agent-browser) report healthy on their respective ports/sockets. Compatible with claude.ai Remote Control runs.
Related skills
/ork:expect— diff-aware browser tests; reuses the agent-browser session this skill warms/ork:emulate-seed— generates the emulator config that step 3 consumesportless(skill) — underlying tool docsbrowser-tools(skill) — agent-browser command reference
/ork:dev boot sequence — annotated walkthrough
Verified against real CLI surfaces (2026-04-27): portless 0.10.x, emulate 0.4.x, agent-browser 0.25.x+. The actual implementation lives at scripts/boot.sh — this doc explains why each step does what it does.
Architecture
portless is a wrapper, not a sidecar: portless <name> <cmd> spawns <cmd> with PORT=<auto>, registers https://<name>.localhost against the portless proxy, and tears down the route when <cmd> exits. The boot.sh script runs portless <slug> <pkg-mgr> run dev as a single fused process and tracks the portless wrapper PID.
┌──────────────── shared, long-lived ───────────────┐
│ │
│ portless proxy daemon (HTTPS, port 443 or │
│ custom -p, may already │
│ be running for other │
│ apps — never killed by │
│ this script) │
│ │
└──────────────────┬─────────────────────────────────┘
│ TCP
▼
┌─────────────────────────────────┐
│ portless <slug> <pkg> run dev │ ← wrap_pid (we track this)
│ (forks the dev server with │
│ PORT=auto, registers route │
│ <slug>.localhost in the │
│ proxy) │
└─────────────┬───────────────────┘
│ fork
▼
┌───────────────┐
│ next dev / │ ← child of portless,
│ vite / etc. │ auto-killed by stop.sh's
│ (random port │ pgrep -P walk
│ in 4000-4999)│
└────────────────┘
sidecars (independent, optional):
┌────────────────────────────────────────┐
│ emulate --seed emulate.config.yaml │ ← only if config present
│ (sidecar, NOT wrapped in portless) │
└────────────────────────────────────────┘Step 0 — detect package manager
| Signal | Manager |
|---|---|
pnpm-lock.yaml | pnpm |
yarn.lock | yarn |
bun.lockb | bun |
| (else) | npm |
Step 1 — resolve subdomain slug
git rev-parse --abbrev-ref HEAD # feat/m125-lane-b
| tr '[:upper:]' '[:lower:]'
| tr '/' '-' # feat-m125-lane-b
| tr -cd 'a-z0-9-'
| cut -c1-63 # DNS label limitDetached HEAD → falls back to dev. No git repo → also dev.
Step 2 — portless proxy start
portless list >/dev/null 2>&1 || portless proxy startThe proxy daemon is shared. If it's already running, leave it alone — don't pass --lan or --no-tls flags that would conflict with the existing settings (portless rejects starts with mismatched flags). Only start it if portless list fails.
The user is responsible for the proxy's flavor (TLS on/off, LAN mode, custom port). boot.sh only ensures it's running.
Why no portless start --domain?
That syntax doesn't exist. The proxy doesn't take a domain — domains come from the wrapped commands.
Step 3 — emulate (sidecar, optional)
[[ -f emulate.config.yaml ]] && ( exec emulate --seed emulate.config.yaml ) &emulate runs in foreground by default; we shell-background. Real flags (verified):
emulate— start all servicesemulate --service vercel,github— selectiveemulate --seed config.yaml— load seed configemulate init— generate starteremulate list— list available services
The emulate up subcommand from earlier docs does not exist.
Step 4 — wrap the dev server in portless
( cd "${PROJECT_DIR}" && exec portless "${slug}" "${pkg_mgr}" run dev >>"${LOG_FILE}" 2>&1 ) &
wrap_pid=$!Critical: the exec matters. Without it, $! captures the subshell PID, not portless's. When stop.sh later kills wrap_pid, it kills a dead subshell while portless + dev keep running orphaned. With exec, the subshell is replaced by portless and $! is the real wrapper.
portless gives the child:
PORT— random port in 4000-4999HOST— usually 127.0.0.1PORTLESS_URL— public URL (https://<slug>.localhost[:proxyport])NODE_EXTRA_CA_CERTS— path to portless CA (so node child trusts the local TLS)
Step 5 — wait for portless to register the route, then wait-on the dev server
Two-stage wait:
1. Poll portless get <slug> for up to 30s — returns the canonical URL (with the proxy's actual port) once portless registers the route. 2. npx wait-on <url> for another 30s — confirms the dev server is responding through the proxy.
Skipping stage 1 risks racing portless's registration; skipping stage 2 races the dev server's startup.
Step 6 — agent-browser session
AGENT_BROWSER_SESSION="${slug}" agent-browser open "${base_url}" >>"${LOG_FILE}" 2>&1 || trueSessions in agent-browser 0.25.x are lazy — they don't have daemons, they're just isolation namespaces. Setting AGENT_BROWSER_SESSION=<name> is equivalent to --session <name> flag. The browser actually starts on first navigation. Pre-warming with open <base_url> registers the session as active, so subsequent /ork:expect runs reuse the same session by name.
Step 7 — atomic state write
jq -n builds the JSON in one pass; we write to <file>.tmp and mv to the final path. If the script is killed mid-write, we don't leave a half-written state file.
State shape: see references/state-schema.md.
Step 8 — summary
Plain stderr text — never JSON. The summary is consumed by humans only; the state file is the machine-readable record.
Teardown order (scripts/stop.sh)
agent-browser session close (via AGENT_BROWSER_SESSION env)
↓
portless wrapper descendants (pgrep -P walk, leaves-first SIGTERM)
↓
portless wrapper itself (SIGTERM, then SIGKILL after 5s)
↓
emulate sidecar (SIGTERM)
↓
remove .claude/state/dev-stack.jsonThe shared portless proxy daemon is intentionally left running. It serves other projects on the same machine; stop.sh only tears down this branch's stack. Use portless proxy stop separately if you really want to kill the daemon.
Why walk the process tree?
portless doesn't always propagate SIGTERM cleanly to its child. Tested on macOS: killing only the wrapper PID leaves the wrapped Next.js process orphaned, holding its port. pgrep -P <wrap_pid> enumerates immediate children; we recurse to grand-descendants and SIGTERM leaves-first so each parent sees its children gone before being killed itself.
.claude/state/dev-stack.json schema
Written atomically by scripts/boot.sh. Read by scripts/status.sh, scripts/stop.sh, and the posttool/ui-change-detector hook (for baseUrl injection into the auto-expect nudge).
Shape
interface DevStackState {
bootedAt: string; // ISO 8601
branch: string; // git branch at boot time
subdomain: string; // e.g. "feat-m125-lane-b.localhost"
baseUrl: string; // canonical URL from `portless get <slug>` —
// includes the proxy's actual port if non-default
// (e.g. "https://feat-m125-lane-b.localhost:1355")
processes: {
portlessWrapper: { // ALWAYS present after a successful boot.
pid: number; // The PID of `portless <slug> <pkg-mgr> run dev`.
// Killing this PID + its descendants tears down
// the entire wrapped dev server (see stop.sh).
command: string; // For diagnostic display only.
};
agentBrowser: { // ALWAYS present.
sessionName: string; // == subdomain slug. Used as AGENT_BROWSER_SESSION env
// value or `--session <name>` flag. Sessions are lazy —
// no daemon PID to track.
};
emulate?: { // OPTIONAL — only present if emulate.config.yaml exists.
pid: number; // The PID of `emulate --seed <yaml>`.
command: string;
};
};
emulators: string[]; // Top-level service keys parsed from emulate.config.yaml
// by awk. Empty array if no config or empty services map.
// Example: ["github", "stripe", "google-oauth"]
notes: string; // Free-form context. Currently used to remind readers
// that the portless proxy daemon is shared and not
// tracked in this file.
}What the file does NOT contain
- The portless proxy daemon's PID — it's a shared, long-lived service. boot.sh only ensures it's running; stop.sh deliberately leaves it alone.
- The dev server's child PID — it's a child of
portlessWrapper, discovered at teardown viapgrep -P. - The agent-browser browser process PID — sessions are lazy, no daemon.
Liveness contract
A "live" stack has the portlessWrapper.pid responding to process.kill(pid, 0). If that PID is dead, the route is unregistered and the wrapped dev server is gone — even if the state file still exists. status.sh --quiet returns exit 0 only when at least one tracked PID (wrapper or emulate) is alive.
Reader contract
External consumers (other hooks, skills, the user via cat) MUST treat the file as read-only and tolerate it not existing. Writers go through scripts/boot.sh and scripts/stop.sh.
The hook posttool/ui-change-detector reads baseUrl to inject the dev URL into the auto-expect nudge — if the file is missing or malformed, the hook silently skips (graceful no-op).
Worktree isolation
Each git worktree has its own .claude/state/ directory and its own dev-stack.json. Two worktrees with branches feat/foo and feat/bar produce subdomains feat-foo.localhost and feat-bar.localhost and coexist without conflict.
Example (real, captured during end-to-end test 2026-04-27)
{
"bootedAt": "2026-04-27T19:36:54Z",
"branch": "feat/m125-lane-b-dev-skill-properly-structured",
"subdomain": "feat-m125-lane-b-dev-skill-properly-structured.localhost",
"baseUrl": "https://feat-m125-lane-b-dev-skill-properly-structured.localhost:1355",
"processes": {
"portlessWrapper": {
"pid": 86104,
"command": "portless feat-m125-lane-b-dev-skill-properly-structured npm run dev"
},
"agentBrowser": {
"sessionName": "feat-m125-lane-b-dev-skill-properly-structured"
}
},
"emulators": [],
"notes": "portless proxy daemon is shared and not tracked here — stop.sh leaves it running."
}Rule Categories
1. Lab-Stack Prerequisites (prerequisites) — CRITICAL — 1 rule
Required binaries must be on PATH before boot. Half-stacks confuse more than help.
lab-stack-prerequisites.md— All-or-nothing prereq check, install hints, CI escape hatch
2. Subdomain Naming (naming) — HIGH — 1 rule
Branch slug determines the URL — must be deterministic, DNS-safe, and worktree-isolated.
branch-named-subdomain.md— Slug rules (case, slash → dash, length cap), DNS reservation policy
3. Idempotency (idempotency) — HIGH — 1 rule
Re-running /ork:dev while live must not double-boot or corrupt state.
idempotent-boot.md— Liveness probe, state-file gating, recovery from stale state
4. Teardown Order (teardown) — MEDIUM — 1 rule
SIGTERM must propagate in reverse boot order so nothing holds connections to dead peers.
teardown-order.md— Why agent-browser → dev-server → emulate → portless, SIGKILL fallback
[Rule Name]
[Brief description — 1-2 sentences.]
Incorrect:
// Bad patternCorrect:
// Good patternKey rules:
- [Rule 1]
- [Rule 2]
- [Rule 3]
Reference: [link]
Branch-Named Subdomain
The portless subdomain MUST derive deterministically from the current git branch. Two worktrees branched off the same repo coexist by living at different .localhost hostnames.
Incorrect:
# Hardcoded subdomain — every worktree fights for it
portless start --domain dev.localhostCorrect:
# Slug from branch: lowercase, slashes → dashes, DNS-safe charset, 63-char cap
slug=$(git rev-parse --abbrev-ref HEAD \
| tr '[:upper:]' '[:lower:]' \
| tr '/' '-' \
| tr -cd 'a-z0-9-' \
| cut -c1-63)
[[ "$slug" == "head" ]] && slug="dev" # detached HEAD fallback
portless start --domain "${slug}.localhost"Key rules:
- Replace
/with-(DNS labels can't contain slashes) - Lowercase (DNS is case-insensitive but tools differ in normalization)
- Strip non-
[a-z0-9-]characters - Cap at 63 characters (DNS label limit)
- Detached HEAD (
HEADliteral) → fall back todev - Empty branch (no git repo) → fall back to
dev agent-browsersession name == subdomain (so commands without--sessionflag attach correctly when there's only one)
Reference: slug_branch() in src/skills/dev/scripts/boot.sh
Idempotent Boot
Re-running /ork:dev while the stack is already live MUST be a no-op. The script reads .claude/state/dev-stack.json, probes each tracked PID with kill -0, and short-circuits if any are alive.
Incorrect:
# Always boots, regardless of existing state
portless start ...
emulate up &
pnpm dev &
agent-browser session start ...
# → second invocation gets EADDRINUSE on every port and a duplicate sessionCorrect:
if [[ -f .claude/state/dev-stack.json ]]; then
if bash scripts/status.sh --quiet; then
echo "ork:dev — already running. Run /ork:dev stop to tear down."
exit 0
fi
# State file exists but PIDs dead → stale, clean up before booting
rm -f .claude/state/dev-stack.json
fi
# proceed with full bootKey rules:
- Check state file existence BEFORE any side effects
- Liveness probe =
kill -0 <pid>(POSIX signal-0; doesn't actually signal, just checks) - "Live" = at least one tracked PID still running (not all — partial death is recoverable)
- Stale state file (file exists, all PIDs dead) → clean it up and proceed to boot
- Exit code 0 for the no-op case (this is success, not error)
- Atomic state write:
tmp.json+os.replaceso crashes mid-write don't leave half-files
Reference: main() in src/skills/dev/scripts/boot.sh, status.sh --quiet
Lab-Stack Prerequisites
Boot must verify all four binaries (portless, emulate, agent-browser, plus a runnable dev script) are present BEFORE touching any of them. If any are missing, exit 0 with install hints — never spin up a partial stack.
Incorrect:
# Boots dev server even without portless — user gets http://localhost:3000
# instead of the stable HTTPS subdomain. Auth callbacks break in any flow
# that depends on the production-shaped URL.
pnpm dev &
agent-browser session start --name devCorrect:
# All-or-nothing prereq sweep first
require() { command -v "$1" >/dev/null 2>&1 || { echo "✗ $1 missing"; return 1; }; }
require portless && require emulate && require agent-browser || {
echo "Skipping boot — install missing tools and re-run."
exit 0
}
# Then proceed with full stackKey rules:
- Check ALL four prerequisites before starting ANY of them
- Exit code 0 (not 1) when missing prereqs — this is a graceful skip, not an error
- Print explicit
Install: npm i -g <pkg>hints alongside missing binary - Honor
CI=1env var: skip boot entirely (CI doesn't need a dev server) - Don't auto-install — let the user decide
Optional prerequisites (M127 #1561)
tailscale is checked only when --share, --funnel, or --live is passed. Default /ork:dev invocation does not require it. Adding it to the always-required set would create an install wall for existing users who never use sharing.
if [[ -n "${share_mode}" ]]; then
require tailscale "brew install tailscale (or https://tailscale.com/download)" || missing=1
fiReference: src/skills/dev/scripts/boot.sh (prereq sweep at the top of main())
Reverse Teardown Order
/ork:dev stop must SIGTERM components in REVERSE boot order. SIGKILL fallback after 5 seconds for processes that don't respond to SIGTERM.
Boot order: portless → emulate → dev-server → agent-browser Teardown order: agent-browser → dev-server → emulate → portless
Incorrect:
# Forward order — agent-browser holds TLS to dead portless, dev server still
# accepts requests after emulate vanishes (returns 500s instead of mocked data)
kill $(cat .pidfile/portless)
kill $(cat .pidfile/emulate)
kill $(cat .pidfile/dev-server)
agent-browser session stop --name devCorrect:
# Reverse order — each layer has live peers up to the moment it exits
agent-browser session stop --name "${slug}"
kill -TERM ${dev_pid}; sleep 5; kill -0 ${dev_pid} 2>/dev/null && kill -KILL ${dev_pid}
kill -TERM ${emu_pid}
portless stop --domain "${sub}"
rm -f .claude/state/dev-stack.jsonKey rules:
- agent-browser stops FIRST (it's the consumer; nothing should depend on it)
- dev server stops before emulate (avoids "emulate gone, dev server returns 500s")
- emulate stops before portless (emulate uses portless's TLS for callbacks)
- portless stops LAST (its CA + /etc/hosts cleanup is a separate concern)
- SIGTERM with 5-second SIGKILL fallback per process
- Always remove the state file last — if anything in the chain throws, leave the file so
status.shcan show the user what's stuck
Reference: src/skills/dev/scripts/stop.sh
#!/usr/bin/env bash
# Generated by OrchestKit Claude Plugin — Created: 2026-04-27
# /ork:dev boot — wrap a dev server in a portless proxy with a stable HTTPS subdomain,
# optionally start emulators, register an agent-browser session.
#
# Verified against:
# portless 0.10.x `portless proxy start`, `portless <name> <cmd>`,
# `portless list`, `portless get <name>` (no --json on list)
# emulate 0.4.x `emulate --seed <yaml>`, foreground default
# agent-browser 0.25+ `--session <name>` global flag,
# AGENT_BROWSER_SESSION env, sessions are lazy
#
# Architecture: portless WRAPS the dev command (not sidecar). The wrapper lives until
# the child dev server exits. We track the wrapper PID; killing it terminates the
# wrapped dev server.
#
# Idempotent. Writes state to .claude/state/dev-stack.json via jq.
# Exit codes: 0 = booted (or already live), 1 = aborted (missing prereq / startup fail).
set -euo pipefail
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
STATE_DIR="${PROJECT_DIR}/.claude/state"
STATE_FILE="${STATE_DIR}/dev-stack.json"
LOG_FILE="${STATE_DIR}/dev.log"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── 0. Detect package manager from lockfile ─────────────────────────────────
detect_pkg_mgr() {
if [[ -f "${PROJECT_DIR}/pnpm-lock.yaml" ]]; then echo "pnpm"; return; fi
if [[ -f "${PROJECT_DIR}/yarn.lock" ]]; then echo "yarn"; return; fi
if [[ -f "${PROJECT_DIR}/bun.lockb" ]]; then echo "bun"; return; fi
echo "npm"
}
# Detect Turborepo or pnpm/yarn/npm workspaces — triggers portless 0.11
# zero-config bare invocation (auto-discovers dev scripts, assigns subdomains).
is_monorepo() {
[[ -f "${PROJECT_DIR}/turbo.json" ]] && return 0
if [[ -f "${PROJECT_DIR}/package.json" ]] && command -v jq >/dev/null 2>&1; then
jq -e '.workspaces // empty | length > 0' "${PROJECT_DIR}/package.json" >/dev/null 2>&1 && return 0 # silent: gating-relaxed
fi
[[ -f "${PROJECT_DIR}/pnpm-workspace.yaml" ]] && return 0
return 1
}
# Detect json-render adapter family (#1560) — single answer or empty.
# Output: react|vue|svelte|solid (or empty if none).
json_render_adapter() {
[[ -f "${PROJECT_DIR}/package.json" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
local q='
((.dependencies // {}) + (.devDependencies // {})) | keys |
if any(. == "@json-render/devtools-react") then "react"
elif any(. == "@json-render/devtools-vue") then "vue"
elif any(. == "@json-render/devtools-svelte") then "svelte"
elif any(. == "@json-render/devtools-solid") then "solid"
elif any(startswith("@json-render/")) then "react"
else empty end'
jq -r "${q}" "${PROJECT_DIR}/package.json" 2>/dev/null # silent: gating-relaxed
}
# Detect @clerk/* deps (#1563) — emit hint if found.
has_clerk() {
[[ -f "${PROJECT_DIR}/package.json" ]] || return 1
command -v jq >/dev/null 2>&1 || return 1
jq -e '((.dependencies // {}) + (.devDependencies // {})) | keys | any(startswith("@clerk/"))' "${PROJECT_DIR}/package.json" >/dev/null 2>&1 # silent: gating-relaxed
}
# ── 1. Resolve slug from branch (DNS-safe, ≤63 chars) ───────────────────────
slug_branch() {
local b
b=$(git -C "${PROJECT_DIR}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
[[ "${b}" == "HEAD" ]] && b="dev"
printf '%s' "${b}" | tr '[:upper:]' '[:lower:]' | tr '/' '-' | tr -cd 'a-z0-9-' | cut -c1-63
}
# Top-level service keys from emulate.config.yaml (awk, no PyYAML dep).
emulator_services_json() {
local cfg="${PROJECT_DIR}/emulate.config.yaml"
[[ ! -f "${cfg}" ]] && { printf '[]'; return; }
local names
names=$(awk '
/^[[:space:]]*#/ { next }
/^services:[[:space:]]*$/ { in_svc=1; next }
in_svc && /^[a-zA-Z]/ { in_svc=0 }
in_svc && /^[[:space:]]+[a-zA-Z][a-zA-Z0-9_-]*:[[:space:]]*$/ {
sub(/^[[:space:]]+/, ""); sub(/:.*$/, ""); print
}
' "${cfg}")
if [[ -z "${names}" ]]; then printf '[]'; return; fi
printf '%s\n' "${names}" | jq -R . | jq -s .
}
# True if portless proxy daemon is responding (idempotent check).
portless_proxy_running() {
portless list >/dev/null 2>&1
}
# True if a route with the given slug is already registered (conflict guard).
portless_route_exists() {
local sub="$1"
portless list 2>/dev/null | grep -qE "https?://${sub}\.localhost(:[0-9]+)?\b"
}
# Canonical URL for a registered slug — uses portless's own resolver so we
# inherit the proxy's actual port (443 default; custom -p possible).
portless_url() {
local sub="$1"
portless get "${sub}" 2>/dev/null | head -1 | tr -d '\r\n'
}
require() {
local cmd="$1" install="$2"
if ! command -v "${cmd}" >/dev/null 2>&1; then
printf '✗ %s not found. Install: %s\n' "${cmd}" "${install}" >&2
return 1
fi
printf '✓ %s found\n' "${cmd}" >&2
}
main() {
mkdir -p "${STATE_DIR}"
# ── Arg parsing (M127 #1561+#1565) ────────────────────────────────────────
# --share : portless --tailscale (tailnet-only)
# --funnel : portless --funnel (publicly exposed via Tailscale Funnel)
# --live N : equivalent to --funnel with N-hour expiry tracked in state file
local share_mode="" # "" | "tailscale" | "funnel"
local live_duration_hours=0
while [[ $# -gt 0 ]]; do
case "$1" in
--share) share_mode="tailscale"; shift ;;
--funnel) share_mode="funnel"; shift ;;
--live) share_mode="funnel"; live_duration_hours="${2:-4}"; shift 2 ;;
stop|status|start) shift ;;
*) shift ;;
esac
done
if [[ "${CI:-}" == "1" || "${CI:-}" == "true" ]]; then
printf 'ork:dev — CI=1, skipping boot.\n' >&2
exit 0
fi
printf 'ork:dev — checking prerequisites…\n' >&2
local missing=0
require portless "npm i -g portless" || missing=1
require agent-browser "npm i -g agent-browser" || missing=1
require jq "brew install jq" || missing=1
if [[ -f "${PROJECT_DIR}/emulate.config.yaml" ]]; then
require emulate "npm i -g emulate" || missing=1
fi
# Tailscale is OPTIONAL — only required when --share / --funnel / --live used.
if [[ -n "${share_mode}" ]]; then
require tailscale "brew install tailscale (or https://tailscale.com/download)" || missing=1
fi
if [[ ${missing} -ne 0 ]]; then
printf '\nSkipping boot — install missing tools and re-run.\n' >&2
exit 1
fi
if [[ -f "${STATE_FILE}" ]]; then
if bash "${SCRIPT_DIR}/status.sh" --quiet 2>/dev/null; then
printf 'ork:dev — already running. Run /ork:dev stop to tear down.\n' >&2
exit 0
fi
printf 'ork:dev — state file exists but stack is dead. Cleaning up…\n' >&2
rm -f "${STATE_FILE}"
fi
local pkg_mgr; pkg_mgr=$(detect_pkg_mgr)
local slug; slug=$(slug_branch)
local branch; branch=$(git -C "${PROJECT_DIR}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
printf '[1] slug %s\n' "${slug}" >&2
# 2. portless proxy — start if not already running. Don't disturb existing settings.
if portless_proxy_running; then
printf '[2] portless proxy already running — reusing.\n' >&2
else
printf '[2] portless proxy start (HTTPS by default)\n' >&2
portless proxy start >>"${LOG_FILE}" 2>&1 || {
printf '✗ portless proxy start failed (see %s)\n' "${LOG_FILE}" >&2
exit 1
}
fi
if portless_route_exists "${slug}"; then
printf '✗ portless route %s.localhost already exists. Use a different branch or stop the conflicting service.\n' "${slug}" >&2
exit 1
fi
# 3. emulate (sidecar — optional, runs only if config present)
local e_pid=0
local e_cmd=""
if [[ -f "${PROJECT_DIR}/emulate.config.yaml" ]]; then
printf '[3] emulate --seed emulate.config.yaml (background)\n' >&2
e_cmd="emulate --seed ${PROJECT_DIR}/emulate.config.yaml"
# exec so $! captures the actual emulate PID, not the subshell.
( exec emulate --seed "${PROJECT_DIR}/emulate.config.yaml" >>"${LOG_FILE}" 2>&1 ) &
e_pid=$!
else
printf '[3] no emulate.config.yaml — skipping (run /ork:emulate-seed --auto to generate)\n' >&2
fi
# 4. WRAPPED dev server: portless owns the lifecycle. We track the wrapper PID.
# CRITICAL: use `exec` inside the subshell so $! captures the portless PID directly.
# Without exec, $! is the subshell PID (gone after its child exits) and stop.sh
# would kill the wrong process, leaving portless + dev server orphaned.
#
# Mode selection (M127):
# share_mode=tailscale (#1561) → portless --tailscale (tailnet-only, auto-HTTPS)
# share_mode=funnel (#1561) → portless --funnel (publicly exposed)
# is_monorepo (#1562) → bare portless (auto-discovers workspace dev scripts)
# default → portless <slug> <pkg-mgr> run dev (current behavior)
local mode_label="single"
local share_flag=""
case "${share_mode}" in
tailscale) share_flag="--tailscale" ;;
funnel) share_flag="--funnel" ;;
esac
local wrap_cmd=""
if is_monorepo; then
mode_label="monorepo"
if [[ -n "${share_flag}" ]]; then
wrap_cmd="portless ${share_flag}"
printf '[4] monorepo + %s — portless %s (background, zero-config)\n' "${share_mode}" "${share_flag}" >&2
( cd "${PROJECT_DIR}" && exec portless "${share_flag}" >>"${LOG_FILE}" 2>&1 ) &
else
wrap_cmd="portless"
printf '[4] monorepo detected — portless (bare, zero-config)\n' >&2
( cd "${PROJECT_DIR}" && exec portless >>"${LOG_FILE}" 2>&1 ) &
fi
else
if [[ -n "${share_flag}" ]]; then
wrap_cmd="portless ${share_flag} ${slug} ${pkg_mgr} run dev"
printf '[4] portless %s %s %s run dev (background)\n' "${share_flag}" "${slug}" "${pkg_mgr}" >&2
( cd "${PROJECT_DIR}" && exec portless "${share_flag}" "${slug}" "${pkg_mgr}" run dev >>"${LOG_FILE}" 2>&1 ) &
else
wrap_cmd="portless ${slug} ${pkg_mgr} run dev"
printf '[4] portless %s %s run dev (background)\n' "${slug}" "${pkg_mgr}" >&2
( cd "${PROJECT_DIR}" && exec portless "${slug}" "${pkg_mgr}" run dev >>"${LOG_FILE}" 2>&1 ) &
fi
fi
local wrap_pid=$!
# 5. Wait for portless to register the route, then wait-on the dev server.
# Monorepo mode skips slug-based polling (multiple subdomains auto-assigned).
local base_url=""
if [[ "${mode_label}" == "monorepo" ]]; then
sleep 3 # give bare portless a moment to discover and register subdomains
base_url="$(portless list 2>/dev/null | head -1 | awk '{print $1}')" # silent: best-effort
[[ -z "${base_url}" ]] && base_url="(see 'portless list')"
printf '[5] portless registered subdomain map (monorepo) — see /ork:dev status or `portless list`\n' >&2
else
for _ in $(seq 1 30); do
base_url=$(portless_url "${slug}" || true) # silent: gating-relaxed
if [[ -n "${base_url}" ]]; then break; fi
sleep 1
done
if [[ -z "${base_url}" ]]; then
printf '✗ portless never registered route %s.localhost (see %s)\n' "${slug}" "${LOG_FILE}" >&2
kill "${wrap_pid}" 2>/dev/null || true # silent: post-cleanup
[[ "${e_pid}" != "0" ]] && kill "${e_pid}" 2>/dev/null || true # silent: post-cleanup
exit 1
fi
printf '[5] route registered: %s — waiting for dev server to respond\n' "${base_url}" >&2
if ! npx --yes wait-on --httpTimeout 30000 --tlsCheck false "${base_url}" 2>>"${LOG_FILE}"; then
printf '✗ %s did not respond in 30s (see %s)\n' "${base_url}" "${LOG_FILE}" >&2
kill "${wrap_pid}" 2>/dev/null || true # silent: post-cleanup
[[ "${e_pid}" != "0" ]] && kill "${e_pid}" 2>/dev/null || true # silent: post-cleanup
exit 1
fi
fi
# 5b. Tailscale URL capture (M127 #1561) — best-effort.
local tailscale_url=""
local expires_at=""
if [[ -n "${share_mode}" ]]; then
tailscale_url="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty' | sed 's:\.$::')" # silent: best-effort
if [[ -n "${tailscale_url}" ]]; then
tailscale_url="https://${tailscale_url}"
fi
if [[ "${live_duration_hours}" != "0" ]]; then
# Cross-platform date arithmetic (BSD vs GNU).
if date -u -v+1H +%s >/dev/null 2>&1; then # silent: gating-relaxed
expires_at="$(date -u -v+"${live_duration_hours}"H +%Y-%m-%dT%H:%M:%SZ)"
else
expires_at="$(date -u -d "+${live_duration_hours} hours" +%Y-%m-%dT%H:%M:%SZ)"
fi
fi
fi
# 6. agent-browser session — lazy; first navigate registers it. Skipped in monorepo.
if [[ "${mode_label}" != "monorepo" ]]; then
printf '[6] agent-browser session %s + warmup\n' "${slug}" >&2
AGENT_BROWSER_SESSION="${slug}" agent-browser open "${base_url}" >>"${LOG_FILE}" 2>&1 || true # silent: best-effort
fi
# 7. atomic state write — pure jq.
local tmp="${STATE_FILE}.tmp"
local emu_services_json; emu_services_json=$(emulator_services_json)
local now; now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq -n \
--arg bootedAt "${now}" \
--arg branch "${branch}" \
--arg subdomain "${slug}.localhost" \
--arg baseUrl "${base_url}" \
--argjson wrap_pid "${wrap_pid}" \
--arg wrap_cmd "${wrap_cmd}" \
--argjson e_pid "${e_pid}" \
--arg e_cmd "${e_cmd}" \
--arg session "${slug}" \
--argjson emu "${emu_services_json}" \
--arg mode "${mode_label}" \
--arg shareMode "${share_mode}" \
--arg tsUrl "${tailscale_url}" \
--arg expiresAt "${expires_at}" \
'{
bootedAt: $bootedAt,
branch: $branch,
subdomain: $subdomain,
baseUrl: $baseUrl,
mode: $mode,
processes: ({
portlessWrapper: {pid: $wrap_pid, command: $wrap_cmd},
agentBrowser: {sessionName: $session}
} + (if $e_pid > 0 then {emulate: {pid: $e_pid, command: $e_cmd}} else {} end)),
emulators: $emu,
share: (if $shareMode == "" then null else
{mode: $shareMode, tailscaleUrl: $tsUrl, expiresAt: (if $expiresAt == "" then null else $expiresAt end)}
end),
notes: "portless proxy daemon is shared and not tracked here — stop.sh leaves it running."
}' > "${tmp}"
mv "${tmp}" "${STATE_FILE}"
# Append a live-demo entry when --live is used (#1565). Doctor reads this jsonl
# to warn about demos older than 24h.
if [[ "${share_mode}" == "funnel" && "${live_duration_hours}" != "0" ]]; then
local live_log="${STATE_DIR}/live-demos.jsonl"
jq -nc \
--arg branch "${branch}" \
--arg bootedAt "${now}" \
--arg expiresAt "${expires_at}" \
--arg url "${tailscale_url}" \
'{branch: $branch, bootedAt: $bootedAt, expiresAt: $expiresAt, url: $url}' \
>> "${live_log}"
fi
# 8. summary
printf '\nork:dev — booted (branch %s, mode %s)\n' "${branch}" "${mode_label}" >&2
printf ' ✓ portless %s\n' "${base_url}" >&2
if [[ "${e_pid}" != "0" ]]; then
local svc; svc=$(printf '%s' "${emu_services_json}" | jq -r 'if (length>0) then join(", ") else "(empty config)" end')
printf ' ✓ emulate %s (pid %s)\n' "${svc}" "${e_pid}" >&2
fi
printf ' ✓ %s dev pid %s (wrapped by portless)\n' "${pkg_mgr}" "${wrap_pid}" >&2
if [[ "${mode_label}" != "monorepo" ]]; then
printf ' ✓ agent-browser session "%s"\n' "${slug}" >&2
fi
# M127 #1561: share-mode banner with security note for --funnel.
if [[ -n "${share_mode}" ]]; then
if [[ -n "${tailscale_url}" ]]; then
printf ' ✓ public URL %s (mode: %s)\n' "${tailscale_url}" "${share_mode}" >&2
printf ' PORTLESS_TAILSCALE_URL=%s\n' "${tailscale_url}" >&2
else
printf ' ⚠ %s mode requested but Tailscale URL not yet available — check `tailscale status`\n' "${share_mode}" >&2
fi
if [[ "${share_mode}" == "funnel" ]]; then
printf ' ⚠ funnel mode is PUBLIC on the internet. Anyone with the URL can reach the dev server.\n' >&2
fi
if [[ -n "${expires_at}" ]]; then
printf ' expires at %s (%sh)\n' "${expires_at}" "${live_duration_hours}" >&2
fi
fi
# M127 #1560: surface json-render devtools URL hint when adapter detected.
local jr_adapter; jr_adapter=$(json_render_adapter)
if [[ -n "${jr_adapter}" ]]; then
printf ' → json-render devtools: import "@json-render/devtools-%s" in your dev entry to enable the Spec/State/Actions/Stream/Catalog/Pick inspector.\n' "${jr_adapter}" >&2
fi
# M127 #1563: surface Clerk emulator hint when @clerk/* detected.
if has_clerk && [[ "${e_pid}" != "0" ]]; then
if printf '%s' "${emu_services_json}" | jq -e 'index("clerk")' >/dev/null 2>&1; then # silent: gating-relaxed
printf ' → clerk emulator mock login at http://localhost:4012 (run /ork:emulate-seed --auto if missing)\n' >&2
else
printf ' ⚠ @clerk/* detected in deps but `clerk` not in emulate.config.yaml — run /ork:emulate-seed --auto.\n' >&2
fi
fi
printf '\nOpen %s or run /ork:expect\n' "${base_url}" >&2
}
# Run main() only when executed directly. Sourcing the file (e.g. from tests)
# exposes the helper functions without booting the lab stack.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
#!/usr/bin/env bash
# Generated by OrchestKit Claude Plugin — Created: 2026-04-27
# /ork:dev status — inspect this branch's lab-stack state.
# Exit codes: 0 = at least one tracked PID alive, 1 = down or no state.
# --quiet : suppress output, exit code only (used by boot.sh idempotency check).
set -euo pipefail
QUIET=0
[[ "${1:-}" == "--quiet" ]] && QUIET=1
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
STATE_FILE="${PROJECT_DIR}/.claude/state/dev-stack.json"
if [[ ! -f "${STATE_FILE}" ]]; then
[[ ${QUIET} -eq 0 ]] && printf 'ork:dev — no state file. Run /ork:dev to boot.\n' >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
[[ ${QUIET} -eq 0 ]] && printf 'ork:dev — jq not found. Install: brew install jq\n' >&2
exit 1
fi
# Liveness probe: is at least one tracked, integer PID still running?
any_live=0
while IFS= read -r pid; do
[[ -z "${pid}" || "${pid}" == "0" || "${pid}" == "null" ]] && continue
if kill -0 "${pid}" 2>/dev/null; then any_live=1; break; fi
done < <(jq -r '.processes // {} | to_entries[] | .value.pid // empty' "${STATE_FILE}")
if [[ ${any_live} -ne 1 ]]; then
[[ ${QUIET} -eq 0 ]] && printf 'ork:dev — state file exists but stack is dead.\n' >&2
exit 1
fi
if [[ ${QUIET} -eq 1 ]]; then
exit 0
fi
# Pretty status.
branch=$( jq -r '.branch // "?"' "${STATE_FILE}")
base_url=$( jq -r '.baseUrl // "?"' "${STATE_FILE}")
booted=$( jq -r '.bootedAt // ""' "${STATE_FILE}")
ems=$( jq -r '(.emulators // []) | join(", ")' "${STATE_FILE}")
printf 'ork:dev — %s\n' "${branch}"
while IFS=$'\t' read -r name pid cmd; do
[[ -z "${name}" ]] && continue
local_mark="✗"
if [[ -n "${pid}" && "${pid}" != "0" && "${pid}" != "null" ]] && kill -0 "${pid}" 2>/dev/null; then
local_mark="✓"
elif [[ "${name}" == "agentBrowser" ]]; then
# Sessions are lazy — sessionName presence is enough to consider them registered.
local_mark="✓"
fi
printf ' %s %-18s %s\n' "${local_mark}" "${name}" "${cmd}"
done < <(jq -r '
(.processes // {}) | to_entries[]
| [ .key, (.value.pid // 0 | tostring), (.value.command // .value.sessionName // "") ]
| @tsv
' "${STATE_FILE}")
[[ -n "${ems}" ]] && printf ' emulators: %s\n' "${ems}"
printf ' base url: %s\n' "${base_url}"
[[ -n "${booted}" ]] && printf ' booted: %s\n' "${booted}"
# Cross-check: portless's own view of the route.
if command -v portless >/dev/null 2>&1; then
slug="$(jq -r '.subdomain // ""' "${STATE_FILE}" | sed 's/\.localhost$//')"
if [[ -n "${slug}" ]]; then
if portless list 2>/dev/null | grep -qE "https?://${slug}\.localhost(:[0-9]+)?\b"; then
printf ' portless: route registered ✓\n'
else
printf ' portless: route NOT registered (wrapper may have died)\n'
fi
fi
fi
#!/usr/bin/env bash
# Generated by OrchestKit Claude Plugin — Created: 2026-04-27
# /ork:dev stop — tear down this branch's lab stack.
#
# Reverse boot order: agent-browser session → portless wrapper (kills wrapped dev server)
# → emulate. The portless PROXY DAEMON is shared and is NOT stopped here — that's a
# separate concern (`portless proxy stop` if you really mean it).
set -euo pipefail
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
STATE_FILE="${PROJECT_DIR}/.claude/state/dev-stack.json"
if [[ ! -f "${STATE_FILE}" ]]; then
printf 'ork:dev — no state file found, nothing to stop.\n' >&2
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
printf 'ork:dev — jq not found. Install: brew install jq\n' >&2
exit 1
fi
session=$( jq -r '.processes.agentBrowser.sessionName // ""' "${STATE_FILE}")
wrap_pid=$(jq -r '.processes.portlessWrapper.pid // 0' "${STATE_FILE}")
e_pid=$( jq -r '.processes.emulate.pid // 0' "${STATE_FILE}")
slug=$( jq -r '.subdomain // ""' "${STATE_FILE}" | sed 's/\.localhost$//')
printf 'ork:dev — sending SIGTERM in reverse boot order…\n' >&2
# 1. agent-browser — close the session's browser if any.
if [[ -n "${session}" ]]; then
if AGENT_BROWSER_SESSION="${session}" agent-browser close 2>/dev/null; then
printf ' ✓ agent-browser session "%s" closed\n' "${session}" >&2
else
printf ' ⚠ agent-browser session "%s" not active\n' "${session}" >&2
fi
fi
# 2. portless wrapper — kill its descendants FIRST (the wrapped dev server +
# any forked node processes), then the wrapper itself. portless doesn't always
# propagate SIGTERM cleanly, so we walk the process tree explicitly.
if [[ "${wrap_pid}" != "0" ]] && kill -0 "${wrap_pid}" 2>/dev/null; then
descendants=()
queue=("${wrap_pid}")
while [[ ${#queue[@]} -gt 0 ]]; do
parent="${queue[0]}"
queue=("${queue[@]:1}")
while IFS= read -r kid; do
[[ -z "${kid}" ]] && continue
descendants+=("${kid}")
queue+=("${kid}")
done < <(pgrep -P "${parent}" 2>/dev/null || true)
done
# SIGTERM leaves-first, then the wrapper.
for ((i=${#descendants[@]}-1; i>=0; i--)); do
kill -TERM "${descendants[i]}" 2>/dev/null || true
done
kill -TERM "${wrap_pid}" 2>/dev/null || true
for _ in 1 2 3 4 5; do kill -0 "${wrap_pid}" 2>/dev/null || break; sleep 1; done
if kill -0 "${wrap_pid}" 2>/dev/null; then
for d in "${descendants[@]}"; do kill -KILL "${d}" 2>/dev/null || true; done
kill -KILL "${wrap_pid}" 2>/dev/null || true
fi
printf ' ✓ portless wrapper (pid %s) + %d descendant(s) stopped\n' \
"${wrap_pid}" "${#descendants[@]}" >&2
fi
# 3. emulate (sidecar)
if [[ "${e_pid}" != "0" ]] && kill -0 "${e_pid}" 2>/dev/null; then
kill -TERM "${e_pid}" 2>/dev/null || true
printf ' ✓ emulate (pid %s) stopped\n' "${e_pid}" >&2
fi
# Note: portless PROXY DAEMON intentionally NOT stopped — it's a shared resource.
# Verify the route is gone (portless detects wrapper exit and unregisters).
if [[ -n "${slug}" ]] && portless list 2>/dev/null | grep -qE "https?://${slug}\.localhost(:[0-9]+)?\b"; then
printf ' ⚠ portless route %s.localhost still registered (proxy may need a moment)\n' "${slug}" >&2
fi
rm -f "${STATE_FILE}"
printf 'Cleared %s\n' "${STATE_FILE}" >&2
printf '\nNote: portless proxy daemon left running (shared). Run `portless proxy stop` if you really mean to stop the daemon.\n' >&2
{
"skill": "dev",
"version": "1.0.0",
"testCases": [
{
"id": "trigger-bare",
"rule": null,
"query": "/ork:dev",
"expectedBehavior": [
"Triggers the dev skill, not portless or emulate-seed alone",
"Calls scripts/boot.sh",
"Detects package manager from lockfile (pnpm > yarn > bun > npm)",
"Resolves subdomain from current git branch slug",
"Verifies portless + emulate + agent-browser binaries before any side effects"
]
},
{
"id": "trigger-stop",
"rule": null,
"query": "/ork:dev stop",
"expectedBehavior": [
"Calls scripts/stop.sh",
"Reads .claude/state/dev-stack.json for tracked PIDs",
"Sends SIGTERM in reverse boot order: agent-browser, dev server, emulate, portless",
"Falls back to SIGKILL after 5 seconds for unresponsive processes",
"Removes the state file last"
]
},
{
"id": "trigger-status",
"rule": null,
"query": "/ork:dev status",
"expectedBehavior": [
"Calls scripts/status.sh",
"Probes each tracked PID with kill -0",
"Prints booted-at, branch, baseUrl, and per-process live/dead",
"Exit code 0 if at least one PID is alive, 1 if all dead or no state file"
]
},
{
"id": "rule-prereq-missing",
"rule": "lab-stack-prerequisites",
"query": "/ork:dev (with portless not installed)",
"expectedBehavior": [
"Detects missing portless binary",
"Prints `✗ portless not found. Install: npm i -g portless`",
"Does NOT start emulate, dev server, or agent-browser",
"Exits 0 (graceful skip, not an error)"
]
},
{
"id": "rule-idempotent-rerun",
"rule": "idempotent-boot",
"query": "/ork:dev (when stack is already live)",
"expectedBehavior": [
"Reads existing .claude/state/dev-stack.json",
"Probes PIDs via kill -0 to confirm at least one is alive",
"Prints 'already running' message and exits 0",
"Does NOT spawn duplicate processes"
]
},
{
"id": "rule-stale-state",
"rule": "idempotent-boot",
"query": "/ork:dev (state file exists but all PIDs dead)",
"expectedBehavior": [
"Detects stale state via liveness probe failure",
"Removes .claude/state/dev-stack.json",
"Proceeds with full boot sequence"
]
},
{
"id": "rule-subdomain-slug",
"rule": "branch-named-subdomain",
"query": "/ork:dev (on branch feat/m125-Lane-B with mixed case + slash)",
"expectedBehavior": [
"Slug becomes feat-m125-lane-b (slashes → dashes, lowercased)",
"DNS hostname is feat-m125-lane-b.localhost",
"Capped at 63 characters",
"agent-browser session name == feat-m125-lane-b"
]
},
{
"id": "ci-escape-hatch",
"rule": "lab-stack-prerequisites",
"query": "/ork:dev (with CI=1 env)",
"expectedBehavior": [
"Detects CI environment",
"Prints 'CI=1, skipping boot.'",
"Exits 0 immediately, before any prerequisite checks"
]
},
{
"id": "compose-with-emulate-seed",
"rule": null,
"query": "/ork:dev (with no emulate.config.yaml)",
"expectedBehavior": [
"Skips emulate up step (no config file present)",
"Suggests running /ork:emulate-seed --auto first",
"Continues to boot portless, dev server, agent-browser without emulators"
]
},
{
"id": "compose-with-expect",
"rule": null,
"query": "/ork:expect (after /ork:dev has booted)",
"expectedBehavior": [
"Reads .claude/state/dev-stack.json to find baseUrl + agent-browser session",
"Reuses existing session named after the branch slug (no second handshake)",
"If posttool/ui-change-detector fired before, picks up the route hint"
]
}
]
}