Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/port-claim-settle-why.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"minor"
---

Lock resume waits until claimed ports are free before re-ensuring, so a pause no longer races a dirty bind. Composite servers declare a port span or named subports so sibling worktrees rebind a whole claim block (relative offsets move, absolute ports stay singleton), and `devctl why` keeps the refusal lines from the last run across ensure retries instead of going blank on exit 0.
5 changes: 5 additions & 0 deletions .changeset/quiet-upgrade-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": patch
---

Installing or restarting the daemon no longer floods the menu bar with false "server crashed" alerts for the expected bounce, and boot restore finishes before clients connect so upgrade re-ensure does not race and leave servers on a bad port. `devctl why` also diagnoses file-backed projects correctly, and an unexpected server exit tears down leftover child processes so the next ensure is not blocked by stale locks.
5 changes: 5 additions & 0 deletions .changeset/worktree-coexistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": minor
---

Two git worktrees of one repo can both run under supervision without editing `devservers.json`. When a sibling checkout already holds the committed port, `ensure` auto-rebinds to a free port, keeps the main origin on the declared host, and gives the worktree an ephemeral `worktree-*.<preferred>.localhost` URL; unrelated projects and unmanaged listeners still get a loud `port-held` naming the holder. Session context and ensure/status JSON warn about latent and rebound conflicts so agents use the live URL, and `lock --no-pause` lets a harness take the mutex without stopping the server it reuses. Discarding a worktree stops its servers and frees the ephemeral host without a manual doctor pass. Bad-state servers in the session-start block still lead with a `devctl why` recommendation and a stderr line count, never the server's own output.
11 changes: 8 additions & 3 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
Open work only; entries are removed by the change that resolves them.

- Orphan re-adoption without the bounce: after a daemon crash, re-adopt live orphan servers (pid + start-time match, resume spool tailing) instead of group-kill + restart. Blocked on: exit codes are unknowable for non-children; needs a design for degraded forensics.
- Reverse proxy on :80/:443 routing by host signature, making ports disappear from `*.localhost` URLs (Valet/Herd territory).
- Reverse proxy on :80/:443 routing by host signature, making ports disappear from `*.localhost` URLs (Valet/Herd territory). Ephemeral `worktree-*.<preferred>.localhost` hosts are the unprivileged addressing half; the proxy would drop ports from URLs entirely.
- MenuBarExtraAccess (orchetect) if `.window` presentation quirks bite in practice.
- Populate Apple Developer ID + App Store Connect API secrets for `.github/workflows/release-dmg.yml` (and a Homebrew tap) so the Release→DMG dispatch can notarize on Actions; until then, mint locally (`SIGN_IDENTITY=… make dmg` + `scripts/notarize.sh` + `gh release upload`). `v1.3.0` already has a stapled DMG from the local path.
- App Intents / Shortcuts wrappers over the existing `DeepLink` verbs (`open`, `ensure`, `stop`, `why`) for Siri / Gemini-Siri and Control Center. The `devctl://` URL table and `DeepLinkRunner` are the shared surface; intents should call the runner, not reimplement dispatch. Also the next plausible path for Spotlight ranking (IndexedEntity) once Core Spotlight levers are exhausted.
- swift-subprocess 0.5 occasionally fatals in its kqueue AsyncIO cleanup at process exit ("Failed to close kqueue fds: Bad file descriptor"), seen once under parallel test load; harmless to the long-lived daemon but track against upstream releases (pinned revision in Package.swift).
- Field-level config editing in the dashboard to preserve `devservers.json` formatting instead of normalizing writes.
- Lock-release false `crashed` (2026-07-25 pnpm monorepo, healthy then exit 0 in ~230ms): unreproducible on fixture rapid acquire/release (N=20), grandchild fixture, and live pirl `lock d1` cycles (2026-07-28). Resume now settles until the PortClaim is free or refuses to spawn dirty. Reopen with a failing repro that names exiting pid vs resumed pid before changing the exit classifier.
- Spotlight thumbnails: confirm config icons render in the real Spotlight UI. Within-app ranking levers are maxed (lastUsed preserved across sync, incremental index updates, live/pinned rankingHint, alternateNames). Outranking filesystem / Cursor Top Hits remains an Apple ceiling; do not chase without a new system API.
- Automatic port deconflicting / pre-spawn allocation: today is refuse (`port-held`) + observe framework bumps (`observedPort`). True auto-pick needs env injection, URL/head rewrite, and ephemeral-vs-committed policy.
- Machine-wide resource lock opt-in: locks are already path-scoped (`canonicalPath::resource`) and pause only that path's declarers. A rare shared system resource (one Docker Postgres, a fixed system daemon) may still want an explicit machine-wide scope so two projects serialize. Not the Cloudflare worktree pause case (that was misread against path-scoped keys); defer until a concrete cross-project shared-resource incident.
- `healthcheck.type: "http"` with no `url` silently degrades to a TCP probe on the declared port, or to `.none` (`EffectiveHealthcheck.resolve`), with no config-check warning. A typo in the `url` key yields a green server that was never HTTP-probed. The validator should warn (or error) on `type: http` with no resolvable url.
- `shell: true` joins argv with spaces (`ServerSupervisor.effectiveArgv`), so any argv word containing a space or glob is re-split by zsh. The two spawn modes are not equivalent; a shell-quoting pass (or documenting the limit) would close the gap.
- `IntegrationTests` is a single `placeholder()` while `docs/design.md` promises a real end-to-end suite there (port-conflict from a second project, concurrent double-ensure). Those now live in `scripts/smoke.sh` and unit suites; either build the integration target out or retire the promise in the design doc.
- The `<slug>` algorithm exists twice, byte-identical: inline in `Paths.serverLogDir` and `ProjectConfigLoader.defaultSlug`. Consolidate to one home.
- `CLAUDE.md`'s codebase map lists `Switch`, `Lock`, `Link` as files under `Sources/devctl` and `PortGuard` under `Health/`; the first three are structs inside the ~1500-line `CLI.swift` and `PortGuard` sits in `HealthProber.swift`. Either fix the map or split those command structs into their own files (which also brings `CLI.swift` back under the size where splitting is worth asking about).
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ Identity and stack
- Three products, one daemon: devctld owns all server processes; devctl (CLI) and devctl.app (SwiftUI MenuBarExtra) are thin clients over a unix socket (default ~/Library/Application Support/devctl/daemon.sock; DEVCTL_SOCKET overrides; /tmp fallback near the sun_path limit), NDJSON protocol. devctl daemon install/uninstall/start/stop/restart manage the LaunchAgent (dev.quantizor.devctl); tests and the smoke gate run devctld --foreground.

Codebase map
- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, portable SHA-256; agent.path holds login-shell PATH for the daemon), Setup/SetupPlanner.swift (first-run / upgrade decisions, harness offers, stage-and-rename binary install), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests).
- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, portable SHA-256; agent.path holds login-shell PATH for the daemon), Setup/SetupPlanner.swift (first-run / upgrade decisions, harness offers, stage-and-rename binary install), Agent/ (AgentContext: the pure session-context renderer the hook injects, bad-state servers first with a devctl why recommendation and devctl's own stderr count, never raw child output; DiscoveryStanza), Net/LoopbackProbe.swift (dual-stack loopback listen probe shared by the daemon port pre-check and CLI doctor), Net/PortClaim.swift + PortMaterializer.swift (effectivePort claim: portSpan and named ports; env injection and URL rewrite), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests).
- Sources/DevCtlDaemonCore: daemon logic as a library. Supervisor/ (ServerSupervisor actor per server: spawn, spool capture, health-gated phase machine, ensure/wait, group + descendant teardown with ProcessIdentity start-time revalidation; the ProcessLauncher seam; ProcessTree QA1123 sysctl sweep), Health/ (EffectiveHealthcheck resolution, the HealthProber seam with ephemeral URLSession HTTP probes + BSD TCP, PortGuard lsof diagnostics), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + persisted resource locks with daemon-owned pause/resume and dead-holder auto-release + NWListener ControlServer with stateUpdateHandlers).
- Sources/devctld: thin main; identical behavior under launchd and --foreground (tests and the smoke gate use foreground). Applies agent.path into process env before spawn.
- Sources/devctl: CLI (swift-argument-parser); HookSupport (AgentContext + HarnessAdapter registry; adding a harness: CONTRIBUTING.md), Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Link / x-url (deep links).
- Sources/devctl: CLI (swift-argument-parser); HookSupport (HookContext, the thin socket fetch over DevCtlKit's AgentContext renderer, + HarnessAdapter registry; adding a harness: CONTRIBUTING.md), Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Link / x-url (deep links).
- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications with Open/Why actions; AgentService wraps SMAppService.agent for Login Items registration, escalating to unregister + register when the agent reads `enabled` but the socket stays silent (a bootout or replaced bundle leaves the registration intact with nothing loaded) and reporting `requiresApproval` as its own failure instead of retrying; unregister records the stop intent so the recovery poll does not undo it; an unreachable daemon self-recovers via AgentService under DaemonRecoveryPolicy, falling back to legacy LaunchdAdmin only when the bundle carries no agent plist; DaemonDownRow offers Start, or Open Login Items while approval is pending, since a deliberate `daemon stop` stands down auto recovery; PresenceLabel is AppKit-drawn colored tally dots only with renderingMode(.original); popover autogrows to a cap; nested head rows with UserDefaults-persisted pins; DashboardView logs/timeline/config tabs; SpotlightIndexer named Core Spotlight index; AppDeepLink handles `devctl://` including `daemon/ensure` and `daemon/unregister`; SetupPanel first-run / upgrade installer from bundle Resources). Pure DaemonClient consumer.
- Sources/fixture-server: test double dev server (heartbeat printer; TCP-listen, timed-exit, grandchild, ignore-sigterm, binary, flood modes; see its header comment).

Expand All @@ -28,7 +28,7 @@ Commands

Hard rules
- Output capture is spool-file fds, never pipes: children must survive daemon death without SIGPIPE. Do not introduce pipe-based capture anywhere.
- Teardown signals the process group AND every live descendant found by a sysctl sweep, snapshotted before the first signal (children that setpgid/setsid escape the group; orphans reparent to launchd and fall out of the parent-pid chain). Escalation unions the snapshot with a fresh sweep. Keep both halves when touching stop().
- Teardown signals the process group AND every live descendant found by a sysctl sweep, snapshotted while the root still parents them (children that setpgid/setsid escape the group; orphans reparent to launchd and fall out of the parent-pid chain). Deliberate stop escalates after grace (snapshot union fresh sweep). Unexpected root exit applies the same two halves with SIGTERM using the run's last descendant snapshot. Keep both halves when touching stop() or the crash exit path.
- All JSON goes through JSONCoding: sorted keys, ISO-8601 UTC with milliseconds, no interior newlines. Never construct a raw JSONEncoder or JSONDecoder; the golden tests and NDJSON line framing depend on this determinism.
- Wire methods are typed end to end: the daemon sniffs the {id, method} head, then re-decodes the full typed frame. A new method extends WireMethod plus Codable params/result types in Wire.swift; no untyped dictionaries on the wire.
- Every CLI command supports --json with a stable schema generated from the shared Codable types; failures emit {ok:false, error:{code,message,hint}} on stdout, hint being the literal remediation command. Error codes grow append-only. Golden tests in Tests/DevCtlKitTests assert exact schema strings; a changed field is an API change: update docs/cli-contract.md in the same commit, then the golden.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Local path: `SIGN_IDENTITY="Developer ID Application: …" make dmg` then `scrip
The session-context payload is harness-agnostic: `devctl context` prints a fenced plain-text block describing the current project's servers, and `devctl statusline` prints a one-line presence summary from statusline stdin JSON. Wiring those into a harness is the only per-harness work.

1. Conform to `HarnessAdapter` in `Sources/devctl/HookSupport.swift`: a `name` (the `--harness` value) and an idempotent `install(devctlPath:)` that merges a session-start hook into the harness's settings without clobbering what is already there.
2. If the harness wants a structured payload (as Claude Code does with `hookSpecificOutput.additionalContext`, or Cursor with `{additional_context}`), add a hidden subcommand like `HookClaudeSessionStart` / `HookCursorSessionStart` that adapts `AgentContext.render` output to that shape. Keep the guarantees: exit 0 always, fast, silent when there is nothing to say, never auto-starting the daemon, and never emitting raw log lines or command strings (child output and committed configs are attacker-influenceable). Resolve the session directory via `HookSessionCwd` (Cursor: `workspace_roots` / `CURSOR_PROJECT_DIR`; Claude: `cwd`).
2. If the harness wants a structured payload (as Claude Code does with `hookSpecificOutput.additionalContext`, or Cursor with `{additional_context}`), add a hidden subcommand like `HookClaudeSessionStart` / `HookCursorSessionStart` that adapts the `HookContext.render` output (a thin socket fetch over the pure `AgentContext.render` renderer in DevCtlKit) to that shape. Keep the guarantees: exit 0 always, fast, silent when there is nothing to say, never auto-starting the daemon, and never emitting raw log lines or command strings (child output and committed configs are attacker-influenceable). Resolve the session directory via `HookSessionCwd` (Cursor: `workspace_roots` / `CURSOR_PROJECT_DIR`; Claude: `cwd`).
3. Register the adapter in `harnessAdapters` and document the harness in `docs/cli-contract.md` under `devctl hook install`.

A harness that only supports plain-text injection needs no adapter code at all: point its hook at `devctl context`. Shipped adapters today: `claude`, `cursor`.
3 changes: 2 additions & 1 deletion Sources/DevCtlApp/DaemonModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@ final class DaemonModel {
else { return }
for event in feed.events where event.at > lastEventCheck {
lastEventCheck = max(lastEventCheck, event.at)
guard event.kind == .crashed || event.kind == .failed else { continue }
guard CrashNotificationPolicy.shouldNotify(kind: event.kind, detail: event.detail)
else { continue }
let content = UNMutableNotificationContent()
content.title = "\(event.server) \(event.kind.rawValue)"
content.body = event.detail.map { "\($0) · \((event.project as NSString).lastPathComponent)" }
Expand Down
Loading
Loading