agents-pod 0.1.0 → 0.3.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 CHANGED
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
3
4
  import {
5
+ access,
4
6
  appendFile,
5
7
  mkdir,
6
8
  readFile,
@@ -20,6 +22,11 @@ import {
20
22
  writeAgents,
21
23
  } from "./agents-store.ts";
22
24
  import { renderTemplate, scanInbox, type InboxItem } from "./inbox.ts";
25
+ import {
26
+ doctor,
27
+ type DoctorMark,
28
+ type DoctorReport,
29
+ } from "./doctor.ts";
23
30
  import {
24
31
  claimKey,
25
32
  newListenerId,
@@ -31,7 +38,8 @@ import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
31
38
  import { loadNotifyConfig } from "./notify/config.ts";
32
39
  import { dispatchNotification, shouldNotify } from "./notify/dispatch.ts";
33
40
  import type { NotificationsConfig, PodNotification } from "./notify/types.ts";
34
- import { cronLogPath, podDir, reconcileLockPath } from "./paths.ts";
41
+ import { cronLogPath, delivererFile, lastPassFile, podDir, reconcileLockPath } from "./paths.ts";
42
+ import { readDeliverer, retryOutbox, writeDeliverer } from "./outbox.ts";
35
43
  import {
36
44
  projectHasCapacity,
37
45
  projectMaxActive,
@@ -40,6 +48,15 @@ import {
40
48
  } from "./projects.ts";
41
49
  import type { PodRecord } from "./registry.ts";
42
50
  import { readPods, withRegistryLock, writePods } from "./registry.ts";
51
+ import {
52
+ commitSourceCursors,
53
+ drainSources,
54
+ pruneSourceClaims,
55
+ readSources,
56
+ recordSourceFailure,
57
+ writeSources,
58
+ type SourceDrain,
59
+ } from "./sources.ts";
43
60
  import {
44
61
  HEARTBEAT_STALE_MS,
45
62
  WEDGED_ESCALATE_MS,
@@ -225,22 +242,54 @@ async function acquireReconcileLock(): Promise<boolean> {
225
242
  * queued while policy is derived, recorded in `notifiedAt`, and delivered
226
243
  * after the locks are gone — a slow channel never holds the registry.
227
244
  *
228
- * The body runs inside the registry lock (ADR 0018): pods.json is re-read
229
- * there, so each transition is a compare-and-swap against the record as it is
230
- * now. The single-flight lock above is a courtesy; this is the correctness.
245
+ * Sources drain before the body takes the registry lock (ADR 0024). The body
246
+ * then re-reads pods.json there, so each transition is a compare-and-swap
247
+ * against the record as it is now. The single-flight lock above is a courtesy;
248
+ * this is the correctness.
231
249
  */
232
250
  async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
233
251
  if (!(await acquireReconcileLock())) return 0;
234
252
  const warnings: string[] = [];
235
253
  const notify = await loadNotifyConfig(undefined, (message) => warnings.push(message));
236
- // Listener-spawned pods live under the project the tick was invoked from:
237
- // the cron line's cwd is the project (ADR 0007, ADR 0004).
254
+ // Listener-spawned pods default to the tick's project; `listener add
255
+ // --project` overrides that destination per Listener (ADR 0007, ADR 0004).
238
256
  const project = deps.cwd ?? process.cwd();
239
257
  let pending: PodNotification[] = [];
240
258
  try {
241
- pending = (await withRegistryLock(() => reconcilePass(notify, project))) ?? [];
259
+ // A slow drain holds no registry lock and runs before any Pod is touched.
260
+ const drained = await drainSources(project, await readListeners());
261
+ for (const result of drained) {
262
+ if (result.error !== undefined) {
263
+ await appendCronLog(
264
+ `source ${result.name}: drain failed (${result.error}); no items, cursor unchanged`,
265
+ );
266
+ // The failure is also a record fact, so `doctor` can report it without
267
+ // tailing cron.log (ADR 0027).
268
+ await recordSourceFailure(result.name, result.error);
269
+ }
270
+ for (const bad of result.bad) {
271
+ await appendCronLog(
272
+ `source ${result.name}: line ${bad.line}: ${bad.reason} (skipped, unclaimed)`,
273
+ );
274
+ }
275
+ }
276
+ // Delivery is the drain's mirror: network-bound, so outside the lock
277
+ // (ADRs 0024, 0025). Every Pod that is not active gets the Wrapper's own
278
+ // bounded flush; the state is re-read per Pod so a wake that started
279
+ // meanwhile keeps its Wrapper the only writer over that outbox.
280
+ for (const pod of await readPods()) {
281
+ if (pod.state === "active") continue;
282
+ const fresh = await podState(pod.id);
283
+ if (fresh === undefined || fresh === "active") continue;
284
+ await retryOutbox(pod.id, pod.project);
285
+ }
286
+ pending =
287
+ (await withRegistryLock(() => reconcilePass(notify, project, drained))) ?? [];
242
288
  } finally {
243
289
  await releaseLock(reconcileLockPath());
290
+ // The pass marker (ADR 0027): without it, "cron is not installed" and
291
+ // "cron runs and finds nothing" are the same silence in every report.
292
+ await writeFile(lastPassFile(), `${JSON.stringify({ at: Date.now() })}\n`).catch(() => undefined);
244
293
  }
245
294
  // A bad config degrades, it never kills the pass (ADR 0021): the warnings
246
295
  // and any delivery failure are cron.log lines, and nothing is retried.
@@ -259,6 +308,7 @@ async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
259
308
  async function reconcilePass(
260
309
  notify: NotificationsConfig,
261
310
  project: string,
311
+ drained: SourceDrain[],
262
312
  ): Promise<PodNotification[]> {
263
313
  const pods = await readPods();
264
314
  const agents = await readAgents();
@@ -372,6 +422,10 @@ async function reconcilePass(
372
422
  }
373
423
  }
374
424
 
425
+ // Delivery retries ran outside this lock, before the pass (ADR 0025).
426
+ // Delivery failure writes only cron.log, never Pod policy or the
427
+ // two-event notification inventory (ADRs 0021, 0025).
428
+
375
429
  // 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
376
430
  const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
377
431
  for (const wake of fresh) {
@@ -434,7 +488,7 @@ async function reconcilePass(
434
488
  // pod in this pass must be served by this pass's wake — one batch, one wake,
435
489
  // appended and marked before anything is launched. Claim-before-touch still
436
490
  // holds (ADR 0007): the claims are written before a document is opened.
437
- await listenersPass(project);
491
+ await listenersPass(project, drained);
438
492
 
439
493
  // 4. The wake phase (ADR 0011): the guarded idle → active transition, read
440
494
  // fresh because intake may have claimed, appended, or spawned since the pass
@@ -468,6 +522,12 @@ async function wakeDuePods(now: number): Promise<void> {
468
522
  if (changed) await writePods(pods);
469
523
  }
470
524
 
525
+ /** One Pod's state as the registry holds it now, read without any lock (ADR 0025). */
526
+ async function podState(podId: string): Promise<PodRecord["state"] | undefined> {
527
+ const pods = await readPods();
528
+ return pods.find((candidate) => candidate.id === podId)?.state;
529
+ }
530
+
471
531
  /** Non-terminal materializations occupy a listener's bound, even while capacity-waiting. */
472
532
  function livePod(claimedBy: string, pods: PodRecord[]): boolean {
473
533
  const pod = pods.find((candidate) => candidate.id === claimedBy);
@@ -489,7 +549,7 @@ function matchesListener(rule: string | undefined, item: InboxItem): boolean {
489
549
  * every pass until their writer fixes them. Claims then reach their target: a
490
550
  * template spawns a pod, a bound standing pod takes them as `## Inbox` blocks.
491
551
  */
492
- async function listenersPass(project: string): Promise<void> {
552
+ async function listenersPass(project: string, drained: SourceDrain[]): Promise<void> {
493
553
  const listeners = await readListeners();
494
554
  if (listeners.length === 0) return;
495
555
 
@@ -507,13 +567,27 @@ async function listenersPass(project: string): Promise<void> {
507
567
 
508
568
  // Phase 1: scan, hold the stability memory, and claim. Nothing is spawned yet.
509
569
  for (const listener of listeners) {
510
- const scan = await scanInbox(listener.inbox);
511
- itemsByListener.set(listener.id, new Map(scan.items.map((item) => [item.id, item])));
570
+ const origin =
571
+ listener.source === undefined ? listener.inbox! : `source:${listener.source}`;
572
+ const sourceDrain = drained.find((candidate) => candidate.name === listener.source);
573
+ const scan =
574
+ listener.source === undefined
575
+ ? await scanInbox(listener.inbox!)
576
+ : {
577
+ items: sourceDrain?.items ?? [],
578
+ bad: [],
579
+ ...(sourceDrain?.error === undefined ? {} : { error: sourceDrain.error }),
580
+ };
581
+ const byId = new Map<string, InboxItem>();
582
+ for (const item of scan.items) if (!byId.has(item.id)) byId.set(item.id, item);
583
+ itemsByListener.set(listener.id, byId);
512
584
  if (scan.error !== undefined) {
513
585
  unscannable.add(listener.id);
514
- await appendCronLog(
515
- `listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
516
- );
586
+ if (listener.source === undefined) {
587
+ await appendCronLog(
588
+ `listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
589
+ );
590
+ }
517
591
  continue;
518
592
  }
519
593
  for (const bad of scan.bad) {
@@ -522,24 +596,27 @@ async function listenersPass(project: string): Promise<void> {
522
596
  );
523
597
  }
524
598
 
525
- const seen = new Map(listener.seen.map((fact) => [fact.item, fact]));
526
- for (const known of [...seen.keys()]) {
527
- if (!scan.items.some((item) => item.id === known)) {
528
- seen.delete(known); // gone from the inbox; the inbox is the writer's
529
- changed = true;
599
+ let stable: InboxItem[] = scan.items;
600
+ if (listener.source === undefined) {
601
+ const seen = new Map(listener.seen.map((fact) => [fact.item, fact]));
602
+ for (const known of [...seen.keys()]) {
603
+ if (!scan.items.some((item) => item.id === known)) {
604
+ seen.delete(known); // gone from the inbox; the inbox is the writer's
605
+ changed = true;
606
+ }
530
607
  }
531
- }
532
- const stable: InboxItem[] = [];
533
- for (const item of scan.items) {
534
- const before = seen.get(item.id);
535
- if (before !== undefined && before.mtimeMs === item.mtimeMs && before.size === item.size) {
536
- stable.push(item);
537
- } else {
538
- seen.set(item.id, { item: item.id, mtimeMs: item.mtimeMs, size: item.size });
539
- changed = true;
608
+ stable = [];
609
+ for (const item of scan.items) {
610
+ const before = seen.get(item.id);
611
+ if (before !== undefined && before.mtimeMs === item.mtimeMs && before.size === item.size) {
612
+ stable.push(item);
613
+ } else {
614
+ seen.set(item.id, { item: item.id, mtimeMs: item.mtimeMs, size: item.size });
615
+ changed = true;
616
+ }
540
617
  }
618
+ listener.seen = [...seen.values()];
541
619
  }
542
- listener.seen = [...seen.values()];
543
620
 
544
621
  const pendingClaims = listener.claimed.filter(
545
622
  (claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
@@ -549,20 +626,29 @@ async function listenersPass(project: string): Promise<void> {
549
626
  (claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
550
627
  ).length;
551
628
  // A bound pod is served by a queue (ADR 0014), so only template spawns
552
- // compete for its slots — and a pending claim already holds one.
629
+ // compete for its slots — and a pending claim already holds one. A Source
630
+ // claim is unbounded too: its items exist only in this drain's output and
631
+ // the cursor commits behind the claims, so an unclaimed item would be lost
632
+ // rather than re-scanned. The bound then defers materialization, below.
553
633
  let room =
554
- listener.mode === "pod"
634
+ listener.mode === "pod" || listener.source !== undefined
555
635
  ? Number.POSITIVE_INFINITY
556
636
  : Math.max(0, listener.maxConcurrent - live - pendingClaims);
557
637
 
558
638
  for (const item of stable) {
559
639
  if (room <= 0) break;
560
- const key = claimKey(listener.inbox, item.id);
640
+ const key = claimKey(origin, item.id);
561
641
  if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
562
642
  // One claim shape for both targets (ADR 0013): a template materializes a
563
643
  // pod from it later, a bound standing pod takes it as a document in
564
644
  // phase 3 (ADR 0014). Claim first, always.
565
- listener.claimed.push({ inbox: listener.inbox, item: item.id });
645
+ listener.claimed.push({
646
+ inbox: origin,
647
+ item: item.id,
648
+ ...(sourceDrain?.drainedAt === undefined
649
+ ? {}
650
+ : { claimedAt: sourceDrain.drainedAt }),
651
+ });
566
652
  claimedInInbox.add(key);
567
653
  changed = true;
568
654
  if (room !== Number.POSITIVE_INFINITY) room -= 1;
@@ -575,7 +661,7 @@ async function listenersPass(project: string): Promise<void> {
575
661
  // Phase 2: materialize what is claimed and still unserved.
576
662
  let materialized = false;
577
663
  for (const listener of listeners) {
578
- if (listener.mode !== "template") continue;
664
+ if (listener.mode !== "template" || unscannable.has(listener.id)) continue;
579
665
  const pending = () =>
580
666
  listener.claimed.filter(
581
667
  (claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
@@ -620,7 +706,7 @@ async function listenersPass(project: string): Promise<void> {
620
706
  }
621
707
  const record = await spawnPodRecord(
622
708
  rendered.goal,
623
- project,
709
+ listener.project ?? project,
624
710
  "default",
625
711
  undefined,
626
712
  undefined,
@@ -693,6 +779,35 @@ async function listenersPass(project: string): Promise<void> {
693
779
  // launch anything, so a crash anywhere between claim and wake repairs on the
694
780
  // next pass (ADR 0014). The wake itself is the pass's ordinary transition.
695
781
  if (appended) await writeListeners(listeners);
782
+
783
+ // Two stores, in this order: claims first, then each successful cursor in
784
+ // the source store's own atomic write. A bound standing pod adds one gate:
785
+ // its claims must also be appended, because their bodies exist only in this
786
+ // drain's output. While a wake-up holds the append back, the unchanged cursor
787
+ // makes the next pass drain the same items again. A crash between writes
788
+ // replays into id and document dedupe rather than repeating work (ADR 0024).
789
+ // A Listener removed while its drain was running no longer licenses a cursor.
790
+ const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
791
+ const waitingForAppend = new Set(
792
+ listeners.flatMap((listener) =>
793
+ listener.mode === "pod" &&
794
+ listener.source !== undefined &&
795
+ listener.claimed.some(
796
+ (claim) =>
797
+ claim.inbox === `source:${listener.source}` &&
798
+ claim.blocked === undefined &&
799
+ claim.appended !== true,
800
+ )
801
+ ? [listener.source]
802
+ : [],
803
+ ),
804
+ );
805
+ const committable = drained.filter(
806
+ (drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
807
+ );
808
+ const pruning = pruneSourceClaims(listeners, committable);
809
+ if (pruning.changed) await writeListeners(pruning.listeners);
810
+ await commitSourceCursors(committable);
696
811
  }
697
812
 
698
813
  export interface CliDeps {
@@ -1324,6 +1439,209 @@ async function agentCmd(
1324
1439
  }
1325
1440
  }
1326
1441
 
1442
+ /** Sources are named operator commands; configuration errors fail fast (ADR 0024). */
1443
+ async function sourceCmd(
1444
+ words: string[],
1445
+ deps: CliDeps,
1446
+ out: (line: string) => void,
1447
+ err: (line: string) => void,
1448
+ ): Promise<number> {
1449
+ const [sub, ...rest] = words;
1450
+ switch (sub) {
1451
+ case "list": {
1452
+ const sources = await readSources();
1453
+ if (rest.includes("--json")) {
1454
+ out(
1455
+ JSON.stringify(
1456
+ sources.map((source) => ({
1457
+ name: source.name,
1458
+ cmd: source.cmd,
1459
+ timeoutMs: source.timeoutMs,
1460
+ cursor: source.cursor ?? null,
1461
+ })),
1462
+ ),
1463
+ );
1464
+ return 0;
1465
+ }
1466
+ if (sources.length === 0) {
1467
+ out("No sources");
1468
+ return 0;
1469
+ }
1470
+ for (const source of sources) {
1471
+ out(
1472
+ `${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}`,
1473
+ );
1474
+ }
1475
+ return 0;
1476
+ }
1477
+
1478
+ case "add": {
1479
+ const { flags, rest: positional } = parseFlags(rest, ["cmd", "timeout"]);
1480
+ const name = positional[0];
1481
+ if (positional.length !== 1 || name === undefined || flags.cmd === undefined) {
1482
+ err("Usage: agents-pod source add <name> --cmd <path> [--timeout <s>]");
1483
+ return 2;
1484
+ }
1485
+ const timeoutSeconds = flags.timeout === undefined ? 10 : Number(flags.timeout);
1486
+ const timeoutMs = timeoutSeconds * 1000;
1487
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || !Number.isFinite(timeoutMs)) {
1488
+ err(`--timeout must be a positive number, got ${flags.timeout}`);
1489
+ return 2;
1490
+ }
1491
+ const cmd = resolve(deps.cwd ?? process.cwd(), flags.cmd);
1492
+ const info = await stat(cmd).catch(() => undefined);
1493
+ if (info === undefined || !info.isFile()) {
1494
+ err(`Drain command ${cmd} is missing or not a regular file`);
1495
+ return 2;
1496
+ }
1497
+ if (!(await access(cmd, constants.X_OK).then(() => true, () => false))) {
1498
+ err(`Drain command ${cmd} is not executable`);
1499
+ return 2;
1500
+ }
1501
+ const added = await withRegistryLock(async () => {
1502
+ const sources = await readSources();
1503
+ if (sources.some((source) => source.name === name)) return false;
1504
+ sources.push({ name, cmd, timeoutMs });
1505
+ await writeSources(sources);
1506
+ return true;
1507
+ });
1508
+ if (added === undefined) {
1509
+ err("registry lock unavailable: source not added");
1510
+ return 2;
1511
+ }
1512
+ if (!added) {
1513
+ err(`Source ${name} already exists`);
1514
+ return 2;
1515
+ }
1516
+ return 0;
1517
+ }
1518
+
1519
+ case "rm": {
1520
+ const name = rest.find((word) => !word.startsWith("--"));
1521
+ if (name === undefined) {
1522
+ err("Usage: agents-pod source rm <name>");
1523
+ return 2;
1524
+ }
1525
+ const removed = await withRegistryLock(async () => {
1526
+ const sources = await readSources();
1527
+ if (!sources.some((source) => source.name === name)) return "missing" as const;
1528
+ const listeners = await readListeners();
1529
+ if (listeners.some((listener) => listener.source === name)) return "in-use" as const;
1530
+ await writeSources(sources.filter((source) => source.name !== name));
1531
+ return "removed" as const;
1532
+ });
1533
+ if (removed === undefined) {
1534
+ err("registry lock unavailable: source not removed");
1535
+ return 2;
1536
+ }
1537
+ if (removed === "missing") {
1538
+ err(`Unknown source ${name}`);
1539
+ return 2;
1540
+ }
1541
+ if (removed === "in-use") {
1542
+ err(`Source ${name} is still consumed by a Listener`);
1543
+ return 2;
1544
+ }
1545
+ return 0;
1546
+ }
1547
+
1548
+ default:
1549
+ err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
1550
+ return 2;
1551
+ }
1552
+ }
1553
+
1554
+ /** P0 has one outbound delivery command; configuration errors fail fast (ADR 0025). */
1555
+ async function deliverCmd(
1556
+ words: string[],
1557
+ deps: CliDeps,
1558
+ out: (line: string) => void,
1559
+ err: (line: string) => void,
1560
+ ): Promise<number> {
1561
+ const [sub, ...rest] = words;
1562
+ switch (sub) {
1563
+ case "list": {
1564
+ const deliverer = await readDeliverer();
1565
+ if (rest.includes("--json")) {
1566
+ out(JSON.stringify(deliverer === undefined ? [] : [deliverer]));
1567
+ return 0;
1568
+ }
1569
+ if (deliverer === undefined) {
1570
+ out("No deliverer");
1571
+ return 0;
1572
+ }
1573
+ out(`${deliverer.name} ${deliverer.cmd} timeout ${deliverer.timeoutMs / 1000}s`);
1574
+ return 0;
1575
+ }
1576
+
1577
+ case "add": {
1578
+ const { flags, rest: positional } = parseFlags(rest, ["cmd", "timeout"]);
1579
+ const name = positional[0];
1580
+ if (positional.length !== 1 || name === undefined || flags.cmd === undefined) {
1581
+ err("Usage: agents-pod deliver add <name> --cmd <path> [--timeout <s>]");
1582
+ return 2;
1583
+ }
1584
+ const timeoutSeconds = flags.timeout === undefined ? 10 : Number(flags.timeout);
1585
+ const timeoutMs = timeoutSeconds * 1000;
1586
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || !Number.isFinite(timeoutMs)) {
1587
+ err(`--timeout must be a positive number, got ${flags.timeout}`);
1588
+ return 2;
1589
+ }
1590
+ const cmd = resolve(deps.cwd ?? process.cwd(), flags.cmd);
1591
+ const info = await stat(cmd).catch(() => undefined);
1592
+ if (info === undefined || !info.isFile()) {
1593
+ err(`Delivery command ${cmd} is missing or not a regular file`);
1594
+ return 2;
1595
+ }
1596
+ if (!(await access(cmd, constants.X_OK).then(() => true, () => false))) {
1597
+ err(`Delivery command ${cmd} is not executable`);
1598
+ return 2;
1599
+ }
1600
+ const added = await withRegistryLock(async () => {
1601
+ if ((await readDeliverer()) !== undefined) return false;
1602
+ await writeDeliverer({ name, cmd, timeoutMs });
1603
+ return true;
1604
+ });
1605
+ if (added === undefined) {
1606
+ err("registry lock unavailable: deliverer not added");
1607
+ return 2;
1608
+ }
1609
+ if (!added) {
1610
+ err("A deliverer is already configured: remove it before adding another");
1611
+ return 2;
1612
+ }
1613
+ return 0;
1614
+ }
1615
+
1616
+ case "rm": {
1617
+ const name = rest.find((word) => !word.startsWith("--"));
1618
+ if (name === undefined) {
1619
+ err("Usage: agents-pod deliver rm <name>");
1620
+ return 2;
1621
+ }
1622
+ const removed = await withRegistryLock(async () => {
1623
+ const deliverer = await readDeliverer();
1624
+ if (deliverer?.name !== name) return false;
1625
+ await rm(delivererFile(), { force: true });
1626
+ return true;
1627
+ });
1628
+ if (removed === undefined) {
1629
+ err("registry lock unavailable: deliverer not removed");
1630
+ return 2;
1631
+ }
1632
+ if (!removed) {
1633
+ err(`Unknown deliverer ${name}`);
1634
+ return 2;
1635
+ }
1636
+ return 0;
1637
+ }
1638
+
1639
+ default:
1640
+ err(`Unknown deliver command: ${sub ?? "(none)"}. Try: add | list | rm`);
1641
+ return 2;
1642
+ }
1643
+ }
1644
+
1327
1645
  /**
1328
1646
  * `agents-pod listener` is add / list / rm (ADR 0007): a listener is a registry
1329
1647
  * record, never a process. Bad references are exit-2 configuration errors
@@ -1345,10 +1663,12 @@ async function listenerCmd(
1345
1663
  JSON.stringify(
1346
1664
  listeners.map((listener) => ({
1347
1665
  id: listener.id,
1348
- inbox: listener.inbox,
1666
+ inbox: listener.inbox ?? null,
1667
+ source: listener.source ?? null,
1349
1668
  match: listener.match ?? null,
1350
1669
  mode: listener.mode,
1351
1670
  template: listener.template ?? null,
1671
+ project: listener.project ?? null,
1352
1672
  podId: listener.podId ?? null,
1353
1673
  maxConcurrent: listener.maxConcurrent,
1354
1674
  claimed: listener.claimed ?? [],
@@ -1366,19 +1686,26 @@ async function listenerCmd(
1366
1686
  listener.mode === "template"
1367
1687
  ? `template ${listener.template}`
1368
1688
  : `pod ${listener.podId}`;
1369
- out(
1370
- `${listener.id} ${target} ${listener.inbox} claimed ${listener.claimed?.length ?? 0}`,
1371
- );
1689
+ const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
1690
+ out(`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}`);
1372
1691
  }
1373
1692
  return 0;
1374
1693
  }
1375
1694
 
1376
1695
  case "add": {
1377
- const { flags } = parseFlags(rest, ["inbox", "template", "pod", "match", "max-concurrent"]);
1696
+ const { flags } = parseFlags(rest, [
1697
+ "inbox",
1698
+ "source",
1699
+ "template",
1700
+ "pod",
1701
+ "match",
1702
+ "max-concurrent",
1703
+ "project",
1704
+ ]);
1378
1705
  const usage =
1379
- "Usage: agents-pod listener add --inbox <dir|file> [--template <file> | --pod <podId>] [--match <substr>] [--max-concurrent <n>]";
1380
- if (flags.inbox === undefined) {
1381
- err(usage);
1706
+ "Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--max-concurrent <n>] [--project <dir>]";
1707
+ if ((flags.inbox === undefined) === (flags.source === undefined)) {
1708
+ err(`listener add takes exactly one of --inbox or --source. ${usage}`);
1382
1709
  return 2;
1383
1710
  }
1384
1711
  // A listener has one target, exactly (ADR 0007): a fresh pod per item, or
@@ -1389,11 +1716,33 @@ async function listenerCmd(
1389
1716
  }
1390
1717
 
1391
1718
  const cwd = deps.cwd ?? process.cwd();
1392
- const inbox = resolve(cwd, flags.inbox);
1393
- if (!(await exists(inbox))) {
1719
+ // Only a template materializes a Pod, so only a template has a destination
1720
+ // project; a bound standing Pod already lives where it lives (ADR 0007).
1721
+ if (flags.project !== undefined && flags.pod !== undefined) {
1722
+ err(`--project needs --template: a bound standing Pod keeps its own project. ${usage}`);
1723
+ return 2;
1724
+ }
1725
+ const targetProject =
1726
+ flags.project === undefined ? undefined : resolve(cwd, flags.project);
1727
+ if (targetProject !== undefined) {
1728
+ const info = await stat(targetProject).catch(() => undefined);
1729
+ if (info === undefined || !info.isDirectory()) {
1730
+ err(`Project ${targetProject} does not exist or is not a directory`);
1731
+ return 2;
1732
+ }
1733
+ }
1734
+ const inbox = flags.inbox === undefined ? undefined : resolve(cwd, flags.inbox);
1735
+ if (inbox !== undefined && !(await exists(inbox))) {
1394
1736
  err(`Inbox ${inbox} does not exist: pass a directory or a JSONL file`);
1395
1737
  return 2;
1396
1738
  }
1739
+ if (
1740
+ flags.source !== undefined &&
1741
+ !(await readSources()).some((source) => source.name === flags.source)
1742
+ ) {
1743
+ err(`Unknown source ${flags.source}`);
1744
+ return 2;
1745
+ }
1397
1746
 
1398
1747
  let maxConcurrent = 1;
1399
1748
  if (flags["max-concurrent"] !== undefined) {
@@ -1407,8 +1756,9 @@ async function listenerCmd(
1407
1756
 
1408
1757
  const listener: ListenerRecord = {
1409
1758
  id: newListenerId(),
1410
- inbox,
1759
+ ...(inbox === undefined ? { source: flags.source! } : { inbox }),
1411
1760
  mode: "template",
1761
+ ...(targetProject === undefined ? {} : { project: targetProject }),
1412
1762
  maxConcurrent,
1413
1763
  claimed: [],
1414
1764
  seen: [],
@@ -1435,6 +1785,12 @@ async function listenerCmd(
1435
1785
  }
1436
1786
 
1437
1787
  const added = await withRegistryLock(async () => {
1788
+ if (
1789
+ listener.source !== undefined &&
1790
+ !(await readSources()).some((source) => source.name === listener.source)
1791
+ ) {
1792
+ return false;
1793
+ }
1438
1794
  const listeners = await readListeners();
1439
1795
  listeners.push(listener);
1440
1796
  await writeListeners(listeners);
@@ -1444,6 +1800,10 @@ async function listenerCmd(
1444
1800
  err("registry lock unavailable: listener not added");
1445
1801
  return 2;
1446
1802
  }
1803
+ if (!added) {
1804
+ err(`Unknown source ${listener.source}`);
1805
+ return 2;
1806
+ }
1447
1807
  out(listener.id);
1448
1808
  return 0;
1449
1809
  }
@@ -1479,11 +1839,70 @@ async function listenerCmd(
1479
1839
  }
1480
1840
  }
1481
1841
 
1842
+ const MARKS: Record<DoctorMark, string> = { ok: "✓", bad: "✗", note: "?" };
1843
+
1844
+ /** One line per finding, then the first run only when it is what is missing (ADR 0027). */
1845
+ function printDoctor(report: DoctorReport, out: (line: string) => void): void {
1846
+ out(`agents-pod doctor ${report.store}`);
1847
+ if (report.sections.length > 0) out("");
1848
+ for (const section of report.sections) {
1849
+ section.lines.forEach((line, index) => {
1850
+ const head = index === 0 ? section.title.padEnd(10) : " ".repeat(10);
1851
+ out(`${head}${MARKS[line.mark]} ${line.text}`);
1852
+ for (const detail of line.detail ?? []) out(`${" ".repeat(12)}${detail}`);
1853
+ });
1854
+ }
1855
+ if (report.recipe.length > 0) {
1856
+ out("");
1857
+ out(
1858
+ report.sections.length === 0
1859
+ ? "nothing is configured yet — the first run, in order:"
1860
+ : "still missing:",
1861
+ );
1862
+ for (const step of report.recipe) out(` ${step}`);
1863
+ }
1864
+ }
1865
+
1866
+ /**
1867
+ * `doctor` is read-only, and `--probe` is its only mode that runs operator code
1868
+ * (ADR 0027). Exit 0 healthy, 1 something blocks a channel, 2 bad usage.
1869
+ */
1870
+ async function doctorCmd(
1871
+ words: string[],
1872
+ deps: CliDeps,
1873
+ out: (line: string) => void,
1874
+ err: (line: string) => void,
1875
+ ): Promise<number> {
1876
+ const unknown = words.filter((word) => word !== "--probe" && word !== "--json");
1877
+ if (unknown.length > 0) {
1878
+ err(`Unknown doctor argument: ${unknown[0]}. Usage: agents-pod doctor [--probe] [--json]`);
1879
+ return 2;
1880
+ }
1881
+ const report = await doctor({
1882
+ probe: words.includes("--probe"),
1883
+ ...(deps.cwd === undefined ? {} : { cwd: deps.cwd }),
1884
+ });
1885
+ if (words.includes("--json")) out(JSON.stringify(report));
1886
+ else printDoctor(report, out);
1887
+ return report.healthy ? 0 : 1;
1888
+ }
1889
+
1482
1890
  export async function main(argv: string[], deps: CliDeps = {}): Promise<number> {
1483
1891
  const out = deps.stdout ?? ((line: string) => console.log(line));
1484
1892
  const err = deps.stderr ?? ((line: string) => console.error(line));
1485
1893
  const [cmd, ...rest] = argv;
1486
1894
 
1895
+ // The bare invocation is where a first run happens, so it points at the
1896
+ // report that says what is missing (ADR 0027).
1897
+ if (cmd === undefined) {
1898
+ out("agents-pod — standing Pods across days, one cron-ticked pass");
1899
+ out("");
1900
+ out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
1901
+ out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
1902
+ out("records agent | listener | source | deliver | project | reconcile");
1903
+ return 0;
1904
+ }
1905
+
1487
1906
  switch (cmd) {
1488
1907
  case "spawn":
1489
1908
  return spawnCmd(rest, deps, out, err);
@@ -1503,10 +1922,16 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
1503
1922
  return agentCmd(rest, out, err);
1504
1923
  case "listener":
1505
1924
  return listenerCmd(rest, deps, out, err);
1925
+ case "source":
1926
+ return sourceCmd(rest, deps, out, err);
1927
+ case "deliver":
1928
+ return deliverCmd(rest, deps, out, err);
1506
1929
  case "project":
1507
1930
  return projectCmd(rest, deps, out, err);
1931
+ case "doctor":
1932
+ return doctorCmd(rest, deps, out, err);
1508
1933
  default:
1509
- err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | project`);
1934
+ err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
1510
1935
  return 2;
1511
1936
  }
1512
1937
  }