@detergent-software/conductor 1.0.0-dev.17 → 1.0.0-dev.19

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 CHANGED
@@ -2,6 +2,26 @@
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.19](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.18&targetVersion=GTconductor-v1.0.0-dev.19) (2026-10-11)
6
+
7
+ ### Features
8
+
9
+ * **conductor:** show the broker's locks in conduct status (ATK-372) ([77a3f54](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/77a3f547e15a653a9e4dbc8a1308e5118ac5492d))
10
+
11
+ ### Bug Fixes
12
+
13
+ * **conductor:** keep the daemon's reason when GET /locks fails (ATK-372) ([79f3e69](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/79f3e6979c2475612ff6b4a8780cb1dd01fa9718))
14
+
15
+ ## [1.0.0-dev.18](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.17&targetVersion=GTconductor-v1.0.0-dev.18) (2026-10-11)
16
+
17
+ ### Features
18
+
19
+ * **conductor:** port broker and shared locks across projects (ATK-318) ([90a46d0](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/90a46d0827dd805668fc98c11b4082487f9bd806))
20
+
21
+ ### Bug Fixes
22
+
23
+ * **conductor:** review fixes for the port broker (ATK-318) ([db0bf0d](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/db0bf0de8d0404d155ad2d95ac32923ab5d0fb7f))
24
+
5
25
  ## [1.0.0-dev.17](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.16&targetVersion=GTconductor-v1.0.0-dev.17) (2026-10-11)
6
26
 
7
27
  ### Features
package/README.md CHANGED
@@ -1,6 +1,6 @@
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), step 8, the scheduler, settings and rules (ATK-315), and the daemon's side of step 7, the console (ATK-314).
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), the daemon's side of step 7, the console (ATK-314), and step 12, the port broker (ATK-318).
4
4
 
5
5
  ## Commands
6
6
 
@@ -8,7 +8,7 @@
8
8
  conduct serve run the daemon in this process (one per machine)
9
9
  conduct start start the daemon detached and wait until it answers (see Console)
10
10
  conduct stop stop the running daemon
11
- conduct status [--json] the daemon's pid, port, projects and workers
11
+ conduct status [--json] the daemon's pid, port, projects, workers and held locks
12
12
  conduct project add <path> register the project whose checkout holds .claude/conductor.json
13
13
  conduct project list [--json] the registered projects
14
14
  conduct launch <item> start a worker: worktree, background session, tab
@@ -48,6 +48,10 @@ conduct rules test <file> [--json]
48
48
  conduct note <item> <file> post a phase note on the item and record its phase (see Notes)
49
49
  [--worker <worker>]
50
50
  conduct wait <condition> wait for an outside event (see Waits)
51
+ conduct ports [<path>] [--prefer <slot>] [--json]
52
+ a checkout's dev ports from the port broker, or every worktree's (see Broker)
53
+ conduct lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
54
+ a lock every project shares, such as chrome (see Broker)
51
55
  conduct events [--since <id>] [--follow]
52
56
  the event log, one JSON event per line (see API)
53
57
  conduct install-startup start the daemon at logon (Windows Task Scheduler)
@@ -254,6 +258,29 @@ A worker waits by posting its note, running `conduct wait …` and ending the tu
254
258
 
255
259
  **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.
256
260
 
261
+ ## Broker
262
+
263
+ The broker shares what every project on the machine uses (ATK-318).
264
+
265
+ **Ports.** Each profile declares its dev servers' base ports by name, such as ATK's `"ports": { "web": 3000, "api": 7071, "maxSlot": 50 }` or `{ "web": 3000 }`.
266
+ - A main checkout serves on the base ports (slot 0).
267
+ - Each worktree gets a slot from 1 to `maxSlot` (default 50) and serves on base + slot. `conduct ports <path>` gives it the first slot whose ports no other checkout of any registered project has and nothing listens on, `--prefer` first. It's the same answer each time after that.
268
+ - A retired worker's worktree frees its slot, and so does a worktree whose directory is gone (at the next assignment).
269
+ - Ports are compared, not slots, so projects with different bases stay apart too.
270
+ - A project's dev scripts read the assignment with `--json`, as ATK's `scripts/dev-ports.mjs assign` does. They keep their own scheme when it exits 3: no daemon, an older one, or a checkout in no registered project.
271
+ - The broker knows only the slots it gave out. A slot a script picked on its own while no daemon answered stays unknown until that worktree asks the broker; until then only the listening probe keeps the slot clear. So `--prefer` is the slot the script would pick itself (`dev-ports.mjs` passes the checkout's own slot or its own free-slot scan's). The preferred slot is taken without the listening probe, because the checkout's own servers may be what listens there.
272
+ - A stored assignment follows its project's profile: when the base ports or `maxSlot` change, the worktree moves, its old slot first.
273
+
274
+ **Locks.** `conduct lock acquire chrome` takes the lock every project's verify runs share. It answers the work-issue helper's JSON (`acquired`, `heldBy`, `since`, `refreshed`; `released` for a release; `verdict` for a status).
275
+ - The owner is `--owner`, else the item of the worker whose worktree this is.
276
+ - Acquiring again refreshes it. 30 minutes after its holder last acquired it, another owner may take it over.
277
+ - `--wait <minutes>` asks again every 15 s until then.
278
+ - Retiring a worker releases the locks its item holds (a probe's retire doesn't).
279
+ - `conduct status` lists the locks held, each with its holder, its last acquire and whether it is stale.
280
+ - Exit 3 when no daemon answers.
281
+
282
+ Events: `ports.assigned`, `ports.freed`, `lock.acquired`, `lock.released`, `lock.taken-over`.
283
+
257
284
  ## History
258
285
 
259
286
  `conduct history` and `conduct retro` read a window of the log and say what happened in it, so a review of a conducted run needs no transcripts. Both are queries over `conductor.db`, so they work without a daemon too (or with one older than this build).
@@ -324,6 +351,8 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
324
351
  - `GET /history?since=<ISO time>&until=<ISO time>&project=<name>`: the report for that window (see History). Every parameter is optional; the default window is the 24 hours before `until` (default: now). 400 for a time it can't read, a window whose start isn't before its end, or an unknown parameter.
325
352
  - `GET /why/:id`: the event's chain of causes and the rule behind it; `GET /why?item=ATK-315` the same for the item's newest actions.
326
353
  - `POST /rules/test` with `{ "event"?: "pr.comment", "project"?: "atk", "fields"?: { … } }`: a dry run; 400 for a sample it can't read.
354
+ - `GET /ports`: every worktree's port assignment. `POST /ports` with `{ "worktree": "<absolute path>", "prefer"?: 3 }`: the checkout's (see Broker); 200 with `assignment`, 409 when the path is in no registered project or no slot is free.
355
+ - `GET /locks`: the locks held. `POST /locks/:name` with `{ "action": "acquire" | "release" | "status", "owner"? }`: 200 with the work-issue helper's lock JSON; 409 for an acquire or release without an owner.
327
356
  - `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.
328
357
  - A refusal (a duplicate launch, a bad condition, a busy worker) is a 409 with `error` and `details`.
329
358
 
@@ -0,0 +1,125 @@
1
+ import { resolve } from 'node:path';
2
+ import { z } from 'zod';
3
+ import { lockVerdict } from "./broker.js";
4
+ import { callApi, NotRunningError } from "./client.js";
5
+ import { ConductorError } from "./errors.js";
6
+ import { lockSchema, portAssignmentSchema } from "./store.js";
7
+ import { daemonWorker, gitToplevel } from "./wait.js";
8
+ // Exit code when no daemon brokered the request: none runs, an older one has no broker, or it
9
+ // refused (a checkout in no registered project, no free slot). The caller keeps its own scheme,
10
+ // as ATK's scripts/dev-ports.mjs and the work-issue helper's file lock do.
11
+ export const NOT_BROKERED = 3;
12
+ // How often `conduct lock acquire --wait` asks again, as the work-issue helper's lock does.
13
+ const LOCK_POLL_MS = 15_000;
14
+ const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
15
+ const lockAnswer = z.looseObject({ acquired: z.boolean().optional(), lock: z.string() });
16
+ // The broker's locks for `conduct status`, or null from a daemon older than the broker.
17
+ export async function heldLocks(paths, now = new Date()) {
18
+ const answer = await callApi(paths, { method: 'GET', path: '/locks' });
19
+ if (answer.status === 404)
20
+ return null;
21
+ if (answer.status !== 200) {
22
+ const refusal = errorBody.safeParse(answer.body).data;
23
+ throw new ConductorError(refusal?.error ?? `the API answered ${answer.status} for the locks`, refusal?.details ?? []);
24
+ }
25
+ return z
26
+ .object({ locks: z.array(lockSchema) })
27
+ .parse(answer.body)
28
+ .locks.map((lock) => ({ ...lock, verdict: lockVerdict(lock, null, now) === 'stale' ? 'stale' : 'held' }));
29
+ }
30
+ // `conduct lock <acquire|release|status> <name>`: the broker's named lock, such as chrome, which
31
+ // verify skills of every project take turns with. Prints the work-issue helper's JSON. The owner
32
+ // is --owner, else the item of the worker whose worktree this is.
33
+ export async function lockCommand(paths, action, name, options, io) {
34
+ if (action !== 'acquire' && action !== 'release' && action !== 'status')
35
+ throw new ConductorError('usage: conduct lock <acquire|release|status> <name> [--owner <id>] [--wait <minutes>]');
36
+ if (!/^[\w-]+$/.test(name))
37
+ throw new ConductorError(`a lock name is letters, digits, _ and -, not "${name}"`);
38
+ let owner = options.owner?.trim() || null;
39
+ if (owner === null) {
40
+ const worker = await daemonWorker(paths, await gitToplevel(io.cwd), undefined);
41
+ if (worker === 'no-daemon')
42
+ return notBrokered(io, 'the conductor is not running');
43
+ if (worker === 'unreachable')
44
+ throw new ConductorError("the conductor didn't answer");
45
+ owner = worker?.item ?? null;
46
+ if (owner === null && action !== 'status')
47
+ throw new ConductorError('no worker of the conductor works in this checkout: pass --owner <id>');
48
+ }
49
+ const until = Date.now() + (options.waitMinutes ?? 0) * 60_000;
50
+ const sleep = io.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
51
+ for (;;) {
52
+ const answer = await ask(paths, { body: { action, ...(owner === null ? {} : { owner }) }, method: 'POST', path: `/locks/${name}` });
53
+ if (!answer.ok)
54
+ return notBrokered(io, answer.why);
55
+ const body = lockAnswer.parse(answer.body);
56
+ if (action !== 'acquire' || body.acquired !== false || Date.now() >= until) {
57
+ io.out(JSON.stringify(body, null, 2));
58
+ return 0;
59
+ }
60
+ await sleep(LOCK_POLL_MS);
61
+ }
62
+ }
63
+ // One line per lock: name, holder, and its last acquire.
64
+ export function lockLine(lock) {
65
+ return ` ${lock.name} ${lock.holder} since ${new Date(lock.since).toLocaleString()}${lock.verdict === 'stale' ? ' (stale: free to take over)' : ''}`;
66
+ }
67
+ // `conduct ports [<path>]`: the checkout's dev ports from the broker, or every assignment.
68
+ export async function portsCommand(paths, path, options, io) {
69
+ if (path === undefined) {
70
+ const answer = await ask(paths, { method: 'GET', path: '/ports' });
71
+ if (!answer.ok)
72
+ return notBrokered(io, answer.why);
73
+ const assignments = z.object({ assignments: z.array(portAssignmentSchema) }).parse(answer.body).assignments;
74
+ if (options.json)
75
+ io.out(JSON.stringify(assignments, null, 2));
76
+ else if (assignments.length === 0)
77
+ io.out('no worktree has ports from the conductor');
78
+ else
79
+ assignments.forEach((a) => io.out(portsLine(a)));
80
+ return 0;
81
+ }
82
+ const answer = await ask(paths, {
83
+ body: { worktree: resolve(io.cwd, path), ...(options.prefer === undefined ? {} : { prefer: options.prefer }) },
84
+ method: 'POST',
85
+ path: '/ports',
86
+ });
87
+ if (!answer.ok)
88
+ return notBrokered(io, answer.why);
89
+ const { assignment } = z.object({ assignment: portAssignmentSchema }).parse(answer.body);
90
+ io.out(options.json ? JSON.stringify(assignment, null, 2) : portsLine(assignment));
91
+ return 0;
92
+ }
93
+ // One line per checkout: project, slot, each port, then where.
94
+ export function portsLine(a) {
95
+ const ports = Object.entries(a.ports)
96
+ .map(([name, port]) => `${name} ${port}`)
97
+ .join(' · ');
98
+ return `${a.project} slot ${a.slot} ${ports} ${a.worktree}`;
99
+ }
100
+ // The body of a 200 answer, or why nothing was brokered.
101
+ async function ask(paths, request) {
102
+ let answer;
103
+ try {
104
+ answer = await callApi(paths, request);
105
+ }
106
+ catch (error) {
107
+ if (error instanceof NotRunningError)
108
+ return { ok: false, why: 'the conductor is not running' };
109
+ throw error;
110
+ }
111
+ if (answer.status === 200)
112
+ return { body: answer.body, ok: true };
113
+ // A daemon from before the broker has no such route.
114
+ if (answer.status === 404)
115
+ return { ok: false, why: 'this conductor has no port broker (upgrade it)' };
116
+ const refusal = errorBody.safeParse(answer.body).data;
117
+ if (answer.status === 409 && refusal)
118
+ return { ok: false, why: [refusal.error, ...(refusal.details ?? [])].join('; ') };
119
+ throw new ConductorError(refusal?.error ?? `the API answered ${answer.status}`, refusal?.details ?? []);
120
+ }
121
+ function notBrokered(io, why) {
122
+ io.err(`not brokered: ${why}`);
123
+ return NOT_BROKERED;
124
+ }
125
+ //# sourceMappingURL=broker-cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broker-cli.js","sourceRoot":"","sources":["../src/broker-cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,154 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { connect } from 'node:net';
3
+ import { ConductorError } from "./errors.js";
4
+ import { samePath } from "./paths.js";
5
+ import { basePorts } from "./profile.js";
6
+ import { checkoutOf } from "./registry.js";
7
+ import {} from "./store.js";
8
+ // The broker shares what every project on the machine uses (design note, sections 3 and 12): dev
9
+ // server ports, so two projects' worktrees never serve on the same port, and named locks such as
10
+ // chrome, so verify skills of different projects take turns with the one browser.
11
+ // A holder keeps a lock by acquiring it again; one it hasn't acquired for this long is free to take
12
+ // over. The same rule as the work-issue helper's file lock.
13
+ export const LOCK_STALE_MS = 30 * 60_000;
14
+ const MAX_PORT = 65_535;
15
+ export function broker({ exists = existsSync, listening = portListening, now = () => new Date(), store }) {
16
+ // One assignment at a time: each waits on port probes, and two at once could pick the same slot.
17
+ let assigning = Promise.resolve();
18
+ // Frees the ports of worktrees whose directory is gone, such as one /worktree-gc removed.
19
+ const sweep = () => {
20
+ for (const held of store.portAssignments())
21
+ if (!exists(held.worktree))
22
+ freePorts(store, held.worktree, 'worktree gone');
23
+ return store.portAssignments();
24
+ };
25
+ const assign = async (path, wanted) => {
26
+ const { checkout, root } = checkoutOf(path);
27
+ const projects = store.listProjects();
28
+ const project = projects.find((p) => samePath(p.root, root));
29
+ if (!project)
30
+ throw new ConductorError(`${checkout} is in no registered project`, ['`conduct project add <path>` registers one']);
31
+ const profile = profileOf(project);
32
+ const base = basePorts(profile);
33
+ if (samePath(checkout, root))
34
+ return { assignedAt: null, ports: base, project: project.name, slot: 0, worktree: checkout };
35
+ const portsOf = (slot) => Object.fromEntries(Object.entries(base).map(([name, port]) => [name, port + slot]));
36
+ let prefer = wanted;
37
+ const held = sweep().find((a) => samePath(a.worktree, checkout));
38
+ if (held) {
39
+ // Kept while the profile still gives its slot those ports; otherwise it moves, its old slot first.
40
+ if (held.slot <= profile.ports.maxSlot && samePorts(held.ports, portsOf(held.slot)))
41
+ return held;
42
+ freePorts(store, held.worktree, 'profile changed');
43
+ prefer ??= held.slot;
44
+ }
45
+ // Every main checkout's ports, and every worktree's.
46
+ const taken = new Set([
47
+ ...projects.flatMap((p) => (p.profile ? Object.values(basePorts(p.profile)) : [])),
48
+ ...store.portAssignments().flatMap((a) => Object.values(a.ports)),
49
+ ]);
50
+ const slots = Array.from({ length: profile.ports.maxSlot }, (_, i) => i + 1);
51
+ const order = prefer !== undefined && slots.includes(prefer) ? [prefer, ...slots.filter((s) => s !== prefer)] : slots;
52
+ for (const slot of order) {
53
+ const ports = portsOf(slot);
54
+ const values = Object.values(ports);
55
+ if (values.some((port) => port > MAX_PORT || taken.has(port)))
56
+ continue;
57
+ // The preferred slot is the one the checkout already serves on (its own scheme gave it), so
58
+ // its own dev servers may be what listens there.
59
+ if (slot !== prefer && (await Promise.all(values.map(listening))).some(Boolean))
60
+ continue;
61
+ const assignment = store.savePorts({ ports, project: project.name, slot, worktree: checkout });
62
+ store.appendEvent({ data: { ports, slot, worktree: checkout }, kind: 'ports.assigned', project: project.name });
63
+ return assignment;
64
+ }
65
+ throw new ConductorError(`no free slot between 1 and ${profile.ports.maxSlot} for ${checkout}`, [
66
+ '`conduct ports` lists the assignments; a removed worktree frees its slot',
67
+ ]);
68
+ };
69
+ return {
70
+ assignPorts: (path, prefer) => {
71
+ const next = assigning.then(() => assign(path, prefer));
72
+ assigning = next.catch(() => { });
73
+ return next;
74
+ },
75
+ lock: (name, action, owner) => {
76
+ const current = store.getLock(name);
77
+ const verdict = lockVerdict(current, owner, now());
78
+ if (action === 'status')
79
+ return { lock: name, verdict, ...(current ? { heldBy: current.holder, since: current.since } : {}) };
80
+ if (owner === null)
81
+ throw new ConductorError(`an owner is needed to ${action} a lock`);
82
+ if (action === 'release') {
83
+ if (verdict === 'mine') {
84
+ store.deleteLock(name);
85
+ store.appendEvent({ data: { holder: owner }, kind: 'lock.released', subject: name });
86
+ }
87
+ return { lock: name, released: verdict === 'mine', ...(current && verdict !== 'mine' ? { heldBy: current.holder } : {}) };
88
+ }
89
+ if (current && verdict === 'held')
90
+ return { acquired: false, heldBy: current.holder, lock: name, since: current.since };
91
+ store.saveLock(name, owner);
92
+ if (verdict === 'stale' && current) {
93
+ store.appendEvent({ data: { holder: owner, previous: current.holder, since: current.since }, kind: 'lock.taken-over', subject: name });
94
+ }
95
+ else if (verdict === 'free') {
96
+ store.appendEvent({ data: { holder: owner }, kind: 'lock.acquired', subject: name });
97
+ }
98
+ return { acquired: true, lock: name, owner, ...(verdict === 'mine' ? { refreshed: true } : {}) };
99
+ },
100
+ locks: () => store.listLocks(),
101
+ portAssignments: sweep,
102
+ };
103
+ }
104
+ // Frees the ports of a worktree (a retired worker's, or one whose directory is gone).
105
+ export function freePorts(store, worktree, reason) {
106
+ for (const held of store.portAssignments()) {
107
+ if (!samePath(held.worktree, worktree))
108
+ continue;
109
+ store.deletePorts(held.worktree);
110
+ store.appendEvent({ data: { ports: held.ports, reason, slot: held.slot, worktree: held.worktree }, kind: 'ports.freed', project: held.project });
111
+ }
112
+ }
113
+ export function lockVerdict(lock, owner, now) {
114
+ if (!lock)
115
+ return 'free';
116
+ if (owner !== null && lock.holder === owner)
117
+ return 'mine';
118
+ const since = Date.parse(lock.since);
119
+ return Number.isNaN(since) || now.getTime() - since > LOCK_STALE_MS ? 'stale' : 'held';
120
+ }
121
+ // True when something accepts connections on the port, on IPv4 or IPv6 loopback.
122
+ export async function portListening(port) {
123
+ const probe = (host) => new Promise((done) => {
124
+ const socket = connect({ host, port });
125
+ const finish = (open) => {
126
+ socket.destroy();
127
+ done(open);
128
+ };
129
+ socket.setTimeout(300, () => finish(false));
130
+ socket.once('connect', () => finish(true));
131
+ socket.once('error', () => finish(false));
132
+ });
133
+ return (await Promise.all(['127.0.0.1', '::1'].map(probe))).some(Boolean);
134
+ }
135
+ // Releases every lock the holder has, such as a retired worker's item.
136
+ export function releaseLocks(store, holder, reason) {
137
+ for (const lock of store.listLocks()) {
138
+ if (lock.holder !== holder)
139
+ continue;
140
+ store.deleteLock(lock.name);
141
+ store.appendEvent({ data: { holder, reason }, kind: 'lock.released', subject: lock.name });
142
+ }
143
+ }
144
+ function profileOf(project) {
145
+ if (project.profile)
146
+ return project.profile;
147
+ throw new ConductorError(`project ${project.name}'s stored profile is invalid: ${project.problem ?? 'unknown'}`, [
148
+ '`conduct project add <path>` reads it again',
149
+ ]);
150
+ }
151
+ function samePorts(a, b) {
152
+ return Object.keys(a).length === Object.keys(b).length && Object.entries(b).every(([name, port]) => a[name] === port);
153
+ }
154
+ //# sourceMappingURL=broker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broker.js","sourceRoot":"","sources":["../src/broker.ts"],"names":[],"mappings":"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"}
package/build/cli.js CHANGED
@@ -5,6 +5,7 @@ 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 { heldLocks, lockCommand, lockLine, portsCommand } from "./broker-cli.js";
8
9
  import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
9
10
  import { loadConfig } from "./config.js";
10
11
  import { CONSOLE_ARGS, CONSOLE_WINDOW, consoleProject, consoleTabArgs, escalationLines, escalationSchema, handledSchema } from "./console.js";
@@ -38,7 +39,7 @@ const USAGE = `Usage: conduct <command>
38
39
  start start the daemon detached (the logon task, else a detached serve) and
39
40
  wait until it answers; nothing when it runs already
40
41
  stop stop the running daemon
41
- status [--json] the daemon's pid, port, projects and workers
42
+ status [--json] the daemon's pid, port, projects, workers and held locks
42
43
  project add <path> register the project whose checkout holds .claude/conductor.json
43
44
  project list [--json] the registered projects
44
45
  launch <item> start a worker: its worktree, a background session, a tab
@@ -103,6 +104,16 @@ const USAGE = `Usage: conduct <command>
103
104
  time <when> such as 17:00 (the next one) or 2026-10-10T09:00 (local)
104
105
  <full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
105
106
  [--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
107
+ ports [<path>] [--prefer <slot>] [--json]
108
+ the checkout's dev ports from the port broker: its project's base ports
109
+ + its slot, which no other project's worktree shares (a main checkout:
110
+ slot 0); --prefer asks for a slot first. Without <path>, every worktree's.
111
+ Exit 3 when no conductor brokered it, so the caller keeps its own ports
112
+ lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
113
+ a lock every project shares, such as chrome for verify runs; stale 30 min
114
+ after its holder last acquired it (acquire again to keep it). The owner is
115
+ this checkout's worker's item unless given; --wait polls. Prints JSON;
116
+ exit 3 when no conductor answers
106
117
  events [--since <id>] the event log after <id> (all of it without), one JSON event per line
107
118
  events --follow [--since <id>]
108
119
  keep following it (live without --since), reconnecting when the
@@ -218,6 +229,10 @@ async function awaitDaemon(paths) {
218
229
  }
219
230
  throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
220
231
  }
232
+ // What `conduct ports` and `conduct lock` print to and run in.
233
+ function brokerIo() {
234
+ return { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out };
235
+ }
221
236
  // `conduct console`: the console (design note, section 11), the Claude session the conductor mod
222
237
  // serves, started with CONDUCTOR_CONSOLE=1 in the project's main checkout, where the console skill
223
238
  // is installed. It keeps no state of its own, so a new one after a close loses nothing.
@@ -442,6 +457,8 @@ async function main(argv) {
442
457
  here: { type: 'boolean' },
443
458
  json: { type: 'boolean' },
444
459
  kind: { type: 'string' },
460
+ owner: { type: 'string' },
461
+ prefer: { type: 'string' },
445
462
  probe: { type: 'boolean' },
446
463
  project: { type: 'string' },
447
464
  prompt: { type: 'string' },
@@ -453,6 +470,7 @@ async function main(argv) {
453
470
  text: { type: 'string' },
454
471
  until: { type: 'string' },
455
472
  version: { short: 'v', type: 'boolean' },
473
+ wait: { type: 'string' },
456
474
  weight: { type: 'string' },
457
475
  worker: { type: 'string' },
458
476
  },
@@ -502,6 +520,15 @@ async function main(argv) {
502
520
  project: values.project,
503
521
  prompt: values.prompt,
504
522
  });
523
+ case 'lock': {
524
+ if (sub === undefined || arg === undefined)
525
+ throw new ConductorError('usage: conduct lock <acquire|release|status> <name> [--owner <id>] [--wait <minutes>]');
526
+ const waitMinutes = values.wait === undefined ? undefined : Number(values.wait);
527
+ if (waitMinutes !== undefined && !(waitMinutes >= 0))
528
+ throw new ConductorError('--wait takes minutes, such as --wait 30');
529
+ process.exitCode = await lockCommand(paths, sub, arg, { owner: values.owner, waitMinutes }, brokerIo());
530
+ return;
531
+ }
505
532
  case 'note':
506
533
  if (sub === undefined || arg === undefined)
507
534
  throw new ConductorError('usage: conduct note <item> <file> [--worker <worker>]');
@@ -537,6 +564,9 @@ async function main(argv) {
537
564
  });
538
565
  case 'pause':
539
566
  return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
567
+ case 'ports':
568
+ process.exitCode = await portsCommand(paths, sub, { json: values.json === true, ...(values.prefer === undefined ? {} : { prefer: wholeNumber('--prefer', values.prefer) }) }, brokerIo());
569
+ return;
540
570
  case 'queue':
541
571
  return queue(paths, values.json === true, values.fresh === true);
542
572
  case 'rules':
@@ -823,15 +853,22 @@ async function status(paths, json) {
823
853
  const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
824
854
  // A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
825
855
  const items = await openItems(paths);
856
+ // Null from a daemon older than the broker.
857
+ const locks = await heldLocks(paths);
826
858
  const pid = readPid(paths.pid);
827
859
  const port = readEndpoint(paths)?.port ?? null;
828
860
  if (json)
829
- return out(JSON.stringify({ home: paths.home, inbox: items, pid, port, projects, running: true, workers }, null, 2));
861
+ return out(JSON.stringify({ home: paths.home, inbox: items, locks, pid, port, projects, running: true, workers }, null, 2));
830
862
  out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
831
863
  out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
832
864
  projects.forEach((p) => out(projectLine(p)));
833
865
  out(workers.length === 0 ? 'no workers' : 'workers:');
834
866
  workers.forEach((w) => out(workerLine(w)));
867
+ if (locks === null)
868
+ out('locks: not in this daemon (restart it to run this build)');
869
+ else
870
+ out(locks.length === 0 ? 'locks: none held' : 'locks:');
871
+ locks?.forEach((l) => out(lockLine(l)));
835
872
  if (items === null)
836
873
  out('inbox: not in this daemon (restart it to run this build)');
837
874
  else