agents-pod 0.1.0 → 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 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,
@@ -31,7 +33,8 @@ import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
31
33
  import { loadNotifyConfig } from "./notify/config.ts";
32
34
  import { dispatchNotification, shouldNotify } from "./notify/dispatch.ts";
33
35
  import type { NotificationsConfig, PodNotification } from "./notify/types.ts";
34
- import { cronLogPath, podDir, reconcileLockPath } from "./paths.ts";
36
+ import { cronLogPath, delivererFile, podDir, reconcileLockPath } from "./paths.ts";
37
+ import { readDeliverer, retryOutbox, writeDeliverer } from "./outbox.ts";
35
38
  import {
36
39
  projectHasCapacity,
37
40
  projectMaxActive,
@@ -40,6 +43,14 @@ import {
40
43
  } from "./projects.ts";
41
44
  import type { PodRecord } from "./registry.ts";
42
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";
43
54
  import {
44
55
  HEARTBEAT_STALE_MS,
45
56
  WEDGED_ESCALATE_MS,
@@ -225,20 +236,46 @@ async function acquireReconcileLock(): Promise<boolean> {
225
236
  * queued while policy is derived, recorded in `notifiedAt`, and delivered
226
237
  * after the locks are gone — a slow channel never holds the registry.
227
238
  *
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.
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.
231
243
  */
232
244
  async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
233
245
  if (!(await acquireReconcileLock())) return 0;
234
246
  const warnings: string[] = [];
235
247
  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).
248
+ // Listener-spawned pods default to the tick's project; `listener add
249
+ // --project` overrides that destination per Listener (ADR 0007, ADR 0004).
238
250
  const project = deps.cwd ?? process.cwd();
239
251
  let pending: PodNotification[] = [];
240
252
  try {
241
- pending = (await withRegistryLock(() => reconcilePass(notify, project))) ?? [];
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))) ?? [];
242
279
  } finally {
243
280
  await releaseLock(reconcileLockPath());
244
281
  }
@@ -259,6 +296,7 @@ async function reconcileCmd(deps: CliDeps = {}): Promise<number> {
259
296
  async function reconcilePass(
260
297
  notify: NotificationsConfig,
261
298
  project: string,
299
+ drained: SourceDrain[],
262
300
  ): Promise<PodNotification[]> {
263
301
  const pods = await readPods();
264
302
  const agents = await readAgents();
@@ -372,6 +410,10 @@ async function reconcilePass(
372
410
  }
373
411
  }
374
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
+
375
417
  // 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
376
418
  const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
377
419
  for (const wake of fresh) {
@@ -434,7 +476,7 @@ async function reconcilePass(
434
476
  // pod in this pass must be served by this pass's wake — one batch, one wake,
435
477
  // appended and marked before anything is launched. Claim-before-touch still
436
478
  // holds (ADR 0007): the claims are written before a document is opened.
437
- await listenersPass(project);
479
+ await listenersPass(project, drained);
438
480
 
439
481
  // 4. The wake phase (ADR 0011): the guarded idle → active transition, read
440
482
  // fresh because intake may have claimed, appended, or spawned since the pass
@@ -468,6 +510,12 @@ async function wakeDuePods(now: number): Promise<void> {
468
510
  if (changed) await writePods(pods);
469
511
  }
470
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
+
471
519
  /** Non-terminal materializations occupy a listener's bound, even while capacity-waiting. */
472
520
  function livePod(claimedBy: string, pods: PodRecord[]): boolean {
473
521
  const pod = pods.find((candidate) => candidate.id === claimedBy);
@@ -489,7 +537,7 @@ function matchesListener(rule: string | undefined, item: InboxItem): boolean {
489
537
  * every pass until their writer fixes them. Claims then reach their target: a
490
538
  * template spawns a pod, a bound standing pod takes them as `## Inbox` blocks.
491
539
  */
492
- async function listenersPass(project: string): Promise<void> {
540
+ async function listenersPass(project: string, drained: SourceDrain[]): Promise<void> {
493
541
  const listeners = await readListeners();
494
542
  if (listeners.length === 0) return;
495
543
 
@@ -507,13 +555,27 @@ async function listenersPass(project: string): Promise<void> {
507
555
 
508
556
  // Phase 1: scan, hold the stability memory, and claim. Nothing is spawned yet.
509
557
  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])));
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);
512
572
  if (scan.error !== undefined) {
513
573
  unscannable.add(listener.id);
514
- await appendCronLog(
515
- `listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
516
- );
574
+ if (listener.source === undefined) {
575
+ await appendCronLog(
576
+ `listener ${listener.id}: inbox ${listener.inbox} unreadable (${scan.error})`,
577
+ );
578
+ }
517
579
  continue;
518
580
  }
519
581
  for (const bad of scan.bad) {
@@ -522,24 +584,27 @@ async function listenersPass(project: string): Promise<void> {
522
584
  );
523
585
  }
524
586
 
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;
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
+ }
530
595
  }
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;
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
+ }
540
605
  }
606
+ listener.seen = [...seen.values()];
541
607
  }
542
- listener.seen = [...seen.values()];
543
608
 
544
609
  const pendingClaims = listener.claimed.filter(
545
610
  (claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
@@ -549,20 +614,29 @@ async function listenersPass(project: string): Promise<void> {
549
614
  (claim) => claim.spawnedPodId !== undefined && livePod(claim.spawnedPodId, pods),
550
615
  ).length;
551
616
  // 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.
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.
553
621
  let room =
554
- listener.mode === "pod"
622
+ listener.mode === "pod" || listener.source !== undefined
555
623
  ? Number.POSITIVE_INFINITY
556
624
  : Math.max(0, listener.maxConcurrent - live - pendingClaims);
557
625
 
558
626
  for (const item of stable) {
559
627
  if (room <= 0) break;
560
- const key = claimKey(listener.inbox, item.id);
628
+ const key = claimKey(origin, item.id);
561
629
  if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
562
630
  // One claim shape for both targets (ADR 0013): a template materializes a
563
631
  // pod from it later, a bound standing pod takes it as a document in
564
632
  // phase 3 (ADR 0014). Claim first, always.
565
- listener.claimed.push({ inbox: listener.inbox, item: item.id });
633
+ listener.claimed.push({
634
+ inbox: origin,
635
+ item: item.id,
636
+ ...(sourceDrain?.drainedAt === undefined
637
+ ? {}
638
+ : { claimedAt: sourceDrain.drainedAt }),
639
+ });
566
640
  claimedInInbox.add(key);
567
641
  changed = true;
568
642
  if (room !== Number.POSITIVE_INFINITY) room -= 1;
@@ -575,7 +649,7 @@ async function listenersPass(project: string): Promise<void> {
575
649
  // Phase 2: materialize what is claimed and still unserved.
576
650
  let materialized = false;
577
651
  for (const listener of listeners) {
578
- if (listener.mode !== "template") continue;
652
+ if (listener.mode !== "template" || unscannable.has(listener.id)) continue;
579
653
  const pending = () =>
580
654
  listener.claimed.filter(
581
655
  (claim) => claim.spawnedPodId === undefined && claim.blocked === undefined,
@@ -620,7 +694,7 @@ async function listenersPass(project: string): Promise<void> {
620
694
  }
621
695
  const record = await spawnPodRecord(
622
696
  rendered.goal,
623
- project,
697
+ listener.project ?? project,
624
698
  "default",
625
699
  undefined,
626
700
  undefined,
@@ -693,6 +767,35 @@ async function listenersPass(project: string): Promise<void> {
693
767
  // launch anything, so a crash anywhere between claim and wake repairs on the
694
768
  // next pass (ADR 0014). The wake itself is the pass's ordinary transition.
695
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);
696
799
  }
697
800
 
698
801
  export interface CliDeps {
@@ -1324,6 +1427,209 @@ async function agentCmd(
1324
1427
  }
1325
1428
  }
1326
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
+
1327
1633
  /**
1328
1634
  * `agents-pod listener` is add / list / rm (ADR 0007): a listener is a registry
1329
1635
  * record, never a process. Bad references are exit-2 configuration errors
@@ -1345,10 +1651,12 @@ async function listenerCmd(
1345
1651
  JSON.stringify(
1346
1652
  listeners.map((listener) => ({
1347
1653
  id: listener.id,
1348
- inbox: listener.inbox,
1654
+ inbox: listener.inbox ?? null,
1655
+ source: listener.source ?? null,
1349
1656
  match: listener.match ?? null,
1350
1657
  mode: listener.mode,
1351
1658
  template: listener.template ?? null,
1659
+ project: listener.project ?? null,
1352
1660
  podId: listener.podId ?? null,
1353
1661
  maxConcurrent: listener.maxConcurrent,
1354
1662
  claimed: listener.claimed ?? [],
@@ -1366,19 +1674,26 @@ async function listenerCmd(
1366
1674
  listener.mode === "template"
1367
1675
  ? `template ${listener.template}`
1368
1676
  : `pod ${listener.podId}`;
1369
- out(
1370
- `${listener.id} ${target} ${listener.inbox} claimed ${listener.claimed?.length ?? 0}`,
1371
- );
1677
+ const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
1678
+ out(`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}`);
1372
1679
  }
1373
1680
  return 0;
1374
1681
  }
1375
1682
 
1376
1683
  case "add": {
1377
- const { flags } = parseFlags(rest, ["inbox", "template", "pod", "match", "max-concurrent"]);
1684
+ const { flags } = parseFlags(rest, [
1685
+ "inbox",
1686
+ "source",
1687
+ "template",
1688
+ "pod",
1689
+ "match",
1690
+ "max-concurrent",
1691
+ "project",
1692
+ ]);
1378
1693
  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);
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}`);
1382
1697
  return 2;
1383
1698
  }
1384
1699
  // A listener has one target, exactly (ADR 0007): a fresh pod per item, or
@@ -1389,11 +1704,33 @@ async function listenerCmd(
1389
1704
  }
1390
1705
 
1391
1706
  const cwd = deps.cwd ?? process.cwd();
1392
- const inbox = resolve(cwd, flags.inbox);
1393
- if (!(await exists(inbox))) {
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))) {
1394
1724
  err(`Inbox ${inbox} does not exist: pass a directory or a JSONL file`);
1395
1725
  return 2;
1396
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
+ }
1397
1734
 
1398
1735
  let maxConcurrent = 1;
1399
1736
  if (flags["max-concurrent"] !== undefined) {
@@ -1407,8 +1744,9 @@ async function listenerCmd(
1407
1744
 
1408
1745
  const listener: ListenerRecord = {
1409
1746
  id: newListenerId(),
1410
- inbox,
1747
+ ...(inbox === undefined ? { source: flags.source! } : { inbox }),
1411
1748
  mode: "template",
1749
+ ...(targetProject === undefined ? {} : { project: targetProject }),
1412
1750
  maxConcurrent,
1413
1751
  claimed: [],
1414
1752
  seen: [],
@@ -1435,6 +1773,12 @@ async function listenerCmd(
1435
1773
  }
1436
1774
 
1437
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
+ }
1438
1782
  const listeners = await readListeners();
1439
1783
  listeners.push(listener);
1440
1784
  await writeListeners(listeners);
@@ -1444,6 +1788,10 @@ async function listenerCmd(
1444
1788
  err("registry lock unavailable: listener not added");
1445
1789
  return 2;
1446
1790
  }
1791
+ if (!added) {
1792
+ err(`Unknown source ${listener.source}`);
1793
+ return 2;
1794
+ }
1447
1795
  out(listener.id);
1448
1796
  return 0;
1449
1797
  }
@@ -1503,10 +1851,14 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
1503
1851
  return agentCmd(rest, out, err);
1504
1852
  case "listener":
1505
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);
1506
1858
  case "project":
1507
1859
  return projectCmd(rest, deps, out, err);
1508
1860
  default:
1509
- err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | project`);
1861
+ err(`Unknown command: ${cmd ?? "(none)"}. Try: spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
1510
1862
  return 2;
1511
1863
  }
1512
1864
  }