agents-pod 0.3.6 → 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
@@ -40,6 +40,7 @@ import {
40
40
  newListenerId,
41
41
  readListeners,
42
42
  writeListeners,
43
+ type ClaimRecord,
43
44
  type ListenerRecord,
44
45
  } from "./listeners.ts";
45
46
  import { acquireLock, readLock, releaseLock, sleep } from "./locks.ts";
@@ -116,27 +117,59 @@ function launchWrapperDetached(podId: string, agentId?: string, warm = false): v
116
117
  * value; one that arrives without a value is reported as `dangling` — left in
117
118
  * `rest` it would become part of a goal (`spawn fix it --wake-budget`) and the
118
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).
119
129
  */
120
130
  function parseFlags(
121
131
  words: string[],
122
132
  allowed: readonly string[],
123
- ): { flags: Record<string, string>; rest: string[]; dangling: string[] } {
133
+ multi: readonly string[] = [],
134
+ ): {
135
+ flags: Record<string, string>;
136
+ multiFlags: Record<string, string[]>;
137
+ rest: string[];
138
+ dangling: string[];
139
+ } {
124
140
  const flags: Record<string, string> = {};
141
+ const multiFlags: Record<string, string[]> = {};
125
142
  const rest: string[] = [];
126
143
  const dangling: string[] = [];
127
144
  for (let index = 0; index < words.length; index += 1) {
128
145
  const word = words[index]!;
129
146
  const name = word.startsWith("--") ? word.slice(2) : undefined;
130
- const value = words[index + 1];
147
+ const next = words[index + 1];
148
+ const value = next !== undefined && !next.startsWith("--") ? next : undefined;
131
149
  if (name !== undefined && allowed.includes(name) && value !== undefined) {
132
- 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;
133
154
  index += 1;
134
155
  } else {
135
156
  if (name !== undefined && allowed.includes(name)) dangling.push(`--${name}`);
136
157
  rest.push(word);
137
158
  }
138
159
  }
139
- return { flags, rest, dangling };
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;
140
173
  }
141
174
 
142
175
  /** Exponential backoff after `failed`/`lost`: 1m ×2, capped at 15m (ADR 0011). */
@@ -720,10 +753,47 @@ function livePod(claimedBy: string, pods: PodRecord[]): boolean {
720
753
  return pod !== undefined && pod.state !== "done" && pod.state !== "cancelled";
721
754
  }
722
755
 
723
- /** P0's match rule: a plain substring over title and body; absent means all. */
724
- function matchesListener(rule: string | undefined, item: InboxItem): boolean {
725
- if (rule === undefined || rule === "") return true;
726
- 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
+ };
727
797
  }
728
798
 
729
799
  /**
@@ -741,10 +811,20 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
741
811
 
742
812
  /** The inbox, not the listener, is the claim namespace (ADR 0013). */
743
813
  const claimedInInbox = new Set<string>();
814
+ const deferredClaims = new Set<ClaimRecord>();
744
815
  for (const listener of listeners) {
745
816
  listener.claimed ??= [];
746
817
  listener.seen ??= [];
747
- 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
+ }
748
828
  }
749
829
 
750
830
  const itemsByListener = new Map<string, Map<string, InboxItem>>();
@@ -813,9 +893,9 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
813
893
  ).length;
814
894
  // A bound pod is served by a queue (ADR 0014), so only template spawns
815
895
  // compete for its slots — and a pending claim already holds one. A Source
816
- // claim is unbounded too: its items exist only in this drain's output and
817
- // the cursor commits behind the claims, so an unclaimed item would be lost
818
- // 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).
819
899
  let room =
820
900
  listener.mode === "pod" || listener.source !== undefined
821
901
  ? Number.POSITIVE_INFINITY
@@ -824,16 +904,28 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
824
904
  for (const item of stable) {
825
905
  if (room <= 0) break;
826
906
  const key = claimKey(origin, item.id);
827
- if (claimedInInbox.has(key) || !matchesListener(listener.match, item)) continue;
907
+ if (claimedInInbox.has(key) || !matchesListener(listener, item)) continue;
828
908
  // One claim shape for both targets (ADR 0013): a template materializes a
829
909
  // pod from it later, a bound standing pod takes it as a document in
830
- // 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.
831
914
  listener.claimed.push({
832
915
  inbox: origin,
833
916
  item: item.id,
834
917
  ...(sourceDrain?.drainedAt === undefined
835
918
  ? {}
836
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
+ }),
837
929
  });
838
930
  claimedInInbox.add(key);
839
931
  changed = true;
@@ -841,6 +933,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
841
933
  }
842
934
  }
843
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
+
844
948
  // The claim is on disk before any pod is touched (ADR 0007).
845
949
  if (changed) await writeListeners(listeners);
846
950
 
@@ -872,7 +976,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
872
976
  ).length;
873
977
  for (const claim of pending()) {
874
978
  if (live >= listener.maxConcurrent) break; // bound: the claim waits a pass
875
- 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
+ );
876
984
  if (item === undefined) {
877
985
  claim.blocked = "item vanished before materialization";
878
986
  await appendCronLog(
@@ -912,7 +1020,7 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
912
1020
  let appended = false;
913
1021
  for (const listener of listeners) {
914
1022
  if (listener.mode !== "pod" || listener.podId === undefined) continue;
915
- 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
916
1024
  const waiting = listener.claimed.filter(
917
1025
  (claim) => claim.blocked === undefined && claim.appended !== true,
918
1026
  );
@@ -928,12 +1036,12 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
928
1036
  }
929
1037
  if (pod.state !== "idle" || pod.policy?.parked) continue;
930
1038
 
931
- // The body is copied at append time (ADR 0014), so the item must still be
932
- // 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.
933
1041
  const scanned = itemsByListener.get(listener.id);
934
1042
  const batch: InboxItem[] = [];
935
1043
  for (const claim of waiting) {
936
- const item = scanned?.get(claim.item);
1044
+ const item = itemForClaim(listener, claim, scanned?.get(claim.item));
937
1045
  if (item === undefined) {
938
1046
  claim.blocked = "item vanished before append";
939
1047
  await appendCronLog(
@@ -979,10 +1087,11 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
979
1087
 
980
1088
  // Two stores, in this order: claims first, then each successful cursor in
981
1089
  // the source store's own atomic write. A bound standing pod adds one gate:
982
- // its claims must also be appended, because their bodies exist only in this
983
- // drain's output. While a wake-up holds the append back, the unchanged cursor
984
- // makes the next pass drain the same items again. A crash between writes
985
- // 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).
986
1095
  // A Listener removed while its drain was running no longer licenses a cursor.
987
1096
  const consumed = new Set(listeners.flatMap((listener) => listener.source ?? []));
988
1097
  const waitingForAppend = new Set(
@@ -1002,8 +1111,18 @@ async function listenersPass(project: string, drained: SourceDrain[]): Promise<v
1002
1111
  const committable = drained.filter(
1003
1112
  (drain) => consumed.has(drain.name) && !waitingForAppend.has(drain.name),
1004
1113
  );
1005
- const pruning = pruneSourceClaims(listeners, committable);
1114
+ const pruning = pruneSourceClaims(listeners, committable, deferredClaims);
1006
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
+ }
1007
1126
  await commitSourceCursors(committable);
1008
1127
  }
1009
1128
 
@@ -1091,18 +1210,30 @@ async function statusCmd(
1091
1210
  const last = pod.wakeUps.at(-1);
1092
1211
  out(pod.id);
1093
1212
  out(` goal: ${goalHeadline(pod.goal)}`);
1094
- // The suffix qualifies a resting pod: `awaiting` says why the tick leaves it
1095
- // alone, and a live wake-up is a different answer to the same question.
1096
- const awaiting = pod.policy?.awaiting === true && pod.state === "idle";
1097
- out(` state: ${pod.state}${awaiting ? " (awaiting)" : ""}`);
1098
1213
  // Who admits this Pod (ADR 0030) — the fact that decides whether the tick
1099
- // wakes it at all, so `status` says it before it says anything about liveness.
1214
+ // wakes it at all, so the record's owner is read before the state line, which
1215
+ // now also depends on it.
1100
1216
  const bound = new Set(
1101
1217
  (await readListeners()).flatMap((listener) =>
1102
1218
  listener.mode === "pod" && listener.podId !== undefined ? [listener.podId] : [],
1103
1219
  ),
1104
1220
  );
1105
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).
1106
1237
  out(
1107
1238
  ` admission: ${owner}${
1108
1239
  pod.admission === undefined
@@ -1153,19 +1284,51 @@ function cadenceLine(pod: PodRecord, now: number): string {
1153
1284
  return `${day} in 24h, ${rhythm}, last trigger ${trigger}`;
1154
1285
  }
1155
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
+
1156
1292
  /**
1157
1293
  * The attention ruler's first use (DESIGN Part 1): a healthy pod takes one
1158
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.
1159
1300
  */
1160
- 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");
1161
1312
  const pods = await readPods();
1162
- if (pods.length === 0) {
1163
- 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));
1164
1327
  return 0;
1165
1328
  }
1166
1329
 
1167
1330
  const rows = await Promise.all(
1168
- pods.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
1331
+ visible.map(async (pod) => ({ pod, liveness: await livenessOf(pod) })),
1169
1332
  );
1170
1333
  const needsHuman = (row: (typeof rows)[number]) =>
1171
1334
  row.pod.policy?.parked === true || row.liveness !== "healthy";
@@ -1175,6 +1338,9 @@ async function listCmd(out: (line: string) => void): Promise<number> {
1175
1338
  const parked = pod.policy?.parked ? " (parked)" : "";
1176
1339
  out(`${pod.id} ${pod.state}${parked} ${goalHeadline(pod.goal)}`);
1177
1340
  }
1341
+ if (hidden > 0) {
1342
+ out(`${hidden} terminal pod${hidden === 1 ? "" : "s"} hidden (agents-pod list --all)`);
1343
+ }
1178
1344
  return 0;
1179
1345
  }
1180
1346
 
@@ -1268,6 +1434,7 @@ async function wakeCmd(
1268
1434
 
1269
1435
  if (pod.state === "done" || pod.state === "cancelled") {
1270
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}`);
1271
1438
  return 2;
1272
1439
  }
1273
1440
  if (pod.state === "active" || pod.state === "spawning") {
@@ -1388,7 +1555,12 @@ async function cancelCmd(
1388
1555
  if (!pod) return 2;
1389
1556
 
1390
1557
  if (pod.state !== "active") {
1391
- 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
+ );
1392
1564
  return 0;
1393
1565
  }
1394
1566
  const wrapper = await readWrapperRecord(pod);
@@ -1414,6 +1586,107 @@ async function cancelCmd(
1414
1586
  return 0;
1415
1587
  }
1416
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
+
1417
1690
  /**
1418
1691
  * The one spawn path: record, workspace, then wake admission (ADR 0022). An
1419
1692
  * admitted pod gets its detached wrapper; a blocked one waits idle. `spawn`
@@ -1428,6 +1701,13 @@ export interface SpawnOptions {
1428
1701
  model?: string;
1429
1702
  after?: string;
1430
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;
1431
1711
  }
1432
1712
 
1433
1713
  async function spawnPodRecord(
@@ -1436,7 +1716,7 @@ async function spawnPodRecord(
1436
1716
  options: SpawnOptions,
1437
1717
  registryHeld = false,
1438
1718
  ): Promise<PodRecord> {
1439
- const { agentId, admission, model, after, wakeBudgetPerDay } = options;
1719
+ const { agentId, admission, model, after, wakeBudgetPerDay, dormant } = options;
1440
1720
  const record: PodRecord = {
1441
1721
  id: newPodId(),
1442
1722
  goal,
@@ -1475,6 +1755,16 @@ async function spawnPodRecord(
1475
1755
  return record;
1476
1756
  }
1477
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
+
1478
1768
  record.admitted = "spawn";
1479
1769
  await writePods(pods);
1480
1770
  launchWrapperDetached(record.id, agentId);
@@ -1504,23 +1794,28 @@ async function spawnCmd(
1504
1794
  "admission",
1505
1795
  "wake-budget",
1506
1796
  ]);
1507
- // `--external` carries no value, so parseFlags leaves it among the words: it
1508
- // is the shorthand ADR 0030 documents for `--admission external`, and it is
1509
- // taken out before the remaining words compose the goal.
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.
1510
1801
  const external = rest.includes("--external");
1511
- const goal = rest.filter((word) => word !== "--external").join(" ").trim();
1802
+ const dormant = rest.includes("--dormant");
1803
+ const goal = rest
1804
+ .filter((word) => word !== "--external" && word !== "--dormant")
1805
+ .join(" ")
1806
+ .trim();
1512
1807
  if (dangling.length > 0) {
1513
1808
  // A flag with no value would otherwise compose the goal and leave its own
1514
1809
  // setting at the default — a typo that silently disables a guardrail is not
1515
1810
  // a guardrail (ADR 0032).
1516
1811
  err(`${dangling[0]} needs a value`);
1517
1812
  err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
1518
- err(" [--admission intake|external|self | --external] [--wake-budget <n>]");
1813
+ err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
1519
1814
  return 2;
1520
1815
  }
1521
1816
  if (!goal) {
1522
1817
  err("Usage: agents-pod spawn <goal prompt> [--agent <id>] [--model <id>] [--cwd <dir>] [--after <podId>]");
1523
- err(" [--admission intake|external|self | --external] [--wake-budget <n>]");
1818
+ err(" [--admission intake|external|self | --external] [--wake-budget <n>] [--dormant]");
1524
1819
  return 2;
1525
1820
  }
1526
1821
 
@@ -1557,6 +1852,17 @@ async function spawnCmd(
1557
1852
  return 2;
1558
1853
  }
1559
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");
1864
+ return 2;
1865
+ }
1560
1866
 
1561
1867
  // Fail fast at the surface (ADR 0012): an unknown id is an exit-2
1562
1868
  // configuration error, before any pod record or workspace exists. An
@@ -1588,6 +1894,7 @@ async function spawnCmd(
1588
1894
  ...(flags.model === undefined ? {} : { model: flags.model }),
1589
1895
  ...(flags.after === undefined ? {} : { after: flags.after }),
1590
1896
  ...(wakeBudgetPerDay === undefined ? {} : { wakeBudgetPerDay }),
1897
+ ...(dormant ? { dormant: true } : {}),
1591
1898
  });
1592
1899
  out(record.id);
1593
1900
  return 0;
@@ -1964,6 +2271,8 @@ async function sourceCmd(
1964
2271
  cmd: source.cmd,
1965
2272
  timeoutMs: source.timeoutMs,
1966
2273
  cursor: source.cursor ?? null,
2274
+ lastDrainItems: source.lastDrainItems ?? null,
2275
+ unrouted: source.lastDrainUnrouted ?? null,
1967
2276
  })),
1968
2277
  ),
1969
2278
  );
@@ -1974,8 +2283,13 @@ async function sourceCmd(
1974
2283
  return 0;
1975
2284
  }
1976
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}`;
1977
2291
  out(
1978
- `${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}`,
1979
2293
  );
1980
2294
  }
1981
2295
  return 0;
@@ -2051,8 +2365,40 @@ async function sourceCmd(
2051
2365
  return 0;
2052
2366
  }
2053
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
+
2054
2400
  default:
2055
- err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm`);
2401
+ err(`Unknown source command: ${sub ?? "(none)"}. Try: add | list | rm | rewind`);
2056
2402
  return 2;
2057
2403
  }
2058
2404
  }
@@ -2172,6 +2518,7 @@ async function listenerCmd(
2172
2518
  inbox: listener.inbox ?? null,
2173
2519
  source: listener.source ?? null,
2174
2520
  match: listener.match ?? null,
2521
+ chats: listener.chats ?? null,
2175
2522
  mode: listener.mode,
2176
2523
  template: listener.template ?? null,
2177
2524
  project: listener.project ?? null,
@@ -2193,23 +2540,32 @@ async function listenerCmd(
2193
2540
  ? `template ${listener.template}`
2194
2541
  : `pod ${listener.podId}`;
2195
2542
  const origin = listener.source === undefined ? listener.inbox : `source:${listener.source}`;
2196
- 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
+ );
2197
2548
  }
2198
2549
  return 0;
2199
2550
  }
2200
2551
 
2201
2552
  case "add": {
2202
- const { flags } = parseFlags(rest, [
2203
- "inbox",
2204
- "source",
2205
- "template",
2206
- "pod",
2207
- "match",
2208
- "max-concurrent",
2209
- "project",
2210
- ]);
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
+ );
2211
2567
  const usage =
2212
- "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>]";
2213
2569
  if ((flags.inbox === undefined) === (flags.source === undefined)) {
2214
2570
  err(`listener add takes exactly one of --inbox or --source. ${usage}`);
2215
2571
  return 2;
@@ -2250,6 +2606,18 @@ async function listenerCmd(
2250
2606
  return 2;
2251
2607
  }
2252
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
+
2253
2621
  let maxConcurrent = 1;
2254
2622
  if (flags["max-concurrent"] !== undefined) {
2255
2623
  const parsed = Number(flags["max-concurrent"]);
@@ -2270,6 +2638,7 @@ async function listenerCmd(
2270
2638
  seen: [],
2271
2639
  };
2272
2640
  if (flags.match !== undefined) listener.match = flags.match;
2641
+ if (chats !== undefined) listener.chats = chats;
2273
2642
 
2274
2643
  if (flags.template !== undefined) {
2275
2644
  // Read once to prove the reference, store the path (ADR 0007): the
@@ -2327,6 +2696,98 @@ async function listenerCmd(
2327
2696
  return 0;
2328
2697
  }
2329
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
+
2330
2791
  case "rm": {
2331
2792
  const id = rest.find((word) => !word.startsWith("--"));
2332
2793
  if (!id) {
@@ -2371,7 +2832,7 @@ async function listenerCmd(
2371
2832
  }
2372
2833
 
2373
2834
  default:
2374
- err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | list | rm`);
2835
+ err(`Unknown listener command: ${sub ?? "(none)"}. Try: add | edit | list | rm`);
2375
2836
  return 2;
2376
2837
  }
2377
2838
  }
@@ -2435,7 +2896,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
2435
2896
  out("agents-pod — standing Pods across days, one cron-ticked pass");
2436
2897
  out("");
2437
2898
  out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
2438
- 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>");
2439
2900
  out("records agent | listener | source | deliver | adapter | project | reconcile");
2440
2901
  return 0;
2441
2902
  }
@@ -2446,13 +2907,15 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
2446
2907
  case "status":
2447
2908
  return statusCmd(rest, out, err);
2448
2909
  case "list":
2449
- return listCmd(out);
2910
+ return listCmd(rest, out, err);
2450
2911
  case "logs":
2451
2912
  return logsCmd(rest, out, err);
2452
2913
  case "wake":
2453
2914
  return wakeCmd(rest, out, err);
2454
2915
  case "cancel":
2455
2916
  return cancelCmd(rest, out, err);
2917
+ case "rm":
2918
+ return removeCmd(rest, out, err);
2456
2919
  case "reconcile":
2457
2920
  return reconcileCmd(deps);
2458
2921
  case "agent":
@@ -2470,7 +2933,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
2470
2933
  case "doctor":
2471
2934
  return doctorCmd(rest, deps, out, err);
2472
2935
  default:
2473
- 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`);
2474
2937
  return 2;
2475
2938
  }
2476
2939
  }