@detergent-software/conductor 1.0.0-dev.13 → 1.0.0-dev.15
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.
- package/CHANGELOG.md +18 -0
- package/README.md +36 -10
- package/build/cli.js +135 -26
- package/build/cli.js.map +1 -1
- package/build/config.js +3 -3
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/daemon.js +26 -0
- package/build/daemon.js.map +1 -1
- package/build/inbox.js +11 -11
- package/build/inbox.js.map +1 -1
- package/build/rules.js +6 -0
- package/build/rules.js.map +1 -1
- package/build/scheduler.js +2 -2
- package/build/scheduler.js.map +1 -1
- package/build/server.js +29 -0
- package/build/server.js.map +1 -1
- package/build/store.js +24 -2
- package/build/store.js.map +1 -1
- package/build/upgrade.js +16 -14
- package/build/upgrade.js.map +1 -1
- package/mod/hooks/model.ts +4 -1
- package/mod/hooks/register.tsx +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
+
## [1.0.0-dev.15](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.14&targetVersion=GTconductor-v1.0.0-dev.15) (2026-10-11)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **conductor:** console escalations, conduct start and conduct console (ATK-314) ([8e394dd](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/8e394ddb3a6ef2b7b7cafa905d49d6beb50d58aa))
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **conductor:** console tab arguments in one tested place (ATK-314) ([58cb8f3](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/58cb8f348cace168644400623e8cd4ea7ab12fce))
|
|
14
|
+
* **conductor:** review fixes for the console commands (ATK-314) ([94cc9e1](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/94cc9e1b7269b73ce3b7eca2ea4890815343c384))
|
|
15
|
+
|
|
16
|
+
## [1.0.0-dev.14](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.13&targetVersion=GTconductor-v1.0.0-dev.14) (2026-10-11)
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **conductor:** read an unclean exit from the event log alone (ATK-353) ([44ca320](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/44ca3204a2947fa6df06d2a957565d38c7751d3c))
|
|
21
|
+
* **conductor:** record an unclean daemon exit on the next start (ATK-353) ([b466dd9](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/b466dd98e8876ae08cc5f298acf590dd4486ce4c))
|
|
22
|
+
|
|
5
23
|
## [1.0.0-dev.13](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.12&targetVersion=GTconductor-v1.0.0-dev.13) (2026-10-11)
|
|
6
24
|
|
|
7
25
|
### Features
|
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Conductor
|
|
2
2
|
|
|
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), step 3, the watchers and `conduct wait` (ATK-310), the daemon's side of step 4, worker protocol v1's `conduct note` (ATK-311), step 5, the inbox with `conduct approve` / `revise` (ATK-312), step 6, the notifier (ATK-313), step 9, the conductor mod for Claude Code in `mod/` (ATK-265),
|
|
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), step 3, the watchers and `conduct wait` (ATK-310), the daemon's side of step 4, worker protocol v1's `conduct note` (ATK-311), step 5, the inbox with `conduct approve` / `revise` (ATK-312), step 6, the notifier (ATK-313), step 9, the conductor mod for Claude Code in `mod/` (ATK-265), step 8, the scheduler, settings and rules (ATK-315), and the daemon's side of step 7, the console (ATK-314).
|
|
4
4
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
8
|
conduct serve run the daemon in this process (one per machine)
|
|
9
|
+
conduct start start the daemon detached and wait until it answers (see Console)
|
|
9
10
|
conduct stop stop the running daemon
|
|
10
11
|
conduct status [--json] the daemon's pid, port, projects and workers
|
|
11
12
|
conduct project add <path> register the project whose checkout holds .claude/conductor.json
|
|
@@ -32,6 +33,11 @@ conduct focus <project> [--slots <n>] [--weight <n>] --until <when>
|
|
|
32
33
|
a project's slots or ordering weight, for a while (neither: it goes first)
|
|
33
34
|
conduct why <event id | item> [--json]
|
|
34
35
|
the rule behind an action, and the events that led to it
|
|
36
|
+
conduct console [--project <name>] [--here]
|
|
37
|
+
open the console in a Windows Terminal tab, or in this terminal (see Console)
|
|
38
|
+
conduct escalations [--json] what rules escalated to the console and it hasn't handled
|
|
39
|
+
conduct escalations done <id> [--ask] <text>
|
|
40
|
+
the console's finding on an escalation; --ask puts it in the inbox
|
|
35
41
|
conduct history [<range>] [--since <when>] [--until <when>] [--project <name>] [--json]
|
|
36
42
|
what a window of the log says: shipped, phases, waits, stalls, spend (see History)
|
|
37
43
|
conduct retro [<range>] [--project <name>]
|
|
@@ -103,8 +109,10 @@ An item resolves itself when its condition is seen, and records what resolved it
|
|
|
103
109
|
| `permission`, `stall` | `worker.stalled` | `worker.stall-cleared`, a park or resume, a new wait (stall only), or a daemon restart (it is raised again if it persists) |
|
|
104
110
|
| `failed` | a failed launch, or a session that stopped unexpectedly | a new launch, the worker running again, or its retirement |
|
|
105
111
|
| `runaway` | a worker passing `inbox.runawayTokens` (2M) input, output and cache-write tokens in all, once per worker | `conduct done`, or its retirement |
|
|
112
|
+
| `ready-to-release`, `queue-drained` | the scheduler's default rules (see Scheduler) | `conduct done` |
|
|
113
|
+
| `console` | the console's finding on an escalation, with `conduct escalations done <id> --ask` (see Console) | `conduct done` |
|
|
106
114
|
|
|
107
|
-
Nothing raises `
|
|
115
|
+
Nothing raises `prod-approval` yet; `/release-atk` will. When a worker is retired, its open items resolve with it.
|
|
108
116
|
|
|
109
117
|
For an item raised from a note:
|
|
110
118
|
|
|
@@ -170,7 +178,7 @@ Rules say what the conductor does when something happens (design note, section 9
|
|
|
170
178
|
]
|
|
171
179
|
```
|
|
172
180
|
|
|
173
|
-
- **Sources, in order:** the item's project's `rules` (in its `.claude/conductor.json`, changed by PR), then `~/.conductor/rules.json`, then the built-in defaults: conduct-atk's behavior written as rules (`launch`, `retire-closed`, `park-on-wait`, `resume-on-wait`, `runaway`, `daily-cap`, `ready-to-release`, `queue-drained`). A rule with an `id` replaces the rule with the same id further down, so a file holds only the differences.
|
|
181
|
+
- **Sources, in order:** the item's project's `rules` (in its `.claude/conductor.json`, changed by PR), then `~/.conductor/rules.json`, then the built-in defaults: conduct-atk's behavior written as rules (`launch`, `retire-closed`, `park-on-wait`, `resume-on-wait`, `runaway`, `daily-cap`, `ready-to-release`, `queue-drained`, and `conductor-handoff`, which escalates a worker's `Handoff · conductor` note to the console). A rule with an `id` replaces the rule with the same id further down, so a file holds only the differences.
|
|
174
182
|
- **when:** `and`, `or`, `not` and parentheses over comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`, `in [a, b]`, `<label> in item.labels`) and events. An event rule fires when one of its events happens: `note`, `plan`, `wait.resolved`, `run.failed`, `worker.idle`, `worker.exited`, `worker.permission`, `worker.waiting`, `inbox.raised`, `pr.comment`, `item.unblocked`, `item.closed`, `queue.pick`, `queue.drained`, `queue.ready-to-release`. A rule without one is a state rule, checked on each scheduler tick against each wait, inbox item, worker or project, or once for the machine, by the fields it reads. Fields: `project`, `item.id/area/labels/tier/priority`, `plan.tier`, `note.phase/kind`, `wait.kind/age`, `inbox.kind/age`, `worker.retries/phase`, `spend.worker/item/project/today`, `time`, `day`, `notify.profile`. Values carry units: `15m`, `2h`, `500k`, `2M`, `22:00`, `saturday`.
|
|
175
183
|
- **do:** `launch`, `retire`, `park`, `resume worker`, `retry`, `pause launches [in <project>] [until <when> | for <duration>]`, `pause all launches …`, `unpause launches`, `focus [<project>] weight|slots <n> until <when>`, `auto-approve after <duration>`, `inbox <kind> ["<text>"]`, `remind jason`, `notify <channel> ["<text>"]`, `notify profile <name> [until <when>]`, `label add|remove human|triage|shipped-open`, `flag shipped-open`, `escalate console "<ask>"`, `none`. One string, or a list.
|
|
176
184
|
- **Guardrails:** rules act inside Area locks, slots, quiet hours and config.json's limits. A rule that would go past one is refused when it loads: `focus … slots` over `scheduler.slots`, an override with no end or one over 14 days, `retire` on anything but `item.closed`, `launch` outside `queue.pick` / `item.unblocked`, `park` outside `worker.waiting`, `resume` outside `wait.resolved` / `pr.comment`, `retry` outside `run.failed`. `merge`, `publish`, `approve`, `delete` and `run` aren't in the vocabulary.
|
|
@@ -192,7 +200,7 @@ What each action does:
|
|
|
192
200
|
- `inbox <kind>` raises an item, once while it is open. `inbox runaway` is raised once per worker.
|
|
193
201
|
- `pause` / `unpause` / `focus` set overrides that name the rule.
|
|
194
202
|
- `label add|remove` edits one label on the item (the profile's human label for `human`), at most 20 times a day per item.
|
|
195
|
-
- `escalate console` emits `console.escalated`, which wakes the console's model.
|
|
203
|
+
- `escalate console` emits `console.escalated`, which wakes the console's model; the console reports back with `conduct escalations done` (see Console).
|
|
196
204
|
- `retry` launches a failed launch again, or wakes a session that stopped unexpectedly with the fact. It does this once per item, whatever the rule says: a second failure stays in the inbox. `worker.retries` counts the earlier tries.
|
|
197
205
|
- `auto-approve after N` pushes "auto-approves at HH:MM" where plans go. At the deadline, if the plan still waits (no `revise:`, no newer Plan), it posts `approve: auto-approved by the conductor: rule "<id>", no revise by HH:MM` as the key's owner, which the worker's approval wait reads. No default rule auto-approves. A Plan note's `**Size tier: S**` gives `plan.tier` (and `item.tier` later).
|
|
198
206
|
- `launch` on `item.unblocked` brings the scheduler's next tick forward.
|
|
@@ -214,8 +222,8 @@ The scheduler moves conduct-atk's tick into the daemon (design note, section 8).
|
|
|
214
222
|
3. **Act.** It runs the rules on what it found: `queue.pick` → `launch` (default `launch`), `item.closed` → `retire` (default `retire-closed`; a refused retire raises an inbox item once), `queue.drained` and `queue.ready-to-release` → an inbox item once a day. Each action is a `rule.fired` event, then `rule.done` or `rule.failed`. A launch that failed isn't tried again while its inbox item is open.
|
|
215
223
|
|
|
216
224
|
`scheduler.mode` decides how far it goes:
|
|
217
|
-
- **`
|
|
218
|
-
- **`
|
|
225
|
+
- **`on`** (the default): it acts.
|
|
226
|
+
- **`observe`:** it reads, plans and logs each action once, and does none of them. Set it on a machine where conduct-atk's loop still launches.
|
|
219
227
|
- **`off`:** it doesn't run; `conduct queue --fresh` still works out a plan on request.
|
|
220
228
|
|
|
221
229
|
`conduct queue` shows the last plan (`--fresh` reads again): slots, holds, picks with their Area, and why each other item waits. A change of plan is a `scheduler.planned` event. A project whose read fails picks nothing that tick and logs `scheduler.read-failed` once, then `scheduler.read-recovered`. An item whose last open blocker closed is an `item.unblocked` event.
|
|
@@ -279,7 +287,7 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
279
287
|
|
|
280
288
|
| File | Holds |
|
|
281
289
|
|---|---|
|
|
282
|
-
| `config.json` | machine settings, JSONC (comments allowed), validated at startup; `inbox.runawayTokens` (2000000, the `runaway` rule's number); `notify` and `quietHours` (see Notifications); `scheduler` holds `mode` (`
|
|
290
|
+
| `config.json` | machine settings, JSONC (comments allowed), validated at startup; `inbox.runawayTokens` (2000000, the `runaway` rule's number); `notify` and `quietHours` (see Notifications); `scheduler` holds `mode` (`on`, `observe` or `off`), `slots` (4, across projects, and the most any rule can give one project), `dailyTokens` (20000000, the `daily-cap` rule's number) and `tickSeconds` (120); `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) |
|
|
283
291
|
| `rules.json` | machine rules, JSONC, reloaded on save (see Rules) |
|
|
284
292
|
| `token` | the API token, readable by this user only; every request sends `Authorization: Bearer <token>` |
|
|
285
293
|
| `conductor.pid`, `port` | the single-instance lock and where the API listens (`127.0.0.1`) |
|
|
@@ -291,6 +299,8 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
|
|
|
291
299
|
- `GET /projects`: the registered projects. `POST /projects` with `{ "path": "<absolute path>" }` registers one.
|
|
292
300
|
- `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. `conduct events` prints it one JSON event per line: the events after `--since` (all of them without it), or with `--follow` everything from there on (live without `--since`). `--follow` reconnects with `Last-Event-ID` when the daemon goes away, so a restart loses nothing, and prints the stream's state as lines without an id: `{"kind":"conduct.connected"}` and, once per outage, `{"kind":"conduct.disconnected","error":…}`.
|
|
293
301
|
- `POST /shutdown`: a clean stop (`conduct stop`).
|
|
302
|
+
- `GET /console/escalations`: the open escalations, oldest first (see Console).
|
|
303
|
+
- `POST /console/escalations/:id` with `{ "text": "<finding>", "ask"?: true }`: the console's report on one; 201 with the escalation, the `console.handled` event's id and the inbox item `ask` raised. 404 when the id isn't an escalation, 409 when it is handled already or older than 14 days.
|
|
294
304
|
- `GET /fleet`: the workers in flight, with session activity, waits and spend (today, total).
|
|
295
305
|
- `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.
|
|
296
306
|
- `DELETE /workers/:id` (`?force=true`): retire; 409 with the unfinished work when it refuses.
|
|
@@ -346,12 +356,28 @@ conduct install-startup
|
|
|
346
356
|
|
|
347
357
|
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`.
|
|
348
358
|
|
|
359
|
+
## Console
|
|
360
|
+
|
|
361
|
+
The console (design note, section 11) is the one Claude session that stays open beside the daemon. The conductor mod in it does the plumbing in code (see Mod). Its model wakes only for an escalation or for you, and runs the project's console skill (`/conductor-console`, installed by ATK). It has no loop: no Monitor, no heartbeat, no ticks. Everything it knows comes from the daemon, so a new console after a close loses nothing.
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
conduct console # a tab in Windows Terminal (window "conductor"), in the project's main checkout
|
|
365
|
+
conduct console --here # the same session in this terminal
|
|
366
|
+
conduct console --project cadence # with several projects registered: whose checkout it runs in
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
It runs `claude -n conductor --permission-mode auto -- /conductor-console` with `CONDUCTOR_CONSOLE=1`. The skill starts the daemon if it isn't running (`conduct start`, detached, so it doesn't end with the session), then handles what waits.
|
|
370
|
+
|
|
371
|
+
**Escalations.** A rule's `escalate console "<ask>"` appends `console.escalated` (the default `conductor-handoff` does it for a worker's `Handoff · conductor` note). The mod wakes the model with `[conductor] escalation #<id> on <item>: <ask>`. The console does the job and reports back with `conduct escalations done <id> "<what it found or did>"`, which logs `console.handled` with the escalation as its cause, so `conduct why <that event>` leads to the rule. With `--ask`, the finding also goes in the inbox as a `console` item, pushed like any other, for something only you can do. `conduct escalations` lists those not yet handled, from the last 14 days, including the ones raised while no console was open; the console reads it when it starts.
|
|
372
|
+
|
|
373
|
+
**Moving from conduct-atk.** Stop the conduct-atk session, `conduct upgrade` (the scheduler now launches by default), `atk install` in the main checkout, then `conduct console`. A machine that keeps conduct-atk's loop sets `"scheduler": { "mode": "observe" }` in `config.json`.
|
|
374
|
+
|
|
349
375
|
## Mod
|
|
350
376
|
|
|
351
377
|
`mod/` is the conductor mod: a Claude Code plugin (a hooks module, code that costs no tokens) that does the console's plumbing. `conduct install-mod` installs it for this user from the package's own `mod/` folder, as a folder marketplace (`claude plugin install conductor --marketplace <package>/mod --scope user`). Claude Code reads a folder marketplace's plugin in place, so `conduct upgrade` replaces the mod along with the daemon; open sessions load the new version with `/reload-plugins`. `--remove` uninstalls it.
|
|
352
378
|
|
|
353
379
|
- **Every session** shows a status line, `conductor 3▶ 2⏸ ✉2` (running, parked, inbox), from `GET /fleet` and `GET /inbox` every 30 s. It clears while no daemon answers.
|
|
354
|
-
- **The console**, the session started with `CONDUCTOR_CONSOLE=1` (
|
|
380
|
+
- **The console**, the session started with `CONDUCTOR_CONSOLE=1` (`conduct console` starts it; see Console), also gets:
|
|
355
381
|
- **the band** above the prompt while the inbox has items: one hotkey per item (a plan approves after a second press, `y`, since a digit typed into an empty prompt presses a band button; an npm OTP copies its `npm stage approve` line; the rest open the pane), and `o` for the pane;
|
|
356
382
|
- **the pane** (`/conductor`, `o`, or opened at the start, docked as a sidebar on a fullscreen terminal of 144 columns or more): the notification profile (a picker; buttons on mobile) and Remote Control's state, the inbox with each item's actions (approve, revise, copy a command, open a link, done), the workers by project (each project's rule has [pause] and [focus]; a row expands to attach and why), and today's spend;
|
|
357
383
|
- **commands answered in code**, never by the model: `/conductor`, `/inbox`, `/approve <item | #id>`, `/revise <item | #id> <text>`, `/attach <item | worker>`, `/notify [<profile> [--until <when>]]`, `/pause [<project>] [--until <when>]`, `/unpause [<project>]`, `/focus-project <project> [--until <when>]` (puts its items first, until midnight without `--until`; not `/focus`, which is Claude Code's own);
|
|
@@ -360,10 +386,10 @@ To switch from a linked build to the package: `conduct stop`, `npm rm --global @
|
|
|
360
386
|
| Event | Data | The mod |
|
|
361
387
|
|---|---|---|
|
|
362
388
|
| `notify.requested` | `channel: "remote-control"`, `message` | pushes it to the phone (PushNotification), with no model turn, and reports every outcome to `POST /notify/report` (see Notifications). When the push comes back "Remote Control inactive" (PushNotification's `disabledReason` `no_transport`), mobile pushes are off in Claude Code's config (`config_off`), or the call fails, it toasts once, saying which; it says so again only after a push has reached the phone. A push Claude Code holds back while you are at the terminal (`user_present`) is reported as `skipped`, with no toast |
|
|
363
|
-
| `console.escalated` | `text` | wakes the console's model once with `[conductor] escalation on <item>: <text>`; nothing else wakes it |
|
|
389
|
+
| `console.escalated` | `text` | wakes the console's model once with `[conductor] escalation #<event id> on <item>: <text>`; nothing else wakes it |
|
|
364
390
|
| `worker.message-requested` | `to`, `message` | sends the message to that worker's session |
|
|
365
391
|
|
|
366
|
-
The notifier emits `notify.requested` (see Notifications)
|
|
392
|
+
The notifier emits `notify.requested` (see Notifications), and the rules' `escalate console` action emits `console.escalated`. Nothing emits `worker.message-requested` yet.
|
|
367
393
|
|
|
368
394
|
Its checks: `pnpm -C apps/conductor test` runs `mod/tests/model.spec.ts` (the logic, in CI). `claude plugin validate apps/conductor/mod` and `claude plugin test apps/conductor/mod` (the hooks against the engine, UI on the terminal, desktop, VS Code and mobile surfaces) need Claude Code, so they run locally. To try a change before installing it: `claude --plugin-dir apps/conductor/mod`.
|
|
369
395
|
|
package/build/cli.js
CHANGED
|
@@ -7,7 +7,8 @@ import { parseArgs } from 'node:util';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
|
|
9
9
|
import { loadConfig } from "./config.js";
|
|
10
|
-
import {
|
|
10
|
+
import { CONSOLE_ARGS, CONSOLE_WINDOW, consoleProject, consoleTabArgs, escalationLines, escalationSchema, handledSchema } from "./console.js";
|
|
11
|
+
import { logCrashes, startDaemon } from "./daemon.js";
|
|
11
12
|
import { ConductorError } from "./errors.js";
|
|
12
13
|
import { eventsCommand } from "./events.js";
|
|
13
14
|
import { flagWindow, historyLines, historyReport, historySchema, parseRange, retroMarkdown } from "./history.js";
|
|
@@ -28,12 +29,14 @@ import { queueLines, queueViewSchema } from "./scheduler.js";
|
|
|
28
29
|
import { formatTokens } from "./spend.js";
|
|
29
30
|
import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
|
|
30
31
|
import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
|
|
31
|
-
import { parseTag, upgrade } from "./upgrade.js";
|
|
32
|
+
import { parseTag, restartDaemon, upgrade } from "./upgrade.js";
|
|
32
33
|
import { waitCommand } from "./wait.js";
|
|
33
34
|
import { whyLines, whySchema } from "./why.js";
|
|
34
35
|
const USAGE = `Usage: conduct <command>
|
|
35
36
|
|
|
36
37
|
serve run the daemon in this process (one per machine)
|
|
38
|
+
start start the daemon detached (the logon task, else a detached serve) and
|
|
39
|
+
wait until it answers; nothing when it runs already
|
|
37
40
|
stop stop the running daemon
|
|
38
41
|
status [--json] the daemon's pid, port, projects and workers
|
|
39
42
|
project add <path> register the project whose checkout holds .claude/conductor.json
|
|
@@ -65,6 +68,14 @@ const USAGE = `Usage: conduct <command>
|
|
|
65
68
|
its items go first (weight 100)
|
|
66
69
|
why <event id | item> [--json]
|
|
67
70
|
the rule behind an action, and the events that led to it
|
|
71
|
+
console [--project <name>] [--here]
|
|
72
|
+
open the console (the Claude session the conductor mod serves) in a
|
|
73
|
+
Windows Terminal tab in the project's main checkout; --here runs it in
|
|
74
|
+
this terminal. Also how to get it back after it closed
|
|
75
|
+
escalations [--json] what rules escalated to the console and it hasn't handled (14 days)
|
|
76
|
+
escalations done <id> [--ask] <text>
|
|
77
|
+
the console's finding on escalation #<id>, logged; --ask also puts it
|
|
78
|
+
in the inbox for you (a "console" item)
|
|
68
79
|
history [<range>] [--since <when>] [--until <when>] [--project <name>] [--json]
|
|
69
80
|
what a window of the log says: shipped items, time in each phase by tier,
|
|
70
81
|
plan latency, waits by kind, stalls, failures, inbox, slot use, rules, spend
|
|
@@ -207,6 +218,70 @@ async function awaitDaemon(paths) {
|
|
|
207
218
|
}
|
|
208
219
|
throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
|
|
209
220
|
}
|
|
221
|
+
// `conduct console`: the console (design note, section 11), the Claude session the conductor mod
|
|
222
|
+
// serves, started with CONDUCTOR_CONSOLE=1 in the project's main checkout, where the console skill
|
|
223
|
+
// is installed. It keeps no state of its own, so a new one after a close loses nothing.
|
|
224
|
+
async function consoleCommand(paths, options) {
|
|
225
|
+
const project = consoleProject(await projectsNow(paths), options.project);
|
|
226
|
+
if (options.here) {
|
|
227
|
+
const child = spawn('claude', CONSOLE_ARGS, { cwd: project.root, env: { ...process.env, CONDUCTOR_CONSOLE: '1' }, stdio: 'inherit' });
|
|
228
|
+
// Ctrl+C belongs to the session; this process only waits for it to end.
|
|
229
|
+
process.on('SIGINT', () => { });
|
|
230
|
+
process.exitCode = await new Promise((done, fail) => {
|
|
231
|
+
child.on('error', (error) => fail(new ConductorError('could not start claude', [error.message])));
|
|
232
|
+
child.on('exit', (code) => done(code ?? 1));
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (process.platform !== 'win32') {
|
|
237
|
+
throw new ConductorError('conduct console opens a Windows Terminal tab', ['run `conduct console --here` in a terminal of your own']);
|
|
238
|
+
}
|
|
239
|
+
// A tab starts with Windows Terminal's environment, not this one's.
|
|
240
|
+
if (process.env.CONDUCTOR_HOME) {
|
|
241
|
+
throw new ConductorError('a console tab would not see CONDUCTOR_HOME', ['run `conduct console --here` in a terminal where it is set']);
|
|
242
|
+
}
|
|
243
|
+
const args = consoleTabArgs(project, process.execPath, realpathSync(fileURLToPath(import.meta.url)));
|
|
244
|
+
const result = await run('wt.exe', args, { cwd: project.root, timeoutMs: 60_000 });
|
|
245
|
+
if (result.code !== 0)
|
|
246
|
+
throw new ConductorError('could not open the console tab', [outputOf(result, 500), 'run `conduct console --here` in a terminal instead']);
|
|
247
|
+
out(`opened the console in ${project.root} (Windows Terminal window "${CONSOLE_WINDOW}")`);
|
|
248
|
+
}
|
|
249
|
+
// How `conduct start` and upgrade's restart start the daemon detached from this process.
|
|
250
|
+
function daemonStarter(paths, cliPath) {
|
|
251
|
+
return {
|
|
252
|
+
awaitDaemon: () => awaitDaemon(paths),
|
|
253
|
+
runTask: runStartup,
|
|
254
|
+
// A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
|
|
255
|
+
// caller's directory, which it would hold open (a worktree it may later remove, say).
|
|
256
|
+
serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
|
|
257
|
+
.on('error', () => { })
|
|
258
|
+
.unref(),
|
|
259
|
+
// The logon task's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
|
|
260
|
+
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// `conduct escalations done`: the console reports back on an escalation.
|
|
264
|
+
async function escalationDone(paths, ref, finding) {
|
|
265
|
+
const id = /^#?(\d+)$/.exec(ref)?.[1];
|
|
266
|
+
if (id === undefined)
|
|
267
|
+
throw new ConductorError(`"${ref}" is not an escalation id`, ['`conduct escalations` lists them as #<id>']);
|
|
268
|
+
const answer = await callApi(paths, { body: finding, method: 'POST', path: `/console/escalations/${id}` });
|
|
269
|
+
if (answer.status === 404 && !errorBody.safeParse(answer.body).data?.details)
|
|
270
|
+
throw olderDaemon('no console escalations');
|
|
271
|
+
const { escalation, event, inbox: raised } = handledSchema.parse(await expectOk(Promise.resolve(answer)));
|
|
272
|
+
out(`handled escalation #${escalation.id}${escalation.item ? ` on ${escalation.item}` : ''} (event #${event})`);
|
|
273
|
+
if (raised)
|
|
274
|
+
out(`and raised inbox item #${raised.id} (console) for you`);
|
|
275
|
+
}
|
|
276
|
+
async function escalations(paths, json) {
|
|
277
|
+
const answer = await callApi(paths, { method: 'GET', path: '/console/escalations' });
|
|
278
|
+
if (answer.status === 404)
|
|
279
|
+
throw olderDaemon('no console escalations');
|
|
280
|
+
const { escalations: open } = z.object({ escalations: z.array(escalationSchema) }).parse(await expectOk(Promise.resolve(answer)));
|
|
281
|
+
if (json)
|
|
282
|
+
return out(JSON.stringify(open, null, 2));
|
|
283
|
+
escalationLines(open, new Date()).forEach((line) => out(line));
|
|
284
|
+
}
|
|
210
285
|
// `conduct events`: until Ctrl+C with --follow.
|
|
211
286
|
async function events(paths, follow, since) {
|
|
212
287
|
if (since !== undefined && !/^\d+$/.test(since))
|
|
@@ -324,15 +399,7 @@ async function launch(paths, item, options) {
|
|
|
324
399
|
]);
|
|
325
400
|
}
|
|
326
401
|
async function listProjects(paths, json) {
|
|
327
|
-
|
|
328
|
-
try {
|
|
329
|
-
projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
|
|
330
|
-
}
|
|
331
|
-
catch (error) {
|
|
332
|
-
if (!(error instanceof NotRunningError))
|
|
333
|
-
throw error;
|
|
334
|
-
projects = withStore(paths, (store) => store.listProjects());
|
|
335
|
-
}
|
|
402
|
+
const projects = await projectsNow(paths);
|
|
336
403
|
if (json)
|
|
337
404
|
return out(JSON.stringify(projects, null, 2));
|
|
338
405
|
if (projects.length === 0)
|
|
@@ -352,6 +419,15 @@ async function main(argv) {
|
|
|
352
419
|
throw new ConductorError('usage: conduct revise <item> "<what to change>"');
|
|
353
420
|
return answer(conductorPaths(), ref, { action: 'revise', text });
|
|
354
421
|
}
|
|
422
|
+
// The same for a finding: free text after the id, with --ask as a word of its own anywhere.
|
|
423
|
+
if (argv[0] === 'escalations' && argv[1] === 'done') {
|
|
424
|
+
const [ref, ...rest] = argv.slice(2);
|
|
425
|
+
const ask = rest.includes('--ask');
|
|
426
|
+
const text = rest.filter((word) => word !== '--ask').join(' ').trim();
|
|
427
|
+
if (ref === undefined || !text)
|
|
428
|
+
throw new ConductorError('usage: conduct escalations done <id> [--ask] "<what you found or did>"');
|
|
429
|
+
return escalationDone(conductorPaths(), ref, { ask, text });
|
|
430
|
+
}
|
|
355
431
|
const { positionals, values } = parseArgs({
|
|
356
432
|
allowPositionals: true,
|
|
357
433
|
args: argv,
|
|
@@ -363,6 +439,7 @@ async function main(argv) {
|
|
|
363
439
|
force: { type: 'boolean' },
|
|
364
440
|
fresh: { type: 'boolean' },
|
|
365
441
|
help: { short: 'h', type: 'boolean' },
|
|
442
|
+
here: { type: 'boolean' },
|
|
366
443
|
json: { type: 'boolean' },
|
|
367
444
|
kind: { type: 'string' },
|
|
368
445
|
probe: { type: 'boolean' },
|
|
@@ -395,10 +472,16 @@ async function main(argv) {
|
|
|
395
472
|
if (sub === undefined)
|
|
396
473
|
throw new ConductorError('usage: conduct attach <worker>');
|
|
397
474
|
return attach(paths, sub);
|
|
475
|
+
case 'console':
|
|
476
|
+
return consoleCommand(paths, { here: values.here === true, project: values.project });
|
|
398
477
|
case 'done':
|
|
399
478
|
if (sub === undefined)
|
|
400
479
|
throw new ConductorError('usage: conduct done <inbox id>');
|
|
401
480
|
return answer(paths, sub, { action: 'done' });
|
|
481
|
+
case 'escalations':
|
|
482
|
+
if (sub !== undefined)
|
|
483
|
+
throw new ConductorError('usage: conduct escalations [--json] | conduct escalations done <id> [--ask] <text>');
|
|
484
|
+
return escalations(paths, values.json === true);
|
|
402
485
|
case 'events':
|
|
403
486
|
return events(paths, values.follow === true, values.since);
|
|
404
487
|
case 'history':
|
|
@@ -468,6 +551,8 @@ async function main(argv) {
|
|
|
468
551
|
return retire(paths, sub, values.force === true);
|
|
469
552
|
case 'serve':
|
|
470
553
|
return serve(paths);
|
|
554
|
+
case 'start':
|
|
555
|
+
return start(paths);
|
|
471
556
|
case 'status':
|
|
472
557
|
return status(paths, values.json === true);
|
|
473
558
|
case 'stop':
|
|
@@ -594,6 +679,17 @@ function projectLine(p) {
|
|
|
594
679
|
const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
|
|
595
680
|
return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
|
|
596
681
|
}
|
|
682
|
+
// The registered projects: from the daemon, or from the store when it isn't running.
|
|
683
|
+
async function projectsNow(paths) {
|
|
684
|
+
try {
|
|
685
|
+
return projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
if (!(error instanceof NotRunningError))
|
|
689
|
+
throw error;
|
|
690
|
+
return withStore(paths, (store) => store.listProjects());
|
|
691
|
+
}
|
|
692
|
+
}
|
|
597
693
|
async function queue(paths, json, fresh) {
|
|
598
694
|
// A fresh read asks every project's tracker, within the shared rate budget.
|
|
599
695
|
const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
|
|
@@ -679,17 +775,38 @@ async function serve(paths) {
|
|
|
679
775
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
|
|
680
776
|
process.once(signal, () => void daemon.stop(signal));
|
|
681
777
|
}
|
|
682
|
-
|
|
683
|
-
// names a dead process, which the next start replaces.
|
|
684
|
-
process.on('uncaughtException', (error) => {
|
|
685
|
-
daemon.log.log('error', 'uncaught exception', { error: error.stack ?? String(error) });
|
|
686
|
-
process.exit(1);
|
|
687
|
-
});
|
|
778
|
+
logCrashes(daemon.log);
|
|
688
779
|
await daemon.closed;
|
|
689
780
|
}
|
|
690
781
|
function sleep(ms) {
|
|
691
782
|
return new Promise((done) => setTimeout(done, ms));
|
|
692
783
|
}
|
|
784
|
+
// `conduct start`: the daemon, detached from this process, so it outlives the shell or the Claude
|
|
785
|
+
// session that started it (the console's start runs this). Nothing when one answers already.
|
|
786
|
+
async function start(paths) {
|
|
787
|
+
const pid = readPid(paths.pid);
|
|
788
|
+
if (pid !== null && (await ping(paths, pid)))
|
|
789
|
+
return out(`the conductor is running (pid ${pid})`);
|
|
790
|
+
// A daemon that is still starting (the logon task's, say) holds the lock: wait for it rather than
|
|
791
|
+
// start a second one, which would exit on that lock. A pid a reboot reused gets a start after.
|
|
792
|
+
if (pid !== null && processAlive(pid)) {
|
|
793
|
+
try {
|
|
794
|
+
return out(`the conductor is running (pid ${await awaitDaemon(paths)})`);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
// Not a daemon that answers: start one, which replaces a stale lock (or says who holds it).
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
out(`started the conductor ${await restartDaemon(daemonStarter(paths, realpathSync(fileURLToPath(import.meta.url))))}`);
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
const holder = readPid(paths.pid);
|
|
805
|
+
if (holder === null || !processAlive(holder))
|
|
806
|
+
throw error;
|
|
807
|
+
throw new ConductorError(`process ${holder} holds the conductor's lock (${paths.pid}) but doesn't answer`, ['if it is a hung conductor, stop it, then run conduct start again', `see ${paths.log}`]);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
693
810
|
async function status(paths, json) {
|
|
694
811
|
let projects;
|
|
695
812
|
try {
|
|
@@ -755,7 +872,7 @@ function untilOf(text) {
|
|
|
755
872
|
async function upgradeCommand(paths, tag, allowDowngrade) {
|
|
756
873
|
const cliPath = realpathSync(fileURLToPath(import.meta.url));
|
|
757
874
|
await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
|
|
758
|
-
|
|
875
|
+
...daemonStarter(paths, cliPath),
|
|
759
876
|
cliPath,
|
|
760
877
|
installedVersion: version,
|
|
761
878
|
npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
|
|
@@ -773,15 +890,7 @@ async function upgradeCommand(paths, tag, allowDowngrade) {
|
|
|
773
890
|
return found;
|
|
774
891
|
},
|
|
775
892
|
out,
|
|
776
|
-
runTask: runStartup,
|
|
777
|
-
// A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
|
|
778
|
-
// caller's directory, which it would hold open (a worktree it may later remove, say).
|
|
779
|
-
serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
|
|
780
|
-
.on('error', () => { })
|
|
781
|
-
.unref(),
|
|
782
893
|
stopDaemon: () => stopDaemon(paths),
|
|
783
|
-
// The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
|
|
784
|
-
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
785
894
|
});
|
|
786
895
|
}
|
|
787
896
|
// Read from disk each time: upgrade asks again after npm has replaced the package.
|