@detergent-software/conductor 1.0.0-dev.9 → 1.0.1-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.
- package/CHANGELOG.md +305 -0
- package/README.md +277 -24
- package/build/brief-cli.js +68 -0
- package/build/brief-cli.js.map +1 -0
- package/build/brief.js +211 -0
- package/build/brief.js.map +1 -0
- package/build/broker-cli.js +126 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +442 -132
- package/build/cli.js.map +1 -1
- package/build/conditions.js +15 -8
- package/build/conditions.js.map +1 -1
- package/build/config.js +26 -3
- package/build/config.js.map +1 -1
- package/build/console.js +225 -0
- package/build/console.js.map +1 -0
- package/build/daemon.js +136 -5
- package/build/daemon.js.map +1 -1
- package/build/events.js +2 -1
- package/build/events.js.map +1 -1
- package/build/help.js +448 -0
- package/build/help.js.map +1 -0
- package/build/history.js +637 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +117 -18
- package/build/inbox.js.map +1 -1
- package/build/manager.js +118 -43
- package/build/manager.js.map +1 -1
- package/build/notes.js +27 -43
- package/build/notes.js.map +1 -1
- package/build/notify.js +32 -3
- package/build/notify.js.map +1 -1
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +293 -0
- package/build/scheduler.js.map +1 -0
- package/build/serve.js +51 -0
- package/build/serve.js.map +1 -0
- package/build/server.js +244 -7
- package/build/server.js.map +1 -1
- package/build/spend.js +42 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +266 -12
- package/build/store.js.map +1 -1
- package/build/trackers.js +15 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +16 -14
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +3 -9
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +60 -52
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-brief.js +63 -0
- package/build/watchers/boards-brief.js.map +1 -0
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +271 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/conflicts.js +132 -0
- package/build/watchers/conflicts.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +21 -7
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +18 -16
- package/build/watchers/linear.js.map +1 -1
- package/build/watchers/source.js +3 -3
- package/build/watchers/source.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/README.md +2 -2
- package/mod/hooks/model.ts +135 -8
- package/mod/hooks/register.tsx +219 -19
- package/mod/types/index.d.ts +6 -0
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -5,79 +5,37 @@ import { resolve, sep } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
+
import { briefCommand } from "./brief-cli.js";
|
|
9
|
+
import { heldLocks, lockCommand, lockLine, portsCommand } from "./broker-cli.js";
|
|
8
10
|
import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
|
|
9
|
-
import {
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { claimConsole, CONSOLE_ARGS, CONSOLE_WINDOW, consoleProject, consoleTabArgs, escalationLines, escalationSchema, handledSchema } from "./console.js";
|
|
13
|
+
import { logCrashes, startDaemon } from "./daemon.js";
|
|
10
14
|
import { ConductorError } from "./errors.js";
|
|
11
15
|
import { eventsCommand } from "./events.js";
|
|
16
|
+
import { commandHelp, isCommand, listing, unknownCommand, usageError } from "./help.js";
|
|
17
|
+
import { flagWindow, historyLines, historyReport, historySchema, parseRange, retroMarkdown } from "./history.js";
|
|
12
18
|
import { inboxLines } from "./inbox.js";
|
|
19
|
+
import { parseJsonc } from "./jsonc.js";
|
|
13
20
|
import { processAlive, readPid } from "./lock.js";
|
|
14
|
-
import { fleetWorkerSchema } from "./manager.js";
|
|
21
|
+
import { fleetWorkerSchema, storedFleet } from "./manager.js";
|
|
15
22
|
import { installMod, removeMod } from "./mod.js";
|
|
16
23
|
import { noteCommand } from "./note.js";
|
|
17
24
|
import { notifyLines, notifyStatusSchema, parseUntil, testResultSchema } from "./notify.js";
|
|
25
|
+
import { overrideViewSchema, PAUSE } from "./overrides.js";
|
|
18
26
|
import { conductorPaths } from "./paths.js";
|
|
19
27
|
import { registerProject } from "./registry.js";
|
|
28
|
+
import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines } from "./rules-service.js";
|
|
29
|
+
import { resolveUntil } from "./rules.js";
|
|
20
30
|
import { outputOf, run } from "./run.js";
|
|
21
|
-
import {
|
|
31
|
+
import { queueLines, queueViewSchema } from "./scheduler.js";
|
|
32
|
+
import { awaitStart, serveDetached, ServeStartError } from "./serve.js";
|
|
33
|
+
import { formatTokens, localDay } from "./spend.js";
|
|
22
34
|
import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
|
|
23
35
|
import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
|
|
24
|
-
import { parseTag, upgrade } from "./upgrade.js";
|
|
36
|
+
import { parseTag, restartDaemon, upgrade } from "./upgrade.js";
|
|
25
37
|
import { waitCommand } from "./wait.js";
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
serve run the daemon in this process (one per machine)
|
|
29
|
-
stop stop the running daemon
|
|
30
|
-
status [--json] the daemon's pid, port, projects and workers
|
|
31
|
-
project add <path> register the project whose checkout holds .claude/conductor.json
|
|
32
|
-
project list [--json] the registered projects
|
|
33
|
-
launch <item> start a worker: its worktree, a background session, a tab
|
|
34
|
-
[--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
|
|
35
|
-
retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
|
|
36
|
-
attach <worker> wake a parked worker (no event text) and open it in a tab
|
|
37
|
-
inbox [--json] what waits on you: each open item with its commands and links
|
|
38
|
-
approve <item> [--text <text>]
|
|
39
|
-
approve a plan: post "approve" on the item as you, wake its worker;
|
|
40
|
-
<item> is ATK-312, or the inbox item's id as #12
|
|
41
|
-
revise <item> <text> ask for a revised plan: post "revise: <text>", wake its worker
|
|
42
|
-
done <id> mark an inbox item done (a physical action, or one you handled)
|
|
43
|
-
notify [--json] the notification profile, quiet hours, and each channel's state
|
|
44
|
-
notify profile [<name>] [--until <when>]
|
|
45
|
-
switch the profile now (built in: default, away, focus); --until 17:00 or
|
|
46
|
-
a date and time hands back to the one before it then
|
|
47
|
-
notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
|
|
48
|
-
push a sample item (default npm-otp) on the profile's channels now;
|
|
49
|
-
--raise puts a real item in the inbox instead (quiet hours, reminders)
|
|
50
|
-
note <item> <file> post the file as a phase note on the item ("## <work skill> · <Phase>"), and
|
|
51
|
-
record its phase on the worker (this checkout's, or --worker <worker>);
|
|
52
|
-
exit 3 when nobody posted it (no conductor, or the tracker refused it)
|
|
53
|
-
wait <condition> wait for an outside event: in a worker's worktree with the daemon
|
|
54
|
-
running, register it and end the turn (the daemon resumes the session);
|
|
55
|
-
otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
|
|
56
|
-
pr <n> merged|checks|comment [--repo <owner>/<repo>]
|
|
57
|
-
run <pipeline> <run id> [done]
|
|
58
|
-
npm <version> [--tag <dist-tag>] [--package <name>]
|
|
59
|
-
health <env> --version <x> | --commit <sha>
|
|
60
|
-
approval [<item>] an approve / revise: comment after the item's newest Plan note
|
|
61
|
-
item <item> <state> | item <item> label|note <value>
|
|
62
|
-
time <when> such as 17:00 (the next one) or 2026-10-10T09:00 (local)
|
|
63
|
-
<full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
|
|
64
|
-
[--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
|
|
65
|
-
events [--since <id>] the event log after <id> (all of it without), one JSON event per line
|
|
66
|
-
events --follow [--since <id>]
|
|
67
|
-
keep following it (live without --since), reconnecting when the
|
|
68
|
-
conductor restarts; state lines conduct.connected / conduct.disconnected
|
|
69
|
-
install-startup start the daemon at logon (Windows Task Scheduler)
|
|
70
|
-
install-startup --remove remove that logon task
|
|
71
|
-
install-mod install the conductor mod in Claude Code (user scope) from this package:
|
|
72
|
-
the status line in every session, the inbox band, pane and commands in
|
|
73
|
-
the console (a session started with CONDUCTOR_CONSOLE=1)
|
|
74
|
-
install-mod --remove uninstall it
|
|
75
|
-
upgrade [--tag dev|latest] stop the daemon, npm install the newest version on the tag (default:
|
|
76
|
-
the tag the installed version came from), start the daemon again
|
|
77
|
-
[--allow-downgrade] install the tag's version even when it is older than the installed one
|
|
78
|
-
|
|
79
|
-
A <worker> is its item (ATK-309) or its name (probe-ATK-309).
|
|
80
|
-
Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
|
|
38
|
+
import { whyLines, whySchema } from "./why.js";
|
|
81
39
|
const STOP_WAIT_MS = 10_000;
|
|
82
40
|
// A restarted daemon opens its store and binds the port; under the logon task node starts cold.
|
|
83
41
|
const START_WAIT_MS = 30_000;
|
|
@@ -127,7 +85,9 @@ async function addProject(paths, path) {
|
|
|
127
85
|
if (!(error instanceof NotRunningError))
|
|
128
86
|
throw error;
|
|
129
87
|
// No daemon: write the store directly. The daemon reads it when it starts.
|
|
130
|
-
|
|
88
|
+
// Its rules are checked against config.json here too, as the daemon would.
|
|
89
|
+
const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
|
|
90
|
+
result = withStore(paths, (store) => registerProject(store, absolute, check));
|
|
131
91
|
}
|
|
132
92
|
out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
|
|
133
93
|
}
|
|
@@ -164,25 +124,98 @@ async function attach(paths, ref) {
|
|
|
164
124
|
if (result.tab)
|
|
165
125
|
out(`no tab (${result.tab}); open it yourself: claude attach ${result.worker.handle ?? worker.name}`);
|
|
166
126
|
}
|
|
167
|
-
// The pid of the daemon once it answers /health, for upgrade's restart.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return pid;
|
|
174
|
-
|
|
127
|
+
// The pid of the daemon once it answers /health, for upgrade's restart. failure: the detached
|
|
128
|
+
// serve's spawn error, which ends the wait early.
|
|
129
|
+
async function awaitDaemon(paths, failure) {
|
|
130
|
+
return awaitStart({
|
|
131
|
+
answer: async () => {
|
|
132
|
+
const pid = readPid(paths.pid);
|
|
133
|
+
return pid !== null && (await ping(paths, pid)) ? pid : null;
|
|
134
|
+
},
|
|
135
|
+
failure,
|
|
136
|
+
log: paths.log,
|
|
137
|
+
timeoutMs: START_WAIT_MS,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// What `conduct ports` and `conduct lock` print to and run in.
|
|
141
|
+
function brokerIo() {
|
|
142
|
+
return { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out };
|
|
143
|
+
}
|
|
144
|
+
// `conduct console`: the console (design note, section 11), the Claude session the conductor mod
|
|
145
|
+
// serves, started with CONDUCTOR_CONSOLE=1 in the project's main checkout, where the console skill
|
|
146
|
+
// is installed. It keeps no state of its own, so a new one after a close loses nothing. One at a
|
|
147
|
+
// time: the daemon refuses the start while another console is open or starting (ATK-393).
|
|
148
|
+
async function consoleCommand(paths, options) {
|
|
149
|
+
const project = consoleProject(await projectsNow(paths), options.project);
|
|
150
|
+
if (options.here) {
|
|
151
|
+
await claimConsole(paths, options.force);
|
|
152
|
+
const child = spawn('claude', CONSOLE_ARGS, { cwd: project.root, env: { ...process.env, CONDUCTOR_CONSOLE: '1' }, stdio: 'inherit' });
|
|
153
|
+
// Ctrl+C belongs to the session; this process only waits for it to end.
|
|
154
|
+
process.on('SIGINT', () => { });
|
|
155
|
+
process.exitCode = await new Promise((done, fail) => {
|
|
156
|
+
// The start is claimed: a retry within the minute needs --force.
|
|
157
|
+
child.on('error', (error) => fail(new ConductorError('could not start claude', [error.message, 'try again with `conduct console --here --force`'])));
|
|
158
|
+
child.on('exit', (code) => done(code ?? 1));
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (process.platform !== 'win32') {
|
|
163
|
+
throw new ConductorError('conduct console opens a Windows Terminal tab', ['run `conduct console --here` in a terminal of your own']);
|
|
164
|
+
}
|
|
165
|
+
// A tab starts with Windows Terminal's environment, not this one's.
|
|
166
|
+
if (process.env.CONDUCTOR_HOME) {
|
|
167
|
+
throw new ConductorError('a console tab would not see CONDUCTOR_HOME', ['run `conduct console --here` in a terminal where it is set']);
|
|
175
168
|
}
|
|
176
|
-
|
|
169
|
+
const args = consoleTabArgs(project, process.execPath, realpathSync(fileURLToPath(import.meta.url)));
|
|
170
|
+
await claimConsole(paths, options.force);
|
|
171
|
+
const result = await run('wt.exe', args, { cwd: project.root, timeoutMs: 60_000 });
|
|
172
|
+
if (result.code !== 0)
|
|
173
|
+
throw new ConductorError('could not open the console tab', [outputOf(result, 500), 'run `conduct console --here --force` in a terminal instead']);
|
|
174
|
+
out(`opened the console in ${project.root} (Windows Terminal window "${CONSOLE_WINDOW}")`);
|
|
175
|
+
}
|
|
176
|
+
// How `conduct start` and upgrade's restart start the daemon detached from this process.
|
|
177
|
+
function daemonStarter(paths, cliPath) {
|
|
178
|
+
let failure = () => undefined;
|
|
179
|
+
return {
|
|
180
|
+
awaitDaemon: () => awaitDaemon(paths, failure),
|
|
181
|
+
runTask: runStartup,
|
|
182
|
+
serveDetached: () => {
|
|
183
|
+
failure = serveDetached(paths.home, process.execPath, [cliPath, 'serve']);
|
|
184
|
+
},
|
|
185
|
+
// The logon task's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
|
|
186
|
+
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// `conduct escalations done`: the console reports back on an escalation.
|
|
190
|
+
async function escalationDone(paths, ref, finding) {
|
|
191
|
+
const id = /^#?(\d+)$/.exec(ref)?.[1];
|
|
192
|
+
if (id === undefined)
|
|
193
|
+
throw new ConductorError(`"${ref}" is not an escalation id`, ['`conduct escalations` lists them as #<id>']);
|
|
194
|
+
const answer = await callApi(paths, { body: finding, method: 'POST', path: `/console/escalations/${id}` });
|
|
195
|
+
if (answer.status === 404 && !errorBody.safeParse(answer.body).data?.details)
|
|
196
|
+
throw olderDaemon('no console escalations');
|
|
197
|
+
const { escalation, event, inbox: raised } = handledSchema.parse(await expectOk(Promise.resolve(answer)));
|
|
198
|
+
out(`handled escalation #${escalation.id}${escalation.item ? ` on ${escalation.item}` : ''} (event #${event})`);
|
|
199
|
+
if (raised)
|
|
200
|
+
out(`and raised inbox item #${raised.id} (console) for you`);
|
|
201
|
+
}
|
|
202
|
+
async function escalations(paths, json) {
|
|
203
|
+
const answer = await callApi(paths, { method: 'GET', path: '/console/escalations' });
|
|
204
|
+
if (answer.status === 404)
|
|
205
|
+
throw olderDaemon('no console escalations');
|
|
206
|
+
const { escalations: open } = z.object({ escalations: z.array(escalationSchema) }).parse(await expectOk(Promise.resolve(answer)));
|
|
207
|
+
if (json)
|
|
208
|
+
return out(JSON.stringify(open, null, 2));
|
|
209
|
+
escalationLines(open, new Date()).forEach((line) => out(line));
|
|
177
210
|
}
|
|
178
211
|
// `conduct events`: until Ctrl+C with --follow.
|
|
179
|
-
async function events(paths, follow, since) {
|
|
212
|
+
async function events(paths, follow, since, isConsole) {
|
|
180
213
|
if (since !== undefined && !/^\d+$/.test(since))
|
|
181
214
|
throw new ConductorError('--since takes an event id, such as --since 120');
|
|
182
215
|
const stop = new AbortController();
|
|
183
216
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK'])
|
|
184
217
|
process.once(signal, () => stop.abort());
|
|
185
|
-
await eventsCommand(paths, { follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
|
|
218
|
+
await eventsCommand(paths, { console: isConsole, follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
|
|
186
219
|
}
|
|
187
220
|
async function expectOk(call) {
|
|
188
221
|
const { body, status } = await call;
|
|
@@ -202,6 +235,55 @@ async function findWorker(paths, ref) {
|
|
|
202
235
|
throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
|
|
203
236
|
throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
|
|
204
237
|
}
|
|
238
|
+
// `conduct help <name>`; `conduct help help` is the listing.
|
|
239
|
+
function helpOf(name) {
|
|
240
|
+
if (name === 'help')
|
|
241
|
+
return listing();
|
|
242
|
+
if (!isCommand(name))
|
|
243
|
+
throw unknownCommand(name);
|
|
244
|
+
return commandHelp(name);
|
|
245
|
+
}
|
|
246
|
+
// `conduct history` and `retro`: from the daemon, or straight from conductor.db when none runs or
|
|
247
|
+
// it is older than /history. Reading needs no daemon: the report is a query over the store.
|
|
248
|
+
async function history(paths, command, range, options) {
|
|
249
|
+
const flags = options.since !== undefined || options.until !== undefined;
|
|
250
|
+
if (flags && range !== undefined)
|
|
251
|
+
throw new ConductorError(`conduct ${command} takes a <range> or --since/--until, not both`);
|
|
252
|
+
const now = new Date();
|
|
253
|
+
const window = flags ? flagWindow(options.since, options.until, now) : parseRange(range, now);
|
|
254
|
+
const request = { project: options.project ?? null, since: window.since.toISOString(), until: window.until.toISOString() };
|
|
255
|
+
// Read-only: a daemon older than this build may be running, and a migration would strand it.
|
|
256
|
+
const local = () => {
|
|
257
|
+
try {
|
|
258
|
+
return withStore(paths, (store) => historyReport(store, request), { readOnly: true });
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (error instanceof ConductorError)
|
|
262
|
+
throw error;
|
|
263
|
+
throw new ConductorError(`could not read ${paths.db}`, [
|
|
264
|
+
error instanceof Error ? error.message : String(error),
|
|
265
|
+
'a schema older than this conduct, or a log left by a crash: start the conductor once (conduct serve, or conduct upgrade), then try again',
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
let report;
|
|
270
|
+
try {
|
|
271
|
+
const query = new URLSearchParams({ since: request.since, until: request.until, ...(options.project ? { project: options.project } : {}) });
|
|
272
|
+
const answer = await callApi(paths, { method: 'GET', path: `/history?${query.toString()}`, timeoutMs: 60_000 });
|
|
273
|
+
report = answer.status === 404 ? local() : historySchema.parse(await expectOk(Promise.resolve(answer)));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!(error instanceof NotRunningError))
|
|
277
|
+
throw error;
|
|
278
|
+
report = local();
|
|
279
|
+
}
|
|
280
|
+
if (options.json)
|
|
281
|
+
return out(JSON.stringify(report, null, 2));
|
|
282
|
+
if (command === 'retro')
|
|
283
|
+
process.stdout.write(retroMarkdown(report));
|
|
284
|
+
else
|
|
285
|
+
historyLines(report).forEach((line) => out(line));
|
|
286
|
+
}
|
|
205
287
|
async function inbox(paths, json) {
|
|
206
288
|
const items = await openItems(paths);
|
|
207
289
|
if (items === null)
|
|
@@ -227,6 +309,9 @@ function install() {
|
|
|
227
309
|
out('warning: that build is in a worktree, which goes away; run install-startup from the main checkout');
|
|
228
310
|
}
|
|
229
311
|
}
|
|
312
|
+
function isHelpFlag(word) {
|
|
313
|
+
return word === '--help' || word === '-h';
|
|
314
|
+
}
|
|
230
315
|
async function launch(paths, item, options) {
|
|
231
316
|
const { worker } = workerBody.parse(await expectOk(callApi(paths, { body: { item, ...options }, method: 'POST', path: '/workers' })));
|
|
232
317
|
out(`launching ${worker.name} in ${worker.project} (worker ${worker.id}): worktree, then session`);
|
|
@@ -251,15 +336,7 @@ async function launch(paths, item, options) {
|
|
|
251
336
|
]);
|
|
252
337
|
}
|
|
253
338
|
async function listProjects(paths, json) {
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
|
|
257
|
-
}
|
|
258
|
-
catch (error) {
|
|
259
|
-
if (!(error instanceof NotRunningError))
|
|
260
|
-
throw error;
|
|
261
|
-
projects = withStore(paths, (store) => store.listProjects());
|
|
262
|
-
}
|
|
339
|
+
const projects = await projectsNow(paths);
|
|
263
340
|
if (json)
|
|
264
341
|
return out(JSON.stringify(projects, null, 2));
|
|
265
342
|
if (projects.length === 0)
|
|
@@ -269,62 +346,108 @@ async function listProjects(paths, json) {
|
|
|
269
346
|
async function main(argv) {
|
|
270
347
|
// `wait` has options of its own: its --version is a value, not this CLI's version.
|
|
271
348
|
if (argv[0] === 'wait')
|
|
272
|
-
return wait(argv.slice(1));
|
|
349
|
+
return argv.slice(1).some(isHelpFlag) ? out(commandHelp('wait')) : wait(argv.slice(1));
|
|
273
350
|
// `revise` takes free text, which option parsing would cut at a word such as --force. Every word
|
|
274
351
|
// after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
|
|
275
352
|
if (argv[0] === 'revise') {
|
|
353
|
+
// A lone help flag as the text is a call for help: posted, it would be a revision of "--help".
|
|
354
|
+
if (isHelpFlag(argv[1]) || (argv.length === 3 && isHelpFlag(argv[2])))
|
|
355
|
+
return out(commandHelp('revise'));
|
|
276
356
|
const [ref, ...words] = argv.slice(1);
|
|
277
357
|
const text = words.join(' ').trim();
|
|
278
358
|
if (ref === undefined || ref.startsWith('-') || !text)
|
|
279
|
-
throw
|
|
359
|
+
throw usageError('revise');
|
|
280
360
|
return answer(conductorPaths(), ref, { action: 'revise', text });
|
|
281
361
|
}
|
|
282
|
-
|
|
362
|
+
// The same for a finding: free text after the id, with --ask as a word of its own anywhere.
|
|
363
|
+
if (argv[0] === 'escalations' && argv[1] === 'done') {
|
|
364
|
+
if (isHelpFlag(argv[2]) || (argv.length === 4 && isHelpFlag(argv[3])))
|
|
365
|
+
return out(commandHelp('escalations'));
|
|
366
|
+
const [ref, ...rest] = argv.slice(2);
|
|
367
|
+
const ask = rest.includes('--ask');
|
|
368
|
+
const text = rest.filter((word) => word !== '--ask').join(' ').trim();
|
|
369
|
+
if (ref === undefined || !text)
|
|
370
|
+
throw usageError('escalations');
|
|
371
|
+
return escalationDone(conductorPaths(), ref, { ask, text });
|
|
372
|
+
}
|
|
373
|
+
// The first word that names a command: an option's value (--project atk) comes before it at times.
|
|
374
|
+
const { positionals, values } = parsing(argv.find((word) => isCommand(word)), () => parseArgs({
|
|
283
375
|
allowPositionals: true,
|
|
284
376
|
args: argv,
|
|
285
377
|
options: {
|
|
286
378
|
'allow-downgrade': { type: 'boolean' },
|
|
287
379
|
channel: { type: 'string' },
|
|
288
380
|
conductor: { type: 'string' },
|
|
381
|
+
console: { type: 'boolean' },
|
|
289
382
|
follow: { type: 'boolean' },
|
|
290
383
|
force: { type: 'boolean' },
|
|
384
|
+
fresh: { type: 'boolean' },
|
|
291
385
|
help: { short: 'h', type: 'boolean' },
|
|
386
|
+
here: { type: 'boolean' },
|
|
292
387
|
json: { type: 'boolean' },
|
|
293
388
|
kind: { type: 'string' },
|
|
389
|
+
owner: { type: 'string' },
|
|
390
|
+
prefer: { type: 'string' },
|
|
294
391
|
probe: { type: 'boolean' },
|
|
295
392
|
project: { type: 'string' },
|
|
296
393
|
prompt: { type: 'string' },
|
|
297
394
|
raise: { type: 'boolean' },
|
|
298
395
|
remove: { type: 'boolean' },
|
|
299
396
|
since: { type: 'string' },
|
|
397
|
+
slots: { type: 'string' },
|
|
300
398
|
tag: { type: 'string' },
|
|
301
399
|
text: { type: 'string' },
|
|
302
400
|
until: { type: 'string' },
|
|
303
401
|
version: { short: 'v', type: 'boolean' },
|
|
402
|
+
wait: { type: 'string' },
|
|
403
|
+
weight: { type: 'string' },
|
|
304
404
|
worker: { type: 'string' },
|
|
305
405
|
},
|
|
306
|
-
});
|
|
406
|
+
}));
|
|
307
407
|
const paths = conductorPaths();
|
|
308
408
|
const [command, sub, arg] = positionals;
|
|
309
409
|
if (values.version)
|
|
310
410
|
return out(version());
|
|
311
|
-
if (
|
|
312
|
-
return out(
|
|
411
|
+
if (command === undefined || command === 'help')
|
|
412
|
+
return out(sub === undefined ? listing() : helpOf(sub));
|
|
413
|
+
if (!isCommand(command))
|
|
414
|
+
throw unknownCommand(command);
|
|
415
|
+
if (values.help)
|
|
416
|
+
return out(commandHelp(command));
|
|
313
417
|
switch (command) {
|
|
314
418
|
case 'approve':
|
|
315
419
|
if (sub === undefined)
|
|
316
|
-
throw
|
|
420
|
+
throw usageError('approve');
|
|
317
421
|
return answer(paths, sub, { action: 'approve', ...(values.text?.trim() ? { text: values.text.trim() } : {}) });
|
|
318
422
|
case 'attach':
|
|
319
423
|
if (sub === undefined)
|
|
320
|
-
throw
|
|
424
|
+
throw usageError('attach');
|
|
321
425
|
return attach(paths, sub);
|
|
426
|
+
case 'brief':
|
|
427
|
+
if (sub === undefined)
|
|
428
|
+
throw usageError('brief');
|
|
429
|
+
process.exitCode = await briefCommand(paths, sub, { fresh: values.fresh === true, json: values.json === true }, {
|
|
430
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
431
|
+
out,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
case 'console':
|
|
435
|
+
return consoleCommand(paths, { force: values.force === true, here: values.here === true, project: values.project });
|
|
322
436
|
case 'done':
|
|
323
437
|
if (sub === undefined)
|
|
324
|
-
throw
|
|
438
|
+
throw usageError('done');
|
|
325
439
|
return answer(paths, sub, { action: 'done' });
|
|
440
|
+
case 'escalations':
|
|
441
|
+
if (sub !== undefined)
|
|
442
|
+
throw usageError('escalations');
|
|
443
|
+
return escalations(paths, values.json === true);
|
|
326
444
|
case 'events':
|
|
327
|
-
|
|
445
|
+
// CONDUCTOR_CONSOLE as well: a console still running a mod from before --console (until
|
|
446
|
+
// /reload-plugins after an upgrade) starts this package's new build, in the console's env.
|
|
447
|
+
return events(paths, values.follow === true, values.since, values.console === true || process.env.CONDUCTOR_CONSOLE === '1');
|
|
448
|
+
case 'history':
|
|
449
|
+
case 'retro':
|
|
450
|
+
return history(paths, command, sub, { json: values.json === true, project: values.project, since: values.since, until: values.until });
|
|
328
451
|
case 'inbox':
|
|
329
452
|
return inbox(paths, values.json === true);
|
|
330
453
|
case 'install-mod':
|
|
@@ -333,16 +456,25 @@ async function main(argv) {
|
|
|
333
456
|
return values.remove ? uninstall() : install();
|
|
334
457
|
case 'launch':
|
|
335
458
|
if (sub === undefined)
|
|
336
|
-
throw
|
|
459
|
+
throw usageError('launch');
|
|
337
460
|
return launch(paths, sub, {
|
|
338
461
|
conductor: values.conductor,
|
|
339
462
|
probe: values.probe,
|
|
340
463
|
project: values.project,
|
|
341
464
|
prompt: values.prompt,
|
|
342
465
|
});
|
|
466
|
+
case 'lock': {
|
|
467
|
+
if (sub === undefined || arg === undefined)
|
|
468
|
+
throw usageError('lock');
|
|
469
|
+
const waitMinutes = values.wait === undefined ? undefined : Number(values.wait);
|
|
470
|
+
if (waitMinutes !== undefined && !(waitMinutes >= 0))
|
|
471
|
+
throw new ConductorError('--wait takes minutes, such as --wait 30');
|
|
472
|
+
process.exitCode = await lockCommand(paths, sub, arg, { owner: values.owner, waitMinutes }, brokerIo());
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
343
475
|
case 'note':
|
|
344
476
|
if (sub === undefined || arg === undefined)
|
|
345
|
-
throw
|
|
477
|
+
throw usageError('note');
|
|
346
478
|
process.exitCode = await noteCommand(paths, sub, arg, { worker: values.worker }, {
|
|
347
479
|
cwd: process.cwd(),
|
|
348
480
|
err: (line) => process.stderr.write(`${line}\n`),
|
|
@@ -356,53 +488,85 @@ async function main(argv) {
|
|
|
356
488
|
return notifyProfile(paths, arg, values.until);
|
|
357
489
|
if (sub === 'test')
|
|
358
490
|
return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
|
|
359
|
-
throw
|
|
491
|
+
throw usageError('notify');
|
|
360
492
|
case 'project':
|
|
361
493
|
if (sub === 'add' && arg !== undefined)
|
|
362
494
|
return addProject(paths, arg);
|
|
363
495
|
if (sub === 'list')
|
|
364
496
|
return listProjects(paths, values.json === true);
|
|
365
|
-
throw
|
|
497
|
+
throw usageError('project');
|
|
498
|
+
case 'focus':
|
|
499
|
+
if (sub === undefined || values.until === undefined)
|
|
500
|
+
throw usageError('focus');
|
|
501
|
+
return override(paths, {
|
|
502
|
+
action: 'focus',
|
|
503
|
+
project: sub,
|
|
504
|
+
...(values.slots === undefined ? {} : { slots: wholeNumber('--slots', values.slots) }),
|
|
505
|
+
until: untilOf(values.until),
|
|
506
|
+
...(values.weight === undefined ? {} : { weight: wholeNumber('--weight', values.weight) }),
|
|
507
|
+
});
|
|
508
|
+
case 'pause':
|
|
509
|
+
return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
|
|
510
|
+
case 'ports':
|
|
511
|
+
process.exitCode = await portsCommand(paths, sub, { json: values.json === true, ...(values.prefer === undefined ? {} : { prefer: wholeNumber('--prefer', values.prefer) }) }, brokerIo());
|
|
512
|
+
return;
|
|
513
|
+
case 'queue':
|
|
514
|
+
return queue(paths, values.json === true, values.fresh === true);
|
|
515
|
+
case 'rules':
|
|
516
|
+
if (sub === undefined)
|
|
517
|
+
return rules(paths, values.json === true);
|
|
518
|
+
if (sub === 'test' && arg !== undefined)
|
|
519
|
+
return rulesTest(paths, arg, values.json === true);
|
|
520
|
+
throw usageError('rules');
|
|
366
521
|
case 'retire':
|
|
367
522
|
if (sub === undefined)
|
|
368
|
-
throw
|
|
523
|
+
throw usageError('retire');
|
|
369
524
|
return retire(paths, sub, values.force === true);
|
|
370
525
|
case 'serve':
|
|
371
526
|
return serve(paths);
|
|
527
|
+
case 'start':
|
|
528
|
+
return start(paths);
|
|
372
529
|
case 'status':
|
|
373
530
|
return status(paths, values.json === true);
|
|
374
531
|
case 'stop':
|
|
375
532
|
await stopDaemon(paths);
|
|
376
533
|
return;
|
|
534
|
+
case 'unpause':
|
|
535
|
+
return override(paths, { action: 'unpause', ...(sub === undefined ? {} : { project: sub }) });
|
|
377
536
|
case 'upgrade':
|
|
378
537
|
return upgradeCommand(paths, values.tag, values['allow-downgrade'] === true);
|
|
379
|
-
|
|
380
|
-
|
|
538
|
+
case 'why':
|
|
539
|
+
if (sub === undefined)
|
|
540
|
+
throw usageError('why');
|
|
541
|
+
return why(paths, sub, values.json === true);
|
|
542
|
+
// Only when an option comes first: as the first word they parse their own arguments, above.
|
|
543
|
+
case 'revise':
|
|
544
|
+
case 'wait':
|
|
545
|
+
throw usageError(command, `conduct ${command} takes its arguments right after the command`);
|
|
546
|
+
default: {
|
|
547
|
+
const unhandled = command;
|
|
548
|
+
throw unknownCommand(String(unhandled));
|
|
549
|
+
}
|
|
381
550
|
}
|
|
382
551
|
}
|
|
383
552
|
function modDeps() {
|
|
384
553
|
return { claude: (args) => run('claude', args, { timeoutMs: 120_000 }), out };
|
|
385
554
|
}
|
|
386
555
|
function noInbox() {
|
|
387
|
-
return
|
|
388
|
-
'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
|
|
389
|
-
]);
|
|
556
|
+
return olderDaemon('no inbox');
|
|
390
557
|
}
|
|
391
558
|
// GET /notify, or a ConductorError from a daemon older than the notifier.
|
|
392
559
|
async function notifyAnswer(paths, request) {
|
|
393
560
|
const answer = await callApi(paths, request);
|
|
394
|
-
if (answer.status === 404)
|
|
395
|
-
throw
|
|
396
|
-
'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
|
|
397
|
-
]);
|
|
398
|
-
}
|
|
561
|
+
if (answer.status === 404)
|
|
562
|
+
throw olderDaemon('no notifier');
|
|
399
563
|
return expectOk(Promise.resolve(answer));
|
|
400
564
|
}
|
|
401
565
|
// `conduct notify profile`: the active profile, or switch to <name>.
|
|
402
566
|
async function notifyProfile(paths, name, until) {
|
|
403
567
|
if (name === undefined) {
|
|
404
568
|
if (until !== undefined)
|
|
405
|
-
throw
|
|
569
|
+
throw usageError('notify', '--until needs a profile: conduct notify profile <name> --until <when>');
|
|
406
570
|
return notifyStatus(paths, false);
|
|
407
571
|
}
|
|
408
572
|
const body = { name, ...(until === undefined ? {} : { until: parseUntil(until, new Date()).toISOString() }) };
|
|
@@ -438,6 +602,11 @@ async function notifyTest(paths, options) {
|
|
|
438
602
|
function npm(args, timeoutMs) {
|
|
439
603
|
return run('npm', args, { shell: process.platform === 'win32', timeoutMs });
|
|
440
604
|
}
|
|
605
|
+
function olderDaemon(what) {
|
|
606
|
+
return new ConductorError(`the running conductor has ${what}: it is older than this conduct`, [
|
|
607
|
+
'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
|
|
608
|
+
]);
|
|
609
|
+
}
|
|
441
610
|
// The open inbox items, or null from a daemon older than the inbox (a linked build ahead of it).
|
|
442
611
|
async function openItems(paths) {
|
|
443
612
|
const answer = await callApi(paths, { method: 'GET', path: '/inbox' });
|
|
@@ -448,6 +617,39 @@ async function openItems(paths) {
|
|
|
448
617
|
function out(line) {
|
|
449
618
|
process.stdout.write(`${line}\n`);
|
|
450
619
|
}
|
|
620
|
+
// `conduct pause|unpause|focus`: an override that ends on its own (unpause ends a pause).
|
|
621
|
+
async function override(paths, body) {
|
|
622
|
+
const answer = await callApi(paths, { body: { ...body, by: `conduct ${String(body.action)}` }, method: 'POST', path: '/overrides' });
|
|
623
|
+
if (answer.status === 404)
|
|
624
|
+
throw olderDaemon('no overrides');
|
|
625
|
+
const { overrides, set } = z.object({ overrides: z.array(overrideViewSchema), set: z.array(overrideViewSchema) }).parse(await expectOk(Promise.resolve(answer)));
|
|
626
|
+
const untilOfView = (o) => (o.until === null ? ' until you unpause' : ` until ${new Date(o.until).toLocaleString()}`);
|
|
627
|
+
for (const o of set) {
|
|
628
|
+
const who = o.project ?? 'every project';
|
|
629
|
+
if (o.kind === PAUSE)
|
|
630
|
+
out(o.value === 'on' ? `paused launches in ${who}${untilOfView(o)}` : `launches in ${who} are no longer paused`);
|
|
631
|
+
else
|
|
632
|
+
out(`${who}: ${o.kind.replace('focus.', '')} ${o.value}${untilOfView(o)}`);
|
|
633
|
+
}
|
|
634
|
+
// A pause of every project still holds a project unpaused on its own.
|
|
635
|
+
const all = overrides.find((o) => o.kind === PAUSE && o.project === null);
|
|
636
|
+
if (body.action === 'unpause' && body.project !== undefined && all)
|
|
637
|
+
out(`but all launches stay paused${untilOfView(all)}: conduct unpause ends that`);
|
|
638
|
+
}
|
|
639
|
+
// parseArgs' own errors (an unknown option, a missing value) with the command's usage, not every
|
|
640
|
+
// command's.
|
|
641
|
+
function parsing(command, parse) {
|
|
642
|
+
try {
|
|
643
|
+
return parse();
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
if (!(error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS')))
|
|
647
|
+
throw error;
|
|
648
|
+
// Node's advice on `--` for a positional that starts with a dash is about the shell, not conduct.
|
|
649
|
+
const message = error.message.replace(/\. To specify a positional argument.*$/s, '');
|
|
650
|
+
throw isCommand(command) ? usageError(command, message) : new ConductorError(message, ['run `conduct help` for the commands']);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
451
653
|
// The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
|
|
452
654
|
// bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
|
|
453
655
|
async function planItem(paths, ref) {
|
|
@@ -470,6 +672,27 @@ function projectLine(p) {
|
|
|
470
672
|
const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
|
|
471
673
|
return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
|
|
472
674
|
}
|
|
675
|
+
// The registered projects: from the daemon, or from the store when it isn't running.
|
|
676
|
+
async function projectsNow(paths) {
|
|
677
|
+
try {
|
|
678
|
+
return projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (!(error instanceof NotRunningError))
|
|
682
|
+
throw error;
|
|
683
|
+
return withStore(paths, (store) => store.listProjects());
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async function queue(paths, json, fresh) {
|
|
687
|
+
// A fresh read asks every project's tracker, within the shared rate budget.
|
|
688
|
+
const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
|
|
689
|
+
if (answer.status === 404)
|
|
690
|
+
throw olderDaemon('no scheduler');
|
|
691
|
+
const view = queueViewSchema.parse(await expectOk(Promise.resolve(answer)));
|
|
692
|
+
if (json)
|
|
693
|
+
return out(JSON.stringify(view, null, 2));
|
|
694
|
+
queueLines(view).forEach((line) => out(line));
|
|
695
|
+
}
|
|
473
696
|
async function retire(paths, ref, force) {
|
|
474
697
|
const worker = await findWorker(paths, ref);
|
|
475
698
|
const { body, status } = await callApi(paths, {
|
|
@@ -497,23 +720,86 @@ async function retire(paths, ref, force) {
|
|
|
497
720
|
out(`retired ${worker.name}`);
|
|
498
721
|
steps.forEach((s) => out(` ${s}`));
|
|
499
722
|
}
|
|
723
|
+
// `conduct rules`: from the daemon, or straight from the files when it isn't running.
|
|
724
|
+
async function rules(paths, json) {
|
|
725
|
+
const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
|
|
726
|
+
if (json)
|
|
727
|
+
return out(JSON.stringify(view, null, 2));
|
|
728
|
+
rulesLines(view).forEach((line) => out(line));
|
|
729
|
+
}
|
|
730
|
+
// The daemon's answer, or, when none runs, the same thing worked out here from the files.
|
|
731
|
+
async function rulesAnswer(paths, request, parse, local) {
|
|
732
|
+
let answer;
|
|
733
|
+
try {
|
|
734
|
+
answer = await callApi(paths, request);
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
if (!(error instanceof NotRunningError))
|
|
738
|
+
throw error;
|
|
739
|
+
process.stderr.write('conduct: the conductor is not running; read from the files\n');
|
|
740
|
+
const config = loadConfig(paths.config);
|
|
741
|
+
return withStore(paths, (store) => {
|
|
742
|
+
const service = rulesService({ config, listProjects: () => store.listProjects(), paths });
|
|
743
|
+
service.reload();
|
|
744
|
+
return local(service);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (answer.status === 404)
|
|
748
|
+
throw olderDaemon('no rules engine');
|
|
749
|
+
return parse(await expectOk(Promise.resolve(answer)));
|
|
750
|
+
}
|
|
751
|
+
async function rulesTest(paths, file, json) {
|
|
752
|
+
let text;
|
|
753
|
+
try {
|
|
754
|
+
text = readFileSync(file, 'utf8');
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
throw new ConductorError(`could not read ${file}`, [String(error)]);
|
|
758
|
+
}
|
|
759
|
+
const sample = parseJsonc(text, z.unknown(), file);
|
|
760
|
+
const result = await rulesAnswer(paths, { body: sample, method: 'POST', path: '/rules/test' }, (body) => rulesTestResultSchema.parse(body), (service) => service.test(sample));
|
|
761
|
+
if (json)
|
|
762
|
+
return out(JSON.stringify(result, null, 2));
|
|
763
|
+
testLines(result).forEach((line) => out(line));
|
|
764
|
+
}
|
|
500
765
|
async function serve(paths) {
|
|
501
766
|
const daemon = await startDaemon(paths, { version: version() });
|
|
502
767
|
out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
|
|
503
768
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
|
|
504
769
|
process.once(signal, () => void daemon.stop(signal));
|
|
505
770
|
}
|
|
506
|
-
|
|
507
|
-
// names a dead process, which the next start replaces.
|
|
508
|
-
process.on('uncaughtException', (error) => {
|
|
509
|
-
daemon.log.log('error', 'uncaught exception', { error: error.stack ?? String(error) });
|
|
510
|
-
process.exit(1);
|
|
511
|
-
});
|
|
771
|
+
logCrashes(daemon.log);
|
|
512
772
|
await daemon.closed;
|
|
513
773
|
}
|
|
514
774
|
function sleep(ms) {
|
|
515
775
|
return new Promise((done) => setTimeout(done, ms));
|
|
516
776
|
}
|
|
777
|
+
// `conduct start`: the daemon, detached from this process, so it outlives the shell or the Claude
|
|
778
|
+
// session that started it (the console's start runs this). Nothing when one answers already.
|
|
779
|
+
async function start(paths) {
|
|
780
|
+
const pid = readPid(paths.pid);
|
|
781
|
+
if (pid !== null && (await ping(paths, pid)))
|
|
782
|
+
return out(`the conductor is running (pid ${pid})`);
|
|
783
|
+
// A daemon that is still starting (the logon task's, say) holds the lock: wait for it rather than
|
|
784
|
+
// start a second one, which would exit on that lock. A pid a reboot reused gets a start after.
|
|
785
|
+
if (pid !== null && processAlive(pid)) {
|
|
786
|
+
try {
|
|
787
|
+
return out(`the conductor is running (pid ${await awaitDaemon(paths)})`);
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// Not a daemon that answers: start one, which replaces a stale lock (or says who holds it).
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
out(`started the conductor ${await restartDaemon(daemonStarter(paths, realpathSync(fileURLToPath(import.meta.url))))}`);
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
const holder = readPid(paths.pid);
|
|
798
|
+
if (error instanceof ServeStartError || holder === null || !processAlive(holder))
|
|
799
|
+
throw error;
|
|
800
|
+
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}`]);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
517
803
|
async function status(paths, json) {
|
|
518
804
|
let projects;
|
|
519
805
|
try {
|
|
@@ -522,23 +808,32 @@ async function status(paths, json) {
|
|
|
522
808
|
catch (error) {
|
|
523
809
|
if (!(json && error instanceof NotRunningError))
|
|
524
810
|
throw error;
|
|
525
|
-
// Scripts and the conductor mod read the answer, so "not running" is JSON too (exit 1)
|
|
526
|
-
|
|
811
|
+
// Scripts and the conductor mod read the answer, so "not running" is JSON too (exit 1), with the
|
|
812
|
+
// store's workers while the daemon restarts or upgrades.
|
|
813
|
+
const workers = storedFleet(paths.db, localDay(new Date()));
|
|
814
|
+
out(JSON.stringify({ home: paths.home, running: false, ...(workers ? { workers, workersFrom: 'store' } : {}) }, null, 2));
|
|
527
815
|
process.exitCode = 1;
|
|
528
816
|
return;
|
|
529
817
|
}
|
|
530
818
|
const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
|
|
531
819
|
// A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
|
|
532
820
|
const items = await openItems(paths);
|
|
821
|
+
// Null from a daemon older than the broker.
|
|
822
|
+
const locks = await heldLocks(paths);
|
|
533
823
|
const pid = readPid(paths.pid);
|
|
534
824
|
const port = readEndpoint(paths)?.port ?? null;
|
|
535
825
|
if (json)
|
|
536
|
-
return out(JSON.stringify({ home: paths.home, inbox: items, pid, port, projects, running: true, workers }, null, 2));
|
|
826
|
+
return out(JSON.stringify({ home: paths.home, inbox: items, locks, pid, port, projects, running: true, workers, workersFrom: 'daemon' }, null, 2));
|
|
537
827
|
out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
|
|
538
828
|
out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
|
|
539
829
|
projects.forEach((p) => out(projectLine(p)));
|
|
540
830
|
out(workers.length === 0 ? 'no workers' : 'workers:');
|
|
541
831
|
workers.forEach((w) => out(workerLine(w)));
|
|
832
|
+
if (locks === null)
|
|
833
|
+
out('locks: not in this daemon (restart it to run this build)');
|
|
834
|
+
else
|
|
835
|
+
out(locks.length === 0 ? 'locks: none held' : 'locks:');
|
|
836
|
+
locks?.forEach((l) => out(lockLine(l)));
|
|
542
837
|
if (items === null)
|
|
543
838
|
out('inbox: not in this daemon (restart it to run this build)');
|
|
544
839
|
else
|
|
@@ -572,10 +867,14 @@ async function stopDaemon(paths) {
|
|
|
572
867
|
function uninstall() {
|
|
573
868
|
out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
|
|
574
869
|
}
|
|
870
|
+
// --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
|
|
871
|
+
function untilOf(text) {
|
|
872
|
+
return resolveUntil({ kind: 'at', text }, new Date()).toISOString();
|
|
873
|
+
}
|
|
575
874
|
async function upgradeCommand(paths, tag, allowDowngrade) {
|
|
576
875
|
const cliPath = realpathSync(fileURLToPath(import.meta.url));
|
|
577
876
|
await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
|
|
578
|
-
|
|
877
|
+
...daemonStarter(paths, cliPath),
|
|
579
878
|
cliPath,
|
|
580
879
|
installedVersion: version,
|
|
581
880
|
npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
|
|
@@ -593,15 +892,7 @@ async function upgradeCommand(paths, tag, allowDowngrade) {
|
|
|
593
892
|
return found;
|
|
594
893
|
},
|
|
595
894
|
out,
|
|
596
|
-
runTask: runStartup,
|
|
597
|
-
// A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
|
|
598
|
-
// caller's directory, which it would hold open (a worktree it may later remove, say).
|
|
599
|
-
serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
|
|
600
|
-
.on('error', () => { })
|
|
601
|
-
.unref(),
|
|
602
895
|
stopDaemon: () => stopDaemon(paths),
|
|
603
|
-
// The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
|
|
604
|
-
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
605
896
|
});
|
|
606
897
|
}
|
|
607
898
|
// Read from disk each time: upgrade asks again after npm has replaced the package.
|
|
@@ -610,7 +901,7 @@ function version() {
|
|
|
610
901
|
return z.object({ version: z.string() }).parse(JSON.parse(readFileSync(file, 'utf8'))).version;
|
|
611
902
|
}
|
|
612
903
|
async function wait(argv) {
|
|
613
|
-
const { positionals, values } = parseArgs({
|
|
904
|
+
const { positionals, values } = parsing('wait', () => parseArgs({
|
|
614
905
|
allowPositionals: true,
|
|
615
906
|
args: argv,
|
|
616
907
|
options: {
|
|
@@ -624,17 +915,39 @@ async function wait(argv) {
|
|
|
624
915
|
version: { type: 'string' },
|
|
625
916
|
worker: { type: 'string' },
|
|
626
917
|
},
|
|
627
|
-
});
|
|
918
|
+
}));
|
|
628
919
|
const timeout = values.timeout === undefined ? undefined : Number(values.timeout);
|
|
629
920
|
if (timeout !== undefined && !(timeout > 0))
|
|
630
921
|
throw new ConductorError('--timeout takes minutes, such as --timeout 25');
|
|
631
922
|
if (positionals.length === 0)
|
|
632
|
-
throw
|
|
923
|
+
throw usageError('wait');
|
|
633
924
|
process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
|
|
634
925
|
}
|
|
635
|
-
function
|
|
926
|
+
function wholeNumber(flag, text) {
|
|
927
|
+
if (!/^-?\d+$/.test(text))
|
|
928
|
+
throw new ConductorError(`${flag} takes a whole number, not "${text}"`);
|
|
929
|
+
return Number(text);
|
|
930
|
+
}
|
|
931
|
+
// `conduct why <event id>`: the rule behind it and its causes. `conduct why <item>`: the same for
|
|
932
|
+
// each of the item's newest actions.
|
|
933
|
+
async function why(paths, ref, json) {
|
|
934
|
+
const byId = /^#?(\d+)$/.exec(ref)?.[1];
|
|
935
|
+
const answer = await callApi(paths, { method: 'GET', path: byId === undefined ? `/why?item=${encodeURIComponent(ref)}` : `/why/${byId}` });
|
|
936
|
+
if (answer.status === 404 && byId !== undefined) {
|
|
937
|
+
const body = errorBody.safeParse(answer.body);
|
|
938
|
+
throw body.success && body.data.error === 'not_found' ? new ConductorError(`no event ${byId}`) : olderDaemon('no conduct why');
|
|
939
|
+
}
|
|
940
|
+
const body = await expectOk(Promise.resolve(answer));
|
|
941
|
+
const whys = byId === undefined ? z.object({ items: z.array(whySchema) }).parse(body).items : [whySchema.parse(body)];
|
|
942
|
+
if (json)
|
|
943
|
+
return out(JSON.stringify(byId === undefined ? whys : whys[0], null, 2));
|
|
944
|
+
if (whys.length === 0)
|
|
945
|
+
return out(`no action on ${ref} was caused by a rule`);
|
|
946
|
+
whys.forEach((w, i) => [...(i > 0 ? [''] : []), ...whyLines(w)].forEach((line) => out(line)));
|
|
947
|
+
}
|
|
948
|
+
function withStore(paths, use, options = {}) {
|
|
636
949
|
mkdirSync(paths.home, { recursive: true });
|
|
637
|
-
const store = openStore(paths.db);
|
|
950
|
+
const store = openStore(paths.db, undefined, options);
|
|
638
951
|
try {
|
|
639
952
|
return use(store);
|
|
640
953
|
}
|
|
@@ -657,9 +970,6 @@ main(process.argv.slice(2)).catch((error) => {
|
|
|
657
970
|
if (error instanceof ConductorError) {
|
|
658
971
|
process.stderr.write(`conduct: ${error.message}\n${error.details.map((d) => ` ${d}\n`).join('')}`);
|
|
659
972
|
}
|
|
660
|
-
else if (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS')) {
|
|
661
|
-
process.stderr.write(`conduct: ${error.message}\n${USAGE}\n`);
|
|
662
|
-
}
|
|
663
973
|
else {
|
|
664
974
|
process.stderr.write(`conduct: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
|
665
975
|
}
|