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/doctor.ts CHANGED
@@ -397,6 +397,19 @@ async function intakeLines(
397
397
  }
398
398
  lines.push({ mark: "ok", text: `source ${source.name}: ${source.cmd}` });
399
399
  lines.push({ mark: "ok", text: ` ${drained}, ${cursor}` });
400
+ // Routing leaves items behind on purpose (ADR 0034), so the count is a
401
+ // question and not a failure: a group nobody listens to is legitimate, a
402
+ // group nobody wanted is what the operator has to see.
403
+ if ((source.lastDrainUnrouted ?? 0) > 0) {
404
+ lines.push({
405
+ mark: "note",
406
+ text: `source ${source.name}: ${source.lastDrainUnrouted} item(s) of the last drain matched no listener — unrouted`,
407
+ detail: [
408
+ ` the cursor passed them; a route added now applies to what arrives next`,
409
+ `next: agents-pod listener add --source ${source.name} --chat <chat_id> --template <file> [--project <dir>]`,
410
+ ],
411
+ });
412
+ }
400
413
  }
401
414
  if (sources.length === 0) {
402
415
  lines.push({
@@ -49,6 +49,7 @@ const POD_HELP = [
49
49
  "/pod spawn <goal…> spawn a pod in this project",
50
50
  "/pod wake <podId> wake an idle pod now",
51
51
  "/pod cancel <podId> cancel a live wake-up",
52
+ "/pod rm <podId> discard a pod that no wake-up owns, with its workspace",
52
53
  "/pod agents named agents",
53
54
  "/pod listeners listeners and their claims",
54
55
  ].join("\n");
@@ -67,7 +68,7 @@ export function registerManagementCommands(pi: ExtensionAPI): void {
67
68
  ctx.ui.notify(POD_HELP, "info");
68
69
  return;
69
70
  }
70
- const needsPodId = ["status", "logs", "wake", "cancel", "spawn"].includes(action);
71
+ const needsPodId = ["status", "logs", "wake", "cancel", "rm", "spawn"].includes(action);
71
72
  if (needsPodId && rest.length === 0) {
72
73
  ctx.ui.notify(POD_HELP, "info");
73
74
  return;
@@ -109,7 +110,7 @@ export function registerManagementTools(pi: ExtensionAPI): void {
109
110
  promptSnippet: "Spawn a standing pod for work that outlives this session",
110
111
  promptGuidelines: [
111
112
  "Read two axes before you call anything, because one ask can carry both signals, and a trigger alone does not decide — `time` without memory is a schedule (agents-cron / `schedule_task`), `message` or `role alive` is a pod (`spawn_pod`), and only the work axis separates the hybrid. trigger: `time` — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我, 09:00; `message` — 收消息, 服务某个 inbox, 有活就干; `role alive` — 常驻, 监听, 路由, 直到我说停, or one job that spans days. work: `stateless` — one firing produces one answer and keeps nothing; `memory` — it needs what an earlier firing learned (跨天对比, 累计, 记得上次, 一份持续更新的报告).",
112
- "Three shapes fall out of those two axes, and there is no fourth. (1) time + stateless → `schedule_task` alone: agents-cron owns the clock and the work, and a pod here is the incident. (2) time + memory → `schedule_task` owns the clock and hands the work over, `spawn_pod` owns it: bind the pod to the inbox the task appends to (`agents-pod listener add --inbox <file> --pod <podId>`) and give it `admission: intake`, or — when no batch has to be queued — let the task call `agents-pod wake <podId>` with `admission: external`. Its spawn wake-up runs once immediately; every firing after that belongs to the handover. Never a pod alone, and never `self`. (3) message / role alive → one pod via `spawn_pod`, `intake` when a listener or an inbox feeds it and `self` only when the goal reaches done, with no clock anywhere in its goal — call it as your FIRST tool call, without surveying the repo or the plugin first.",
113
+ "Three shapes fall out of those two axes, and there is no fourth. (1) time + stateless → `schedule_task` alone: agents-cron owns the clock and the work, and a pod here is the incident. (2) time + memory → `schedule_task` owns the clock and hands the work over, `spawn_pod` owns it: bind the pod to the inbox the task appends to (`agents-pod listener add --inbox <file> --pod <podId>`) and give it `admission: intake`, or — when no batch has to be queued — let the task call `agents-pod wake <podId>` with `admission: external`. Spawn it with `dormant: true`: the record exists and nothing runs, so the clock owns every firing including the first, and the handover's first batch or `wake` is wake-up 1. Never a pod alone, never `self`, and never a spawn wake-up the clock did not order. (3) message / role alive → one pod via `spawn_pod`, `intake` when a listener or an inbox feeds it and `self` only when the goal reaches done, with no clock anywhere in its goal — call it as your FIRST tool call, without surveying the repo or the plugin first.",
113
114
  "Say the rewrite in one line before the first tool call, naming every unit the ask actually has — and, whenever a pod is involved, its admission owner: 「这句我拆成:09:00 由 schedule_task 写 ci-daily.jsonl → pod X 被 intake 唤醒;pod X 负责汇总与跨天对比」. A schedule-only ask names no pod. If your line comes out as a pod plus a hand-written `crontab wake`, as a clock inside the pod's goal, or as a pod on `self`, that is the illegal combination (#31): say so and fix the shape instead of proceeding.",
114
115
  "Never put a clock in a pod's goal: the phrases the guard recognises — 09:00, 每天, 每 30 分钟, 9 点, every 30 minutes, daily, 到点 — are refused at spawn when the pod's owner is the tick, and a pod re-armed on every pass is the storm. The clock belongs to the schedule; a pod is woken by its admission owner — a listener's batch, an outside wake, or the tick — never by a time rule written inside the goal.",
115
116
  "Say who admits the pod's wake-ups (`admission`), because the tick is only one of three owners and it is the wrong one for a standing pod: `intake` when a listener or inbox feeds it (the router/watcher/inbox-server shape), `external` when an outside clock or a human wakes it with `agents-pod wake`, `self` only for a bounded goal the tick may re-wake until done. Left unstated it defaults to `self`, which wakes a standing pod every tick — the incident #31 exists to prevent.",
@@ -137,6 +138,12 @@ export function registerManagementTools(pi: ExtensionAPI): void {
137
138
  "Who admits this pod's wake-ups (ADR 0030): `intake` — a listener feeds it (the standing/inbox shape); `external` — an outside clock or a human calls `agents-pod wake`; `self` — the tick, legal only for a bounded goal that reaches done. Default is `self`; a standing pod left there is woken on every tick.",
138
139
  }),
139
140
  ),
141
+ dormant: Type.Optional(
142
+ Type.Boolean({
143
+ description:
144
+ "Write the pod and launch nothing, so its first wake-up belongs to its owner (ADR 0033 amendment, #43). Required for the hybrid shape: a schedule owns every firing including the first one. Needs `admission: intake` or `external` — with `self` the tick would wake it on its next pass, so the flag is refused there.",
145
+ }),
146
+ ),
140
147
  }),
141
148
  async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
142
149
  const argv = ["spawn", params.goal, "--project", ctx.cwd];
@@ -145,6 +152,7 @@ export function registerManagementTools(pi: ExtensionAPI): void {
145
152
  if (params.after !== undefined) argv.push("--after", params.after);
146
153
  if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
147
154
  if (params.admission !== undefined) argv.push("--admission", params.admission);
155
+ if (params.dormant === true) argv.push("--dormant");
148
156
 
149
157
  const outcome = await runCli(argv, ctx.cwd);
150
158
  const podId = outcome.lines.join("\n").trim();
@@ -157,10 +165,13 @@ export function registerManagementTools(pi: ExtensionAPI): void {
157
165
  content: [
158
166
  {
159
167
  type: "text" as const,
160
- text: `Spawned pod ${podId}. Wake-ups run detached; /pod status ${podId} shows progress.`,
168
+ text:
169
+ params.dormant === true
170
+ ? `Spawned pod ${podId} dormant: the record exists and no wake-up has run, so its first window belongs to its owner. The first handover starts it — an inbox item for \`intake\`, or \`agents-pod wake ${podId}\` for \`external\`. /pod status ${podId} shows it as "dormant: no wake-up yet".`
171
+ : `Spawned pod ${podId}. Wake-ups run detached; /pod status ${podId} shows progress.`,
161
172
  },
162
173
  ],
163
- details: { podId },
174
+ details: { podId, ...(params.dormant === true ? { dormant: true } : {}) },
164
175
  };
165
176
  },
166
177
  });
package/src/listeners.ts CHANGED
@@ -17,13 +17,19 @@ export type ListenerMode = "template" | "pod";
17
17
  * `blocked` is a reason materialization will not happen
18
18
  * (a configuration error, or an item that vanished). `appended` is ADR 0014's
19
19
  * one boolean for a bound standing pod: the block is in the pod's `## Inbox`.
20
- * Beyond that the document is the truth — there is no per-item lifecycle.
20
+ * The item copy lets a Source claim outlive the drain that produced it (ADR
21
+ * 0034). Beyond that the document is the truth — there is no per-item lifecycle.
21
22
  */
22
23
  export interface ClaimRecord {
23
24
  inbox: string;
24
25
  item: string;
25
- /** Source claims retain the current and previous successful drain (ADR 0024). */
26
+ /** The successful drain that made the claim; re-emission, not age, is its horizon (ADR 0034). */
26
27
  claimedAt?: number;
28
+ title?: string;
29
+ body?: string;
30
+ chatId?: string;
31
+ rootId?: string;
32
+ threadId?: string;
27
33
  spawnedPodId?: string;
28
34
  blocked?: string;
29
35
  appended?: boolean;
@@ -43,6 +49,12 @@ export interface ListenerRecord {
43
49
  source?: string;
44
50
  /** P0's rule: a plain substring; absent means every item matches. */
45
51
  match?: string;
52
+ /**
53
+ * Routing filter (ADR 0034): the groups this Listener claims, by the item's
54
+ * `chat_id`. Absent means every group. A filter is ANDed with `match`, and the
55
+ * first Listener in registry order that holds claims the item.
56
+ */
57
+ chats?: string[];
46
58
  mode: ListenerMode;
47
59
  /** Template mode: the goal template file. */
48
60
  template?: string;
package/src/sources.ts CHANGED
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import type { BadInboxLine, InboxItem } from "./inbox.ts";
6
- import type { ListenerRecord } from "./listeners.ts";
6
+ import type { ClaimRecord, ListenerRecord } from "./listeners.ts";
7
7
  import { sourcesFile } from "./paths.ts";
8
8
 
9
9
  /** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
@@ -12,13 +12,19 @@ export interface SourceRecord {
12
12
  cmd: string;
13
13
  timeoutMs: number;
14
14
  cursor?: string;
15
- /** The previous successful drain is the claim-retention horizon (ADR 0024). */
15
+ /** The last successful drain's timestamp; the cursor, not its age, bounds claims (ADR 0034). */
16
16
  lastDrainAt?: number;
17
17
  /**
18
18
  * That drain's yield (ADR 0027): zero is a fact, not a gap — a pull that runs
19
19
  * and delivers nothing is the shape of a conversation that never reaches it.
20
20
  */
21
21
  lastDrainItems?: number;
22
+ /**
23
+ * How many of that drain's items no Listener claimed (ADR 0034). A group nobody
24
+ * listens to is legitimate; a group being drained into nothing is the number an
25
+ * operator needs, and `doctor` prints it as a question rather than a failure.
26
+ */
27
+ lastDrainUnrouted?: number;
22
28
  /** A failed drain's fact (ADR 0027): a report cannot say `ok` while every pass fails. */
23
29
  lastFailureAt?: number;
24
30
  lastError?: string;
@@ -29,9 +35,10 @@ export interface SourceDrain {
29
35
  items: InboxItem[];
30
36
  bad: BadInboxLine[];
31
37
  cursor?: string;
32
- /** Successful drains carry their generation and the preceding one's boundary. */
38
+ /** A successful drain's timestamp, retained for its Source and claim records. */
33
39
  drainedAt?: number;
34
- previousDrainAt?: number;
40
+ /** Items this drain delivered that no Listener claimed (ADR 0034). */
41
+ unrouted?: number;
35
42
  /** A failed drain contributes nothing and leaves the cursor untouched. */
36
43
  error?: string;
37
44
  }
@@ -202,13 +209,12 @@ async function drainOne(source: SourceRecord, project: string): Promise<SourceDr
202
209
  });
203
210
  });
204
211
  if (result.error !== undefined) return { ...empty, error: result.error };
205
- // Distinct generations need distinct boundaries even when two local drains
206
- // finish within one wall-clock millisecond.
212
+ // Distinct drains need distinct timestamps even when two local drains finish
213
+ // within one wall-clock millisecond.
207
214
  const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
208
215
  return {
209
216
  ...parseDrainOutput(source.name, result.stdout ?? ""),
210
217
  drainedAt,
211
- ...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
212
218
  };
213
219
  }
214
220
 
@@ -232,30 +238,39 @@ export async function drainSources(
232
238
  }
233
239
 
234
240
  /**
235
- * Two generations are enough: after this drain, anything older than the prior
236
- * successful one is behind the committed cursor and cannot arrive again. The
237
- * store is transformed without I/O; the caller writes it inside the registry
238
- * lock before advancing the Source records (ADR 0024). Legacy claims without a
239
- * timestamp are retained because their generation cannot be proven.
241
+ * A successful drain is the claim's horizon (ADRs 0024, 0034): an id it did not
242
+ * re-emit is behind the cursor, while an id it keeps re-emitting stays claimed
243
+ * for any number of passes. Failed drains and other inbox kinds have no say.
244
+ * Legacy claims without `claimedAt` stay because their provenance is unproven.
245
+ *
246
+ * Claims that began this pass unserved are sheltered until the next one. Their
247
+ * own item copy must get a chance to materialize, and a legacy miss must remain
248
+ * `blocked` long enough to be observed; pruning either here would recreate the
249
+ * silent loss this rule is meant to end. The caller writes the transformed store
250
+ * inside the registry lock before advancing the Source records.
240
251
  */
241
252
  export function pruneSourceClaims(
242
253
  listeners: ListenerRecord[],
243
254
  drained: SourceDrain[],
255
+ deferredClaims: ReadonlySet<ClaimRecord> = new Set(),
244
256
  ): { listeners: ListenerRecord[]; changed: boolean } {
245
- const horizons = new Map<string, number>();
257
+ const reemitted = new Map<string, Set<string>>();
246
258
  for (const drain of drained) {
247
- if (drain.error === undefined && drain.previousDrainAt !== undefined) {
248
- horizons.set(`source:${drain.name}`, drain.previousDrainAt);
259
+ if (drain.error === undefined) {
260
+ reemitted.set(`source:${drain.name}`, new Set(drain.items.map((item) => item.id)));
249
261
  }
250
262
  }
251
- if (horizons.size === 0) return { listeners, changed: false };
263
+ if (reemitted.size === 0) return { listeners, changed: false };
252
264
 
253
265
  let changed = false;
254
266
  const pruned = listeners.map((listener) => {
255
267
  const claimed = listener.claimed.filter((claim) => {
256
- const horizon = horizons.get(claim.inbox);
268
+ const emitted = reemitted.get(claim.inbox);
257
269
  return (
258
- horizon === undefined || claim.claimedAt === undefined || claim.claimedAt >= horizon
270
+ emitted === undefined ||
271
+ claim.claimedAt === undefined ||
272
+ deferredClaims.has(claim) ||
273
+ emitted.has(claim.item)
259
274
  );
260
275
  });
261
276
  if (claimed.length === listener.claimed.length) return listener;
@@ -265,7 +280,7 @@ export function pruneSourceClaims(
265
280
  return { listeners: pruned, changed };
266
281
  }
267
282
 
268
- /** Claims and pruning land before this cursor/retention-horizon write (ADR 0024). */
283
+ /** Claims and pruning land before this cursor write (ADRs 0024, 0034). */
269
284
  export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
270
285
  const sources = await readSources();
271
286
  let changed = false;
@@ -287,6 +302,12 @@ export async function commitSourceCursors(drained: SourceDrain[]): Promise<void>
287
302
  source.lastDrainItems = drain.items.length;
288
303
  changed = true;
289
304
  }
305
+ // What the filters left behind is a fact of this drain (ADR 0034), recorded
306
+ // whether it is zero or not for the same reason the yield is.
307
+ if (drain.unrouted !== undefined && source.lastDrainUnrouted !== drain.unrouted) {
308
+ source.lastDrainUnrouted = drain.unrouted;
309
+ changed = true;
310
+ }
290
311
  // A success clears the failure it disproved (ADR 0027).
291
312
  if (source.lastError !== undefined) {
292
313
  delete source.lastError;