agents-pod 0.0.0-stage → 0.2.0

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/src/cli.ts ADDED
@@ -0,0 +1,1864 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import {
5
+ access,
6
+ appendFile,
7
+ mkdir,
8
+ readFile,
9
+ readdir,
10
+ rm,
11
+ stat,
12
+ unlink,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import {
18
+ agentTypeRefusal,
19
+ isAgentType,
20
+ readAgents,
21
+ resolveAgentDefinition,
22
+ writeAgents,
23
+ } from "./agents-store.ts";
24
+ import { renderTemplate, scanInbox, type InboxItem } from "./inbox.ts";
25
+ import {
26
+ claimKey,
27
+ newListenerId,
28
+ readListeners,
29
+ writeListeners,
30
+ type ListenerRecord,
31
+ } from "./listeners.ts";
32
+ import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
33
+ import { loadNotifyConfig } from "./notify/config.ts";
34
+ import { dispatchNotification, shouldNotify } from "./notify/dispatch.ts";
35
+ import type { NotificationsConfig, PodNotification } from "./notify/types.ts";
36
+ import { cronLogPath, delivererFile, podDir, reconcileLockPath } from "./paths.ts";
37
+ import { readDeliverer, retryOutbox, writeDeliverer } from "./outbox.ts";
38
+ import {
39
+ projectHasCapacity,
40
+ projectMaxActive,
41
+ readProjects,
42
+ writeProjects,
43
+ } from "./projects.ts";
44
+ import type { PodRecord } from "./registry.ts";
45
+ import { readPods, withRegistryLock, writePods } from "./registry.ts";
46
+ import {
47
+ commitSourceCursors,
48
+ drainSources,
49
+ pruneSourceClaims,
50
+ readSources,
51
+ writeSources,
52
+ type SourceDrain,
53
+ } from "./sources.ts";
54
+ import {
55
+ HEARTBEAT_STALE_MS,
56
+ WEDGED_ESCALATE_MS,
57
+ livenessFacts,
58
+ pidDead,
59
+ readWrapperRecord,
60
+ verifyProcess,
61
+ wrapperJsonPath,
62
+ wrapperVerifiablyDead,
63
+ type LivenessFacts,
64
+ } from "./liveness.ts";
65
+ import { appendInbox, createPodWorkspace, snapshotDocs, type DocFacts } from "./workspace.ts";
66
+ import { resolveAgentBinary, wrapperScript } from "./wrapper.ts";
67
+
68
+ /**
69
+ * Spawn and wake name their agent explicitly; the wrapper resolves it (ADR
70
+ * 0012). `--warm` is a wrapper argument, not an agent one (ADR 0011): the
71
+ * wrapper parses it and points the agent at the lost wake-up's session.
72
+ */
73
+ function launchWrapperDetached(podId: string, agentId?: string, warm = false): void {
74
+ const args = [wrapperScript(), podId, ...(agentId ? [agentId] : []), ...(warm ? ["--warm"] : [])];
75
+ const child = spawn(process.execPath, args, {
76
+ detached: true,
77
+ stdio: "ignore",
78
+ });
79
+ child.unref();
80
+ }
81
+
82
+ /**
83
+ * Flags may appear anywhere among the goal words (README's CLI surface), and
84
+ * the words that are not flags compose the goal. A flag without a value is
85
+ * left unset and surfaces through the command's own validation.
86
+ */
87
+ function parseFlags(
88
+ words: string[],
89
+ allowed: readonly string[],
90
+ ): { flags: Record<string, string>; rest: string[] } {
91
+ const flags: Record<string, string> = {};
92
+ const rest: string[] = [];
93
+ for (let index = 0; index < words.length; index += 1) {
94
+ const word = words[index]!;
95
+ const name = word.startsWith("--") ? word.slice(2) : undefined;
96
+ const value = words[index + 1];
97
+ if (name !== undefined && allowed.includes(name) && value !== undefined) {
98
+ flags[name] = value;
99
+ index += 1;
100
+ } else {
101
+ rest.push(word);
102
+ }
103
+ }
104
+ return { flags, rest };
105
+ }
106
+
107
+ /** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
108
+ function backoffMs(streak: number): number {
109
+ return Math.min(60_000 * 2 ** (streak - 1), 900_000);
110
+ }
111
+
112
+ /**
113
+ * Signal a pod's process group — never our own. The pgid comes from store
114
+ * data only (wrapper.json, ADR 0003), and the guards make a corrupt record a
115
+ * no-op instead of a self-kill.
116
+ */
117
+ function signalGroup(pgid: number, signal: NodeJS.Signals): boolean {
118
+ if (!Number.isInteger(pgid) || pgid <= 1 || pgid === process.pid) return false;
119
+ try {
120
+ process.kill(-pgid, signal);
121
+ return true;
122
+ } catch {
123
+ return false; // the group is already gone
124
+ }
125
+ }
126
+
127
+ /** The lost detail names the fact that condemned the wake-up (ADR 0002, 0020). */
128
+ function lostDetail(facts: LivenessFacts): string {
129
+ switch (facts.lostCause) {
130
+ case "ghost":
131
+ return "ghost record (no wrapper.json)";
132
+ case "reboot":
133
+ return "machine rebooted";
134
+ default:
135
+ return "wrapper gone, heartbeat stale";
136
+ }
137
+ }
138
+
139
+ /** The highest wake-up number on disk: a ghost is numbered by its directory. */
140
+ async function maxWakeupNumber(pod: PodRecord): Promise<number | undefined> {
141
+ const dir = join(podDir(pod.project, pod.id), "wakeups");
142
+ const entries = await readdir(dir).catch(() => []);
143
+ const numbers = entries
144
+ .map((name) => Number.parseInt(name, 10))
145
+ .filter((n) => Number.isInteger(n));
146
+ return numbers.length > 0 ? Math.max(...numbers) : undefined;
147
+ }
148
+
149
+ async function livenessOf(pod: PodRecord): Promise<string> {
150
+ return (await livenessFacts(pod)).quadrant;
151
+ }
152
+
153
+ /**
154
+ * Staleness is a TTL of five minutes of wall clock, never pid liveness
155
+ * (ADR 0018): a live-but-wedged holder must not wedge every later tick, and a
156
+ * reused pid must not read as alive.
157
+ */
158
+ const LOCK_STALE_MS = 300_000;
159
+
160
+ /** The progress window: 60 minutes of a live wake-up with no document change (ADR 0019). */
161
+ const PROGRESS_WINDOW_MS = 3_600_000;
162
+
163
+ /** The verdict a progress kill records, in reconcile's own word (ADR 0019). */
164
+ const PROGRESSLESS_DETAIL = "progressless (killed by reconcile)";
165
+
166
+ /**
167
+ * ADR 0019's only progress signal: any of the three documents moving mtime or
168
+ * size. A file appearing or vanishing counts; the pod log is never read — a
169
+ * looping printer is the thief this rule exists to catch.
170
+ */
171
+ async function documentsMoved(
172
+ docsDir: string,
173
+ baseline: Record<string, DocFacts>,
174
+ ): Promise<boolean> {
175
+ const current = await snapshotDocs(docsDir);
176
+ for (const name of new Set([...Object.keys(baseline), ...Object.keys(current)])) {
177
+ const before = baseline[name];
178
+ const after = current[name];
179
+ if (before === undefined || after === undefined) return true; // appeared or vanished
180
+ if (before.mtimeMs !== after.mtimeMs || before.size !== after.size) return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ async function exists(file: string): Promise<boolean> {
186
+ return stat(file).then(
187
+ () => true,
188
+ () => false,
189
+ );
190
+ }
191
+
192
+ /** Overlap is audited, not silent: one line per steal (ADR 0018). */
193
+ async function appendCronLog(line: string): Promise<void> {
194
+ const file = cronLogPath();
195
+ await mkdir(dirname(file), { recursive: true });
196
+ await appendFile(file, `${line}\n`);
197
+ }
198
+
199
+ /**
200
+ * The single-flight acquire (ADR 0018). `false` means a fresh holder owns the
201
+ * pass: the tick exits 0 and loses nothing, because passes are idempotent. A
202
+ * lock past its TTL is stolen and the steal recorded. Two stealers both
203
+ * unlink and both race the exclusive create — one wins, the other re-reads a
204
+ * fresh lock and skips; no arbitration needed.
205
+ */
206
+ async function acquireReconcileLock(): Promise<boolean> {
207
+ const file = reconcileLockPath();
208
+ const now = Date.now();
209
+ const held = await readLock(file);
210
+ if (held !== undefined && now - held.startedAt < LOCK_STALE_MS) return false;
211
+ if (held === undefined && !(await exists(file))) {
212
+ return acquireLock(file, { pid: process.pid, startedAt: now });
213
+ }
214
+
215
+ // A lock file whose contents cannot be parsed is stale by definition:
216
+ // nothing about it can prove the holder is recent.
217
+ await unlink(file).catch(() => undefined);
218
+ const stolen = await acquireLock(file, { pid: process.pid, startedAt: now });
219
+ if (stolen) {
220
+ await appendCronLog(
221
+ held === undefined
222
+ ? "reconcile lock stolen from pid unknown (unreadable lock)"
223
+ : `reconcile lock stolen from pid ${held.pid} (${Math.floor((now - held.startedAt) / 1000)}s stale)`,
224
+ );
225
+ }
226
+ return stolen;
227
+ }
228
+
229
+ /**
230
+ * Reconcile is a stateless, idempotent pass (ADR 0002): it derives policy from
231
+ * outcome facts the wrapper recorded, then walks the single wake path —
232
+ * idle + goal open + not parked + now ≥ nextWakeAt (ADR 0011). The wrapper
233
+ * records facts; the pass writes policy; neither does the other's job.
234
+ *
235
+ * The pass also consumes this episode's notification (ADR 0021): the moment is
236
+ * queued while policy is derived, recorded in `notifiedAt`, and delivered
237
+ * after the locks are gone — a slow channel never holds the registry.
238
+ *
239
+ * Sources drain before the body takes the registry lock (ADR 0024). The body
240
+ * then re-reads pods.json there, so each transition is a compare-and-swap
241
+ * against the record as it is now. The single-flight lock above is a courtesy;
242
+ * this is the correctness.
243
+ */
244
+ async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
245
+ if (!(await acquireReconcileLock())) return 0;
246
+ const warnings: string[] = [];
247
+ const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
248
+ // Listener-spawned pods default to the tick's project; `listener add
249
+ // --project` overrides that destination per Listener (ADR 0007, ADR 0004).
250
+ const project = deps.cwd ?? process.cwd();
251
+ let pending: PodNotification[] = [];
252
+ try {
253
+ // A slow drain holds no registry lock and runs before any Pod is touched.
254
+ const drained = await drainSources(project, await readListeners());
255
+ for (const result of drained) {
256
+ if (result.error !== undefined) {
257
+ await appendCronLog(
258
+ `source ${result.name}: drain failed (${result.error}); no items, cursor unchanged`,
259
+ );
260
+ }
261
+ for (const bad of result.bad) {
262
+ await appendCronLog(
263
+ `source ${result.name}: line ${bad.line}: ${bad.reason} (skipped, unclaimed)`,
264
+ );
265
+ }
266
+ }
267
+ // Delivery is the drain's mirror: network-bound, so outside the lock
268
+ // (ADRs 0024, 0025). Every Pod that is not active gets the Wrapper's own
269
+ // bounded flush; the state is re-read per Pod so a wake that started
270
+ // meanwhile keeps its Wrapper the only writer over that outbox.
271
+ for (const pod of await readPods()) {
272
+ if (pod.state === "active") continue;
273
+ const fresh = await podState(pod.id);
274
+ if (fresh === undefined || fresh === "active") continue;
275
+ await retryOutbox(pod.id, pod.project);
276
+ }
277
+ pending =
278
+ (await withRegistryLock(() => reconcilePass(notify, project, drained))) ?? [];
279
+ } finally {
280
+ await releaseLock(reconcileLockPath());
281
+ }
282
+ // A bad config degrades, it never kills the pass (ADR 0021): the warnings
283
+ // and any delivery failure are cron.log lines, and nothing is retried.
284
+ for (const warning of warnings) await appendCronLog(`notify: ${warning}`);
285
+ // Independent pods are announced in parallel: one hung channel spends one
286
+ // ten-second budget, not one per parked pod (ADR 0021).
287
+ const deliveries = await Promise.all(
288
+ pending.map((notification) => dispatchNotification(notify, notification)),
289
+ );
290
+ for (const notes of deliveries) {
291
+ for (const note of notes) await appendCronLog(`notify: ${note}`);
292
+ }
293
+ return 0;
294
+ }
295
+
296
+ async function reconcilePass(
297
+ notify: NotificationsConfig,
298
+ project: string,
299
+ drained: SourceDrain[],
300
+ ): Promise<PodNotification[]> {
301
+ const pods = await readPods();
302
+ const agents = await readAgents();
303
+ const now = Date.now();
304
+ let changed = false;
305
+ const pending: PodNotification[] = [];
306
+
307
+ for (const pod of pods) {
308
+ pod.policy ??= { failureStreak: 0, appliedOutcomes: 0 };
309
+
310
+ // Once per episode (ADR 0021): the flip announces, the marker records.
311
+ // A human wake clears the marker, so the next park is a new episode.
312
+ const announcePark = (reason?: string): void => {
313
+ if (pod.notifiedAt !== undefined) return;
314
+ if (!shouldNotify(notify.on, "park")) return;
315
+ pending.push(parkNotification(pod, reason));
316
+ pod.notifiedAt = now;
317
+ changed = true;
318
+ };
319
+
320
+ // 1. Liveness adjudication for active pods first (ADR 0002, 0017): a
321
+ // ghost or crash becomes a `lost` outcome this pass records, so the
322
+ // policy derivation below sees it and backoff applies before the wake
323
+ // path could touch the pod again.
324
+ if (pod.state === "active") {
325
+ const facts = await livenessFacts(pod);
326
+ if (facts.quadrant === "lost") {
327
+ const n = facts.wrapper?.wakeup ?? (await maxWakeupNumber(pod)) ?? 1;
328
+ pod.wakeUps.push({ n, outcome: "lost", detail: lostDetail(facts) });
329
+ pod.state = "idle";
330
+ delete pod.wedgedAt; // a lost wake-up spends any wedged observation
331
+ changed = true;
332
+ // Orphans are reaped, never adopted (ADR 0003): if the agent's group
333
+ // survives its wrapper, the group goes — nothing here can `wait()` it.
334
+ // The reap is three-verified too (ADR 0020): only a wrapper that is
335
+ // verifiably dead on this same boot licenses a group signal, so a
336
+ // post-reboot pgid — a stale number — is never touched.
337
+ if (facts.wrapper !== undefined && (await wrapperVerifiablyDead(facts.wrapper))) {
338
+ signalGroup(facts.wrapper.pgid, "SIGKILL");
339
+ }
340
+ } else if (facts.quadrant === "wedged-pending") {
341
+ // The two-pass rule (ADR 0017): the first pass records a stably stale
342
+ // observation; the confirming pass runs cancel's signal path, records
343
+ // `lost`, and removes the identity. No grace wait here — a wedged
344
+ // wrapper's trap may be dead, and the next pass collects the group.
345
+ if (pod.wedgedAt === undefined) {
346
+ pod.wedgedAt = now;
347
+ changed = true;
348
+ } else if (
349
+ facts.wrapper !== undefined &&
350
+ (facts.beatAgeMs === undefined || facts.beatAgeMs >= WEDGED_ESCALATE_MS)
351
+ ) {
352
+ // Three-verified (ADR 0020): a live pid whose start time is not the
353
+ // recorded one is somebody else. The wake-up is still lost — the
354
+ // record cannot be trusted — but nothing is signalled. The intent
355
+ // lands first so a live trap defers to this verdict instead of
356
+ // overwriting it with `cancelled` (the same deference ADR 0019
357
+ // gives the progress kill — one mechanism, two causes).
358
+ const trusted = await verifyProcess(facts.wrapper);
359
+ if (trusted) {
360
+ pod.killIntent = { n: facts.wrapper.wakeup, at: now, cause: "wedged" };
361
+ await writePods(pods);
362
+ signalGroup(facts.wrapper.pgid, "SIGTERM");
363
+ }
364
+ pod.wakeUps.push({
365
+ n: facts.wrapper.wakeup,
366
+ outcome: "lost",
367
+ detail: trusted
368
+ ? "wedged wrapper killed by reconcile"
369
+ : "wedged wrapper signal withheld (recorded process is gone)",
370
+ });
371
+ pod.state = "idle";
372
+ delete pod.wedgedAt;
373
+ await rm(wrapperJsonPath(pod.project, pod.id), { force: true });
374
+ changed = true;
375
+ }
376
+ } else if (facts.quadrant === "healthy") {
377
+ // Beats resumed: the wake-from-sleep race closes (ADR 0017).
378
+ if (pod.wedgedAt !== undefined) {
379
+ delete pod.wedgedAt;
380
+ changed = true;
381
+ }
382
+ // Liveness outranks progress (ADR 0019): only a healthy wake-up — pid
383
+ // live, heartbeat fresh — is judged on its documents at all.
384
+ const wrapper = facts.wrapper;
385
+ if (
386
+ wrapper !== undefined &&
387
+ wrapper.docs !== undefined &&
388
+ now - wrapper.startedAt >= PROGRESS_WINDOW_MS &&
389
+ !(await documentsMoved(join(podDir(pod.project, pod.id), "docs"), wrapper.docs))
390
+ ) {
391
+ // Three-verified (ADR 0020): a live pid whose start time is not the
392
+ // recorded one is somebody else — nothing to judge, nothing to kill.
393
+ if (await verifyProcess(wrapper)) {
394
+ // Intent first (ADR 0019): the registry-lock write lands before the
395
+ // signal, so the verdict is on disk before it is acted on, and the
396
+ // wrapper sees it and records no second outcome. The pass waits for
397
+ // nothing — the wrapper owns the wind-down (ADR 0017).
398
+ pod.killIntent = { n: wrapper.wakeup, at: now, cause: "progressless" };
399
+ await writePods(pods);
400
+ signalGroup(wrapper.pgid, "SIGTERM");
401
+ pod.wakeUps.push({
402
+ n: wrapper.wakeup,
403
+ outcome: "failed",
404
+ detail: PROGRESSLESS_DETAIL,
405
+ });
406
+ pod.state = "idle";
407
+ changed = true;
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ // Delivery retries ran outside this lock, before the pass (ADR 0025).
414
+ // Delivery failure writes only cron.log, never Pod policy or the
415
+ // two-event notification inventory (ADRs 0021, 0025).
416
+
417
+ // 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
418
+ const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
419
+ for (const wake of fresh) {
420
+ if (wake.outcome === "failed" || wake.outcome === "lost") {
421
+ pod.policy.failureStreak += 1;
422
+ pod.policy.nextWakeAt = now + backoffMs(pod.policy.failureStreak);
423
+ // Backoff without a stop is a money pit: streak 3 parks the pod, and
424
+ // only a human wake lifts it (ADR 0011). Park is policy data, not a state.
425
+ if (pod.policy.failureStreak >= 3 && !pod.policy.parked) {
426
+ pod.policy.parked = true;
427
+ announcePark();
428
+ }
429
+ } else if (wake.outcome === "more" || wake.outcome === "done") {
430
+ pod.policy.failureStreak = 0;
431
+ delete pod.policy.nextWakeAt;
432
+ }
433
+ pod.policy.appliedOutcomes += 1;
434
+ changed = true;
435
+ }
436
+
437
+ const goalOpen = pod.state !== "done" && pod.state !== "cancelled";
438
+
439
+ // 3. Terminal observation (ADR 0021): done is announced once, however many
440
+ // passes later find the record. The marker is what makes it once.
441
+ if (
442
+ pod.state === "done" &&
443
+ pod.notifiedAt === undefined &&
444
+ shouldNotify(notify.on, "done")
445
+ ) {
446
+ const handoff = await handoffStatusLine(pod);
447
+ pending.push({
448
+ event: "done",
449
+ podId: pod.id,
450
+ goal: goalHeadline(pod.goal),
451
+ ...(handoff ? { handoffStatus: handoff } : {}),
452
+ });
453
+ pod.notifiedAt = now;
454
+ changed = true;
455
+ }
456
+
457
+ // Deleting a definition an idle pod still references is not an error at
458
+ // `agent rm`: the orphan fails resolution here and parks (ADR 0012). An
459
+ // active pod is the wrapper's to fail — the streak machinery takes over.
460
+ // The park it causes is a park episode too, so it notifies (ADR 0021).
461
+ const agentId = pod.policy.agent ?? "default";
462
+ const agentKnown = resolveAgentDefinition(agents, agentId) !== undefined;
463
+ if (!agentKnown && goalOpen && pod.state !== "active") {
464
+ if (!pod.policy.parked) {
465
+ pod.policy.parked = true;
466
+ announcePark(`agent "${agentId}" no longer resolves (ADR 0012)`);
467
+ changed = true;
468
+ }
469
+ continue;
470
+ }
471
+ }
472
+
473
+ if (changed) await writePods(pods);
474
+
475
+ // Intake comes before the wake phase (ADR 0014): a batch appended to an idle
476
+ // pod in this pass must be served by this pass's wake — one batch, one wake,
477
+ // appended and marked before anything is launched. Claim-before-touch still
478
+ // holds (ADR 0007): the claims are written before a document is opened.
479
+ await listenersPass(project, drained);
480
+
481
+ // 4. The wake phase (ADR 0011): the guarded idle → active transition, read
482
+ // fresh because intake may have claimed, appended, or spawned since the pass
483
+ // began. A pod intake already woke is active here and is not woken twice.
484
+ await wakeDuePods(now);
485
+ return pending;
486
+ }
487
+
488
+ /**
489
+ * The wake phase: every idle, goal-open, unparked pod whose backoff expired is
490
+ * woken. It runs after intake so an item that just landed in a pod's `## Inbox`
491
+ * is served by the same pass's wake (ADR 0014), and the read is the registry's
492
+ * current state — the same compare-and-swap ADR 0018 relies on, one write.
493
+ */
494
+ async function wakeDuePods(now: number): Promise<void> {
495
+ const pods = await readPods();
496
+ const projects = await readProjects();
497
+ let changed = false;
498
+ for (const pod of pods) {
499
+ if (pod.state !== "idle" || pod.policy?.parked) continue;
500
+ if (pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) continue;
501
+ if (pod.after !== undefined) {
502
+ const upstream = pods.find((candidate) => candidate.id === pod.after);
503
+ if (upstream?.state !== "done") continue;
504
+ }
505
+ if (!projectHasCapacity(pod.project, pods, projects)) continue;
506
+ launchWrapperDetached(pod.id, pod.policy?.agent ?? "default");
507
+ pod.state = "active";
508
+ changed = true;
509
+ }
510
+ if (changed) await writePods(pods);
511
+ }
512
+
513
+ /** One Pod's state as the registry holds it now, read without any lock (ADR 0025). */
514
+ async function podState(podId: string): Promise<PodRecord["state"] | undefined> {
515
+ const pods = await readPods();
516
+ return pods.find((candidate) => candidate.id === podId)?.state;
517
+ }
518
+
519
+ /** Non-terminal materializations occupy a listener's bound, even while capacity-waiting. */
520
+ function livePod(claimedBy: string, pods: PodRecord[]): boolean {
521
+ const pod = pods.find((candidate) => candidate.id === claimedBy);
522
+ return pod !== undefined && pod.state !== "done" && pod.state !== "cancelled";
523
+ }
524
+
525
+ /** P0's match rule: a plain substring over title and body; absent means all. */
526
+ function matchesListener(rule: string | undefined, item: InboxItem): boolean {
527
+ if (rule === undefined || rule === "") return true;
528
+ return `${item.title}\n${item.body}`.includes(rule);
529
+ }
530
+
531
+ /**
532
+ * The listener pass (ADR 0007, 0013, 0014). The inbox is read-only; a claim is
533
+ * registry data written here — inside the registry lock, before any pod is
534
+ * touched — so a crash between claim and materialization is repaired by the
535
+ * next pass. Directory items are claimable only once their mtime and size held
536
+ * across two consecutive scans; JSONL malformed lines are skipped and warned
537
+ * every pass until their writer fixes them. Claims then reach their target: a
538
+ * template spawns a pod, a bound standing pod takes them as `## Inbox` blocks.
539
+ */
540
+ async function listenersPass(project: string, drained: SourceDrain[]): Promise<void> {
541
+ const listeners = await readListeners();
542
+ if (listeners.length === 0) return;
543
+
544
+ /** The inbox, not the listener, is the claim namespace (ADR 0013). */
545
+ const claimedInInbox = new Set<string>();
546
+ for (const listener of listeners) {
547
+ listener.claimed ??= [];
548
+ listener.seen ??= [];
549
+ for (const claim of listener.claimed) claimedInInbox.add(claimKey(claim.inbox, claim.item));
550
+ }
551
+
552
+ const itemsByListener = new Map<string, Map<string, InboxItem>>();
553
+ const unscannable = new Set<string>();
554
+ let changed = false;
555
+
556
+ // Phase 1: scan, hold the stability memory, and claim. Nothing is spawned yet.
557
+ for (const listener of listeners) {
558
+ const origin =
559
+ listener.source === undefined ? listener.inbox! : `source:${listener.source}`;
560
+ const sourceDrain = drained.find((candidate) => candidate.name === listener.source);
561
+ const scan =
562
+ listener.source === undefined
563
+ ? await scanInbox(listener.inbox!)
564
+ : {
565
+ items: sourceDrain?.items ?? [],
566
+ bad: [],
567
+ ...(sourceDrain?.error === undefined ? {} : { error: sourceDrain.error }),
568
+ };
569
+ const byId = new Map<string, InboxItem>();
570
+ for (const item of scan.items) if (!byId.has(item.id)) byId.set(item.id, item);
571
+ itemsByListener.set(listener.id, byId);
572
+ if (scan.error !== undefined) {
573
+ unscannable.add(listener.id);
574
+ if (listener.source === undefined) {
575
+ await appendCronLog(
576
+ `listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
577
+ );
578
+ }
579
+ continue;
580
+ }
581
+ for (const bad of scan.bad) {
582
+ await appendCronLog(
583
+ `listener ${listener.id}: ${listener.inbox} line ${bad.line}: ${bad.reason} (skipped, unclaimed)`,
584
+ );
585
+ }
586
+
587
+ let stable: InboxItem[] = scan.items;
588
+ if (listener.source === undefined) {
589
+ const seen = new Map(listener.seen.map((fact) => [fact.item, fact]));
590
+ for (const known of [...seen.keys()]) {
591
+ if (!scan.items.some((item) => item.id === known)) {
592
+ seen.delete(known); // gone from the inbox; the inbox is the writer's
593
+ changed = true;
594
+ }
595
+ }
596
+ stable = [];
597
+ for (const item of scan.items) {
598
+ const before = seen.get(item.id);
599
+ if (before !== undefined && before.mtimeMs === item.mtimeMs && before.size === item.size) {
600
+ stable.push(item);
601
+ } else {
602
+ seen.set(item.id, { item: item.id, mtimeMs: item.mtimeMs, size: item.size });
603
+ changed = true;
604
+ }
605
+ }
606
+ listener.seen = [...seen.values()];
607
+ }
608
+
609
+ const pendingClaims = listener.claimed.filter(
610
+ (claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
611
+ ).length;
612
+ const pods = await readPods();
613
+ const live = listener.claimed.filter(
614
+ (claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
615
+ ).length;
616
+ // A bound pod is served by a queue (ADR 0014), so only template spawns
617
+ // compete for its slots — and a pending claim already holds one. A Source
618
+ // claim is unbounded too: its items exist only in this drain's output and
619
+ // the cursor commits behind the claims, so an unclaimed item would be lost
620
+ // rather than re-scanned. The bound then defers materialization, below.
621
+ let room =
622
+ listener.mode === "pod" || listener.source !== undefined
623
+ ? Number.POSITIVE_INFINITY
624
+ : Math.max(0, listener.maxConcurrent - live - pendingClaims);
625
+
626
+ for (const item of stable) {
627
+ if (room <= 0) break;
628
+ const key = claimKey(origin, item.id);
629
+ if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
630
+ // One claim shape for both targets (ADR 0013): a template materializes a
631
+ // pod from it later, a bound standing pod takes it as a document in
632
+ // phase 3 (ADR 0014). Claim first, always.
633
+ listener.claimed.push({
634
+ inbox: origin,
635
+ item: item.id,
636
+ ...(sourceDrain?.drainedAt === undefined
637
+ ? {}
638
+ : { claimedAt: sourceDrain.drainedAt }),
639
+ });
640
+ claimedInInbox.add(key);
641
+ changed = true;
642
+ if (room !== Number.POSITIVE_INFINITY) room -= 1;
643
+ }
644
+ }
645
+
646
+ // The claim is on disk before any pod is touched (ADR 0007).
647
+ if (changed) await writeListeners(listeners);
648
+
649
+ // Phase 2: materialize what is claimed and still unserved.
650
+ let materialized = false;
651
+ for (const listener of listeners) {
652
+ if (listener.mode !== "template" || unscannable.has(listener.id)) continue;
653
+ const pending = () =>
654
+ listener.claimed.filter(
655
+ (claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
656
+ );
657
+ if (pending().length === 0) continue;
658
+
659
+ const body = await readFile(listener.template ?? "", "utf8").catch(() => undefined);
660
+ if (body === undefined) {
661
+ for (const claim of pending()) {
662
+ claim.blocked = `template ${listener.template} unreadable`;
663
+ await appendCronLog(
664
+ `listener ${listener.id}: item ${claim.item} materialization failed: template ${listener.template} unreadable (claim consumed, ADR 0013)`,
665
+ );
666
+ }
667
+ materialized = true;
668
+ continue;
669
+ }
670
+
671
+ const pods = await readPods();
672
+ let live = listener.claimed.filter(
673
+ (claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
674
+ ).length;
675
+ for (const claim of pending()) {
676
+ if (live >= listener.maxConcurrent) break; // bound: the claim waits a pass
677
+ const item = itemsByListener.get(listener.id)?.get(claim.item);
678
+ if (item === undefined) {
679
+ claim.blocked = "item vanished before materialization";
680
+ await appendCronLog(
681
+ `listener ${listener.id}: item ${claim.item} materialization failed: item vanished (claim consumed, ADR 0013)`,
682
+ );
683
+ materialized = true;
684
+ continue;
685
+ }
686
+ const rendered = renderTemplate(body, item);
687
+ if ("error" in rendered) {
688
+ claim.blocked = rendered.error;
689
+ await appendCronLog(
690
+ `listener ${listener.id}: item ${claim.item} materialization failed: ${rendered.error} (claim consumed, ADR 0013)`,
691
+ );
692
+ materialized = true;
693
+ continue;
694
+ }
695
+ const record = await spawnPodRecord(
696
+ rendered.goal,
697
+ listener.project ?? project,
698
+ "default",
699
+ undefined,
700
+ undefined,
701
+ true,
702
+ );
703
+ claim.spawnedPodId = record.id;
704
+ live += 1;
705
+ materialized = true;
706
+ }
707
+ }
708
+ if (materialized) await writeListeners(listeners);
709
+
710
+ // Phase 3: a bound standing pod takes the claimed batch as documents
711
+ // (ADR 0014). Nothing is written while a wake-up is live — TASKS.md is the
712
+ // one file both sides write, so one writer at a time, and a parked pod's
713
+ // items accumulate in the registry until a human lifts the park.
714
+ let appended = false;
715
+ for (const listener of listeners) {
716
+ if (listener.mode !== "pod" || listener.podId === undefined) continue;
717
+ if (unscannable.has(listener.id)) continue; // no bodies to copy this pass
718
+ const waiting = listener.claimed.filter(
719
+ (claim) => claim.blocked === undefined && claim.appended !== true,
720
+ );
721
+ if (waiting.length === 0) continue;
722
+
723
+ const pods = await readPods();
724
+ const pod = pods.find((candidate) => candidate.id === listener.podId);
725
+ if (pod === undefined) {
726
+ await appendCronLog(
727
+ `listener ${listener.id}: bound pod ${listener.podId} is not in the registry (claim waits)`,
728
+ );
729
+ continue;
730
+ }
731
+ if (pod.state !== "idle" || pod.policy?.parked) continue;
732
+
733
+ // The body is copied at append time (ADR 0014), so the item must still be
734
+ // readable: a writer that deleted it first leaves nothing to copy.
735
+ const scanned = itemsByListener.get(listener.id);
736
+ const batch: InboxItem[] = [];
737
+ for (const claim of waiting) {
738
+ const item = scanned?.get(claim.item);
739
+ if (item === undefined) {
740
+ claim.blocked = "item vanished before append";
741
+ await appendCronLog(
742
+ `listener ${listener.id}: item ${claim.item} vanished before append (claim consumed, ADR 0014)`,
743
+ );
744
+ appended = true;
745
+ continue;
746
+ }
747
+ batch.push(item);
748
+ }
749
+ if (batch.length === 0) continue;
750
+
751
+ try {
752
+ await appendInbox(join(podDir(pod.project, pod.id), "docs"), batch);
753
+ } catch (error) {
754
+ // The claims stay unappended: the next pass retries the same batch.
755
+ await appendCronLog(
756
+ `listener ${listener.id}: bound pod ${pod.id} Inbox append failed (${(error as Error).message})`,
757
+ );
758
+ continue;
759
+ }
760
+ for (const item of batch) {
761
+ const claim = waiting.find((candidate) => candidate.item === item.id);
762
+ if (claim !== undefined) claim.appended = true;
763
+ }
764
+ appended = true;
765
+ }
766
+ // Append first, then mark: the marker is on disk before the wake phase can
767
+ // launch anything, so a crash anywhere between claim and wake repairs on the
768
+ // next pass (ADR 0014). The wake itself is the pass's ordinary transition.
769
+ if (appended) await writeListeners(listeners);
770
+
771
+ // Two stores, in this order: claims first, then each successful cursor in
772
+ // the source store's own atomic write. A bound standing pod adds one gate:
773
+ // its claims must also be appended, because their bodies exist only in this
774
+ // drain's output. While a wake-up holds the append back, the unchanged cursor
775
+ // makes the next pass drain the same items again. A crash between writes
776
+ // replays into id and document dedupe rather than repeating work (ADR 0024).
777
+ // A Listener removed while its drain was running no longer licenses a cursor.
778
+ const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
779
+ const waitingForAppend = new Set(
780
+ listeners.flatMap((listener) =>
781
+ listener.mode === "pod" &&
782
+ listener.source !== undefined &&
783
+ listener.claimed.some(
784
+ (claim) =>
785
+ claim.inbox === `source:${listener.source}` &&
786
+ claim.blocked === undefined &&
787
+ claim.appended !== true,
788
+ )
789
+ ? [listener.source]
790
+ : [],
791
+ ),
792
+ );
793
+ const committable = drained.filter(
794
+ (drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
795
+ );
796
+ const pruning = pruneSourceClaims(listeners, committable);
797
+ if (pruning.changed) await writeListeners(pruning.listeners);
798
+ await commitSourceCursors(committable);
799
+ }
800
+
801
+ export interface CliDeps {
802
+ cwd?: string;
803
+ stdout?: (line: string) => void;
804
+ stderr?: (line: string) => void;
805
+ }
806
+
807
+ function newPodId(): string {
808
+ return `pod-${randomBytes(3).toString("hex")}`;
809
+ }
810
+
811
+ /** A goal is standing text; its first line is the one-line handle (ADR 0011). */
812
+ function goalHeadline(goal: string): string {
813
+ return goal.split("\n", 1)[0]!.trim();
814
+ }
815
+
816
+ /**
817
+ * The consecutive failed/lost run that condemned the pod, in wake-up order.
818
+ * The streak is policy's count; this is the evidence behind it (ADR 0021).
819
+ */
820
+ function failureTrail(pod: PodRecord): Array<{ n: number; outcome: string; detail?: string }> {
821
+ const trail: Array<{ n: number; outcome: string; detail?: string }> = [];
822
+ for (let index = pod.wakeUps.length - 1; index >= 0; index -= 1) {
823
+ const wake = pod.wakeUps[index]!;
824
+ if (wake.outcome !== "failed" && wake.outcome !== "lost") break;
825
+ trail.unshift(wake);
826
+ }
827
+ return trail;
828
+ }
829
+
830
+ /** The park verdict: id, goal headline, the failures and their details (ADR 0021). */
831
+ function parkNotification(pod: PodRecord, reason?: string): PodNotification {
832
+ const failures = failureTrail(pod);
833
+ return {
834
+ event: "park",
835
+ podId: pod.id,
836
+ goal: goalHeadline(pod.goal),
837
+ consecutiveFailures: pod.policy?.failureStreak ?? failures.length,
838
+ ...(failures.length > 0 ? { failures } : {}),
839
+ ...(reason ? { reason } : {}),
840
+ };
841
+ }
842
+
843
+ /**
844
+ * The handoff's status line, from the wrapper's snapshot (ADR 0009): a done
845
+ * notification carries the agent's own word, not a reconstruction.
846
+ */
847
+ async function handoffStatusLine(pod: PodRecord): Promise<string | undefined> {
848
+ const wakeup = pod.wakeUps.at(-1)?.n;
849
+ if (wakeup === undefined) return undefined;
850
+ const file = join(podDir(pod.project, pod.id), "wakeups", String(wakeup), "handoff.md");
851
+ const body = await readFile(file, "utf8").catch(() => undefined);
852
+ const line = (body?.split("\n", 1)[0] ?? "").trim();
853
+ return line || undefined;
854
+ }
855
+
856
+ async function requirePod(
857
+ podId: string | undefined,
858
+ command: string,
859
+ err: (line: string) => void,
860
+ ): Promise<PodRecord | undefined> {
861
+ if (!podId) {
862
+ err(`Usage: agents-pod ${command} <podId>`);
863
+ return undefined;
864
+ }
865
+ const pod = (await readPods()).find((candidate) => candidate.id === podId);
866
+ if (!pod) {
867
+ err(`Unknown pod ${podId}`);
868
+ return undefined;
869
+ }
870
+ return pod;
871
+ }
872
+
873
+ /** A read-only view: status never writes the store (ADR 0011, ADR 0015). */
874
+ async function statusCmd(
875
+ rest: string[],
876
+ out: (line: string) => void,
877
+ err: (line: string) => void,
878
+ ): Promise<number> {
879
+ const pod = await requirePod(rest[0], "status", err);
880
+ if (!pod) return 2;
881
+
882
+ const last = pod.wakeUps.at(-1);
883
+ out(pod.id);
884
+ out(` goal: ${goalHeadline(pod.goal)}`);
885
+ out(` state: ${pod.state}`);
886
+ if (pod.after !== undefined) {
887
+ const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
888
+ out(` after: ${pod.after} (${upstream?.state ?? "missing"})`);
889
+ }
890
+ out(` liveness: ${await livenessOf(pod)}`);
891
+ if (pod.policy?.parked) {
892
+ out(` parked (consecutive failures: ${pod.policy.failureStreak})`);
893
+ }
894
+ out(` agent: ${resolveAgentBinary(process.env)}`);
895
+ out(` wake-ups: ${pod.wakeUps.length} (last outcome: ${last?.outcome ?? "none"})`);
896
+ return 0;
897
+ }
898
+
899
+ /**
900
+ * The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
901
+ * quiet line at the bottom; the pods that need a human lead. Read-only.
902
+ */
903
+ async function listCmd(out: (line: string) => void): Promise<number> {
904
+ const pods = await readPods();
905
+ if (pods.length === 0) {
906
+ out("No pods");
907
+ return 0;
908
+ }
909
+
910
+ const rows = await Promise.all(
911
+ pods.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
912
+ );
913
+ const needsHuman = (row: (typeof rows)[number]) =>
914
+ row.pod.policy?.parked === true || row.liveness !== "healthy";
915
+ const ordered = [...rows.filter(needsHuman), ...rows.filter((row) => !needsHuman(row))];
916
+
917
+ for (const { pod } of ordered) {
918
+ const parked = pod.policy?.parked ? " (parked)" : "";
919
+ out(`${pod.id} ${pod.state}${parked} ${goalHeadline(pod.goal)}`);
920
+ }
921
+ return 0;
922
+ }
923
+
924
+ /**
925
+ * `logs` is the human's window into the wrapper's dual log (ADR 0009):
926
+ * output.log carries the wake-up separators and the assistant's text. A tail
927
+ * is deliberately boring — a 50 ms stat poll, no fs.watch (a watch is an
928
+ * inotify edge to miss, a poll is a fact to re-read).
929
+ */
930
+ const FOLLOW_POLL_MS = 50;
931
+
932
+ async function logsCmd(
933
+ words: string[],
934
+ out: (line: string) => void,
935
+ err: (line: string) => void,
936
+ ): Promise<number> {
937
+ // `--follow` carries no value, so parseFlags leaves it among the words; the
938
+ // pod id is the only non-flag word (README's `logs <podId> [--follow]`).
939
+ const { rest } = parseFlags(words, []);
940
+ const follow = rest.includes("--follow");
941
+ const pod = await requirePod(
942
+ rest.find((word) => !word.startsWith("--")),
943
+ "logs",
944
+ err,
945
+ );
946
+ if (!pod) return 2;
947
+
948
+ const file = join(podDir(pod.project, pod.id), "output.log");
949
+ if (!(await exists(file))) return 0; // a pod that never logged is not an error
950
+
951
+ // Complete lines only: bytes past the last newline wait for the rest, so a
952
+ // half-written line is never printed twice or torn in half.
953
+ let offset = 0;
954
+ const drain = async (): Promise<void> => {
955
+ const body = await readFile(file).catch(() => undefined);
956
+ if (body === undefined) return;
957
+ const lastNewline = body.lastIndexOf(0x0a);
958
+ if (lastNewline < offset) return;
959
+ const chunk = body.subarray(offset, lastNewline + 1).toString("utf8");
960
+ offset = lastNewline + 1;
961
+ for (const line of chunk.slice(0, -1).split("\n")) out(line);
962
+ };
963
+
964
+ await drain();
965
+ if (!follow) {
966
+ const body = await readFile(file).catch(() => undefined);
967
+ if (body !== undefined && body.length > offset) {
968
+ out(body.subarray(offset).toString("utf8"));
969
+ }
970
+ return 0;
971
+ }
972
+
973
+ // The tail never returns: the follower is ended by a signal, not by an exit.
974
+ for (;;) {
975
+ await sleep(FOLLOW_POLL_MS);
976
+ await drain();
977
+ }
978
+ }
979
+
980
+ /**
981
+ * The human override (ADR 0011): one rule, no `--force`. A wake on an idle,
982
+ * goal-open pod always succeeds and clears parked, failureStreak, and
983
+ * nextWakeAt; terminal is terminal; a running pod has nothing to wake.
984
+ *
985
+ * `--warm` is crash recovery, not an override (ADR 0011, 0020): it is legal
986
+ * only when the last outcome is `lost`, still counts as wake-up n+1 with its
987
+ * own wrapper and outcome, and neither clears nor advances the streak — so it
988
+ * must not walk through the policy-clearing branch below.
989
+ */
990
+ async function wakeCmd(
991
+ words: string[],
992
+ out: (line: string) => void,
993
+ err: (line: string) => void,
994
+ ): Promise<number> {
995
+ const { flags, rest } = parseFlags(words, ["agent"]);
996
+ // `--warm` carries no value, so parseFlags leaves it among the words.
997
+ const warm = rest.includes("--warm");
998
+ const podId = rest.find((word) => !word.startsWith("--"));
999
+ if (!podId) {
1000
+ err("Usage: agents-pod wake <podId> [--agent <id>] [--warm]");
1001
+ return 2;
1002
+ }
1003
+ const pods = await readPods();
1004
+ const pod = pods.find((candidate) => candidate.id === podId);
1005
+ if (!pod) {
1006
+ err(`Unknown pod ${podId}`);
1007
+ return 2;
1008
+ }
1009
+
1010
+ if (pod.state === "done" || pod.state === "cancelled") {
1011
+ err(`Pod ${pod.id} is ${pod.state}: terminal is terminal — spawn a new pod instead`);
1012
+ return 2;
1013
+ }
1014
+ if (pod.state === "active" || pod.state === "spawning") {
1015
+ err(`Pod ${pod.id} is ${pod.state}: nothing to wake`);
1016
+ return 0;
1017
+ }
1018
+
1019
+ // Only a crash has a session worth resuming (ADR 0011): a `failed` wake-up
1020
+ // ended cleanly, and a fresh window is the honest continuation for it.
1021
+ const last = pod.wakeUps.at(-1);
1022
+ if (warm && last?.outcome !== "lost") {
1023
+ err(
1024
+ `Pod ${pod.id}: --warm is valid only when the last outcome is lost (last: ${last?.outcome ?? "none"})`,
1025
+ );
1026
+ return 2;
1027
+ }
1028
+
1029
+ if (pod.after !== undefined) {
1030
+ const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
1031
+ if (upstream?.state !== "done") {
1032
+ err(`Pod ${pod.id} is waiting for pod ${pod.after} (${upstream?.state ?? "missing"})`);
1033
+ return 2;
1034
+ }
1035
+ }
1036
+ const projects = await readProjects();
1037
+ if (!projectHasCapacity(pod.project, pods, projects)) {
1038
+ const active = pods.filter(
1039
+ (candidate) =>
1040
+ resolve(candidate.project) === resolve(pod.project) &&
1041
+ (candidate.state === "spawning" || candidate.state === "active"),
1042
+ ).length;
1043
+ err(`Pod ${pod.id} is waiting for project capacity (${active}/${projectMaxActive(pod.project, projects)})`);
1044
+ return 2;
1045
+ }
1046
+
1047
+ // A one-off override: it serves this wake-up only, never the pod's policy
1048
+ // (ADR 0012). An unknown id is a configuration error at the surface.
1049
+ const agentId = flags.agent ?? pod.policy?.agent ?? "default";
1050
+ const agent = resolveAgentDefinition(await readAgents(), agentId);
1051
+ if (!agent) {
1052
+ err(`Unknown agent ${agentId}: define it with agents-pod agent add`);
1053
+ return 2;
1054
+ }
1055
+ const refusal = agentTypeRefusal(agent.type);
1056
+ if (refusal !== undefined) {
1057
+ err(`Agent ${agent.id} has type ${agent.type}: ${refusal}`);
1058
+ return 2;
1059
+ }
1060
+
1061
+ // The wake is a read-modify-write compare-and-swap (ADR 0018): the record is
1062
+ // re-read inside the registry lock and the condition re-verified, so a pass
1063
+ // that woke the pod between the check above and here wins and this skips.
1064
+ const woke = await withRegistryLock(async () => {
1065
+ const current = await readPods();
1066
+ const target = current.find((candidate) => candidate.id === pod.id);
1067
+ if (!target || target.state !== "idle") return false;
1068
+ if (target.after !== undefined) {
1069
+ const upstream = current.find((candidate) => candidate.id === target.after);
1070
+ if (upstream?.state !== "done") return false;
1071
+ }
1072
+ if (!projectHasCapacity(target.project, current, await readProjects())) return false;
1073
+ // Warm is crash recovery, not the human override: policy is the streak's
1074
+ // and stays exactly where reconcile left it (ADR 0011). A non-warm wake is
1075
+ // a new episode: the park-clearing also clears notifiedAt (ADR 0021).
1076
+ if (!warm) {
1077
+ if (target.policy) {
1078
+ target.policy.failureStreak = 0;
1079
+ delete target.policy.nextWakeAt;
1080
+ delete target.policy.parked;
1081
+ }
1082
+ delete target.notifiedAt;
1083
+ }
1084
+ launchWrapperDetached(target.id, agentId, warm);
1085
+ target.state = "active";
1086
+ await writePods(current);
1087
+ return true;
1088
+ });
1089
+ if (!woke) {
1090
+ // The loser's CAS saw a pod someone else already woke: skip, not an error.
1091
+ err(`Pod ${pod.id} is already running: nothing to wake`);
1092
+ return 0;
1093
+ }
1094
+ out(`woke ${pod.id}${warm ? " (warm)" : ""}`);
1095
+ return 0;
1096
+ }
1097
+
1098
+ /**
1099
+ * The human's stop (ADR 0003, 0017): signal the wake-up's whole process group
1100
+ * and return — the wrapper owns the wind-down (trap, forward, grace, kill) and
1101
+ * the `cancelled` record. Refusing a non-active pod is a no-op, not an error.
1102
+ */
1103
+ async function cancelCmd(
1104
+ rest: string[],
1105
+ out: (line: string) => void,
1106
+ err: (line: string) => void,
1107
+ ): Promise<number> {
1108
+ const pod = await requirePod(rest[0], "cancel", err);
1109
+ if (!pod) return 2;
1110
+
1111
+ if (pod.state !== "active") {
1112
+ err(`pod ${pod.id} is ${pod.state}: nothing to cancel`);
1113
+ return 0;
1114
+ }
1115
+ const wrapper = await readWrapperRecord(pod);
1116
+ if (wrapper === undefined) {
1117
+ // A ghost record: nothing to signal; reconcile owns that verdict (ADR 0002).
1118
+ err(`pod ${pod.id} has no wrapper record: nothing to cancel`);
1119
+ return 0;
1120
+ }
1121
+ // Three-verified (ADR 0020): the recorded pid is live, its start time
1122
+ // matches, and the boot time is this boot's. Otherwise the record points at
1123
+ // a stranger — a recycled pid, or any pid at all after a reboot — and the
1124
+ // pod's verdict stays reconcile's.
1125
+ if (!(await verifyProcess(wrapper))) {
1126
+ err(`pod ${pod.id} recorded process is gone: nothing to signal`);
1127
+ return 0;
1128
+ }
1129
+ if (!signalGroup(wrapper.pgid, "SIGTERM")) {
1130
+ err(`pod ${pod.id} has no live wrapper: nothing to cancel`);
1131
+ return 0;
1132
+ }
1133
+
1134
+ out(`cancel signalled ${pod.id}`);
1135
+ return 0;
1136
+ }
1137
+
1138
+ /**
1139
+ * The one spawn path: record, workspace, then wake admission (ADR 0022). An
1140
+ * admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
1141
+ * and a listener's template materialization both walk it (ADR 0007).
1142
+ */
1143
+ async function spawnPodRecord(
1144
+ goal: string,
1145
+ project: string,
1146
+ agentId: string,
1147
+ model?: string,
1148
+ after?: string,
1149
+ registryHeld = false,
1150
+ ): Promise<PodRecord> {
1151
+ const record: PodRecord = {
1152
+ id: newPodId(),
1153
+ goal,
1154
+ state: "spawning",
1155
+ project: resolve(project),
1156
+ ...(after === undefined ? {} : { after }),
1157
+ wakeUps: [],
1158
+ // The policy names the agent and the optional model pin; `--model` beats
1159
+ // the definition's model, which beats the adapter default (ADR 0012).
1160
+ policy: {
1161
+ failureStreak: 0,
1162
+ appliedOutcomes: 0,
1163
+ agent: agentId,
1164
+ ...(model === undefined ? {} : { model }),
1165
+ },
1166
+ };
1167
+ await createPodWorkspace(record);
1168
+
1169
+ const admit = async (): Promise<PodRecord> => {
1170
+ const pods = await readPods();
1171
+ const projects = await readProjects();
1172
+ const upstream = after === undefined
1173
+ ? undefined
1174
+ : pods.find((candidate) => candidate.id === after);
1175
+ const blocked =
1176
+ (after !== undefined && upstream?.state !== "done") ||
1177
+ !projectHasCapacity(record.project, pods, projects);
1178
+ pods.push(record);
1179
+
1180
+ if (blocked) {
1181
+ record.state = "idle";
1182
+ await writePods(pods);
1183
+ return record;
1184
+ }
1185
+
1186
+ await writePods(pods);
1187
+ launchWrapperDetached(record.id, agentId);
1188
+ record.state = "active";
1189
+ await writePods(pods);
1190
+ return record;
1191
+ };
1192
+
1193
+ if (registryHeld) return admit();
1194
+ const spawned = await withRegistryLock(admit);
1195
+ if (spawned === undefined) throw new Error("registry lock unavailable: pod not spawned");
1196
+ return spawned;
1197
+ }
1198
+
1199
+ async function spawnCmd(
1200
+ words: string[],
1201
+ deps: CliDeps,
1202
+ out: (line: string) => void,
1203
+ err: (line: string) => void,
1204
+ ): Promise<number> {
1205
+ const { flags, rest } = parseFlags(words, ["agent", "model", "cwd", "project", "after"]);
1206
+ const goal = rest.join(" ").trim();
1207
+ if (!goal) {
1208
+ err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
1209
+ return 2;
1210
+ }
1211
+
1212
+ // Fail fast at the surface (ADR 0012): an unknown id is an exit-2
1213
+ // configuration error, before any pod record or workspace exists. An
1214
+ // unsupported adapter type is the same kind of error, and refusing it here
1215
+ // keeps a pod from being born into a wake-up that can only fail.
1216
+ const agentId = flags.agent ?? "default";
1217
+ const agent = resolveAgentDefinition(await readAgents(), agentId);
1218
+ if (!agent) {
1219
+ err(`Unknown agent ${agentId}: define it with agents-pod agent add`);
1220
+ return 2;
1221
+ }
1222
+ const refusal = agentTypeRefusal(agent.type);
1223
+ if (refusal !== undefined) {
1224
+ err(`Agent ${agent.id} has type ${agent.type}: ${refusal}`);
1225
+ return 2;
1226
+ }
1227
+
1228
+ if (flags.after !== undefined) {
1229
+ const upstream = (await readPods()).find((candidate) => candidate.id === flags.after);
1230
+ if (upstream === undefined) {
1231
+ err(`Unknown pod ${flags.after}`);
1232
+ return 2;
1233
+ }
1234
+ }
1235
+
1236
+ const record = await spawnPodRecord(
1237
+ goal,
1238
+ flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(),
1239
+ agentId,
1240
+ flags.model,
1241
+ flags.after,
1242
+ );
1243
+ out(record.id);
1244
+ return 0;
1245
+ }
1246
+
1247
+ /** Per-project wake-up capacity: a small persistent guardrail, not a scheduler. */
1248
+ async function projectCmd(
1249
+ words: string[],
1250
+ deps: CliDeps,
1251
+ out: (line: string) => void,
1252
+ err: (line: string) => void,
1253
+ ): Promise<number> {
1254
+ const [sub, ...rest] = words;
1255
+ const { flags, rest: positional } = parseFlags(rest, ["project"]);
1256
+ const project = resolve(flags.project ?? deps.cwd ?? process.cwd());
1257
+ if (sub !== "cap" || positional.length > 1) {
1258
+ err("Usage: agents-pod project cap [n|unlimited] [--project <dir>]");
1259
+ return 2;
1260
+ }
1261
+
1262
+ if (positional.length === 0) {
1263
+ const maxActive = projectMaxActive(project, await readProjects());
1264
+ out(`${project} ${maxActive === undefined ? "unlimited" : maxActive}`);
1265
+ return 0;
1266
+ }
1267
+
1268
+ const value = positional[0]!;
1269
+ const maxActive = value === "unlimited" ? undefined : Number(value);
1270
+ if (maxActive !== undefined && (!Number.isInteger(maxActive) || maxActive < 1)) {
1271
+ err("Project capacity must be a positive integer or unlimited");
1272
+ return 2;
1273
+ }
1274
+ const updated = await withRegistryLock(async () => {
1275
+ const projects = await readProjects();
1276
+ const remaining = projects.filter((candidate) => resolve(candidate.project) !== project);
1277
+ if (maxActive !== undefined) remaining.push({ project, maxActive });
1278
+ await writeProjects(remaining);
1279
+ return true;
1280
+ });
1281
+ if (updated === undefined) {
1282
+ err("registry lock unavailable: project capacity not changed");
1283
+ return 2;
1284
+ }
1285
+ out(`${project} ${maxActive === undefined ? "unlimited" : maxActive}`);
1286
+ return 0;
1287
+ }
1288
+
1289
+ /** The first line of role instructions is the list's one-line handle. */
1290
+ function instructionsHeadline(instructions: string): string {
1291
+ return instructions.split("\n", 1)[0]!.trim() || "(no role instructions)";
1292
+ }
1293
+
1294
+ /**
1295
+ * `agents-pod agent` is add / edit / rm / list (ADR 0012): one machine-global
1296
+ * store, no version numbers, no pin flags, no `--force` ceremony. Definitions
1297
+ * are the operator's worker identities, so the store is a file a human reads.
1298
+ */
1299
+ async function agentCmd(
1300
+ words: string[],
1301
+ out: (line: string) => void,
1302
+ err: (line: string) => void,
1303
+ ): Promise<number> {
1304
+ const [sub, ...rest] = words;
1305
+ switch (sub) {
1306
+ case "list": {
1307
+ const agents = await readAgents();
1308
+ if (rest.includes("--json")) {
1309
+ out(
1310
+ JSON.stringify(
1311
+ agents.map((agent) => ({
1312
+ id: agent.id,
1313
+ type: agent.type,
1314
+ model: agent.model ?? null,
1315
+ instructions: agent.instructions,
1316
+ displayName: agent.displayName ?? null,
1317
+ })),
1318
+ ),
1319
+ );
1320
+ return 0;
1321
+ }
1322
+ for (const agent of agents) {
1323
+ out(
1324
+ `${agent.id} ${agent.type} ${agent.model ?? "-"} ${instructionsHeadline(agent.instructions)}`,
1325
+ );
1326
+ }
1327
+ return 0;
1328
+ }
1329
+
1330
+ case "add": {
1331
+ const { flags, rest: positional } = parseFlags(rest, [
1332
+ "type",
1333
+ "model",
1334
+ "instructions",
1335
+ ]);
1336
+ const id = positional[0];
1337
+ if (!id || flags.type === undefined) {
1338
+ err("Usage: agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]");
1339
+ return 2;
1340
+ }
1341
+ if (!isAgentType(flags.type)) {
1342
+ err(`Unknown agent type ${flags.type}: expected pi, claude, or codex`);
1343
+ return 2;
1344
+ }
1345
+ const refusal = agentTypeRefusal(flags.type);
1346
+ if (refusal !== undefined) {
1347
+ err(`Cannot add agent ${id}: --type ${flags.type} — ${refusal}`);
1348
+ return 2;
1349
+ }
1350
+ const agents = await readAgents();
1351
+ if (agents.some((agent) => agent.id === id)) {
1352
+ err(`Agent ${id} already exists: use agents-pod agent edit ${id}`);
1353
+ return 2;
1354
+ }
1355
+ let instructions = "";
1356
+ if (flags.instructions !== undefined) {
1357
+ // Read once, store the text, never the path (ADR 0012).
1358
+ const body = await readFile(flags.instructions, "utf8").catch(() => undefined);
1359
+ if (body === undefined) {
1360
+ err(`Cannot read instructions file ${flags.instructions}`);
1361
+ return 2;
1362
+ }
1363
+ instructions = body;
1364
+ }
1365
+ agents.push({
1366
+ id,
1367
+ type: flags.type,
1368
+ instructions,
1369
+ ...(flags.model === undefined ? {} : { model: flags.model }),
1370
+ });
1371
+ await writeAgents(agents);
1372
+ return 0;
1373
+ }
1374
+
1375
+ case "edit": {
1376
+ const id = rest.find((word) => !word.startsWith("--"));
1377
+ if (!id) {
1378
+ err("Usage: agents-pod agent edit <id>");
1379
+ return 2;
1380
+ }
1381
+ const agents = await readAgents();
1382
+ const agent = agents.find((candidate) => candidate.id === id);
1383
+ if (!agent) {
1384
+ err(`Unknown agent ${id}`);
1385
+ return 2;
1386
+ }
1387
+ // $EDITOR on the stored text, written back on a clean exit (ADR 0012).
1388
+ const draft = join(tmpdir(), `agents-pod-agent-${randomBytes(6).toString("hex")}.md`);
1389
+ await writeFile(draft, agent.instructions);
1390
+ const editor = process.env.EDITOR?.trim() || "vi";
1391
+ const code = await new Promise<number>((resolve) => {
1392
+ const child = spawn(editor, [draft], { stdio: "inherit" });
1393
+ child.on("error", () => resolve(-1));
1394
+ child.on("close", (exitCode) => resolve(exitCode ?? -1));
1395
+ });
1396
+ const saved = code === 0 ? await readFile(draft, "utf8") : undefined;
1397
+ await unlink(draft).catch(() => undefined);
1398
+ if (saved === undefined) {
1399
+ err(`Editor ${editor} exited ${code}: ${id} is unchanged`);
1400
+ return 2;
1401
+ }
1402
+ agent.instructions = saved;
1403
+ await writeAgents(agents);
1404
+ return 0;
1405
+ }
1406
+
1407
+ case "rm": {
1408
+ const id = rest.find((word) => !word.startsWith("--"));
1409
+ if (!id) {
1410
+ err("Usage: agents-pod agent rm <id>");
1411
+ return 2;
1412
+ }
1413
+ const agents = await readAgents();
1414
+ const remaining = agents.filter((agent) => agent.id !== id);
1415
+ if (remaining.length === agents.length) {
1416
+ err(`Unknown agent ${id}`);
1417
+ return 2;
1418
+ }
1419
+ // No --force: pods still referencing it park at the next reconcile (ADR 0012).
1420
+ await writeAgents(remaining);
1421
+ return 0;
1422
+ }
1423
+
1424
+ default:
1425
+ err(`Unknown agent command: ${sub ?? "(none)"}. Try: add | edit | rm | list`);
1426
+ return 2;
1427
+ }
1428
+ }
1429
+
1430
+ /** Sources are named operator commands; configuration errors fail fast (ADR 0024). */
1431
+ async function sourceCmd(
1432
+ words: string[],
1433
+ deps: CliDeps,
1434
+ out: (line: string) => void,
1435
+ err: (line: string) => void,
1436
+ ): Promise<number> {
1437
+ const [sub, ...rest] = words;
1438
+ switch (sub) {
1439
+ case "list": {
1440
+ const sources = await readSources();
1441
+ if (rest.includes("--json")) {
1442
+ out(
1443
+ JSON.stringify(
1444
+ sources.map((source) => ({
1445
+ name: source.name,
1446
+ cmd: source.cmd,
1447
+ timeoutMs: source.timeoutMs,
1448
+ cursor: source.cursor ?? null,
1449
+ })),
1450
+ ),
1451
+ );
1452
+ return 0;
1453
+ }
1454
+ if (sources.length === 0) {
1455
+ out("No sources");
1456
+ return 0;
1457
+ }
1458
+ for (const source of sources) {
1459
+ out(
1460
+ `${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}`,
1461
+ );
1462
+ }
1463
+ return 0;
1464
+ }
1465
+
1466
+ case "add": {
1467
+ const { flags, rest: positional } = parseFlags(rest, ["cmd", "timeout"]);
1468
+ const name = positional[0];
1469
+ if (positional.length !== 1 || name === undefined || flags.cmd === undefined) {
1470
+ err("Usage: agents-pod source add <name> --cmd <path> [--timeout <s>]");
1471
+ return 2;
1472
+ }
1473
+ const timeoutSeconds = flags.timeout === undefined ? 10 : Number(flags.timeout);
1474
+ const timeoutMs = timeoutSeconds * 1000;
1475
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || !Number.isFinite(timeoutMs)) {
1476
+ err(`--timeout must be a positive number, got ${flags.timeout}`);
1477
+ return 2;
1478
+ }
1479
+ const cmd = resolve(deps.cwd ?? process.cwd(), flags.cmd);
1480
+ const info = await stat(cmd).catch(() => undefined);
1481
+ if (info === undefined || !info.isFile()) {
1482
+ err(`Drain command ${cmd} is missing or not a regular file`);
1483
+ return 2;
1484
+ }
1485
+ if (!(await access(cmd, constants.X_OK).then(() => true, () => false))) {
1486
+ err(`Drain command ${cmd} is not executable`);
1487
+ return 2;
1488
+ }
1489
+ const added = await withRegistryLock(async () => {
1490
+ const sources = await readSources();
1491
+ if (sources.some((source) => source.name === name)) return false;
1492
+ sources.push({ name, cmd, timeoutMs });
1493
+ await writeSources(sources);
1494
+ return true;
1495
+ });
1496
+ if (added === undefined) {
1497
+ err("registry lock unavailable: source not added");
1498
+ return 2;
1499
+ }
1500
+ if (!added) {
1501
+ err(`Source ${name} already exists`);
1502
+ return 2;
1503
+ }
1504
+ return 0;
1505
+ }
1506
+
1507
+ case "rm": {
1508
+ const name = rest.find((word) => !word.startsWith("--"));
1509
+ if (name === undefined) {
1510
+ err("Usage: agents-pod source rm <name>");
1511
+ return 2;
1512
+ }
1513
+ const removed = await withRegistryLock(async () => {
1514
+ const sources = await readSources();
1515
+ if (!sources.some((source) => source.name === name)) return "missing" as const;
1516
+ const listeners = await readListeners();
1517
+ if (listeners.some((listener) => listener.source === name)) return "in-use" as const;
1518
+ await writeSources(sources.filter((source) => source.name !== name));
1519
+ return "removed" as const;
1520
+ });
1521
+ if (removed === undefined) {
1522
+ err("registry lock unavailable: source not removed");
1523
+ return 2;
1524
+ }
1525
+ if (removed === "missing") {
1526
+ err(`Unknown source ${name}`);
1527
+ return 2;
1528
+ }
1529
+ if (removed === "in-use") {
1530
+ err(`Source ${name} is still consumed by a Listener`);
1531
+ return 2;
1532
+ }
1533
+ return 0;
1534
+ }
1535
+
1536
+ default:
1537
+ err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
1538
+ return 2;
1539
+ }
1540
+ }
1541
+
1542
+ /** P0 has one outbound delivery command; configuration errors fail fast (ADR 0025). */
1543
+ async function deliverCmd(
1544
+ words: string[],
1545
+ deps: CliDeps,
1546
+ out: (line: string) => void,
1547
+ err: (line: string) => void,
1548
+ ): Promise<number> {
1549
+ const [sub, ...rest] = words;
1550
+ switch (sub) {
1551
+ case "list": {
1552
+ const deliverer = await readDeliverer();
1553
+ if (rest.includes("--json")) {
1554
+ out(JSON.stringify(deliverer === undefined ? [] : [deliverer]));
1555
+ return 0;
1556
+ }
1557
+ if (deliverer === undefined) {
1558
+ out("No deliverer");
1559
+ return 0;
1560
+ }
1561
+ out(`${deliverer.name} ${deliverer.cmd} timeout ${deliverer.timeoutMs / 1000}s`);
1562
+ return 0;
1563
+ }
1564
+
1565
+ case "add": {
1566
+ const { flags, rest: positional } = parseFlags(rest, ["cmd", "timeout"]);
1567
+ const name = positional[0];
1568
+ if (positional.length !== 1 || name === undefined || flags.cmd === undefined) {
1569
+ err("Usage: agents-pod deliver add <name> --cmd <path> [--timeout <s>]");
1570
+ return 2;
1571
+ }
1572
+ const timeoutSeconds = flags.timeout === undefined ? 10 : Number(flags.timeout);
1573
+ const timeoutMs = timeoutSeconds * 1000;
1574
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || !Number.isFinite(timeoutMs)) {
1575
+ err(`--timeout must be a positive number, got ${flags.timeout}`);
1576
+ return 2;
1577
+ }
1578
+ const cmd = resolve(deps.cwd ?? process.cwd(), flags.cmd);
1579
+ const info = await stat(cmd).catch(() => undefined);
1580
+ if (info === undefined || !info.isFile()) {
1581
+ err(`Delivery command ${cmd} is missing or not a regular file`);
1582
+ return 2;
1583
+ }
1584
+ if (!(await access(cmd, constants.X_OK).then(() => true, () => false))) {
1585
+ err(`Delivery command ${cmd} is not executable`);
1586
+ return 2;
1587
+ }
1588
+ const added = await withRegistryLock(async () => {
1589
+ if ((await readDeliverer()) !== undefined) return false;
1590
+ await writeDeliverer({ name, cmd, timeoutMs });
1591
+ return true;
1592
+ });
1593
+ if (added === undefined) {
1594
+ err("registry lock unavailable: deliverer not added");
1595
+ return 2;
1596
+ }
1597
+ if (!added) {
1598
+ err("A deliverer is already configured: remove it before adding another");
1599
+ return 2;
1600
+ }
1601
+ return 0;
1602
+ }
1603
+
1604
+ case "rm": {
1605
+ const name = rest.find((word) => !word.startsWith("--"));
1606
+ if (name === undefined) {
1607
+ err("Usage: agents-pod deliver rm <name>");
1608
+ return 2;
1609
+ }
1610
+ const removed = await withRegistryLock(async () => {
1611
+ const deliverer = await readDeliverer();
1612
+ if (deliverer?.name !== name) return false;
1613
+ await rm(delivererFile(), { force: true });
1614
+ return true;
1615
+ });
1616
+ if (removed === undefined) {
1617
+ err("registry lock unavailable: deliverer not removed");
1618
+ return 2;
1619
+ }
1620
+ if (!removed) {
1621
+ err(`Unknown deliverer ${name}`);
1622
+ return 2;
1623
+ }
1624
+ return 0;
1625
+ }
1626
+
1627
+ default:
1628
+ err(`Unknown deliver command: ${sub ?? "(none)"}. Try: add | list | rm`);
1629
+ return 2;
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * `agents-pod listener` is add / list / rm (ADR 0007): a listener is a registry
1635
+ * record, never a process. Bad references are exit-2 configuration errors
1636
+ * (ADR 0012's fail-fast habit), and add/rm take the registry lock because a
1637
+ * pass rewrites listeners.json wholesale — an unlocked add could be overwritten.
1638
+ */
1639
+ async function listenerCmd(
1640
+ words: string[],
1641
+ deps: CliDeps,
1642
+ out: (line: string) => void,
1643
+ err: (line: string) => void,
1644
+ ): Promise<number> {
1645
+ const [sub, ...rest] = words;
1646
+ switch (sub) {
1647
+ case "list": {
1648
+ const listeners = await readListeners();
1649
+ if (rest.includes("--json")) {
1650
+ out(
1651
+ JSON.stringify(
1652
+ listeners.map((listener) => ({
1653
+ id: listener.id,
1654
+ inbox: listener.inbox ?? null,
1655
+ source: listener.source ?? null,
1656
+ match: listener.match ?? null,
1657
+ mode: listener.mode,
1658
+ template: listener.template ?? null,
1659
+ project: listener.project ?? null,
1660
+ podId: listener.podId ?? null,
1661
+ maxConcurrent: listener.maxConcurrent,
1662
+ claimed: listener.claimed ?? [],
1663
+ })),
1664
+ ),
1665
+ );
1666
+ return 0;
1667
+ }
1668
+ if (listeners.length === 0) {
1669
+ out("No listeners");
1670
+ return 0;
1671
+ }
1672
+ for (const listener of listeners) {
1673
+ const target =
1674
+ listener.mode === "template"
1675
+ ? `template ${listener.template}`
1676
+ : `pod ${listener.podId}`;
1677
+ const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
1678
+ out(`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}`);
1679
+ }
1680
+ return 0;
1681
+ }
1682
+
1683
+ case "add": {
1684
+ const { flags } = parseFlags(rest, [
1685
+ "inbox",
1686
+ "source",
1687
+ "template",
1688
+ "pod",
1689
+ "match",
1690
+ "max-concurrent",
1691
+ "project",
1692
+ ]);
1693
+ const usage =
1694
+ "Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--max-concurrent <n>] [--project <dir>]";
1695
+ if ((flags.inbox === undefined) === (flags.source === undefined)) {
1696
+ err(`listener add takes exactly one of --inbox or --source. ${usage}`);
1697
+ return 2;
1698
+ }
1699
+ // A listener has one target, exactly (ADR 0007): a fresh pod per item, or
1700
+ // a bound standing pod's queue.
1701
+ if ((flags.template === undefined) === (flags.pod === undefined)) {
1702
+ err(`listener add takes exactly one of --template <file> or --pod <podId>. ${usage}`);
1703
+ return 2;
1704
+ }
1705
+
1706
+ const cwd = deps.cwd ?? process.cwd();
1707
+ // Only a template materializes a Pod, so only a template has a destination
1708
+ // project; a bound standing Pod already lives where it lives (ADR 0007).
1709
+ if (flags.project !== undefined && flags.pod !== undefined) {
1710
+ err(`--project needs --template: a bound standing Pod keeps its own project. ${usage}`);
1711
+ return 2;
1712
+ }
1713
+ const targetProject =
1714
+ flags.project === undefined ? undefined : resolve(cwd, flags.project);
1715
+ if (targetProject !== undefined) {
1716
+ const info = await stat(targetProject).catch(() => undefined);
1717
+ if (info === undefined || !info.isDirectory()) {
1718
+ err(`Project ${targetProject} does not exist or is not a directory`);
1719
+ return 2;
1720
+ }
1721
+ }
1722
+ const inbox = flags.inbox === undefined ? undefined : resolve(cwd, flags.inbox);
1723
+ if (inbox !== undefined && !(await exists(inbox))) {
1724
+ err(`Inbox ${inbox} does not exist: pass a directory or a JSONL file`);
1725
+ return 2;
1726
+ }
1727
+ if (
1728
+ flags.source !== undefined &&
1729
+ !(await readSources()).some((source) => source.name === flags.source)
1730
+ ) {
1731
+ err(`Unknown source ${flags.source}`);
1732
+ return 2;
1733
+ }
1734
+
1735
+ let maxConcurrent = 1;
1736
+ if (flags["max-concurrent"] !== undefined) {
1737
+ const parsed = Number(flags["max-concurrent"]);
1738
+ if (!Number.isInteger(parsed) || parsed < 1) {
1739
+ err(`--max-concurrent must be a positive integer, got ${flags["max-concurrent"]}`);
1740
+ return 2;
1741
+ }
1742
+ maxConcurrent = parsed;
1743
+ }
1744
+
1745
+ const listener: ListenerRecord = {
1746
+ id: newListenerId(),
1747
+ ...(inbox === undefined ? { source: flags.source! } : { inbox }),
1748
+ mode: "template",
1749
+ ...(targetProject === undefined ? {} : { project: targetProject }),
1750
+ maxConcurrent,
1751
+ claimed: [],
1752
+ seen: [],
1753
+ };
1754
+ if (flags.match !== undefined) listener.match = flags.match;
1755
+
1756
+ if (flags.template !== undefined) {
1757
+ // Read once to prove the reference, store the path (ADR 0007): the
1758
+ // template is the operator's file, edited in place between passes.
1759
+ const template = resolve(cwd, flags.template);
1760
+ if ((await readFile(template, "utf8").catch(() => undefined)) === undefined) {
1761
+ err(`Cannot read template file ${template}`);
1762
+ return 2;
1763
+ }
1764
+ listener.template = template;
1765
+ } else {
1766
+ const pod = (await readPods()).find((candidate) => candidate.id === flags.pod);
1767
+ if (!pod) {
1768
+ err(`Unknown pod ${flags.pod}`);
1769
+ return 2;
1770
+ }
1771
+ listener.mode = "pod";
1772
+ listener.podId = pod.id;
1773
+ }
1774
+
1775
+ const added = await withRegistryLock(async () => {
1776
+ if (
1777
+ listener.source !== undefined &&
1778
+ !(await readSources()).some((source) => source.name === listener.source)
1779
+ ) {
1780
+ return false;
1781
+ }
1782
+ const listeners = await readListeners();
1783
+ listeners.push(listener);
1784
+ await writeListeners(listeners);
1785
+ return true;
1786
+ });
1787
+ if (added === undefined) {
1788
+ err("registry lock unavailable: listener not added");
1789
+ return 2;
1790
+ }
1791
+ if (!added) {
1792
+ err(`Unknown source ${listener.source}`);
1793
+ return 2;
1794
+ }
1795
+ out(listener.id);
1796
+ return 0;
1797
+ }
1798
+
1799
+ case "rm": {
1800
+ const id = rest.find((word) => !word.startsWith("--"));
1801
+ if (!id) {
1802
+ err("Usage: agents-pod listener rm <listenerId>");
1803
+ return 2;
1804
+ }
1805
+ const removed = await withRegistryLock(async () => {
1806
+ const listeners = await readListeners();
1807
+ const remaining = listeners.filter((listener) => listener.id !== id);
1808
+ if (remaining.length === listeners.length) return false;
1809
+ // Claims go with the record: re-adding is a fresh claim namespace (ADR 0013).
1810
+ await writeListeners(remaining);
1811
+ return true;
1812
+ });
1813
+ if (removed === undefined) {
1814
+ err("registry lock unavailable: listener not removed");
1815
+ return 2;
1816
+ }
1817
+ if (!removed) {
1818
+ err(`Unknown listener ${id}`);
1819
+ return 2;
1820
+ }
1821
+ return 0;
1822
+ }
1823
+
1824
+ default:
1825
+ err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | list | rm`);
1826
+ return 2;
1827
+ }
1828
+ }
1829
+
1830
+ export async function main(argv: string[], deps: CliDeps = {}): Promise<number> {
1831
+ const out = deps.stdout ?? ((line: string) => console.log(line));
1832
+ const err = deps.stderr ?? ((line: string) => console.error(line));
1833
+ const [cmd, ...rest] = argv;
1834
+
1835
+ switch (cmd) {
1836
+ case "spawn":
1837
+ return spawnCmd(rest, deps, out, err);
1838
+ case "status":
1839
+ return statusCmd(rest, out, err);
1840
+ case "list":
1841
+ return listCmd(out);
1842
+ case "logs":
1843
+ return logsCmd(rest, out, err);
1844
+ case "wake":
1845
+ return wakeCmd(rest, out, err);
1846
+ case "cancel":
1847
+ return cancelCmd(rest, out, err);
1848
+ case "reconcile":
1849
+ return reconcileCmd(deps);
1850
+ case "agent":
1851
+ return agentCmd(rest, out, err);
1852
+ case "listener":
1853
+ return listenerCmd(rest, deps, out, err);
1854
+ case "source":
1855
+ return sourceCmd(rest, deps, out, err);
1856
+ case "deliver":
1857
+ return deliverCmd(rest, deps, out, err);
1858
+ case "project":
1859
+ return projectCmd(rest, deps, out, err);
1860
+ default:
1861
+ err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
1862
+ return 2;
1863
+ }
1864
+ }