@detergent-software/conductor 1.0.0-dev.16 → 1.0.0-dev.18

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.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)
6
+
7
+ ### Features
8
+
9
+ * **conductor:** port broker and shared locks across projects (ATK-318) ([90a46d0](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/90a46d0827dd805668fc98c11b4082487f9bd806))
10
+
11
+ ### Bug Fixes
12
+
13
+ * **conductor:** review fixes for the port broker (ATK-318) ([db0bf0d](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/db0bf0de8d0404d155ad2d95ac32923ab5d0fb7f))
14
+
15
+ ## [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)
16
+
17
+ ### Features
18
+
19
+ * **conductor:** work skill aliases and the profile's work section (ATK-317) ([57ee626](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/57ee626dff92b9975a167a33494d4c7098de0119))
20
+
21
+ ### Bug Fixes
22
+
23
+ * **conductor:** one note check, any alias name, profile-led tests (ATK-317) ([df80352](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/commit/df803529b916db61ed96e5febb305f97b15d2b23))
24
+
5
25
  ## [1.0.0-dev.16](https://emergentcode.visualstudio.com/Emergent%20Software/_git/Emergent.AgenticToolkit/branchCompare?baseVersion=GTconductor-v1.0.0-dev.15&targetVersion=GTconductor-v1.0.0-dev.16) (2026-10-11)
6
26
 
7
27
  ### Bug Fixes
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
 
@@ -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)
@@ -80,6 +84,8 @@ Worker statuses: `launching`, `running`, `parked`, `stopped-unexpectedly`, `fail
80
84
 
81
85
  A worker writes each phase note (`## <work skill> · <Phase>[ · <kind>: <one line>]`, such as `## work-issue · PR opened`) with `conduct note <item> <file>`. The daemon posts the file as a comment on the item in the tracker, then records the phase at once: on the worker (`phase` in `GET /fleet` and `conduct status`), on the item, and as a `note.posted` event. The worker is `--worker <worker>`, else the one whose worktree is the current checkout, else the project's live worker on that item. The project is the worker's, else the one whose tracker team is the item's key prefix.
82
86
 
87
+ The work skill is the profile's `skills.work`. A note headed with one of `skills.aliases` counts the same, there and wherever the daemon reads notes (approvals, `conduct wait item … note`, the review-thread checks of `conduct wait pr … comment`). So when a project renames its work skill (ATK's `work-issue` became `work-item`), the old name goes in `aliases` and items in flight keep their phases.
88
+
83
89
  | Exit | Means | The worker then |
84
90
  |---|---|---|
85
91
  | 0 | posted | goes on |
@@ -252,6 +258,28 @@ A worker waits by posting its note, running `conduct wait …` and ending the tu
252
258
 
253
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.
254
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
+ - Exit 3 when no daemon answers.
280
+
281
+ Events: `ports.assigned`, `ports.freed`, `lock.acquired`, `lock.released`, `lock.taken-over`.
282
+
255
283
  ## History
256
284
 
257
285
  `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).
@@ -322,6 +350,8 @@ Everything lives in `~/.conductor` (`CONDUCTOR_HOME` overrides it):
322
350
  - `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.
323
351
  - `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.
324
352
  - `POST /rules/test` with `{ "event"?: "pr.comment", "project"?: "atk", "fields"?: { … } }`: a dry run; 400 for a sample it can't read.
353
+ - `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.
354
+ - `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.
325
355
  - `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.
326
356
  - A refusal (a duplicate launch, a bad condition, a busy worker) is a 409 with `error` and `details`.
327
357
 
@@ -335,7 +365,20 @@ conduct install-startup
335
365
  conduct project add <path to the project's main checkout>
336
366
  ```
337
367
 
338
- `conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout.
368
+ `conduct project add` walks up from the path it is given to the directory that holds `.claude/conductor.json`, and maps a worktree to its main checkout. The daemon reloads a profile's `rules` when the file changes; every other change, such as a new `skills.work`, takes effect at the next `conduct project add`. An older daemon refuses a profile with fields it doesn't know, so upgrade the daemon before a profile that uses new ones lands. The same holds for the copy the daemon stored at `conduct project add`: after an `--allow-downgrade` to a version without those fields, the project shows `the stored profile no longer validates` and isn't conducted until a profile without them is added again.
369
+
370
+ The profile's optional `work` section is for the project's work skill; the daemon only checks it:
371
+
372
+ ```jsonc
373
+ "work": {
374
+ // Changes under these paths, and plans of these size tiers, land in review mode.
375
+ "landing": { "review": ["infra/", "pipelines/"], "reviewTiers": ["L"] },
376
+ // The pipelines that stage an npm package for a merge, by the key the work skill reports them under.
377
+ "releases": [{ "key": "cli", "pipeline": "pipelines/release-cli.yml", "package": "@detergent-software/atk" }],
378
+ // The project's own steps, which the work skill follows at its hook points.
379
+ "hooks": ".claude/work-hooks.md"
380
+ }
381
+ ```
339
382
 
340
383
  The logon task runs `node` on the `cli.js` that ran `install-startup`. A global install keeps that path across upgrades, so the task needs registering once. To upgrade:
341
384
 
@@ -0,0 +1,106 @@
1
+ import { resolve } from 'node:path';
2
+ import { z } from 'zod';
3
+ import { callApi, NotRunningError } from "./client.js";
4
+ import { ConductorError } from "./errors.js";
5
+ import { portAssignmentSchema } from "./store.js";
6
+ import { daemonWorker, gitToplevel } from "./wait.js";
7
+ // Exit code when no daemon brokered the request: none runs, an older one has no broker, or it
8
+ // refused (a checkout in no registered project, no free slot). The caller keeps its own scheme,
9
+ // as ATK's scripts/dev-ports.mjs and the work-issue helper's file lock do.
10
+ export const NOT_BROKERED = 3;
11
+ // How often `conduct lock acquire --wait` asks again, as the work-issue helper's lock does.
12
+ const LOCK_POLL_MS = 15_000;
13
+ const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
14
+ const lockAnswer = z.looseObject({ acquired: z.boolean().optional(), lock: z.string() });
15
+ // `conduct lock <acquire|release|status> <name>`: the broker's named lock, such as chrome, which
16
+ // verify skills of every project take turns with. Prints the work-issue helper's JSON. The owner
17
+ // is --owner, else the item of the worker whose worktree this is.
18
+ export async function lockCommand(paths, action, name, options, io) {
19
+ if (action !== 'acquire' && action !== 'release' && action !== 'status')
20
+ throw new ConductorError('usage: conduct lock <acquire|release|status> <name> [--owner <id>] [--wait <minutes>]');
21
+ if (!/^[\w-]+$/.test(name))
22
+ throw new ConductorError(`a lock name is letters, digits, _ and -, not "${name}"`);
23
+ let owner = options.owner?.trim() || null;
24
+ if (owner === null) {
25
+ const worker = await daemonWorker(paths, await gitToplevel(io.cwd), undefined);
26
+ if (worker === 'no-daemon')
27
+ return notBrokered(io, 'the conductor is not running');
28
+ if (worker === 'unreachable')
29
+ throw new ConductorError("the conductor didn't answer");
30
+ owner = worker?.item ?? null;
31
+ if (owner === null && action !== 'status')
32
+ throw new ConductorError('no worker of the conductor works in this checkout: pass --owner <id>');
33
+ }
34
+ const until = Date.now() + (options.waitMinutes ?? 0) * 60_000;
35
+ const sleep = io.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
36
+ for (;;) {
37
+ const answer = await ask(paths, { body: { action, ...(owner === null ? {} : { owner }) }, method: 'POST', path: `/locks/${name}` });
38
+ if (!answer.ok)
39
+ return notBrokered(io, answer.why);
40
+ const body = lockAnswer.parse(answer.body);
41
+ if (action !== 'acquire' || body.acquired !== false || Date.now() >= until) {
42
+ io.out(JSON.stringify(body, null, 2));
43
+ return 0;
44
+ }
45
+ await sleep(LOCK_POLL_MS);
46
+ }
47
+ }
48
+ // `conduct ports [<path>]`: the checkout's dev ports from the broker, or every assignment.
49
+ export async function portsCommand(paths, path, options, io) {
50
+ if (path === undefined) {
51
+ const answer = await ask(paths, { method: 'GET', path: '/ports' });
52
+ if (!answer.ok)
53
+ return notBrokered(io, answer.why);
54
+ const assignments = z.object({ assignments: z.array(portAssignmentSchema) }).parse(answer.body).assignments;
55
+ if (options.json)
56
+ io.out(JSON.stringify(assignments, null, 2));
57
+ else if (assignments.length === 0)
58
+ io.out('no worktree has ports from the conductor');
59
+ else
60
+ assignments.forEach((a) => io.out(portsLine(a)));
61
+ return 0;
62
+ }
63
+ const answer = await ask(paths, {
64
+ body: { worktree: resolve(io.cwd, path), ...(options.prefer === undefined ? {} : { prefer: options.prefer }) },
65
+ method: 'POST',
66
+ path: '/ports',
67
+ });
68
+ if (!answer.ok)
69
+ return notBrokered(io, answer.why);
70
+ const { assignment } = z.object({ assignment: portAssignmentSchema }).parse(answer.body);
71
+ io.out(options.json ? JSON.stringify(assignment, null, 2) : portsLine(assignment));
72
+ return 0;
73
+ }
74
+ // One line per checkout: project, slot, each port, then where.
75
+ export function portsLine(a) {
76
+ const ports = Object.entries(a.ports)
77
+ .map(([name, port]) => `${name} ${port}`)
78
+ .join(' · ');
79
+ return `${a.project} slot ${a.slot} ${ports} ${a.worktree}`;
80
+ }
81
+ // The body of a 200 answer, or why nothing was brokered.
82
+ async function ask(paths, request) {
83
+ let answer;
84
+ try {
85
+ answer = await callApi(paths, request);
86
+ }
87
+ catch (error) {
88
+ if (error instanceof NotRunningError)
89
+ return { ok: false, why: 'the conductor is not running' };
90
+ throw error;
91
+ }
92
+ if (answer.status === 200)
93
+ return { body: answer.body, ok: true };
94
+ // A daemon from before the broker has no such route.
95
+ if (answer.status === 404)
96
+ return { ok: false, why: 'this conductor has no port broker (upgrade it)' };
97
+ const refusal = errorBody.safeParse(answer.body).data;
98
+ if (answer.status === 409 && refusal)
99
+ return { ok: false, why: [refusal.error, ...(refusal.details ?? [])].join('; ') };
100
+ throw new ConductorError(refusal?.error ?? `the API answered ${answer.status}`, refusal?.details ?? []);
101
+ }
102
+ function notBrokered(io, why) {
103
+ io.err(`not brokered: ${why}`);
104
+ return NOT_BROKERED;
105
+ }
106
+ //# 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 { lockCommand, 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";
@@ -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':