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
2 changes: 2 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ Version and changelog for GitHub releases. This repo is a Swift product: the roo
3. Merge that PR when ready. The workflow tags `vX.Y.Z` (matching historical releases) and creates the GitHub release from the changelog.

`npm run version` also syncs `Sources/DevCtlKit/Model/Models.swift` (`DevCtlVersion.version`) from `package.json` so `devctl --version` and the app bundle stay aligned.

Changelog lines still link the PR/commit via `@changesets/changelog-github`. The local `changelog.cjs` wrapper drops "Thanks @quantizor" (maintainer self-thanks) and keeps thanks for other contributors.
5 changes: 5 additions & 0 deletions .changeset/bare-loopback-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": patch
---

Warn in `config check` when a host or url uses bare `localhost` / `127.0.0.1` instead of a `<slug>.localhost` origin.
30 changes: 30 additions & 0 deletions .changeset/changelog.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** Wraps `@changesets/changelog-github`, omitting self-thanks for the
maintainer login while still thanking other contributors. */
const github = require("@changesets/changelog-github");

const NO_THANKS = new Set(["quantizor"]);

/** @param {string} line */
function stripMaintainerThanks(line) {
return line.replace(
/ Thanks ((?:\[@[^\]]+\]\([^)]+\))(?:, (?:\[@[^\]]+\]\([^)]+\)))*)!/g,
(_, users) => {
const kept = users
.split(/,\s*/)
.filter((user) => {
const match = user.match(/^\[@([^\]]+)\]/);
return match ? !NO_THANKS.has(match[1].toLowerCase()) : true;
});
if (kept.length === 0) return "";
return ` Thanks ${kept.join(", ")}!`;
},
);
}

module.exports = {
getDependencyReleaseLine: github.getDependencyReleaseLine,
getReleaseLine: async (changeset, type, options) => {
const line = await github.getReleaseLine(changeset, type, options);
return stripMaintainerThanks(line);
},
};
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
"./changelog.cjs",
{ "repo": "quantizor/devctl" }
],
"commit": false,
Expand Down
5 changes: 5 additions & 0 deletions .changeset/deeplink-oslog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": minor
---
Comment thread
Copilot marked this conversation as resolved.

Open servers and run lifecycle verbs via `devctl://` URLs (menu bar app + `devctl link` / `devctl x-url`), with unified logging under subsystem `dev.quantizor.devctl`.
5 changes: 5 additions & 0 deletions .changeset/discovery-stanza.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": patch
---

After `hook install`, print a one-bullet CLAUDE.md / AGENTS.md discovery tip for the project (paste-only; never auto-edits those files).
5 changes: 5 additions & 0 deletions .changeset/recover-config-servers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": patch
---

Restore config-defined servers after reboot and `daemon install` upgrades (merged config+registry recover; install re-ensures like restart).
5 changes: 5 additions & 0 deletions .changeset/spotlight-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": patch
---

Spotlight entries use `<project> · <head>` titles with a `devctl · <url>` subtitle for clearer discovery.
7 changes: 3 additions & 4 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ Open work only; entries are removed by the change that resolves them.
- Reverse proxy on :80/:443 routing by host signature, making ports disappear from `*.localhost` URLs (Valet/Herd territory).
- MenuBarExtraAccess (orchetect) if `.window` presentation quirks bite in practice.
- Developer ID signing + notarization + Homebrew tap for OSS release; SIGN_IDENTITY variable already exists in the bundle script.
- Verify the ProcessType=Interactive claim (App Nap/QoS throttling of own-session children) empirically; unconfirmed since install.
- 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.
- 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.
- `devctl hook install` (and possibly `register`) should offer the one-line CLAUDE.md/AGENTS.md discovery stanza for the project, per the design doc; candor's was written by hand. Until then a new project's agents learn about devctl only from the injected context.
- devctl.app accessibility pass: the popover's borderless footer buttons report no AX name to System Events (missing value); audit the app with the a11y toolchain and label every control.
- Spotlight thumbnails: verify the icon renders in the real Spotlight results UI (indexing reports ok and thumbnailData is set from the config icon; the visual result awaits a human Spotlight search).
- Spotlight thumbnails: confirm config icons render in the real Spotlight UI. Ranking above filesystem / Cursor Top Hits is a hard Apple ceiling (tried; stripped Recent Documents / jump-file chase 2026-07-24); do not reopen 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.
15 changes: 9 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ 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, portable SHA-256).
- 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, portable SHA-256), 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; the ProcessLauncher seam; ProcessTree sysctl sweep), Health/ (EffectiveHealthcheck resolution, the HealthProber seam with the real network prober, PortGuard lsof diagnostics), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + resource-lock registry with dead-holder auto-release + NWListener ControlServer).
- Sources/devctld: thin main; identical behavior under launchd and --foreground (tests and the smoke gate use foreground).
- Sources/devctl: CLI (swift-argument-parser); LaunchdAdmin (launchd lifecycle; install drains via daemon.shutdown before bootout), HookSupport (AgentContext + HarnessAdapter registry; adding a harness: CONTRIBUTING.md), Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock).
- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications; 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 indexes servers and heads with config-icon thumbnails). Pure DaemonClient consumer.
- Sources/devctl: CLI (swift-argument-parser); LaunchdAdmin (launchd lifecycle; install drains via daemon.shutdown before bootout), 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/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications with Open/Why actions; 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 best-effort Core Spotlight; AppDeepLink handles `devctl://` and notification actions). 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).

Commands
- make build: swift build -c release (all products)
- make test: swift test; budget under 30s, the run prints the live timing
- scripts/smoke.sh: the end-to-end gate. Debug-builds, boots a real devctld on a temp socket, then asserts register/start/status, spool capture, health/ensure/wait, port conflicts, marks/events/why, resource locks (pause + refused ensure + resume), whole-group death on stop, and child survival across a daemon kill. Run it after touching the supervisor, wire protocol, or CLI.
- scripts/smoke-launchd.sh: the REAL LaunchAgent lifecycle (install, restart bounce + re-ensure, deliberate-stop intent, auto-bootstrap resurrection, uninstall). Mutates the user launchd domain; refuses to run if a devctl agent is already installed; leaves nothing behind.
- make app: assembles devctl.app via scripts/make-app-bundle.sh (no Xcode; ad-hoc signed; SIGN_IDENTITY upgrades). make install: binaries to ~/.local/bin, app to /Applications, daemon install.
- scripts/smoke.sh: the end-to-end gate. Debug-builds, boots a real devctld on a temp socket, then asserts register/start/status, spool capture, health/ensure/wait, port conflicts, marks/events/why, resource locks (pause + refused ensure + resume), whole-group death on stop, child survival across a daemon kill, `link`/`x-url` deep-link dispatch, and that the assembled app declares `CFBundleURLSchemes=devctl`. Run it after touching the supervisor, wire protocol, CLI, or deep links.
- scripts/smoke-deeplink.sh: Launch Services E2E for `devctl://` (warm + cold `open`). Requires a GUI session; run before merging URL-scheme work. OSLog scrape is strict on a tty (`DEVCTL_OSLOG_STRICT=1` forces it).
- scripts/smoke-launchd.sh: the REAL LaunchAgent lifecycle (install, restart bounce + re-ensure, install-upgrade bounce + re-ensure, deliberate-stop intent, auto-bootstrap resurrection, uninstall). Mutates the user launchd domain; refuses to run if a devctl agent is already installed; leaves nothing behind.
- make app: assembles devctl.app via scripts/make-app-bundle.sh (no Xcode; ad-hoc signed; SIGN_IDENTITY upgrades; declares the `devctl://` URL scheme). make install: binaries to ~/.local/bin, app to /Applications, daemon install.
- Unified logging: subsystem `dev.quantizor.devctl` (categories daemon, supervisor, health, app, deeplink). Stream with `log stream --predicate 'subsystem == "dev.quantizor.devctl"' --level debug`. Child stdout/stderr stay in spool files; OSLog is for devctl's own behavior.
- Deep links: `devctl://open|ensure|stop|why/<project-slug>/<server>[/<head>]` (query form also accepted). `devctl link` prints; the app handles via Launch Services; `devctl x-url` runs the same runner for smoke.

Hard rules
- Output capture is spool-file fds, never pipes: children must survive daemon death without SIGPIPE. Do not introduce pipe-based capture anywhere.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A launchd-supervised daemon owns every server process. Sessions come and go, con

## Why

Agents forget their dev servers. After a context compaction they spawn duplicates, tail dead logs, and fight over ports. devctl gives them one idempotent verb (`devctl ensure web`) that always lands in the same place, a session hook that re-teaches every new or compacted session what is running, and a `why` command that turns a broken server into a root-cause diagnosis.
Agents forget their dev servers. After a context compaction they spawn duplicates, tail dead logs, and fight over ports. devctl gives them one idempotent verb (`devctl ensure myproj`) that always lands in the same place, a session hook that re-teaches every new or compacted session what is running, and a `why` command that turns a broken server into a root-cause diagnosis.

## Quick start

Expand All @@ -16,13 +16,15 @@ Agents forget their dev servers. After a context compaction they spawn duplicate
```sh
make install # binaries to ~/.local/bin, app to /Applications, daemon installed
cd your-project
devctl register --name web --cmd bun --cmd run --cmd dev --port 3000
devctl ensure web # idempotent: healthy is a no-op
devctl why web # root cause when something breaks
devctl register --name myproj --cmd bun --cmd run --cmd dev --port 3000
devctl ensure myproj # idempotent: healthy is a no-op
devctl why myproj # root cause when something breaks
devctl hook install --harness cursor # Cursor Agent sessions rediscover servers automatically
devctl hook install --harness claude # same for Claude Code (default harness)
```

Name each server after the project (`myproj`, not a generic `web`) so it is easy to spot in Spotlight and search, and give it a `<project>.localhost` host rather than bare `localhost`: the per-project subdomain keeps browser cookies, storage, and service workers isolated between projects.

Or commit a `devservers.json` at the project root (multiple servers, dependencies, healthchecks, `*.localhost` host signatures, multi-headed proxies, lifecycle playbooks); `devctl up` brings the whole project up in dependency order. The full CLI contract lives in [docs/cli-contract.md](./docs/cli-contract.md).

## The parts
Expand Down
79 changes: 79 additions & 0 deletions Sources/DevCtlApp/AppDeepLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import AppKit
import DevCtlKit
import Foundation
import UserNotifications

/** AppKit / UserNotifications side effects for `DeepLinkRunner`. */
struct AppDeepLinkEffects: DeepLinkEffects {
func copyToPasteboard(_ text: String) async {
await MainActor.run {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}

func notify(title: String, body: String) async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(
identifier: "devctl-deeplink-\(UUID().uuidString)", content: content, trigger: nil)
try? await UNUserNotificationCenter.current().add(request)
}

func openBrowser(_ url: URL) async {
await MainActor.run { NSWorkspace.shared.open(url) }
}
}

/** Shared deep-link dispatch used by URL opens and notification actions. */
enum AppDeepLinkDispatch {
static let serverAlertCategory = "dev.quantizor.devctl.server-alert"
static let userInfoProject = "project"
static let userInfoServer = "server"
static let userInfoHead = "head"

static func run(_ url: URL) {
switch DeepLink.parse(url: url) {
case .failure(let error):
DevCtlLog.deeplink.error("reject \(error.message)")
Task {
await AppDeepLinkEffects().notify(title: "devctl link failed", body: error.message)
}
case .success(let link):
Task { await execute(link) }
}
}

static func run(_ link: DeepLink) {
Task { await execute(link) }
}

private static func execute(_ link: DeepLink) async {
let client = DaemonClient(socketPath: DevCtlPaths().socketPath)
do {
try await client.connect()
let result = try await DeepLinkRunner(client: client, effects: AppDeepLinkEffects()).run(link)
DevCtlLog.app.info(
"deeplink \(result.verb.rawValue) \(result.projectPath) \(result.detail ?? "")")
} catch let error as WireError {
DevCtlLog.deeplink.error("\(error.message)")
await AppDeepLinkEffects().notify(title: "devctl link failed", body: error.message)
} catch {
DevCtlLog.deeplink.error("\(error)")
await AppDeepLinkEffects().notify(title: "devctl link failed", body: String(describing: error))
}
}

/** Register Open / Why actions once at launch. */
static func registerNotificationCategories() {
let open = UNNotificationAction(
identifier: DeepLinkNotificationAction.open.rawValue, title: "Open")
let why = UNNotificationAction(
identifier: DeepLinkNotificationAction.why.rawValue, title: "Why")
let category = UNNotificationCategory(
identifier: serverAlertCategory, actions: [open, why], intentIdentifiers: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
}
}
7 changes: 7 additions & 0 deletions Sources/DevCtlApp/DaemonModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,18 @@ final class DaemonModel {
content.title = "\(event.server) \(event.kind.rawValue)"
content.body = event.detail.map { "\($0) · \((event.project as NSString).lastPathComponent)" }
?? (event.project as NSString).lastPathComponent
content.categoryIdentifier = AppDeepLinkDispatch.serverAlertCategory
content.userInfo = [
AppDeepLinkDispatch.userInfoProject: event.project,
AppDeepLinkDispatch.userInfoServer: event.server,
]
content.sound = .default
let request = UNNotificationRequest(
identifier: "devctl-\(event.server)-\(event.at.timeIntervalSince1970)",
content: content,
trigger: nil)
try? await UNUserNotificationCenter.current().add(request)
DevCtlLog.app.info("notified \(event.kind.rawValue) \(event.server)")
}
}
}
9 changes: 8 additions & 1 deletion Sources/DevCtlApp/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ struct ServerDetail: View {
model: model, reserveSlot: false, server: server, size: 16)
if server.url != nil {
Button {
SpotlightIndexer.noteOpened(identifier: "\(server.project)::\(server.server)")
openURL(server.url)
} label: {
Image(systemName: "safari")
Expand All @@ -103,7 +104,11 @@ struct ServerDetail: View {
if let heads = server.heads, !heads.isEmpty {
Menu {
ForEach(heads.sorted(by: { $0.key < $1.key }), id: \.key) { name, url in
Button("\(name) · \(url)") { openURL(url) }
Button("\(name) · \(url)") {
SpotlightIndexer.noteOpened(
identifier: "\(server.project)::\(server.server)::\(name)")
openURL(url)
}
}
} label: {
Image(systemName: "rectangle.stack")
Expand All @@ -113,6 +118,7 @@ struct ServerDetail: View {
.menuStyle(.borderlessButton)
.frame(width: 22)
.help("Open head")
.accessibilityLabel(Text("Open head"))
}
Button {
NSWorkspace.shared.activateFileViewerSelecting(
Expand All @@ -130,6 +136,7 @@ struct ServerDetail: View {
}
.pickerStyle(.segmented)
.frame(width: 240)
.accessibilityLabel(Text("Detail view"))
}
}
.padding(.horizontal, 12)
Expand Down
Loading
Loading