agents-pod 0.3.5 → 0.3.7

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
@@ -14,6 +14,14 @@ import {
14
14
  } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { dirname, join, resolve } from "node:path";
17
+ import {
18
+ ADMISSIONS,
19
+ admissionOf,
20
+ clockPhrase,
21
+ clockRefusal,
22
+ isAdmission,
23
+ } from "./admission.ts";
24
+ import { DAY_MS, duration, inWindow, medianIntervalMs, stamped } from "./cadence.ts";
17
25
  import {
18
26
  agentTypeRefusal,
19
27
  isAgentType,
@@ -32,6 +40,7 @@ import {
32
40
  newListenerId,
33
41
  readListeners,
34
42
  writeListeners,
43
+ type ClaimRecord,
35
44
  type ListenerRecord,
36
45
  } from "./listeners.ts";
37
46
  import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
@@ -57,7 +66,13 @@ import {
57
66
  type AdapterFileState,
58
67
  type AdapterSyncPlan,
59
68
  } from "./adapters.ts";
60
- import type { PodRecord } from "./registry.ts";
69
+ import {
70
+ DEFAULT_WAKE_BUDGET_PER_DAY,
71
+ type Admission,
72
+ type PodRecord,
73
+ type WakePolicy,
74
+ type WakeUpEntry,
75
+ } from "./registry.ts";
61
76
  import { readPods, withRegistryLock, writePods } from "./registry.ts";
62
77
  import {
63
78
  commitSourceCursors,
@@ -97,28 +112,64 @@ function launchWrapperDetached(podId: string, agentId?: string, warm = false): v
97
112
  }
98
113
 
99
114
  /**
100
- * Flags may appear anywhere among the goal words (README's CLI surface), and
101
- * the words that are not flags compose the goal. A flag without a value is
102
- * left unset and surfaces through the command's own validation.
115
+ * Flags may appear anywhere among the words (README's CLI surface), and the
116
+ * words that are not flags compose the goal. Every flag in `allowed` takes a
117
+ * value; one that arrives without a value is reported as `dangling` — left in
118
+ * `rest` it would become part of a goal (`spawn fix it --wake-budget`) and the
119
+ * command would proceed with the flag silently unset.
120
+ *
121
+ * A token that begins with `--` is the *next* flag's name, never this flag's
122
+ * value: `spawn … --model --dormant` used to set `model` to the literal string
123
+ * `--dormant`, drop the dormancy and launch the wake-up the flag was there to
124
+ * prevent (#43 review). No flag's value is a flag name — an agent id, a model
125
+ * id, a path, a pod id and a count never start with `--` — so the token stays
126
+ * unconsumed and this flag is reported dangling, exactly as if it had arrived
127
+ * last. A single leading `-` is still a value (`--wake-budget -3` must reach the
128
+ * integer check, not the usage error).
103
129
  */
104
130
  function parseFlags(
105
131
  words: string[],
106
132
  allowed: readonly string[],
107
- ): { flags: Record<string, string>; rest: string[] } {
133
+ multi: readonly string[] = [],
134
+ ): {
135
+ flags: Record<string, string>;
136
+ multiFlags: Record<string, string[]>;
137
+ rest: string[];
138
+ dangling: string[];
139
+ } {
108
140
  const flags: Record<string, string> = {};
141
+ const multiFlags: Record<string, string[]> = {};
109
142
  const rest: string[] = [];
143
+ const dangling: string[] = [];
110
144
  for (let index = 0; index < words.length; index += 1) {
111
145
  const word = words[index]!;
112
146
  const name = word.startsWith("--") ? word.slice(2) : undefined;
113
- const value = words[index + 1];
147
+ const next = words[index + 1];
148
+ const value = next !== undefined && !next.startsWith("--") ? next : undefined;
114
149
  if (name !== undefined && allowed.includes(name) && value !== undefined) {
115
- flags[name] = value;
150
+ // A flag that may repeat keeps every value (ADR 0034's `--chat`): a scalar
151
+ // would silently drop all but the last group.
152
+ if (multi.includes(name)) (multiFlags[name] ??= []).push(value);
153
+ else flags[name] = value;
116
154
  index += 1;
117
155
  } else {
156
+ if (name !== undefined && allowed.includes(name)) dangling.push(`--${name}`);
118
157
  rest.push(word);
119
158
  }
120
159
  }
121
- return { flags, rest };
160
+ return { flags, multiFlags, rest, dangling };
161
+ }
162
+
163
+ /**
164
+ * The routing filter as one rule (ADR 0034): the groups an item's `chat_id` must
165
+ * be among, ANDed with the text substring. An item with no routing field can
166
+ * never satisfy a filter; that is a fact about directory items, and `listener
167
+ * add` refuses to write a filter they could never match.
168
+ */
169
+ function routingChats(values: readonly string[] | undefined): string[] | undefined {
170
+ if (values === undefined) return undefined;
171
+ const chats = values.map((value) => value.trim()).filter((value) => value !== "");
172
+ return chats.length === 0 ? undefined : chats;
122
173
  }
123
174
 
124
175
  /** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
@@ -126,6 +177,69 @@ function backoffMs(streak: number): number {
126
177
  return Math.min(60_000 * 2 ** (streak - 1), 900_000);
127
178
  }
128
179
 
180
+ /**
181
+ * One outcome's effect on the record's policy (ADR 0031): the single place that
182
+ * decides when the *next* wake-up may happen, and the reason it takes the owner.
183
+ *
184
+ * **An outcome never schedules a wake-up its owner would not have made.** `more`
185
+ * means "the goal needs another wake-up"; for a Pod the tick owns that is "on the
186
+ * next pass", and for a Pod a Listener feeds or an outside clock wakes, the next
187
+ * wake-up is its owner's decision — a `nextWakeAt` written here would be a second,
188
+ * invisible owner, which is how a goal re-armed itself at 41 wake-ups in five
189
+ * hours (issue #31, #34). `cancelled` says nothing about the schedule: a human
190
+ * stopped a window, not the work.
191
+ *
192
+ * The failure streak is *not* owner-dependent: park obeys it, and park is about
193
+ * money, not about who wakes the Pod.
194
+ */
195
+ function applyOutcome(
196
+ policy: WakePolicy,
197
+ owner: Admission,
198
+ wake: WakeUpEntry,
199
+ now: number,
200
+ ): void {
201
+ // `quiet` (ADR 0011 amendment, #36): the window is satisfied and nothing is
202
+ // scheduled. It is a clean exit — the streak resets, the batch it was admitted
203
+ // for was served — and the difference from `more` is that it asks for nothing:
204
+ // no clock, and the tick stops admitting the Pod until work arrives.
205
+ if (wake.outcome === "quiet") {
206
+ policy.failureStreak = 0;
207
+ delete policy.nextWakeAt;
208
+ delete policy.intakePending;
209
+ policy.awaiting = true;
210
+ return;
211
+ }
212
+ if (wake.outcome === "more" || wake.outcome === "done") {
213
+ policy.failureStreak = 0;
214
+ delete policy.nextWakeAt; // `self`: due on the next pass, at once
215
+ // A clean exit served the batch it was woken for, so the debt is paid
216
+ // (ADR 0026 amendment).
217
+ delete policy.intakePending;
218
+ // Another wake-up is the work again, so a previous "nothing scheduled" is
219
+ // spent: the Pod is wake-eligible by its owner's ordinary rule.
220
+ delete policy.awaiting;
221
+ return;
222
+ }
223
+ // Anything not named here is recorded and ignored, never counted as a failure:
224
+ // `cancelled` (a human stopped a window, not the work) and any outcome a later
225
+ // version adds must not park a Pod by falling through (ADR 0031 — #36's `quiet`
226
+ // is exactly such a value).
227
+ if (wake.outcome !== "failed" && wake.outcome !== "lost") return;
228
+ // Only the tick's own Pod is put on a clock; an intake Pod is re-admitted by
229
+ // the batch it still owes, and an `external` one by its own wake — both
230
+ // bounded by the streak and the park below, not by this field.
231
+ policy.failureStreak += 1;
232
+ // A window that ran and failed was not satisfied by its own account, so the
233
+ // "nothing scheduled" claim is spent too — the streak machinery decides from
234
+ // here, and a parked Pod is skipped before admission is even asked.
235
+ delete policy.awaiting;
236
+ if (owner === "self") {
237
+ policy.nextWakeAt = now + backoffMs(policy.failureStreak);
238
+ } else {
239
+ delete policy.nextWakeAt;
240
+ }
241
+ }
242
+
129
243
  /**
130
244
  * Signal a pod's process group — never our own. The pgid comes from store
131
245
  * data only (wrapper.json, ADR 0003), and the guards make a corrupt record a
@@ -324,6 +438,18 @@ async function reconcilePass(
324
438
  const pods = await readPods();
325
439
  const agents = await readAgents();
326
440
  const now = Date.now();
441
+ // The owner is resolved per Pod below from the bindings as this pass begins.
442
+ // A `listener add` from another process can still land during the Pod loop,
443
+ // and the derivation would then read that Pod as the tick's — which is why the
444
+ // wake phase reads the bindings again (see wakeDuePods) and why `nextWakeAt`,
445
+ // the one field such a stale answer could write, gates nothing but a `self`
446
+ // Pod. A wrong answer here can delay nothing; a wrong answer there would wake
447
+ // a Pod that had just been bound.
448
+ const intakeBound = new Set(
449
+ (await readListeners()).flatMap((listener) =>
450
+ listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
451
+ ),
452
+ );
327
453
  let changed = false;
328
454
  const pending: PodNotification[] = [];
329
455
 
@@ -348,7 +474,13 @@ async function reconcilePass(
348
474
  const facts = await livenessFacts(pod);
349
475
  if (facts.quadrant === "lost") {
350
476
  const n = facts.wrapper?.wakeup ?? (await maxWakeupNumber(pod)) ?? 1;
351
- pod.wakeUps.push({ n, outcome: "lost", detail: lostDetail(facts) });
477
+ pod.wakeUps.push({
478
+ n,
479
+ outcome: "lost",
480
+ detail: lostDetail(facts),
481
+ at: now,
482
+ ...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
483
+ });
352
484
  pod.state = "idle";
353
485
  delete pod.wedgedAt; // a lost wake-up spends any wedged observation
354
486
  changed = true;
@@ -390,6 +522,8 @@ async function reconcilePass(
390
522
  detail: trusted
391
523
  ? "wedged wrapper killed by reconcile"
392
524
  : "wedged wrapper signal withheld (recorded process is gone)",
525
+ at: now,
526
+ ...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
393
527
  });
394
528
  pod.state = "idle";
395
529
  delete pod.wedgedAt;
@@ -425,6 +559,8 @@ async function reconcilePass(
425
559
  n: wrapper.wakeup,
426
560
  outcome: "failed",
427
561
  detail: PROGRESSLESS_DETAIL,
562
+ at: now,
563
+ ...(pod.admitted === undefined ? {} : { trigger: pod.admitted }),
428
564
  });
429
565
  pod.state = "idle";
430
566
  changed = true;
@@ -438,28 +574,21 @@ async function reconcilePass(
438
574
  // two-event notification inventory (ADRs 0021, 0025).
439
575
 
440
576
  // 2. Policy derivation from outcome facts the wrapper (or step 1) recorded.
577
+ // The owner is resolved once here and handed to the derivation, so a new
578
+ // outcome cannot reach the schedule without answering "who wakes this Pod?"
579
+ // (ADR 0031).
580
+ const owner = admissionOf(pod, intakeBound);
441
581
  const fresh = pod.wakeUps.slice(pod.policy.appliedOutcomes);
442
582
  for (const wake of fresh) {
443
- if (wake.outcome === "failed" || wake.outcome === "lost") {
444
- pod.policy.failureStreak += 1;
445
- pod.policy.nextWakeAt = now + backoffMs(pod.policy.failureStreak);
446
- // Backoff without a stop is a money pit: streak 3 parks the pod, and
447
- // only a human wake lifts it (ADR 0011). Park is policy data, not a state.
448
- if (pod.policy.failureStreak >= 3 && !pod.policy.parked) {
449
- pod.policy.parked = true;
450
- announcePark();
451
- }
452
- } else if (wake.outcome === "more" || wake.outcome === "done") {
453
- pod.policy.failureStreak = 0;
454
- delete pod.policy.nextWakeAt;
455
- }
456
- // A clean exit served the batch it was woken for, so the debt is paid
457
- // (ADR 0026 amendment). `failed` and `lost` leave it standing: the next
458
- // admitted wake-up is the retry, and it is bounded by backoff and park
459
- // exactly like any other failure. `cancelled` leaves it too — a human
460
- // stopped a window, not the work.
461
- if (wake.outcome === "more" || wake.outcome === "done") {
462
- delete pod.policy.intakePending;
583
+ applyOutcome(pod.policy, owner, wake, now);
584
+ // Backoff without a stop is a money pit: streak 3 parks the pod, and only
585
+ // a human wake lifts it (ADR 0011). Park is policy data, not a state, and
586
+ // it holds for every owner — a stop is about money, not about scheduling.
587
+ const failed = wake.outcome === "failed" || wake.outcome === "lost";
588
+ if (failed && pod.policy.failureStreak >= 3 && !pod.policy.parked) {
589
+ pod.policy.parked = true;
590
+ pod.policy.parkedBy = "failures";
591
+ announcePark();
463
592
  }
464
593
  pod.policy.appliedOutcomes += 1;
465
594
  changed = true;
@@ -467,6 +596,23 @@ async function reconcilePass(
467
596
 
468
597
  const goalOpen = pod.state !== "done" && pod.state !== "cancelled";
469
598
 
599
+ // 2b. The daily ceiling (ADR 0032). The one defence against a loop nobody
600
+ // predicted: the storm's own round 41 set `parked` by hand, and this is that
601
+ // manoeuvre promoted to machinery. Park is reused rather than invented, so
602
+ // exactly one notification is sent (ADR 0021) and only a human `wake`
603
+ // lifts it (ADR 0011).
604
+ const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
605
+ const today = inWindow(pod.wakeUps, now, DAY_MS).length;
606
+ if (goalOpen && !pod.policy.parked && today > budget) {
607
+ pod.policy.parked = true;
608
+ // A budget stop is not the failure stop: `wake` lifts the latter freely
609
+ // (ADR 0011) but must not lift this one, or an outside clock re-opens the
610
+ // ceiling on every tick and every re-park announces itself (ADR 0032).
611
+ pod.policy.parkedBy = "budget";
612
+ announcePark(`${today} wake-ups in 24 h — over the budget of ${budget} (ADR 0032)`);
613
+ changed = true;
614
+ }
615
+
470
616
  // 3. Terminal observation (ADR 0021): done is announced once, however many
471
617
  // passes later find the record. The marker is what makes it once.
472
618
  if (
@@ -494,6 +640,7 @@ async function reconcilePass(
494
640
  if (!agentKnown && goalOpen && pod.state !== "active") {
495
641
  if (!pod.policy.parked) {
496
642
  pod.policy.parked = true;
643
+ pod.policy.parkedBy = "agent";
497
644
  announcePark(`agent "${agentId}" no longer resolves (ADR 0012)`);
498
645
  changed = true;
499
646
  }
@@ -528,6 +675,19 @@ async function reconcilePass(
528
675
  * inbox burned one whole agent run per tick — the shape that got the first
529
676
  * router pod cancelled. An unbound pod has no inbox to check, so ADR 0011's
530
677
  * optimistic rule stays its whole policy.
678
+ *
679
+ * ADR 0030 makes the owner a recorded fact with three values: `intake` (the debt
680
+ * gate below), `external` (never launched here — an outside clock or a human
681
+ * calls `wake`), `self` (the tick, for a goal that reaches `done`). A Listener
682
+ * binding outranks the record, because binding *is* an intake declaration, and
683
+ * an unrecorded owner falls back to `self` — the old rule, now named and
684
+ * reported by `doctor` rather than silently assumed.
685
+ *
686
+ * The bindings are read here, not handed down from the top of the pass: a
687
+ * `listener add` can land while a pass spawns pods, retries outboxes, and
688
+ * drains sources, and a set read seconds earlier would let this phase tick-wake
689
+ * a pod that had just been bound — the storm's own unit, reintroduced as a race.
690
+ * The derivation reads its own copy for the same reason, closest to its use.
531
691
  */
532
692
  async function wakeDuePods(now: number): Promise<void> {
533
693
  const pods = await readPods();
@@ -538,20 +698,47 @@ async function wakeDuePods(now: number): Promise<void> {
538
698
  ),
539
699
  );
540
700
  let changed = false;
701
+ const toLaunch: Array<{ id: string; agent: string }> = [];
702
+ const lines: string[] = [];
541
703
  for (const pod of pods) {
542
704
  if (pod.state !== "idle" || pod.policy?.parked) continue;
543
- if (intakeBound.has(pod.id) && !pod.policy?.intakePending) continue;
544
- if (pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) continue;
705
+ // `quiet`: the window said it was satisfied and scheduled nothing (#36).
706
+ // No owner admits it until work arrives — a new intake batch or a human
707
+ // `wake`, both of which clear the flag.
708
+ if (pod.policy?.awaiting) continue;
709
+ // Admission first (ADR 0030): the tick is one owner among three, and it
710
+ // reads the owner from the record instead of inferring it. An `external`
711
+ // Pod is somebody else's to wake at every tick of every day.
712
+ const owner = admissionOf(pod, intakeBound);
713
+ if (owner === "external") continue;
714
+ if (owner === "intake" && !pod.policy?.intakePending) continue;
715
+ // A backoff is the tick's own delay, written only for its own Pod (ADR 0031):
716
+ // letting it gate an `intake` Pod would be a second owner again — a record
717
+ // that failed before it was bound, or one written before ADR 0031, would
718
+ // hold a batch back for up to fifteen minutes.
719
+ if (owner === "self" && pod.policy?.nextWakeAt !== undefined && pod.policy.nextWakeAt > now) {
720
+ continue;
721
+ }
545
722
  if (pod.after !== undefined) {
546
723
  const upstream = pods.find((candidate) => candidate.id === pod.after);
547
724
  if (upstream?.state !== "done") continue;
548
725
  }
549
726
  if (!projectHasCapacity(pod.project, pods, projects)) continue;
550
- launchWrapperDetached(pod.id, pod.policy?.agent ?? "default");
727
+ pod.admitted = "tick";
551
728
  pod.state = "active";
729
+ toLaunch.push({ id: pod.id, agent: pod.policy?.agent ?? "default" });
730
+ lines.push(`wake ${pod.id} tick (admission ${owner})`);
552
731
  changed = true;
553
732
  }
733
+ // Record, then launch (ADR 0032). A crash between the two leaves a ghost the
734
+ // next pass adjudicates as `lost` — recoverable; a launch nobody recorded is
735
+ // the same Pod woken twice.
554
736
  if (changed) await writePods(pods);
737
+ for (const launch of toLaunch) launchWrapperDetached(launch.id, launch.agent);
738
+ // The storm left no trace a single command could read (issue #35): every
739
+ // launch now says which launcher it was, next to the sources and listeners
740
+ // that already write here (ADR 0032).
741
+ for (const line of lines) await appendCronLog(line);
555
742
  }
556
743
 
557
744
  /** One Pod's state as the registry holds it now, read without any lock (ADR 0025). */
@@ -566,10 +753,47 @@ function livePod(claimedBy: string, pods: PodRecord[]): boolean {
566
753
  return pod !== undefined && pod.state !== "done" && pod.state !== "cancelled";
567
754
  }
568
755
 
569
- /** P0's match rule: a plain substring over title and body; absent means all. */
570
- function matchesListener(rule: string | undefined, item: InboxItem): boolean {
571
- if (rule === undefined || rule === "") return true;
572
- return `${item.title}\n${item.body}`.includes(rule);
756
+ /**
757
+ * A Listener's match rule (ADRs 0007, 0034): a routing filter over the item's
758
+ * `chat_id`, ANDed with a plain substring over title and body. Either half alone
759
+ * is a rule; both together must hold.
760
+ */
761
+ function matchesListener(
762
+ rule: Pick<ListenerRecord, "match" | "chats">,
763
+ item: InboxItem,
764
+ ): boolean {
765
+ if (rule.chats !== undefined && rule.chats.length > 0) {
766
+ if (item.chatId === undefined || !rule.chats.includes(item.chatId)) return false;
767
+ }
768
+ if (rule.match === undefined || rule.match === "") return true;
769
+ return `${item.title}\n${item.body}`.includes(rule.match);
770
+ }
771
+
772
+ /**
773
+ * A Source claim owns its item after the drain has moved on (ADR 0034). Claims
774
+ * written before that decision fall back to this pass's scan. Filesystem inbox
775
+ * claims carry no copy at all: `{{path}}` and deletion are meaningful there, and
776
+ * the file itself is the truth.
777
+ */
778
+ function itemForClaim(
779
+ listener: ListenerRecord,
780
+ claim: ClaimRecord,
781
+ scanned: InboxItem | undefined,
782
+ ): InboxItem | undefined {
783
+ if (listener.source === undefined || claim.title === undefined || claim.body === undefined) {
784
+ return scanned;
785
+ }
786
+ return {
787
+ id: claim.item,
788
+ title: claim.title,
789
+ body: claim.body,
790
+ path: "",
791
+ mtimeMs: 0,
792
+ size: 0,
793
+ ...(claim.chatId === undefined ? {} : { chatId: claim.chatId }),
794
+ ...(claim.rootId === undefined ? {} : { rootId: claim.rootId }),
795
+ ...(claim.threadId === undefined ? {} : { threadId: claim.threadId }),
796
+ };
573
797
  }
574
798
 
575
799
  /**
@@ -587,10 +811,20 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
587
811
 
588
812
  /** The inbox, not the listener, is the claim namespace (ADR 0013). */
589
813
  const claimedInInbox = new Set<string>();
814
+ const deferredClaims = new Set<ClaimRecord>();
590
815
  for (const listener of listeners) {
591
816
  listener.claimed ??= [];
592
817
  listener.seen ??= [];
593
- for (const claim of listener.claimed) claimedInInbox.add(claimKey(claim.inbox, claim.item));
818
+ for (const claim of listener.claimed) {
819
+ claimedInInbox.add(claimKey(claim.inbox, claim.item));
820
+ if (
821
+ claim.spawnedPodId === undefined &&
822
+ claim.blocked === undefined &&
823
+ claim.appended !== true
824
+ ) {
825
+ deferredClaims.add(claim);
826
+ }
827
+ }
594
828
  }
595
829
 
596
830
  const itemsByListener = new Map<string, Map<string, InboxItem>>();
@@ -659,9 +893,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
659
893
  ).length;
660
894
  // A bound pod is served by a queue (ADR 0014), so only template spawns
661
895
  // compete for its slots — and a pending claim already holds one. A Source
662
- // claim is unbounded too: its items exist only in this drain's output and
663
- // the cursor commits behind the claims, so an unclaimed item would be lost
664
- // rather than re-scanned. The bound then defers materialization, below.
896
+ // claim is unbounded too: the cursor commits behind the claims, so an
897
+ // unclaimed item would be lost rather than re-scanned. Its item copy lets
898
+ // the bound defer only materialization, not the content itself (ADR 0034).
665
899
  let room =
666
900
  listener.mode === "pod" || listener.source !== undefined
667
901
  ? Number.POSITIVE_INFINITY
@@ -670,16 +904,28 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
670
904
  for (const item of stable) {
671
905
  if (room <= 0) break;
672
906
  const key = claimKey(origin, item.id);
673
- if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
907
+ if (claimedInInbox.has(key) || !matchesListener(listener, item)) continue;
674
908
  // One claim shape for both targets (ADR 0013): a template materializes a
675
909
  // pod from it later, a bound standing pod takes it as a document in
676
- // phase 3 (ADR 0014). Claim first, always.
910
+ // phase 3 (ADR 0014). Claim first, always. Only a Source claim copies its
911
+ // item (ADR 0034): a filesystem item is still on disk and the document is
912
+ // the truth there, so a copy would only swell `listeners.json` with
913
+ // bodies nothing ever reads.
677
914
  listener.claimed.push({
678
915
  inbox: origin,
679
916
  item: item.id,
680
917
  ...(sourceDrain?.drainedAt === undefined
681
918
  ? {}
682
919
  : { claimedAt: sourceDrain.drainedAt }),
920
+ ...(listener.source === undefined
921
+ ? {}
922
+ : {
923
+ title: item.title,
924
+ body: item.body,
925
+ ...(item.chatId === undefined ? {} : { chatId: item.chatId }),
926
+ ...(item.rootId === undefined ? {} : { rootId: item.rootId }),
927
+ ...(item.threadId === undefined ? {} : { threadId: item.threadId }),
928
+ }),
683
929
  });
684
930
  claimedInInbox.add(key);
685
931
  changed = true;
@@ -687,6 +933,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
687
933
  }
688
934
  }
689
935
 
936
+ // Routing makes "nobody wants this group" an ordinary state, so the pass counts
937
+ // it (ADR 0034): unique ids only, because the overlap re-emits the same message
938
+ // pass after pass, and a filter's whole job is to leave some of them unclaimed.
939
+ for (const drain of drained) {
940
+ if (drain.error !== undefined) continue;
941
+ let unrouted = 0;
942
+ for (const id of new Set(drain.items.map((item) => item.id))) {
943
+ if (!claimedInInbox.has(claimKey(`source:${drain.name}`, id))) unrouted += 1;
944
+ }
945
+ drain.unrouted = unrouted;
946
+ }
947
+
690
948
  // The claim is on disk before any pod is touched (ADR 0007).
691
949
  if (changed) await writeListeners(listeners);
692
950
 
@@ -718,7 +976,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
718
976
  ).length;
719
977
  for (const claim of pending()) {
720
978
  if (live >= listener.maxConcurrent) break; // bound: the claim waits a pass
721
- const item = itemsByListener.get(listener.id)?.get(claim.item);
979
+ const item = itemForClaim(
980
+ listener,
981
+ claim,
982
+ itemsByListener.get(listener.id)?.get(claim.item),
983
+ );
722
984
  if (item === undefined) {
723
985
  claim.blocked = "item vanished before materialization";
724
986
  await appendCronLog(
@@ -736,12 +998,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
736
998
  materialized = true;
737
999
  continue;
738
1000
  }
1001
+ // A template Pod is spawned per Inbox Item and exists to finish it: the
1002
+ // tick's own rule is its owner (ADR 0030), and `done` is its end.
739
1003
  const record = await spawnPodRecord(
740
1004
  rendered.goal,
741
1005
  listener.project ?? project,
742
- "default",
743
- undefined,
744
- undefined,
1006
+ { agentId: "default", admission: "self" },
745
1007
  true,
746
1008
  );
747
1009
  claim.spawnedPodId = record.id;
@@ -758,7 +1020,7 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
758
1020
  let appended = false;
759
1021
  for (const listener of listeners) {
760
1022
  if (listener.mode !== "pod" || listener.podId === undefined) continue;
761
- if (unscannable.has(listener.id)) continue; // no bodies to copy this pass
1023
+ if (unscannable.has(listener.id)) continue; // a failed scan still waits for a successful retry
762
1024
  const waiting = listener.claimed.filter(
763
1025
  (claim) => claim.blocked === undefined && claim.appended !== true,
764
1026
  );
@@ -774,12 +1036,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
774
1036
  }
775
1037
  if (pod.state !== "idle" || pod.policy?.parked) continue;
776
1038
 
777
- // The body is copied at append time (ADR 0014), so the item must still be
778
- // readable: a writer that deleted it first leaves nothing to copy.
1039
+ // The body is copied at append time (ADR 0014). A Source claim supplies its
1040
+ // own copy; a legacy claim or filesystem inbox still needs the current scan.
779
1041
  const scanned = itemsByListener.get(listener.id);
780
1042
  const batch: InboxItem[] = [];
781
1043
  for (const claim of waiting) {
782
- const item = scanned?.get(claim.item);
1044
+ const item = itemForClaim(listener, claim, scanned?.get(claim.item));
783
1045
  if (item === undefined) {
784
1046
  claim.blocked = "item vanished before append";
785
1047
  await appendCronLog(
@@ -812,6 +1074,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
812
1074
  // leaves the count standing, so the next pass is the retry.
813
1075
  pod.policy ??= { failureStreak: 0, appliedOutcomes: 0 };
814
1076
  pod.policy.intakePending = (pod.policy.intakePending ?? 0) + batch.length;
1077
+ // Work arrived, so a window that had declared itself satisfied is over
1078
+ // (ADR 0011 amendment, #36) — the debt below is what admits the wake.
1079
+ delete pod.policy.awaiting;
815
1080
  await writePods(pods);
816
1081
  appended = true;
817
1082
  }
@@ -822,10 +1087,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
822
1087
 
823
1088
  // Two stores, in this order: claims first, then each successful cursor in
824
1089
  // the source store's own atomic write. A bound standing pod adds one gate:
825
- // its claims must also be appended, because their bodies exist only in this
826
- // drain's output. While a wake-up holds the append back, the unchanged cursor
827
- // makes the next pass drain the same items again. A crash between writes
828
- // replays into id and document dedupe rather than repeating work (ADR 0024).
1090
+ // its claims must also be appended, preserving ADR 0014's document handoff.
1091
+ // While a wake-up holds the append back, the unchanged cursor makes the next
1092
+ // pass drain the same items again; their snapshots now cover a drain that
1093
+ // does not (ADR 0034). A crash between writes replays into id and document
1094
+ // dedupe rather than repeating work (ADR 0024).
829
1095
  // A Listener removed while its drain was running no longer licenses a cursor.
830
1096
  const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
831
1097
  const waitingForAppend = new Set(
@@ -845,8 +1111,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
845
1111
  const committable = drained.filter(
846
1112
  (drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
847
1113
  );
848
- const pruning = pruneSourceClaims(listeners, committable);
1114
+ const pruning = pruneSourceClaims(listeners, committable, deferredClaims);
849
1115
  if (pruning.changed) await writeListeners(pruning.listeners);
1116
+ // The count is a fact of the pass that committed it, printed once: a group an
1117
+ // operator never aimed a Listener at is legitimate, a group nothing wants is
1118
+ // what they need told (ADR 0034).
1119
+ for (const drain of committable) {
1120
+ if ((drain.unrouted ?? 0) > 0) {
1121
+ await appendCronLog(
1122
+ `source ${drain.name}: ${drain.unrouted} item(s) matched no listener (unrouted, the cursor passed them)`,
1123
+ );
1124
+ }
1125
+ }
850
1126
  await commitSourceCursors(committable);
851
1127
  }
852
1128
 
@@ -934,33 +1210,125 @@ async function statusCmd(
934
1210
  const last = pod.wakeUps.at(-1);
935
1211
  out(pod.id);
936
1212
  out(` goal: ${goalHeadline(pod.goal)}`);
937
- out(` state: ${pod.state}`);
1213
+ // Who admits this Pod (ADR 0030) — the fact that decides whether the tick
1214
+ // wakes it at all, so the record's owner is read before the state line, which
1215
+ // now also depends on it.
1216
+ const bound = new Set(
1217
+ (await readListeners()).flatMap((listener) =>
1218
+ listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
1219
+ ),
1220
+ );
1221
+ const owner = admissionOf(pod, bound);
1222
+ // The suffix qualifies a resting pod: `awaiting` says why the tick leaves it
1223
+ // alone, and a live wake-up is a different answer to the same question.
1224
+ const awaiting = pod.policy?.awaiting === true && pod.state === "idle";
1225
+ // `dormant` (#43): the record exists and no wake-up has ever run, so the first
1226
+ // one still belongs to the owner — the fact a spawn wrote and a hybrid ask
1227
+ // depends on. Derived, never stored: the empty wake-up list is the fact, and a
1228
+ // marker would have to be cleared by three launchers. A Pod the tick owns is
1229
+ // never reported this way; the tick is on its way to it.
1230
+ const dormant = pod.state === "idle" && pod.wakeUps.length === 0 && owner !== "self";
1231
+ out(
1232
+ ` state: ${pod.state}${awaiting ? " (awaiting)" : ""}${
1233
+ dormant ? " (dormant: no wake-up yet)" : ""
1234
+ }`,
1235
+ );
1236
+ // The owner line comes before anything about liveness (ADR 0030).
1237
+ out(
1238
+ ` admission: ${owner}${
1239
+ pod.admission === undefined
1240
+ ? " (derived: no owner recorded)"
1241
+ : pod.admission !== owner
1242
+ ? ` (a listener is bound: intake wins over ${pod.admission})`
1243
+ : ""
1244
+ }`,
1245
+ );
938
1246
  if (pod.after !== undefined) {
939
1247
  const upstream = (await readPods()).find((candidate) => candidate.id === pod.after);
940
1248
  out(` after: ${pod.after} (${upstream?.state ?? "missing"})`);
941
1249
  }
942
1250
  out(` liveness: ${await livenessOf(pod)}`);
943
1251
  if (pod.policy?.parked) {
944
- out(` parked (consecutive failures: ${pod.policy.failureStreak})`);
1252
+ // Three stops, three sentences (ADR 0011, 0012, 0032): a park that reads
1253
+ // "consecutive failures: 0" tells the operator nothing true.
1254
+ const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
1255
+ const today = inWindow(pod.wakeUps, Date.now(), DAY_MS).length;
1256
+ out(
1257
+ pod.policy.parkedBy === "budget"
1258
+ ? ` parked (over the daily budget: ${today} in 24h, budget ${budget}; agents-pod wake ${pod.id} --over-budget)`
1259
+ : pod.policy.parkedBy === "agent"
1260
+ ? ` parked (agent "${pod.policy.agent ?? "default"}" no longer resolves; agents-pod agent add ${pod.policy.agent ?? "default"} …)`
1261
+ : ` parked (consecutive failures: ${pod.policy.failureStreak})`,
1262
+ );
945
1263
  }
946
1264
  out(` agent: ${resolveAgentBinary(process.env)}`);
947
1265
  out(` wake-ups: ${pod.wakeUps.length} (last outcome: ${last?.outcome ?? "none"})`);
1266
+ out(` cadence: ${cadenceLine(pod, Date.now())}`);
948
1267
  return 0;
949
1268
  }
950
1269
 
1270
+ /**
1271
+ * The cadence, in the words a storm is recognised by (ADR 0032): how many ran
1272
+ * recently, how tight the rhythm was, and who admitted the last one. A record
1273
+ * written before provenance existed says so instead of showing an invented zero.
1274
+ */
1275
+ function cadenceLine(pod: PodRecord, now: number): string {
1276
+ const timed = stamped(pod.wakeUps);
1277
+ if (timed.length === 0) {
1278
+ return "not recorded (this Pod's wake-ups predate provenance, ADR 0032)";
1279
+ }
1280
+ const day = inWindow(pod.wakeUps, now, DAY_MS).length;
1281
+ const median = medianIntervalMs(pod.wakeUps);
1282
+ const rhythm = median === undefined ? "interval n/a (fewer than two timed)" : `median interval ${duration(median)}`;
1283
+ const trigger = pod.wakeUps.at(-1)?.trigger ?? "unknown";
1284
+ return `${day} in 24h, ${rhythm}, last trigger ${trigger}`;
1285
+ }
1286
+
1287
+ /** Terminal states (ADR 0006): what `list` hides by default and `wake` refuses. */
1288
+ function isTerminal(pod: PodRecord): boolean {
1289
+ return pod.state === "done" || pod.state === "cancelled";
1290
+ }
1291
+
951
1292
  /**
952
1293
  * The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
953
1294
  * quiet line at the bottom; the pods that need a human lead. Read-only.
1295
+ *
1296
+ * Terminal pods are the exception (ADR 0035): a retired Pod asks nothing of a
1297
+ * human, and a wall of `cancelled` records buries the ones that do. They stay
1298
+ * one flag away, and the footer says how many are hidden — silence about them
1299
+ * would make the registry look smaller than it is.
954
1300
  */
955
- async function listCmd(out: (line: string) => void): Promise<number> {
1301
+ async function listCmd(
1302
+ words: string[],
1303
+ out: (line: string) => void,
1304
+ err: (line: string) => void,
1305
+ ): Promise<number> {
1306
+ const unknown = words.filter((word) => word !== "--all" && word !== "--json");
1307
+ if (unknown.length > 0) {
1308
+ err(`Unknown list argument: ${unknown[0]}. Usage: agents-pod list [--all] [--json]`);
1309
+ return 2;
1310
+ }
1311
+ const all = words.includes("--all");
956
1312
  const pods = await readPods();
957
- if (pods.length === 0) {
958
- out("No pods");
1313
+ const visible = all ? pods : pods.filter((pod) => !isTerminal(pod));
1314
+ const hidden = pods.length - visible.length;
1315
+
1316
+ if (visible.length === 0) {
1317
+ out(
1318
+ hidden === 0
1319
+ ? "No pods"
1320
+ : `No live pods (${hidden} terminal: agents-pod list --all)`,
1321
+ );
1322
+ return 0;
1323
+ }
1324
+
1325
+ if (words.includes("--json")) {
1326
+ out(JSON.stringify(visible));
959
1327
  return 0;
960
1328
  }
961
1329
 
962
1330
  const rows = await Promise.all(
963
- pods.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
1331
+ visible.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
964
1332
  );
965
1333
  const needsHuman = (row: (typeof rows)[number]) =>
966
1334
  row.pod.policy?.parked === true || row.liveness !== "healthy";
@@ -970,6 +1338,9 @@ async function listCmd(out: (line: string) => void): Promise<number> {
970
1338
  const parked = pod.policy?.parked ? " (parked)" : "";
971
1339
  out(`${pod.id} ${pod.state}${parked} ${goalHeadline(pod.goal)}`);
972
1340
  }
1341
+ if (hidden > 0) {
1342
+ out(`${hidden} terminal pod${hidden === 1 ? "" : "s"} hidden (agents-pod list --all)`);
1343
+ }
973
1344
  return 0;
974
1345
  }
975
1346
 
@@ -1045,11 +1416,13 @@ async function wakeCmd(
1045
1416
  err: (line: string) => void,
1046
1417
  ): Promise<number> {
1047
1418
  const { flags, rest } = parseFlags(words, ["agent"]);
1048
- // `--warm` carries no value, so parseFlags leaves it among the words.
1419
+ // `--warm` and `--over-budget` carry no value, so parseFlags leaves them among
1420
+ // the words.
1049
1421
  const warm = rest.includes("--warm");
1422
+ const overBudget = rest.includes("--over-budget");
1050
1423
  const podId = rest.find((word) => !word.startsWith("--"));
1051
1424
  if (!podId) {
1052
- err("Usage: agents-pod wake <podId> [--agent <id>] [--warm]");
1425
+ err("Usage: agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]");
1053
1426
  return 2;
1054
1427
  }
1055
1428
  const pods = await readPods();
@@ -1061,6 +1434,7 @@ async function wakeCmd(
1061
1434
 
1062
1435
  if (pod.state === "done" || pod.state === "cancelled") {
1063
1436
  err(`Pod ${pod.id} is ${pod.state}: terminal is terminal — spawn a new pod instead`);
1437
+ err(` or forget it: agents-pod rm ${pod.id}`);
1064
1438
  return 2;
1065
1439
  }
1066
1440
  if (pod.state === "active" || pod.state === "spawning") {
@@ -1068,6 +1442,21 @@ async function wakeCmd(
1068
1442
  return 0;
1069
1443
  }
1070
1444
 
1445
+ // The human override lifts park and backoff (ADR 0011) — except the daily
1446
+ // ceiling, which exists precisely because some launcher keeps firing. Lifting
1447
+ // it silently made a cron-driven Pod re-park and re-announce on every pass;
1448
+ // one more window is still available, but it has to be asked for (ADR 0032).
1449
+ if (pod.policy?.parked && pod.policy.parkedBy === "budget" && !overBudget) {
1450
+ const budget = pod.policy.wakeBudgetPerDay ?? DEFAULT_WAKE_BUDGET_PER_DAY;
1451
+ const today = inWindow(pod.wakeUps, Date.now(), DAY_MS).length;
1452
+ err(
1453
+ `Pod ${pod.id} is parked over its daily budget (${today} wake-ups in 24 h, budget ${budget})`,
1454
+ );
1455
+ err(` one more window, deliberately: agents-pod wake ${pod.id} --over-budget`);
1456
+ err(" or spawn the work again with a ceiling that fits: --wake-budget <n>");
1457
+ return 2;
1458
+ }
1459
+
1071
1460
  // Only a crash has a session worth resuming (ADR 0011): a `failed` wake-up
1072
1461
  // ended cleanly, and a fresh window is the honest continuation for it.
1073
1462
  const last = pod.wakeUps.at(-1);
@@ -1129,13 +1518,18 @@ async function wakeCmd(
1129
1518
  if (target.policy) {
1130
1519
  target.policy.failureStreak = 0;
1131
1520
  delete target.policy.nextWakeAt;
1521
+ // A human wake is the other answer to "wake me when work arrives" (#36).
1522
+ delete target.policy.awaiting;
1132
1523
  delete target.policy.parked;
1524
+ delete target.policy.parkedBy;
1133
1525
  }
1134
1526
  delete target.notifiedAt;
1135
1527
  }
1136
- launchWrapperDetached(target.id, agentId, warm);
1528
+ target.admitted = "wake";
1137
1529
  target.state = "active";
1138
1530
  await writePods(current);
1531
+ // Recorded first, launched second (ADR 0032): see wakeDuePods.
1532
+ launchWrapperDetached(target.id, agentId, warm);
1139
1533
  return true;
1140
1534
  });
1141
1535
  if (!woke) {
@@ -1161,7 +1555,12 @@ async function cancelCmd(
1161
1555
  if (!pod) return 2;
1162
1556
 
1163
1557
  if (pod.state !== "active") {
1164
- err(`pod ${pod.id} is ${pod.state}: nothing to cancel`);
1558
+ err(
1559
+ `pod ${pod.id} is ${pod.state}: nothing to cancel (` +
1560
+ (isTerminal(pod)
1561
+ ? `agents-pod rm ${pod.id} removes the record)`
1562
+ : `agents-pod rm ${pod.id} discards it)`),
1563
+ );
1165
1564
  return 0;
1166
1565
  }
1167
1566
  const wrapper = await readWrapperRecord(pod);
@@ -1187,33 +1586,154 @@ async function cancelCmd(
1187
1586
  return 0;
1188
1587
  }
1189
1588
 
1589
+ /** Undelivered entries a removal would delete with the workspace (ADR 0025, 0035). */
1590
+ async function pendingOutbox(pod: PodRecord): Promise<number> {
1591
+ const dir = join(podDir(pod.project, pod.id), "outbox");
1592
+ const names = await readdir(dir).catch(() => []);
1593
+ // A `.dead` entry is already a verdict, not a message waiting to go out.
1594
+ return names.filter((name) => !name.startsWith(".") && !name.endsWith(".dead")).length;
1595
+ }
1596
+
1597
+ /**
1598
+ * The record's end (ADR 0035): remove a Pod that no live wake-up owns — its
1599
+ * record and its workspace, so the registry and the disk agree again. This is
1600
+ * garbage collection, not a stop: `cancel` still owns every running process,
1601
+ * and a live pod is refused here rather than deleted under its wrapper.
1602
+ *
1603
+ * Refusals are the references that would be left dangling: a bound Listener
1604
+ * (its claims would wait forever), a dependent Pod (`after` could never be
1605
+ * satisfied), and an undelivered Outbox entry (a message a wake-up already
1606
+ * decided to send). A Listener claim's `spawnedPodId` is deliberately *not* a
1607
+ * reference: it is intake's history, and a missing pod simply reads as not
1608
+ * live (ADR 0034).
1609
+ */
1610
+ async function removeCmd(
1611
+ rest: string[],
1612
+ out: (line: string) => void,
1613
+ err: (line: string) => void,
1614
+ ): Promise<number> {
1615
+ const unknown = rest.filter((word) => word.startsWith("--"));
1616
+ if (unknown.length > 0) {
1617
+ err(`Unknown rm argument: ${unknown[0]}. Usage: agents-pod rm <podId>`);
1618
+ return 2;
1619
+ }
1620
+ const pod = await requirePod(rest[0], "rm", err);
1621
+ if (!pod) return 2;
1622
+
1623
+ if (pod.state === "active" || pod.state === "spawning") {
1624
+ err(`pod ${pod.id} is ${pod.state}: a live wake-up owns it — agents-pod cancel ${pod.id} first`);
1625
+ return 2;
1626
+ }
1627
+
1628
+ const bound = (await readListeners()).filter(
1629
+ (listener) => listener.mode === "pod" && listener.podId === pod.id,
1630
+ );
1631
+ if (bound.length > 0) {
1632
+ err(
1633
+ `pod ${pod.id} is bound by ${bound.map((listener) => listener.id).join(", ")}: remove the listener first`,
1634
+ );
1635
+ return 2;
1636
+ }
1637
+
1638
+ const dependents = (await readPods()).filter((candidate) => candidate.after === pod.id);
1639
+ if (dependents.length > 0) {
1640
+ err(
1641
+ `pod ${pod.id} is the upstream of ${dependents.map((candidate) => candidate.id).join(", ")}: remove the dependent pod${dependents.length === 1 ? "" : "s"} first`,
1642
+ );
1643
+ return 2;
1644
+ }
1645
+
1646
+ const waiting = await pendingOutbox(pod);
1647
+ if (waiting > 0) {
1648
+ const dir = join(podDir(pod.project, pod.id), "outbox");
1649
+ err(
1650
+ `pod ${pod.id} has ${waiting} undelivered outbox entr${waiting === 1 ? "y" : "ies"}: deliver or delete ${dir} first`,
1651
+ );
1652
+ return 2;
1653
+ }
1654
+
1655
+ // The same CAS as every other transition (ADR 0018): the refusal checks above
1656
+ // ran outside the lock, so the state and the references are re-read inside it
1657
+ // and a wake, a spawn or a new dependent that landed meanwhile wins.
1658
+ const removed = await withRegistryLock(async () => {
1659
+ const current = await readPods();
1660
+ const target = current.find((candidate) => candidate.id === pod.id);
1661
+ if (!target || target.state === "active" || target.state === "spawning") return false;
1662
+ if (current.some((candidate) => candidate.after === pod.id)) return false;
1663
+ await writePods(current.filter((candidate) => candidate.id !== pod.id));
1664
+ return true;
1665
+ });
1666
+ if (removed === undefined) {
1667
+ err(`pod ${pod.id}: the registry is locked; nothing removed`);
1668
+ return 1;
1669
+ }
1670
+ if (!removed) {
1671
+ err(`pod ${pod.id} changed while removing it: nothing removed`);
1672
+ return 2;
1673
+ }
1674
+
1675
+ // The record goes first: it is what every read surface shows, and a crash
1676
+ // between the two leaves an invisible directory, not a pod that looks alive.
1677
+ const dir = podDir(pod.project, pod.id);
1678
+ const line = `removed ${pod.id} (${pod.state}): ${goalHeadline(pod.goal)}`;
1679
+ try {
1680
+ await rm(dir, { recursive: true, force: true });
1681
+ } catch (error) {
1682
+ out(line);
1683
+ err(`workspace ${dir} could not be deleted (${(error as Error).message}); remove it by hand`);
1684
+ return 1;
1685
+ }
1686
+ out(line);
1687
+ return 0;
1688
+ }
1689
+
1190
1690
  /**
1191
1691
  * The one spawn path: record, workspace, then wake admission (ADR 0022). An
1192
1692
  * admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
1193
1693
  * and a listener's template materialization both walk it (ADR 0007).
1694
+ *
1695
+ * The owner is written into the record here (ADR 0030), always: a Pod whose
1696
+ * owner nobody declared is a Pod the tick will guess about.
1194
1697
  */
1698
+ export interface SpawnOptions {
1699
+ agentId: string;
1700
+ admission: Admission;
1701
+ model?: string;
1702
+ after?: string;
1703
+ wakeBudgetPerDay?: number;
1704
+ /**
1705
+ * Write the record and launch nothing (#43, ADR 0033 amendment). A hybrid
1706
+ * Pod's first firing belongs to the handover, not to the spawn that created
1707
+ * it; the blocked path below already writes exactly this record, so dormancy
1708
+ * is the absence of a launch rather than a state of its own.
1709
+ */
1710
+ dormant?: boolean;
1711
+ }
1712
+
1195
1713
  async function spawnPodRecord(
1196
1714
  goal: string,
1197
1715
  project: string,
1198
- agentId: string,
1199
- model?: string,
1200
- after?: string,
1716
+ options: SpawnOptions,
1201
1717
  registryHeld = false,
1202
1718
  ): Promise<PodRecord> {
1719
+ const { agentId, admission, model, after, wakeBudgetPerDay, dormant } = options;
1203
1720
  const record: PodRecord = {
1204
1721
  id: newPodId(),
1205
1722
  goal,
1206
1723
  state: "spawning",
1207
1724
  project: resolve(project),
1725
+ admission,
1208
1726
  ...(after === undefined ? {} : { after }),
1209
1727
  wakeUps: [],
1210
1728
  // The policy names the agent and the optional model pin; `--model` beats
1211
- // the definition's model, which beats the adapter default (ADR 0012).
1729
+ // the definition's model, which beats the adapter default (ADR 0012). The
1730
+ // daily ceiling rides here too (ADR 0032).
1212
1731
  policy: {
1213
1732
  failureStreak: 0,
1214
1733
  appliedOutcomes: 0,
1215
1734
  agent: agentId,
1216
1735
  ...(model === undefined ? {} : { model }),
1736
+ ...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
1217
1737
  },
1218
1738
  };
1219
1739
  await createPodWorkspace(record);
@@ -1235,6 +1755,17 @@ async function spawnPodRecord(
1235
1755
  return record;
1236
1756
  }
1237
1757
 
1758
+ // Dormant (#43): the record, the workspace and the owner exist; the first
1759
+ // wake-up does not. Nothing in the store says "dormant" — zero wake-ups on
1760
+ // an idle Pod is the fact, and a marker would have to be cleared by three
1761
+ // launchers.
1762
+ if (dormant === true) {
1763
+ record.state = "idle";
1764
+ await writePods(pods);
1765
+ return record;
1766
+ }
1767
+
1768
+ record.admitted = "spawn";
1238
1769
  await writePods(pods);
1239
1770
  launchWrapperDetached(record.id, agentId);
1240
1771
  record.state = "active";
@@ -1254,10 +1785,82 @@ async function spawnCmd(
1254
1785
  out: (line: string) => void,
1255
1786
  err: (line: string) => void,
1256
1787
  ): Promise<number> {
1257
- const { flags, rest } = parseFlags(words, ["agent", "model", "cwd", "project", "after"]);
1258
- const goal = rest.join(" ").trim();
1788
+ const { flags, rest, dangling } = parseFlags(words, [
1789
+ "agent",
1790
+ "model",
1791
+ "cwd",
1792
+ "project",
1793
+ "after",
1794
+ "admission",
1795
+ "wake-budget",
1796
+ ]);
1797
+ // `--external` and `--dormant` carry no value, so parseFlags leaves them among
1798
+ // the words: the first is the shorthand ADR 0030 documents for `--admission
1799
+ // external`, the second declares the Pod without its spawn wake-up (#43).
1800
+ // Both are taken out before the remaining words compose the goal.
1801
+ const external = rest.includes("--external");
1802
+ const dormant = rest.includes("--dormant");
1803
+ const goal = rest
1804
+ .filter((word) => word !== "--external" && word !== "--dormant")
1805
+ .join(" ")
1806
+ .trim();
1807
+ if (dangling.length > 0) {
1808
+ // A flag with no value would otherwise compose the goal and leave its own
1809
+ // setting at the default — a typo that silently disables a guardrail is not
1810
+ // a guardrail (ADR 0032).
1811
+ err(`${dangling[0]} needs a value`);
1812
+ err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
1813
+ err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
1814
+ return 2;
1815
+ }
1259
1816
  if (!goal) {
1260
1817
  err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
1818
+ err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
1819
+ return 2;
1820
+ }
1821
+
1822
+ // The daily ceiling is a guardrail, so its value is validated like any other
1823
+ // surface flag: a typo must not silently disable it (ADR 0032).
1824
+ let wakeBudgetPerDay: number | undefined;
1825
+ if (flags["wake-budget"] !== undefined) {
1826
+ const parsed = Number(flags["wake-budget"]);
1827
+ if (!Number.isInteger(parsed) || parsed < 1) {
1828
+ err(`--wake-budget must be a positive integer, got ${flags["wake-budget"]}`);
1829
+ return 2;
1830
+ }
1831
+ wakeBudgetPerDay = parsed;
1832
+ }
1833
+
1834
+ // Admission is decided before anything is written (ADR 0030): who may wake
1835
+ // this Pod is the fact the incident was missing, and a refused spawn leaves
1836
+ // no record, no workspace, and no wake-up behind.
1837
+ if (flags.admission !== undefined && !isAdmission(flags.admission)) {
1838
+ err(`Unknown admission ${flags.admission}: one of ${ADMISSIONS.join(" | ")}`);
1839
+ return 2;
1840
+ }
1841
+ if (external && flags.admission !== undefined && flags.admission !== "external") {
1842
+ err(`--external means --admission external; not ${flags.admission}`);
1843
+ return 2;
1844
+ }
1845
+ const declared: Admission | undefined = flags.admission ?? (external ? "external" : undefined);
1846
+ // A goal that names a clock under the tick's own rule is the incident: every
1847
+ // `more` re-arms it, so the clock fires as fast as the passes do. The clock
1848
+ // belongs to a schedule; an `external` or `intake` owner makes it context.
1849
+ const clock = clockPhrase(goal);
1850
+ if (clock !== undefined && declared !== "external" && declared !== "intake") {
1851
+ for (const line of clockRefusal(clock)) err(line);
1852
+ return 2;
1853
+ }
1854
+ const admission: Admission = declared ?? "self";
1855
+ // Dormancy is not an owner, it is the absence of a first launch — and the tick
1856
+ // does not wait to launch. A `self` Pod is admitted on the very next pass, so
1857
+ // `--dormant` there would promise a stop nothing honours (#43, ADR 0033
1858
+ // amendment: refuse the flag where it cannot mean anything).
1859
+ if (dormant && admission === "self") {
1860
+ err("--dormant needs an owner that arrives later: a `self` pod is admitted by the tick on its next pass, so it would be dormant for a minute at most");
1861
+ err(" --admission intake a listener feeds it: the first batch is its first wake-up");
1862
+ err(" --admission external an outside clock or a human calls `agents-pod wake`");
1863
+ err("or drop --dormant and let its spawn wake-up run now");
1261
1864
  return 2;
1262
1865
  }
1263
1866
 
@@ -1285,13 +1888,14 @@ async function spawnCmd(
1285
1888
  }
1286
1889
  }
1287
1890
 
1288
- const record = await spawnPodRecord(
1289
- goal,
1290
- flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(),
1891
+ const record = await spawnPodRecord(goal, flags.project ?? flags.cwd ?? deps.cwd ?? process.cwd(), {
1291
1892
  agentId,
1292
- flags.model,
1293
- flags.after,
1294
- );
1893
+ admission,
1894
+ ...(flags.model === undefined ? {} : { model: flags.model }),
1895
+ ...(flags.after === undefined ? {} : { after: flags.after }),
1896
+ ...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
1897
+ ...(dormant ? { dormant: true } : {}),
1898
+ });
1295
1899
  out(record.id);
1296
1900
  return 0;
1297
1901
  }
@@ -1667,6 +2271,8 @@ async function sourceCmd(
1667
2271
  cmd: source.cmd,
1668
2272
  timeoutMs: source.timeoutMs,
1669
2273
  cursor: source.cursor ?? null,
2274
+ lastDrainItems: source.lastDrainItems ?? null,
2275
+ unrouted: source.lastDrainUnrouted ?? null,
1670
2276
  })),
1671
2277
  ),
1672
2278
  );
@@ -1677,8 +2283,13 @@ async function sourceCmd(
1677
2283
  return 0;
1678
2284
  }
1679
2285
  for (const source of sources) {
2286
+ // The unrouted count rides the line an operator already reads (ADR 0034):
2287
+ // a drain that delivers into nothing is otherwise indistinguishable from a
2288
+ // quiet chat, which is the state this number exists to separate.
2289
+ const unrouted =
2290
+ (source.lastDrainUnrouted ?? 0) === 0 ? "" : ` unrouted ${source.lastDrainUnrouted}`;
1680
2291
  out(
1681
- `${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}`,
2292
+ `${source.name} ${source.cmd} timeout ${source.timeoutMs / 1000}s cursor ${source.cursor ?? "-"}${unrouted}`,
1682
2293
  );
1683
2294
  }
1684
2295
  return 0;
@@ -1754,8 +2365,40 @@ async function sourceCmd(
1754
2365
  return 0;
1755
2366
  }
1756
2367
 
2368
+ case "rewind": {
2369
+ // The one explicit way back past a cursor (ADR 0034): a batch an unrouted
2370
+ // drain swallowed, or a route fixed after the fact. Claims dedupe on id, so
2371
+ // a rewind re-serves exactly what was never claimed; `--to ""` clears the
2372
+ // cursor and makes the adapter decide where to start again.
2373
+ const { flags, rest: words } = parseFlags(rest, ["to"]);
2374
+ const name = words.find((word) => !word.startsWith("--"));
2375
+ if (name === undefined || flags.to === undefined) {
2376
+ err('Usage: agents-pod source rewind <name> --to <cursor|"">');
2377
+ return 2;
2378
+ }
2379
+ const rewound = await withRegistryLock(async () => {
2380
+ const sources = await readSources();
2381
+ const source = sources.find((candidate) => candidate.name === name);
2382
+ if (source === undefined) return false;
2383
+ if (flags.to === "") delete source.cursor;
2384
+ else source.cursor = flags.to;
2385
+ await writeSources(sources);
2386
+ return true;
2387
+ });
2388
+ if (rewound === undefined) {
2389
+ err("registry lock unavailable: cursor not rewound");
2390
+ return 2;
2391
+ }
2392
+ if (!rewound) {
2393
+ err(`Unknown source ${name}`);
2394
+ return 2;
2395
+ }
2396
+ out(flags.to === "" ? `${name} cursor cleared` : `${name} cursor ${flags.to}`);
2397
+ return 0;
2398
+ }
2399
+
1757
2400
  default:
1758
- err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
2401
+ err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm | rewind`);
1759
2402
  return 2;
1760
2403
  }
1761
2404
  }
@@ -1875,6 +2518,7 @@ async function listenerCmd(
1875
2518
  inbox: listener.inbox ?? null,
1876
2519
  source: listener.source ?? null,
1877
2520
  match: listener.match ?? null,
2521
+ chats: listener.chats ?? null,
1878
2522
  mode: listener.mode,
1879
2523
  template: listener.template ?? null,
1880
2524
  project: listener.project ?? null,
@@ -1896,23 +2540,32 @@ async function listenerCmd(
1896
2540
  ? `template ${listener.template}`
1897
2541
  : `pod ${listener.podId}`;
1898
2542
  const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
1899
- out(`${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}`);
2543
+ const filter =
2544
+ listener.chats === undefined ? "" : ` chats ${listener.chats.join(",")}`;
2545
+ out(
2546
+ `${listener.id} ${target} ${origin} claimed ${listener.claimed?.length ?? 0}${filter}`,
2547
+ );
1900
2548
  }
1901
2549
  return 0;
1902
2550
  }
1903
2551
 
1904
2552
  case "add": {
1905
- const { flags } = parseFlags(rest, [
1906
- "inbox",
1907
- "source",
1908
- "template",
1909
- "pod",
1910
- "match",
1911
- "max-concurrent",
1912
- "project",
1913
- ]);
2553
+ const { flags, multiFlags } = parseFlags(
2554
+ rest,
2555
+ [
2556
+ "inbox",
2557
+ "source",
2558
+ "template",
2559
+ "pod",
2560
+ "match",
2561
+ "chat",
2562
+ "max-concurrent",
2563
+ "project",
2564
+ ],
2565
+ ["chat"],
2566
+ );
1914
2567
  const usage =
1915
- "Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--max-concurrent <n>] [--project <dir>]";
2568
+ "Usage: agents-pod listener add (--inbox <dir|file> | --source <name>) (--template <file> | --pod <podId>) [--match <substr>] [--chat <chat_id> ...] [--max-concurrent <n>] [--project <dir>]";
1916
2569
  if ((flags.inbox === undefined) === (flags.source === undefined)) {
1917
2570
  err(`listener add takes exactly one of --inbox or --source. ${usage}`);
1918
2571
  return 2;
@@ -1953,6 +2606,18 @@ async function listenerCmd(
1953
2606
  return 2;
1954
2607
  }
1955
2608
 
2609
+ // A filter reads the item's group, and a directory item has none, so a rule
2610
+ // it could never satisfy is refused here rather than shipped (ADR 0034).
2611
+ const chats = routingChats(multiFlags.chat);
2612
+ if (chats !== undefined && inbox !== undefined) {
2613
+ const info = await stat(inbox).catch(() => undefined);
2614
+ if (info?.isDirectory()) {
2615
+ err(`--chat filters on a group, and a directory item carries none: ${inbox}`);
2616
+ err(" filter a Source, or a JSONL inbox whose items carry chat_id");
2617
+ return 2;
2618
+ }
2619
+ }
2620
+
1956
2621
  let maxConcurrent = 1;
1957
2622
  if (flags["max-concurrent"] !== undefined) {
1958
2623
  const parsed = Number(flags["max-concurrent"]);
@@ -1973,6 +2638,7 @@ async function listenerCmd(
1973
2638
  seen: [],
1974
2639
  };
1975
2640
  if (flags.match !== undefined) listener.match = flags.match;
2641
+ if (chats !== undefined) listener.chats = chats;
1976
2642
 
1977
2643
  if (flags.template !== undefined) {
1978
2644
  // Read once to prove the reference, store the path (ADR 0007): the
@@ -1989,6 +2655,19 @@ async function listenerCmd(
1989
2655
  err(`Unknown pod ${flags.pod}`);
1990
2656
  return 2;
1991
2657
  }
2658
+ // Binding is an intake declaration (ADR 0030): a Pod recorded as
2659
+ // `external` is woken from outside, so binding it would silently drop
2660
+ // that owner — and a Pod that owes a batch *and* an outside clock has
2661
+ // two owners, which is zero owners when both fire.
2662
+ if (pod.admission === "external") {
2663
+ err(
2664
+ `Pod ${pod.id} is admitted from outside (--admission external): a listener cannot bind it`,
2665
+ );
2666
+ err(
2667
+ " bind a Pod spawned with --admission intake, or spawn a new one for this inbox",
2668
+ );
2669
+ return 2;
2670
+ }
1992
2671
  listener.mode = "pod";
1993
2672
  listener.podId = pod.id;
1994
2673
  }
@@ -2017,6 +2696,98 @@ async function listenerCmd(
2017
2696
  return 0;
2018
2697
  }
2019
2698
 
2699
+ case "edit": {
2700
+ // Claims live in the Listener record (ADR 0034), so re-creating one forgets
2701
+ // the ids it served and the replay window hands them back as new work. The
2702
+ // rule changes in place instead; the target and the claims do not move.
2703
+ const clearChats = rest.includes("--no-chat");
2704
+ const { flags, multiFlags, rest: words } = parseFlags(
2705
+ rest.filter((word) => word !== "--no-chat"),
2706
+ ["match", "chat", "max-concurrent", "project"],
2707
+ ["chat"],
2708
+ );
2709
+ const id = words.find((word) => !word.startsWith("--"));
2710
+ const usage =
2711
+ "Usage: agents-pod listener edit <listenerId> [--match <substr>] [--chat <chat_id> ... | --no-chat] [--max-concurrent <n>] [--project <dir>]";
2712
+ if (id === undefined) {
2713
+ err(usage);
2714
+ return 2;
2715
+ }
2716
+ if (
2717
+ flags.match === undefined &&
2718
+ multiFlags.chat === undefined &&
2719
+ !clearChats &&
2720
+ flags.project === undefined &&
2721
+ flags["max-concurrent"] === undefined
2722
+ ) {
2723
+ err(`listener edit needs at least one rule. ${usage}`);
2724
+ return 2;
2725
+ }
2726
+ const found = (await readListeners()).find((candidate) => candidate.id === id);
2727
+ if (found === undefined) {
2728
+ err(`Unknown listener ${id}`);
2729
+ return 2;
2730
+ }
2731
+ const cwd = deps.cwd ?? process.cwd();
2732
+ let targetProject: string | undefined;
2733
+ if (flags.project !== undefined) {
2734
+ if (found.mode !== "template") {
2735
+ err(`--project needs a template listener: a bound standing Pod keeps its own project. ${usage}`);
2736
+ return 2;
2737
+ }
2738
+ targetProject = resolve(cwd, flags.project);
2739
+ const info = await stat(targetProject).catch(() => undefined);
2740
+ if (info === undefined || !info.isDirectory()) {
2741
+ err(`Project ${targetProject} does not exist or is not a directory`);
2742
+ return 2;
2743
+ }
2744
+ }
2745
+ let maxConcurrent: number | undefined;
2746
+ if (flags["max-concurrent"] !== undefined) {
2747
+ const parsed = Number(flags["max-concurrent"]);
2748
+ if (!Number.isInteger(parsed) || parsed < 1) {
2749
+ err(`--max-concurrent must be a positive integer, got ${flags["max-concurrent"]}`);
2750
+ return 2;
2751
+ }
2752
+ maxConcurrent = parsed;
2753
+ }
2754
+ const chats = clearChats ? [] : routingChats(multiFlags.chat);
2755
+ if (chats !== undefined && chats.length > 0 && found.inbox !== undefined) {
2756
+ const info = await stat(found.inbox).catch(() => undefined);
2757
+ if (info?.isDirectory()) {
2758
+ err(`--chat filters on a group, and a directory item carries none: ${found.inbox}`);
2759
+ return 2;
2760
+ }
2761
+ }
2762
+ const edited = await withRegistryLock(async () => {
2763
+ const current = await readListeners();
2764
+ const listener = current.find((candidate) => candidate.id === id);
2765
+ if (listener === undefined) return false;
2766
+ if (flags.match !== undefined) {
2767
+ if (flags.match === "") delete listener.match;
2768
+ else listener.match = flags.match;
2769
+ }
2770
+ if (chats !== undefined) {
2771
+ if (chats.length === 0) delete listener.chats;
2772
+ else listener.chats = chats;
2773
+ }
2774
+ if (targetProject !== undefined) listener.project = targetProject;
2775
+ if (maxConcurrent !== undefined) listener.maxConcurrent = maxConcurrent;
2776
+ await writeListeners(current);
2777
+ return true;
2778
+ });
2779
+ if (edited === undefined) {
2780
+ err("registry lock unavailable: listener not edited");
2781
+ return 2;
2782
+ }
2783
+ if (!edited) {
2784
+ err(`Unknown listener ${id}`);
2785
+ return 2;
2786
+ }
2787
+ out(id);
2788
+ return 0;
2789
+ }
2790
+
2020
2791
  case "rm": {
2021
2792
  const id = rest.find((word) => !word.startsWith("--"));
2022
2793
  if (!id) {
@@ -2025,10 +2796,28 @@ async function listenerCmd(
2025
2796
  }
2026
2797
  const removed = await withRegistryLock(async () => {
2027
2798
  const listeners = await readListeners();
2799
+ const gone = listeners.find((listener) => listener.id === id);
2028
2800
  const remaining = listeners.filter((listener) => listener.id !== id);
2029
2801
  if (remaining.length === listeners.length) return false;
2030
2802
  // Claims go with the record: re-adding is a fresh claim namespace (ADR 0013).
2031
2803
  await writeListeners(remaining);
2804
+ // A `quiet` window's promise is made to a Listener ("wake me when work
2805
+ // arrives"), so removing that Listener removes the claimant: the Pod
2806
+ // falls back to the tick, which would otherwise skip it forever and say
2807
+ // nothing (ADR 0011 amendment, #36).
2808
+ const stillBound = new Set(
2809
+ remaining.flatMap((listener) =>
2810
+ listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
2811
+ ),
2812
+ );
2813
+ if (gone?.mode === "pod" && gone.podId !== undefined && !stillBound.has(gone.podId)) {
2814
+ const pods = await readPods();
2815
+ const pod = pods.find((candidate) => candidate.id === gone.podId);
2816
+ if (pod?.policy?.awaiting) {
2817
+ delete pod.policy.awaiting;
2818
+ await writePods(pods);
2819
+ }
2820
+ }
2032
2821
  return true;
2033
2822
  });
2034
2823
  if (removed === undefined) {
@@ -2043,7 +2832,7 @@ async function listenerCmd(
2043
2832
  }
2044
2833
 
2045
2834
  default:
2046
- err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | list | rm`);
2835
+ err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | edit | list | rm`);
2047
2836
  return 2;
2048
2837
  }
2049
2838
  }
@@ -2107,7 +2896,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
2107
2896
  out("agents-pod — standing Pods across days, one cron-ticked pass");
2108
2897
  out("");
2109
2898
  out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
2110
- out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
2899
+ out("pods spawn | list [--all] | status <id> | logs <id> | wake <id> | cancel <id> | rm <id>");
2111
2900
  out("records agent | listener | source | deliver | adapter | project | reconcile");
2112
2901
  return 0;
2113
2902
  }
@@ -2118,13 +2907,15 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
2118
2907
  case "status":
2119
2908
  return statusCmd(rest, out, err);
2120
2909
  case "list":
2121
- return listCmd(out);
2910
+ return listCmd(rest, out, err);
2122
2911
  case "logs":
2123
2912
  return logsCmd(rest, out, err);
2124
2913
  case "wake":
2125
2914
  return wakeCmd(rest, out, err);
2126
2915
  case "cancel":
2127
2916
  return cancelCmd(rest, out, err);
2917
+ case "rm":
2918
+ return removeCmd(rest, out, err);
2128
2919
  case "reconcile":
2129
2920
  return reconcileCmd(deps);
2130
2921
  case "agent":
@@ -2142,7 +2933,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
2142
2933
  case "doctor":
2143
2934
  return doctorCmd(rest, deps, out, err);
2144
2935
  default:
2145
- err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | reconcile | agent | listener | source | deliver | project`);
2936
+ err(`Unknown command: ${cmd ?? "(none)"}. Try: doctor | spawn | list | status | logs | wake | cancel | rm | reconcile | agent | listener | source | deliver | project`);
2146
2937
  return 2;
2147
2938
  }
2148
2939
  }