@detergent-software/conductor 0.0.0-stage → 1.0.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +129 -2
  3. package/build/claude.js +102 -0
  4. package/build/claude.js.map +1 -0
  5. package/build/cli.js +383 -0
  6. package/build/cli.js.map +1 -0
  7. package/build/client.js +64 -0
  8. package/build/client.js.map +1 -0
  9. package/build/conditions.js +198 -0
  10. package/build/conditions.js.map +1 -0
  11. package/build/config.js +74 -0
  12. package/build/config.js.map +1 -0
  13. package/build/daemon.js +127 -0
  14. package/build/daemon.js.map +1 -0
  15. package/build/errors.js +17 -0
  16. package/build/errors.js.map +1 -0
  17. package/build/jsonc.js +24 -0
  18. package/build/jsonc.js.map +1 -0
  19. package/build/lock.js +64 -0
  20. package/build/lock.js.map +1 -0
  21. package/build/log.js +43 -0
  22. package/build/log.js.map +1 -0
  23. package/build/manager.js +614 -0
  24. package/build/manager.js.map +1 -0
  25. package/build/paths.js +16 -0
  26. package/build/paths.js.map +1 -0
  27. package/build/profile.js +74 -0
  28. package/build/profile.js.map +1 -0
  29. package/build/registry.js +33 -0
  30. package/build/registry.js.map +1 -0
  31. package/build/resolve.js +28 -0
  32. package/build/resolve.js.map +1 -0
  33. package/build/run.js +52 -0
  34. package/build/run.js.map +1 -0
  35. package/build/server.js +227 -0
  36. package/build/server.js.map +1 -0
  37. package/build/spend.js +134 -0
  38. package/build/spend.js.map +1 -0
  39. package/build/startup.js +48 -0
  40. package/build/startup.js.map +1 -0
  41. package/build/store.js +528 -0
  42. package/build/store.js.map +1 -0
  43. package/build/tab.js +26 -0
  44. package/build/tab.js.map +1 -0
  45. package/build/token.js +58 -0
  46. package/build/token.js.map +1 -0
  47. package/build/wait.js +149 -0
  48. package/build/wait.js.map +1 -0
  49. package/build/watchers/ado.js +167 -0
  50. package/build/watchers/ado.js.map +1 -0
  51. package/build/watchers/github.js +82 -0
  52. package/build/watchers/github.js.map +1 -0
  53. package/build/watchers/health.js +42 -0
  54. package/build/watchers/health.js.map +1 -0
  55. package/build/watchers/index.js +226 -0
  56. package/build/watchers/index.js.map +1 -0
  57. package/build/watchers/linear-key.js +56 -0
  58. package/build/watchers/linear-key.js.map +1 -0
  59. package/build/watchers/linear.js +218 -0
  60. package/build/watchers/linear.js.map +1 -0
  61. package/build/watchers/npm.js +35 -0
  62. package/build/watchers/npm.js.map +1 -0
  63. package/build/watchers/source.js +94 -0
  64. package/build/watchers/source.js.map +1 -0
  65. package/build/worktrees.js +143 -0
  66. package/build/worktrees.js.map +1 -0
  67. package/package.json +56 -5
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ Releases are tagged `conductor-v<version>` and staged on npm by `atk-conductor-release` (`pipelines/release-conductor.yml`), which writes each section below. `scripts/check-changelog.mjs` fails when a tag has no section here.
4
+
5
+ ## 1.0.0-dev.1 (2026-10-10)
6
+
7
+ ### Features
8
+
9
+ * **conductor:** add the conductor daemon skeleton (ATK-308) ([a33233b](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/a33233bd47c29416bc3f1db85d10a7904954f7de))
10
+ * **conductor:** add the session manager (ATK-309) ([aee8575](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/aee85752ece203463e8ee027459ea26d80a0c689))
11
+ * **conductor:** add the watchers and conduct wait (ATK-310) ([f803cba](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/f803cba1c8de1b31a4282d259b9439a491c28c57))
12
+
13
+ ### Bug Fixes
14
+
15
+ * **ci:** keep two npm releases from skipping or doubling (ATK-338) ([76703d3](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/76703d3454ba4abc14b30d5b3b5867eb26b399ce))
16
+ * **conductor:** address code review of the session manager (ATK-309) ([724ffba](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/724ffba72e4278b6819636ccad95310921582001))
17
+ * **conductor:** address review of the watchers (ATK-310) ([05cbb88](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/05cbb88c9efc14aad83dd40a668109de77e2a74a))
18
+ * **conductor:** harden the session manager after review (ATK-309) ([9675c0d](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/9675c0d40d6373429a9f99eeaad5ba78ba54eae5))
19
+ * **conductor:** harden the skeleton after review (ATK-308) ([0930b74](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/0930b74edd9fae6dc94dfa7b62aaf4f13526b0d3))
20
+ * **conductor:** read a turn's end from status alone (ATK-309) ([a42b6d3](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/a42b6d34f45c309c6da3bae7cf2c3c9d7b2d0032))
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # Conductor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@detergent-software/conductor`: the local daemon that runs the work loop for every conducted project on this machine, and its `conduct` CLI. The design, decisions and build order are in the Linear document *Conductor: design note* (project *Conductor daemon*). This package has build step 1, the skeleton (ATK-308), step 2, the session manager (ATK-309), and step 3, the watchers and `conduct wait` (ATK-310).
4
+
5
+ ## Commands
6
+
7
+ ```text
8
+ conduct serve run the daemon in this process (one per machine)
9
+ conduct stop stop the running daemon
10
+ conduct status [--json] the daemon's pid, port, projects and workers
11
+ conduct project add <path> register the project whose checkout holds .claude/conductor.json
12
+ conduct project list [--json] the registered projects
13
+ conduct launch <item> start a worker: worktree, background session, tab
14
+ [--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
15
+ conduct retire <worker> [--force] stop a worker, remove its worktree and session
16
+ conduct attach <worker> wake a parked worker (no event text) and open it in a tab
17
+ conduct wait <condition> wait for an outside event (see Waits)
18
+ conduct install-startup start the daemon at logon (Windows Task Scheduler)
19
+ conduct install-startup --remove remove that logon task
20
+ ```
21
+
22
+ A `<worker>` is its item (`ATK-309`) or its name (`probe-ATK-309`).
23
+
24
+ ## Workers
25
+
26
+ The session manager owns the `claude --bg` sessions it launches, and only those:
27
+
28
+ - **launch:** the worktree comes first, through the project's `.claude/hooks/worktree-hooks.mjs`. In a repo with `.atk-lock.json`, the profile's work and verify skills must match the lockfile (`atk install` fixes them when it can). Then `claude --bg --worktree <name> -n <name> --disallowedTools EnterPlanMode ExitPlanMode --permission-mode auto -- <prompt>` runs from the main checkout. A profile with `"workers": { "tab": true }` also gets a Windows Terminal tab attached to the session.
29
+ - **park:** a worker with a wait (`POST /waits`) is stopped once `claude agents` shows its turn has ended. Its status becomes `parked`, and its tab closes.
30
+ - **resume:** when a wait resolves, the daemon lets the stop settle (`sessions.settleSeconds`). It checks the process has exited, then runs `claude --bg --resume <session> -- "[conductor] event: …"`. The session keeps its name, model, permission mode and disallowed tools. A resume of a session that still runs starts a copy; the daemon stops and removes the copy, then tries again.
31
+ - **retire:** refuses uncommitted or unpushed work, and a worker whose turn still runs, unless `--force`. It never retires a session that is still starting. Then it stops the session, removes the worktree and runs `claude rm`. If the stop fails, nothing is removed.
32
+ - **restarts:** a launch the daemon was stopped in adopts its session if one was started, and otherwise fails and removes its clean worktree.
33
+ - **attach:** a parked worker is resumed with no event text, so it wakes idle and keeps running when its tab closes. An attached worker isn't parked until it registers a new wait, or until its turn has been over `sessions.idleMinutes` (`worker.detached`). The daemon can't see the tab, so this also delivers a wait that resolved while it was attached.
34
+ - **park failures:** a session whose process outlives `claude stop` by 30 s logs one `worker.park-failed` per spell, and the park is tried again every 5 minutes.
35
+ - **stalls:** a permission prompt open `sessions.permissionMinutes` (5), or idle `sessions.idleMinutes` (20) with no wait, logs one `worker.stalled` event per spell.
36
+ - **spend:** tokens from the session's transcript and its subagents' (`~/.claude/projects/*/<session>.jsonl`), each message counted once, go into `spend` per worker and local day. It keeps input, output, cache read and cache write; `tokens` is their sum.
37
+ - **probes:** `--probe` names the worktree and session `probe-<item>` and gives it a prompt that works no issue, so tests stay out of the live fleet.
38
+
39
+ Worker statuses: `launching`, `running`, `parked`, `stopped-unexpectedly`, `failed`, `exited`. Every step is an event (`worker.launched`, `worker.parked`, `worker.resumed`, …) on `GET /events`.
40
+
41
+ ## Waits
42
+
43
+ A worker waits by posting its note, running `conduct wait …` and ending the turn (worker protocol v1). The verbs take the repo, host and package from the worktree's `.claude/conductor.json`; the full form is accepted too, and is what the store keeps:
44
+
45
+ | Verb | Full form | Holds when |
46
+ |---|---|---|
47
+ | `pr 1850 merged` | `pr:ado:Emergent.AgenticToolkit#1850:merged` | the PR is completed (or abandoned: that resolves it too, as a fact) |
48
+ | `pr 1850 checks` | `…#1850:checks` | no blocking build of the PR's current commit is queued or running (passed or failed; a path-filtered build it doesn't touch counts as passed) |
49
+ | `pr 1850 comment` | `…#1850:comment` | a thread is open whose last word isn't a work-skill note |
50
+ | `pr 458 merged --repo <owner>/<repo>` | `pr:github:<owner>/<repo>#458:merged` | the same on GitHub; `comment` there is a person's comment or review after the wait was registered |
51
+ | `run atk-api-deploy 12345 done` | `run:ado:atk-api-deploy#12345:done` | the run completed, with its result |
52
+ | `npm 8.5.0-dev.2 --tag dev` | `npm:@detergent-software/atk@8.5.0-dev.2:dev` | the dist-tag points at the version |
53
+ | `health dev --version 1.2.3` / `--commit <sha>` | `health:dev:version=1.2.3` | the profile's `health.dev` URL answers with that version or commit |
54
+ | `approval` | `approval:linear:ATK-310` | an `approve` / `revise:` comment by the Linear key's owner after the item's newest `<work skill> · Plan` note |
55
+ | `item ATK-271 done` / `label human` / `note Landed` | `item:linear:ATK-271:state=done` | the state (name or type), a label, or a phase note |
56
+ | `time 2026-10-10T09:00` | `time:2026-10-10T14:00:00.000Z` | the time has passed (no zone means local time) |
57
+
58
+ - **With the daemon**, in a worker's worktree (or with `--worker <worker>`), `conduct wait` registers the wait and returns at once. The daemon parks the session when its turn ends and resumes it with `[conductor] event: <resolution> HH:MM` once the condition holds. The text is a fact, never an approval: the worker reads the source again. The daemon checks waits against the project's profile as registered (`conduct project add`), and refuses one it can't check, such as a `health` environment the profile has no URL for.
59
+ - **Without a daemon**, with `--local`, or when the daemon doesn't answer, it checks the condition itself and blocks. It prints `[conductor] event: …` and exits 0 when the condition holds, or exits 2 after `--timeout` minutes (29: Monitor ends a command at 30) so the caller can run it again. A wait that can't be checked (a PR that doesn't exist, no Linear key, `az` not signed in) exits 1. `--timeout` applies only here.
60
+
61
+ **Watchers.** One per API: Linear (GraphQL with `LINEAR_API_KEY` or the `atk-conductor-linear` credential, ported from conduct-atk), Azure DevOps (REST with the `az login` token), GitHub (`gh api`), npm (the registry's dist-tags, no cache) and `/health`. Time waits are the session manager's sweep. An API is asked nothing while no wait needs it. Otherwise it's asked once per `watchers.<api>.intervalSeconds`, within `perMinute` requests in any rolling minute shared by every project. Each pass covers all of that API's waits in as few requests as it can; Linear's is one request, filtered on `updatedAt` (with a 2-minute overlap) after the first. A pass that runs out of budget stops, and the next one starts a wait further on. A failing API waits twice as long each time (up to 10 minutes) and logs `watcher.failed` once, then `watcher.recovered`. A wait that can't be checked logs `wait.check-failed` once and stays open. Every `watchers.statsMinutes` (10) in which anything waited, a `watcher.stats` event records each API's passes, requests, budget skips, failures and waits.
62
+
63
+ ## Files
64
+
65
+ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
66
+
67
+ | File | Holds |
68
+ |---|---|
69
+ | `config.json` | machine settings, JSONC (comments allowed), validated at startup; `sessions` holds `pollSeconds` (10), `settleSeconds` (5), `permissionMinutes` (5), `idleMinutes` (20); `watchers` holds `ado`, `github`, `health`, `linear` and `npm`, each `{ intervalSeconds, perMinute }` (30/60, 30/30, 30/30, 30/10, 20/30), and `statsMinutes` (10) |
70
+ | `token` | the API token, readable by this user only; every request sends `Authorization: Bearer <token>` |
71
+ | `conductor.pid`, `port` | the single-instance lock and where the API listens (`127.0.0.1`) |
72
+ | `conductor.db` | SQLite state, including the append-only `events` log |
73
+ | `logs/conductor.log` | the rotating log |
74
+
75
+ ## API
76
+
77
+ - `GET /projects`: the registered projects. `POST /projects` with `{ "path": "<absolute path>" }` registers one.
78
+ - `GET /events`: the event log as server-sent events. Add `?since=<id>` (or send `Last-Event-ID`) to replay from an id. Without either, the stream starts at the live end.
79
+ - `POST /shutdown`: a clean stop (`conduct stop`).
80
+ - `GET /fleet`: the workers in flight, with session activity, waits and spend (today, total).
81
+ - `POST /workers` with `{ "item": "ATK-309", "project"?, "probe"?, "prompt"?, "conductor"? }`: launch; answers 202 with the `launching` worker. `GET /workers/:id` shows it with its latest events.
82
+ - `DELETE /workers/:id` (`?force=true`): retire; 409 with the unfinished work when it refuses.
83
+ - `POST /workers/:id/attach`: wake if parked, mark attached, open the tab.
84
+ - `POST /waits` with `{ "worker": 1, "condition": "pr:ado:Emergent.AgenticToolkit#1850:merged" }`: a wait in its full form (see Waits), stored in its normal form; 201 with the wait. `GET /waits/:id` shows one, with its resolution once it has one.
85
+ - A refusal (a duplicate launch, a bad condition, a busy worker) is a 409 with `error` and `details`.
86
+
87
+ ## On PATH
88
+
89
+ Install the package globally, next to `atk`. `dev` follows `develop`; `latest` follows `main`:
90
+
91
+ ```bash
92
+ npm install --global @detergent-software/conductor@dev
93
+ conduct install-startup
94
+ conduct project add <path to the project's main checkout>
95
+ ```
96
+
97
+ `conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout.
98
+
99
+ The logon task runs `node` on the `cli.js` that ran `install-startup`. A global install keeps that path across upgrades, so the task needs registering once. To upgrade, stop the daemon, install, and start it again (or sign out and back in):
100
+
101
+ ```bash
102
+ conduct stop
103
+ npm install --global @detergent-software/conductor@dev
104
+ conduct serve
105
+ ```
106
+
107
+ **From a checkout instead (development).** `npm link` puts the main checkout's build on PATH, so every worktree and the logon task run that build. Run it from the main checkout, and upgrade by pulling `develop`, building, then `conduct stop` and `conduct serve`:
108
+
109
+ ```bash
110
+ pnpm install
111
+ pnpm -C apps/conductor build
112
+ (cd apps/conductor && npm link)
113
+ conduct install-startup
114
+ ```
115
+
116
+ To switch from a linked build to the package: `conduct stop`, `npm rm --global @detergent-software/conductor` (removes the link), install the package as above, and run `conduct install-startup` again so the logon task points at the installed `cli.js`.
117
+
118
+ ## Releases
119
+
120
+ `atk-conductor-release` (`pipelines/release-conductor.yml`) runs semantic-release on each push to `develop` or `main` that touches `apps/conductor/`. It counts only commits that touch this directory, writes `CHANGELOG.md`, tags `conductor-v<version>` (`X.Y.Z-dev.N` on `develop`), and stages the version on npm under `dev` or `latest`. A maintainer publishes it with the `npm stage approve <id> --otp=<code>` line from the run summary.
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ pnpm -C apps/conductor lint
126
+ pnpm -C apps/conductor typecheck
127
+ pnpm -C apps/conductor test
128
+ pnpm -C apps/conductor build
129
+ cd apps/conductor && node scripts/check-changelog.mjs
130
+ ```
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ import { ConductorError } from "./errors.js";
3
+ import { outputOf, run } from "./run.js";
4
+ // One row of `claude agents --json --all`. Only the fields the session manager reads; the rest pass.
5
+ export const agentSessionSchema = z.looseObject({
6
+ cwd: z.string().optional(),
7
+ id: z.string(),
8
+ kind: z.string().optional(),
9
+ name: z.string().optional(),
10
+ pid: z.number().optional(),
11
+ sessionId: z.string(),
12
+ // working | done | blocked: the agent view's label for the work, from the model's last reply.
13
+ // Not whether a turn runs (that is `status`), except before a new session has its pid.
14
+ state: z.string().optional(),
15
+ // idle | busy | waiting; `waiting` comes with `waitingFor` (a permission prompt, input).
16
+ status: z.string().optional(),
17
+ waitingFor: z.string().optional(),
18
+ });
19
+ export function claudeCli() {
20
+ // Resolves to stdout, and stderr after it: the CLI prints its notes (such as a copy started) there.
21
+ const claude = async (args, cwd, timeoutMs = 120_000) => {
22
+ const result = await run('claude', args, { cwd, timeoutMs });
23
+ if (result.code !== 0)
24
+ throw new ConductorError(`claude ${args[0]} failed`, [outputOf(result, 500)]);
25
+ return { all: result.stdout + result.stderr, stdout: result.stdout };
26
+ };
27
+ return {
28
+ // Only stdout is JSON; a notice on stderr must not break the listing.
29
+ agents: async () => parseAgents((await claude(['agents', '--json', '--all'], undefined, 60_000)).stdout),
30
+ launch: async (request) => {
31
+ // The create hook (branch, pnpm install, atk install) runs first, so allow it minutes.
32
+ const output = (await claude(launchArgs(request), request.cwd, 10 * 60_000)).all;
33
+ const handle = parseHandle(output);
34
+ if (!handle)
35
+ throw new ConductorError('claude --bg printed no session id', [output.trim().slice(-500)]);
36
+ return handle;
37
+ },
38
+ resume: async ({ cwd, sessionId, text }) => {
39
+ const output = await claude(['--bg', '--resume', sessionId, ...(text === null ? [] : ['--', text])], cwd);
40
+ return { copy: parseCopy(output.all) };
41
+ },
42
+ rm: async (handle) => void (await claude(['rm', handle])),
43
+ stop: async (handle) => void (await claude(['stop', handle])),
44
+ };
45
+ }
46
+ // The session's process exists. `state: "done"` only means the turn ended.
47
+ export function isLive(session) {
48
+ return session.pid !== undefined || isStarting(session);
49
+ }
50
+ // Listed as working before it has a pid: `claude --bg` is still creating its worktree.
51
+ export function isStarting(session) {
52
+ return session.pid === undefined && session.state === 'working';
53
+ }
54
+ // The flags a worker starts with. A resume keeps them: `claude --bg --resume` wakes a session
55
+ // "with its saved options (-n, --permission-mode, --disallowedTools, --model)" (ATK-309's check).
56
+ // Workers get no plan-mode tools: an ExitPlanMode dialog passes on one stray key (ATK-269).
57
+ export function launchArgs(request) {
58
+ return [
59
+ '--bg',
60
+ '--worktree',
61
+ request.worktree,
62
+ '-n',
63
+ request.name,
64
+ '--disallowedTools',
65
+ ...request.disallowedTools,
66
+ '--permission-mode',
67
+ request.permissionMode,
68
+ '--',
69
+ request.prompt,
70
+ ];
71
+ }
72
+ // The sessions in `claude agents --json --all` that have a short id. Interactive sessions are
73
+ // listed without one; the daemon never manages those, and one odd row mustn't hide the rest.
74
+ export function parseAgents(text) {
75
+ return z
76
+ .array(z.unknown())
77
+ .parse(JSON.parse(text))
78
+ .flatMap((row) => {
79
+ const session = agentSessionSchema.safeParse(row);
80
+ return session.success ? [session.data] : [];
81
+ });
82
+ }
83
+ // A resume of a session that still runs starts a copy and says so. The copy has no name and
84
+ // doesn't show in `claude agents --json --all`, so this line is the only trace of it.
85
+ export function parseCopy(output) {
86
+ return /started a copy as ([0-9a-f]{8})\b/.exec(output)?.[1] ?? null;
87
+ }
88
+ // `claude --bg` prints `backgrounded · <id> · <name>`.
89
+ export function parseHandle(output) {
90
+ return /backgrounded · ([0-9a-f]{8})\b/.exec(output)?.[1] ?? null;
91
+ }
92
+ export function permissionPrompt(session) {
93
+ return session.status === 'waiting' && /permission/i.test(session.waitingFor ?? '') ? (session.waitingFor ?? null) : null;
94
+ }
95
+ // The turn ended and nothing holds the session: no tool running, no prompt open, no Monitor armed
96
+ // (an armed Monitor reads busy). Only `status` says so. `state` is the agent view's label for how
97
+ // the work is going, taken from the last reply: a probe that answered an event with "I'm still
98
+ // waiting" stayed `state: working` at `status: idle` for minutes (ATK-309).
99
+ export function turnEnded(session) {
100
+ return session.pid !== undefined && session.status === 'idle';
101
+ }
102
+ //# sourceMappingURL=claude.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude.js","sourceRoot":"","sources":["../src/claude.ts"],"names":[],"mappings":"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"}
package/build/cli.js ADDED
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, realpathSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseArgs } from 'node:util';
7
+ import { z } from 'zod';
8
+ import { callApi, NotRunningError, readEndpoint } from "./client.js";
9
+ import { startDaemon } from "./daemon.js";
10
+ import { ConductorError } from "./errors.js";
11
+ import { readPid } from "./lock.js";
12
+ import { fleetWorkerSchema } from "./manager.js";
13
+ import { conductorPaths } from "./paths.js";
14
+ import { registerProject } from "./registry.js";
15
+ import { installStartup, removeStartup, TASK_NAME } from "./startup.js";
16
+ import { openStore, projectSchema, workerSchema } from "./store.js";
17
+ import { waitCommand } from "./wait.js";
18
+ const USAGE = `Usage: conduct <command>
19
+
20
+ serve run the daemon in this process (one per machine)
21
+ stop stop the running daemon
22
+ status [--json] the daemon's pid, port, projects and workers
23
+ project add <path> register the project whose checkout holds .claude/conductor.json
24
+ project list [--json] the registered projects
25
+ launch <item> start a worker: its worktree, a background session, a tab
26
+ [--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
27
+ retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
28
+ attach <worker> wake a parked worker (no event text) and open it in a tab
29
+ wait <condition> wait for an outside event: in a worker's worktree with the daemon
30
+ running, register it and end the turn (the daemon resumes the session);
31
+ otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
32
+ pr <n> merged|checks|comment [--repo <owner>/<repo>]
33
+ run <pipeline> <run id> [done]
34
+ npm <version> [--tag <dist-tag>] [--package <name>]
35
+ health <env> --version <x> | --commit <sha>
36
+ approval [<item>] an approve / revise: comment after the item's newest Plan note
37
+ item <item> <state> | item <item> label|note <value>
38
+ time <when> such as 2026-10-10T09:00 (local)
39
+ <full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
40
+ [--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
41
+ install-startup start the daemon at logon (Windows Task Scheduler)
42
+ install-startup --remove remove that logon task
43
+
44
+ A <worker> is its item (ATK-309) or its name (probe-ATK-309).
45
+ Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
46
+ const STOP_WAIT_MS = 10_000;
47
+ // A launch makes a worktree (pnpm install) before the session starts.
48
+ const LAUNCH_WAIT_MS = 15 * 60_000;
49
+ // The daemon's worst cases, so the CLI never gives up on an operation that still runs: retire
50
+ // lists sessions (60 s), stops one (120 s + 30 s), removes the worktree (5 min), rms it (120 s);
51
+ // attach waits out the settle time (up to 5 min), resumes (120 s) and opens the tab (60 s).
52
+ const RETIRE_TIMEOUT_MS = 12 * 60_000;
53
+ const ATTACH_TIMEOUT_MS = 9 * 60_000;
54
+ const projectsBody = z.object({ projects: z.array(projectSchema) });
55
+ const addBody = z.object({ created: z.boolean(), project: projectSchema });
56
+ const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
57
+ const fleetBody = z.object({ workers: z.array(fleetWorkerSchema) });
58
+ const workerBody = z.object({ worker: workerSchema });
59
+ const workerDetailBody = z.object({
60
+ events: z.array(z.object({ data: z.record(z.string(), z.unknown()), kind: z.string() })),
61
+ worker: workerSchema,
62
+ });
63
+ const retireBody = z.object({
64
+ outcome: z.enum(['partial', 'refused', 'retired']),
65
+ reason: z.string().optional(),
66
+ steps: z.array(z.object({ error: z.string().optional(), ok: z.boolean(), step: z.string() })).optional(),
67
+ work: z
68
+ .object({ dirty: z.array(z.string()).optional(), unknown: z.string().optional(), unpushed: z.number().optional() })
69
+ .optional(),
70
+ worker: workerSchema,
71
+ });
72
+ const attachBody = z.object({ resumed: z.boolean(), tab: z.string().nullable(), worker: workerSchema });
73
+ async function addProject(paths, path) {
74
+ const absolute = resolve(path);
75
+ let result;
76
+ try {
77
+ result = addBody.parse(await expectOk(callApi(paths, { body: { path: absolute }, method: 'POST', path: '/projects' })));
78
+ }
79
+ catch (error) {
80
+ if (!(error instanceof NotRunningError))
81
+ throw error;
82
+ // No daemon: write the store directly. The daemon reads it when it starts.
83
+ result = withStore(paths, (store) => registerProject(store, absolute));
84
+ }
85
+ out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
86
+ }
87
+ async function attach(paths, ref) {
88
+ const worker = await findWorker(paths, ref);
89
+ const result = attachBody.parse(await expectOk(callApi(paths, { method: 'POST', path: `/workers/${worker.id}/attach`, timeoutMs: ATTACH_TIMEOUT_MS })));
90
+ out(`${result.resumed ? 'woke' : 'attached to'} ${worker.name} (session ${result.worker.handle ?? '?'})`);
91
+ if (result.tab)
92
+ out(`no tab (${result.tab}); open it yourself: claude attach ${result.worker.handle ?? worker.name}`);
93
+ }
94
+ async function expectOk(call) {
95
+ const { body, status } = await call;
96
+ if (status >= 200 && status < 300)
97
+ return body;
98
+ const error = errorBody.safeParse(body);
99
+ throw new ConductorError(error.success ? error.data.error : `the API answered ${status}`, error.data?.details ?? []);
100
+ }
101
+ // A worker in flight by item or name. A probe and a live worker can share an item, so a name match wins.
102
+ async function findWorker(paths, ref) {
103
+ const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
104
+ const byName = workers.filter((w) => w.name === ref);
105
+ const matches = byName.length > 0 ? byName : workers.filter((w) => w.item === ref);
106
+ if (matches.length === 1 && matches[0])
107
+ return matches[0];
108
+ if (matches.length === 0)
109
+ throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
110
+ throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
111
+ }
112
+ // 1234 → 1.2k, 3_400_000 → 3.4M.
113
+ function formatTokens(tokens) {
114
+ if (tokens >= 1_000_000)
115
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
116
+ if (tokens >= 1_000)
117
+ return `${(tokens / 1_000).toFixed(1)}k`;
118
+ return String(tokens);
119
+ }
120
+ function install() {
121
+ const cliPath = realpathSync(fileURLToPath(import.meta.url));
122
+ installStartup({ cliPath, nodePath: process.execPath });
123
+ out(`registered the "${TASK_NAME}" logon task: ${process.execPath} ${cliPath} serve`);
124
+ if (cliPath.includes(`${sep}.claude${sep}worktrees${sep}`)) {
125
+ out('warning: that build is in a worktree, which goes away; run install-startup from the main checkout');
126
+ }
127
+ }
128
+ async function launch(paths, item, options) {
129
+ const { worker } = workerBody.parse(await expectOk(callApi(paths, { body: { item, ...options }, method: 'POST', path: '/workers' })));
130
+ out(`launching ${worker.name} in ${worker.project} (worker ${worker.id}): worktree, then session`);
131
+ const deadline = Date.now() + LAUNCH_WAIT_MS;
132
+ while (Date.now() < deadline) {
133
+ await sleep(2000);
134
+ const detail = workerDetailBody.parse(await expectOk(callApi(paths, { method: 'GET', path: `/workers/${worker.id}` })));
135
+ const { status } = detail.worker;
136
+ if (status === 'launching')
137
+ continue;
138
+ if (status === 'failed') {
139
+ const failure = detail.events.find((e) => e.kind === 'worker.launch-failed')?.data ?? {};
140
+ throw new ConductorError(`could not launch ${worker.name}`, [
141
+ `${String(failure.step ?? 'launch')}: ${String(failure.error ?? 'see the log')}`,
142
+ ]);
143
+ }
144
+ out(`launched ${worker.name}: session ${detail.worker.handle ?? '?'}, worktree ${detail.worker.worktree ?? '?'}`);
145
+ return;
146
+ }
147
+ throw new ConductorError(`${worker.name} is still launching after ${LAUNCH_WAIT_MS / 60_000} min`, [
148
+ '`conduct status` shows it; the daemon carries on',
149
+ ]);
150
+ }
151
+ async function listProjects(paths, json) {
152
+ let projects;
153
+ try {
154
+ projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
155
+ }
156
+ catch (error) {
157
+ if (!(error instanceof NotRunningError))
158
+ throw error;
159
+ projects = withStore(paths, (store) => store.listProjects());
160
+ }
161
+ if (json)
162
+ return out(JSON.stringify(projects, null, 2));
163
+ if (projects.length === 0)
164
+ return out('no projects; add one with `conduct project add <path>`');
165
+ projects.forEach((p) => out(projectLine(p)));
166
+ }
167
+ async function main(argv) {
168
+ // `wait` has options of its own: its --version is a value, not this CLI's version.
169
+ if (argv[0] === 'wait')
170
+ return wait(argv.slice(1));
171
+ const { positionals, values } = parseArgs({
172
+ allowPositionals: true,
173
+ args: argv,
174
+ options: {
175
+ conductor: { type: 'string' },
176
+ force: { type: 'boolean' },
177
+ help: { short: 'h', type: 'boolean' },
178
+ json: { type: 'boolean' },
179
+ probe: { type: 'boolean' },
180
+ project: { type: 'string' },
181
+ prompt: { type: 'string' },
182
+ remove: { type: 'boolean' },
183
+ version: { short: 'v', type: 'boolean' },
184
+ },
185
+ });
186
+ const paths = conductorPaths();
187
+ const [command, sub, arg] = positionals;
188
+ if (values.version)
189
+ return out(version());
190
+ if (values.help || command === undefined || command === 'help')
191
+ return out(USAGE);
192
+ switch (command) {
193
+ case 'attach':
194
+ if (sub === undefined)
195
+ throw new ConductorError('usage: conduct attach <worker>');
196
+ return attach(paths, sub);
197
+ case 'install-startup':
198
+ return values.remove ? uninstall() : install();
199
+ case 'launch':
200
+ if (sub === undefined)
201
+ throw new ConductorError('usage: conduct launch <item> [--project <name>] [--probe]');
202
+ return launch(paths, sub, {
203
+ conductor: values.conductor,
204
+ probe: values.probe,
205
+ project: values.project,
206
+ prompt: values.prompt,
207
+ });
208
+ case 'project':
209
+ if (sub === 'add' && arg !== undefined)
210
+ return addProject(paths, arg);
211
+ if (sub === 'list')
212
+ return listProjects(paths, values.json === true);
213
+ throw new ConductorError('usage: conduct project add <path> | conduct project list');
214
+ case 'retire':
215
+ if (sub === undefined)
216
+ throw new ConductorError('usage: conduct retire <worker> [--force]');
217
+ return retire(paths, sub, values.force === true);
218
+ case 'serve':
219
+ return serve(paths);
220
+ case 'status':
221
+ return status(paths, values.json === true);
222
+ case 'stop':
223
+ return stop(paths);
224
+ default:
225
+ throw new ConductorError(`unknown command "${command}"`, ['run `conduct help` for the commands']);
226
+ }
227
+ }
228
+ function out(line) {
229
+ process.stdout.write(`${line}\n`);
230
+ }
231
+ function projectLine(p) {
232
+ const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
233
+ return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
234
+ }
235
+ async function retire(paths, ref, force) {
236
+ const worker = await findWorker(paths, ref);
237
+ const { body, status } = await callApi(paths, {
238
+ method: 'DELETE',
239
+ path: `/workers/${worker.id}${force ? '?force=true' : ''}`,
240
+ timeoutMs: RETIRE_TIMEOUT_MS,
241
+ });
242
+ const parsed = retireBody.safeParse(body);
243
+ if (!parsed.success) {
244
+ await expectOk(Promise.resolve({ body, status })); // a plain error answer
245
+ throw new ConductorError(`the API answered ${status} with an unexpected body`);
246
+ }
247
+ const result = parsed.data;
248
+ const steps = (result.steps ?? []).map((s) => `${s.step}: ${s.ok ? 'ok' : `failed (${s.error ?? '?'})`}`);
249
+ if (result.outcome === 'refused') {
250
+ const work = [
251
+ ...(result.work?.unpushed ? [`${result.work.unpushed} unpushed commit(s)`] : []),
252
+ ...(result.work?.dirty ? [`uncommitted: ${result.work.dirty.join(', ')}`] : []),
253
+ ...(result.work?.unknown ? [`not checked: ${result.work.unknown}`] : []),
254
+ ];
255
+ throw new ConductorError(`refused to retire ${worker.name}: ${result.reason ?? ''}`, work);
256
+ }
257
+ if (result.outcome === 'partial')
258
+ throw new ConductorError(`retired ${worker.name} only in part`, steps);
259
+ out(`retired ${worker.name}`);
260
+ steps.forEach((s) => out(` ${s}`));
261
+ }
262
+ async function serve(paths) {
263
+ const daemon = await startDaemon(paths, { version: version() });
264
+ out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
265
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
266
+ process.once(signal, () => void daemon.stop(signal));
267
+ }
268
+ // Under the logon task nobody sees stderr, so a crash goes to the log. The pid file it leaves
269
+ // names a dead process, which the next start replaces.
270
+ process.on('uncaughtException', (error) => {
271
+ daemon.log.log('error', 'uncaught exception', { error: error.stack ?? String(error) });
272
+ process.exit(1);
273
+ });
274
+ await daemon.closed;
275
+ }
276
+ function sleep(ms) {
277
+ return new Promise((done) => setTimeout(done, ms));
278
+ }
279
+ async function status(paths, json) {
280
+ let projects;
281
+ try {
282
+ projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
283
+ }
284
+ catch (error) {
285
+ if (!(json && error instanceof NotRunningError))
286
+ throw error;
287
+ // Scripts and the conductor mod read the answer, so "not running" is JSON too (exit 1).
288
+ out(JSON.stringify({ home: paths.home, running: false }, null, 2));
289
+ process.exitCode = 1;
290
+ return;
291
+ }
292
+ const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
293
+ const pid = readPid(paths.pid);
294
+ const port = readEndpoint(paths)?.port ?? null;
295
+ if (json)
296
+ return out(JSON.stringify({ home: paths.home, pid, port, projects, running: true, workers }, null, 2));
297
+ out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
298
+ out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
299
+ projects.forEach((p) => out(projectLine(p)));
300
+ out(workers.length === 0 ? 'no workers' : 'workers:');
301
+ workers.forEach((w) => out(workerLine(w)));
302
+ }
303
+ async function stop(paths) {
304
+ const pid = readPid(paths.pid);
305
+ try {
306
+ await expectOk(callApi(paths, { method: 'POST', path: '/shutdown' }));
307
+ }
308
+ catch (error) {
309
+ if (!(error instanceof NotRunningError))
310
+ throw error;
311
+ out('the conductor is not running');
312
+ return;
313
+ }
314
+ const deadline = Date.now() + STOP_WAIT_MS;
315
+ while (readEndpoint(paths) !== null && Date.now() < deadline)
316
+ await sleep(100);
317
+ if (readEndpoint(paths) !== null) {
318
+ throw new ConductorError(`the conductor (pid ${pid ?? '?'}) did not stop within ${STOP_WAIT_MS / 1000} s`);
319
+ }
320
+ out(`stopped the conductor (pid ${pid ?? '?'})`);
321
+ }
322
+ function uninstall() {
323
+ out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
324
+ }
325
+ function version() {
326
+ return z.object({ version: z.string() }).parse(createRequire(import.meta.url)('../package.json')).version;
327
+ }
328
+ async function wait(argv) {
329
+ const { positionals, values } = parseArgs({
330
+ allowPositionals: true,
331
+ args: argv,
332
+ options: {
333
+ commit: { type: 'string' },
334
+ item: { type: 'string' },
335
+ local: { type: 'boolean' },
336
+ package: { type: 'string' },
337
+ repo: { type: 'string' },
338
+ tag: { type: 'string' },
339
+ timeout: { type: 'string' },
340
+ version: { type: 'string' },
341
+ worker: { type: 'string' },
342
+ },
343
+ });
344
+ const timeout = values.timeout === undefined ? undefined : Number(values.timeout);
345
+ if (timeout !== undefined && !(timeout > 0))
346
+ throw new ConductorError('--timeout takes minutes, such as --timeout 25');
347
+ if (positionals.length === 0)
348
+ throw new ConductorError('usage: conduct wait <condition>', ['run `conduct help` for the conditions']);
349
+ process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
350
+ }
351
+ function withStore(paths, use) {
352
+ mkdirSync(paths.home, { recursive: true });
353
+ const store = openStore(paths.db);
354
+ try {
355
+ return use(store);
356
+ }
357
+ finally {
358
+ store.close();
359
+ }
360
+ }
361
+ // One line per worker: name, project, status and session activity, its waits, then spend.
362
+ function workerLine(w) {
363
+ const activity = w.status === 'running' ? `running, ${w.activity}` : w.status;
364
+ const waits = w.waits.map((x) => (x.resolvedAt ? `${x.condition} (resolved)` : x.condition));
365
+ return [
366
+ ` ${w.name} ${w.project} ${activity}`,
367
+ ...(waits.length > 0 ? [`waits: ${waits.join(', ')}`] : []),
368
+ `spend ${formatTokens(w.spend.today)} today, ${formatTokens(w.spend.total)} total`,
369
+ ].join(' · ');
370
+ }
371
+ main(process.argv.slice(2)).catch((error) => {
372
+ if (error instanceof ConductorError) {
373
+ process.stderr.write(`conduct: ${error.message}\n${error.details.map((d) => ` ${d}\n`).join('')}`);
374
+ }
375
+ else if (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS')) {
376
+ process.stderr.write(`conduct: ${error.message}\n${USAGE}\n`);
377
+ }
378
+ else {
379
+ process.stderr.write(`conduct: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
380
+ }
381
+ process.exitCode = 1;
382
+ });
383
+ //# sourceMappingURL=cli.js.map