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/CHANGELOG.md CHANGED
@@ -4,6 +4,160 @@ All notable changes to agents-pod. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.7] — 2026-11-05
8
+
9
+ One Feishu app, many groups, one group per project: intake could not say which
10
+ group an item belonged to, so one channel could only serve one project. While
11
+ designing the filter, two intake bugs surfaced that any partition would have
12
+ spread: a capacity-deferred claim lost its item when the cursor moved (#47), and
13
+ a claim was pruned while the overlap was still re-emitting it, so the same
14
+ message came back as a second pod (#46). ADR 0034 answers all three: a Source
15
+ claim owns its item, re-emission (not a generation count) prunes it, and a
16
+ Listener filters on the group. Alongside it, the list stops carrying what can no
17
+ longer act: terminal pods are hidden, and `rm` discards the ones nothing owns.
18
+
19
+ ### Added
20
+
21
+ - `listener add|edit --chat <chat_id>` — a routing filter over the item's
22
+ `chat_id`, ANDed with `--match`; one Source feeds N Listeners, so one channel
23
+ routes each group to its own project (#45, ADR 0034). A directory inbox is
24
+ refused with exit `2`: its items carry no group.
25
+ - `listener edit <id>` changing `--match`, `--chat`, `--project` and
26
+ `--max-concurrent` in place, claims included; `rm` + `add` stays a fresh claim
27
+ namespace and replays the overlap window.
28
+ - `source rewind <name> --to <cursor|"">` — re-read past the cursor after an
29
+ unrouted batch or a route fixed too late; claims dedupe what returns.
30
+ - `lastDrainUnrouted` recorded per Source, printed by `source list` and `doctor`,
31
+ and one `cron.log` line per pass with a non-zero count — a group nobody
32
+ listens to is legitimate, a group nothing wants is not silent.
33
+ - `list` hides `done` and `cancelled` pods by default (`--all` brings them back,
34
+ the footer counts them), and `rm <podId>` discards a pod no wake-up owns — its
35
+ record and its workspace (ADR 0035). `rm` refuses a live pod, a bound Listener,
36
+ a dependent's `after`, and an undelivered Outbox entry.
37
+
38
+ ### Fixed
39
+
40
+ - A capacity-deferred Source claim carries its item, so it materializes when
41
+ capacity frees instead of vanishing with the drain that produced it (#47).
42
+ - A claim is pruned by re-emission, not by a two-generation count, so a quiet
43
+ chat's re-delivered last message no longer returns as a second pod (#46).
44
+ - `parseFlags` keeps every value of a repeatable flag; before, `--chat a --chat b`
45
+ silently dropped `a`.
46
+
47
+ ## [0.3.6] — 2026-11-05
48
+
49
+ One pod woke 41 times in five hours, and the store could not say why. Every
50
+ layer held one fact and named none of them: nothing recorded *who may wake this
51
+ pod* (`more` re-armed it, the tick obeyed, and a hand-written `crontab` line
52
+ raced both), nothing recorded *when* a wake-up happened or *what* woke it, and a
53
+ standing goal whose last word could not be `done` had no honest way to say
54
+ "this window is satisfied" — so it lied `more`, which means "now". Six issues,
55
+ four ADRs: the owner is a field (#33, ADR 0030), the derivation is told the
56
+ owner (#34, ADR 0031), every wake-up says when and who (#35, ADR 0032), a
57
+ satisfied window has a word (#36, ADR 0011 amendment), the incident is a fixture
58
+ (#37), and a hybrid ask decomposes on two axes (#32, ADR 0033).
59
+
60
+ ### Added
61
+
62
+ - **A pod has one admission owner, decided at spawn and recorded** (issue #33,
63
+ ADR 0030). `admission: intake | external | self` — `intake` is the batch a
64
+ Listener owes it, `external` is an outside clock or a human calling
65
+ `agents-pod wake`, `self` is the tick, legal only for a goal that reaches
66
+ `done`. `spawn --admission <owner>` states it and `--external` is the
67
+ shorthand; the default is `self`, which is what the tick used to assume for
68
+ every pod. A Listener binding *is* an intake declaration, so a bound pod reads
69
+ as `intake` without a second flag, and a pod recorded `external` refuses to be
70
+ bound — two owners is zero owners when both fire. `status` prints the owner;
71
+ `doctor` names the two shapes that cost money (a tick-owned pod whose goal
72
+ names a clock, a tick-owned pod whose goal is open-ended) and their mirror (an
73
+ `intake` pod no Listener is bound to, which can never be given work). Records
74
+ written before this release keep their old behaviour and are named, never
75
+ rewritten.
76
+ - **A goal that names a clock is refused at the surface** (issue #33, ADR 0030).
77
+ `spawn` — and so `spawn_pod` — exits 2 when the goal carries a time rule
78
+ (`09:00`, `每天`, `每 30 分钟`, `every 30 minutes`, `daily`) and no outside
79
+ owner is declared; the refusal names `schedule_task` and the three owners. With
80
+ `--admission external` or `intake` the clock is context and the goal is
81
+ accepted; with `self` it is refused, because that is precisely the incident.
82
+ - **Every wake-up says when, and who admitted it** (issue #35, ADR 0032).
83
+ `WakeUpEntry` gains `at` and `trigger: "spawn" | "tick" | "wake"`, and the
84
+ launcher writes `admitted` and `active` in one registry-locked write *before*
85
+ it launches, so a crash leaves an adjudicable `lost` instead of an invisible
86
+ launch. `status` prints the rhythm — `cadence: 3 in 24h, median interval 2m,
87
+ last trigger tick` — and a record written before provenance says `not
88
+ recorded` instead of inventing a timestamp. `doctor` prints a rate line for a
89
+ pod above 6 wake-ups an hour and names the shape it found; `cron.log` records
90
+ each launch as `wake <id> tick (admission self)`. This is the sentence the
91
+ incident needed in round 1 and reconstructed from 41 rounds of its own logs.
92
+ - **A daily ceiling on wake-ups, with a stop that a launcher cannot re-open**
93
+ (issue #35, ADR 0032). `policy.wakeBudgetPerDay` (default 24, `spawn
94
+ --wake-budget <n>`) counts the stamped wake-ups in the last 24 h; above it,
95
+ reconcile parks the pod with `parkedBy: "budget"` and — same park, one
96
+ notification (ADR 0021) — `status` says *over the daily budget* rather than
97
+ *consecutive failures: 0*. An ordinary `wake` refuses (exit 2, naming the
98
+ count, the budget and both ways forward); one more window costs a deliberate
99
+ `wake --over-budget`, because a ceiling any launcher can re-open is not a
100
+ ceiling.
101
+ - **A satisfied window has a word: `quiet`** (issue #36, ADR 0009 and ADR 0011
102
+ amendments). The handoff's first line takes a third declaration — `status:
103
+ quiet` → outcome `quiet` — for a standing goal whose goal forbids `done`: this
104
+ window is satisfied and nothing is scheduled. It is a clean exit (the streak
105
+ resets, the batch it was admitted for is paid) that sets
106
+ `policy.awaiting = true` and writes no clock; the wake phase skips an awaiting
107
+ pod before it even asks who owns it, and `status` prints `state: idle
108
+ (awaiting)`. A new intake batch, a human `wake`, or the removal of the last
109
+ Listener lifts it — a satisfied pod nobody can feed is reported by `doctor`,
110
+ never fixed.
111
+ - **A hybrid ask decomposes on two axes, and the clock's unit hands over**
112
+ (issue #32, ADR 0033). 「每天早上 9:00 汇总 CI,直到我说停」 carries both signals;
113
+ a tie-break applied to a two-signal sentence is a coin flip, and this one
114
+ landed on 41 wake-ups. The prompt side now reads `trigger` (`time` ·
115
+ `message` · `role alive`) and `work` (`stateless per firing` · `needs memory
116
+ across firings`) and produces exactly three shapes — `schedule_task` alone, a
117
+ schedule that **hands over** to a pod admitted `intake` or `external`, or a
118
+ `spawn_pod` with no clock anywhere in the goal — stated as one line before the
119
+ first tool call. The handover's durable form is the Inbox Item and its minimal
120
+ form is `agents-pod wake`; neither surface may reimplement the other, so
121
+ `spawn_pod`'s guidelines and the scheduler skill each name the other. The
122
+ incident's own ask is now a fixture: a `hybrid` class in
123
+ `scripts/first-turn-check.sh` (with `--replay`, so the judgement is
124
+ model-independent) fails a clock inside a `spawn_pod` goal, a missing
125
+ `schedule_task`, and a schedule with no handover named
126
+ (`tests/fixtures/first-turn/`, six cases, `tests/first-turn-check.test.ts`).
127
+
128
+ ### Changed
129
+
130
+ - **An outcome never schedules a wake-up its owner would not have made** (issue
131
+ #34, ADR 0031). One function, `applyOutcome(policy, owner, wake, now)`, is now
132
+ the only writer of `nextWakeAt`, and it is told the owner resolved once per pod
133
+ per pass. `more` re-arms a `self` pod on the next pass, as before; for an
134
+ `intake` or `external` pod it writes nothing, because the next wake-up is its
135
+ owner's decision and a `nextWakeAt` there was a second, invisible owner. The
136
+ backoff follows the same rule (three failures still park any owner — a stop is
137
+ about money, not scheduling); `cancelled`, `quiet` and any outcome a later
138
+ version adds are recorded and ignored rather than counting as failures. The
139
+ two phases still read the Listener bindings at their own moment: sharing one
140
+ read let a `listener add` that landed mid-pass wake the just-bound pod at the
141
+ end of that same pass.
142
+ - **`spawn_pod` takes the owner as a parameter** (ADR 0030, ADR 0033), so a
143
+ model decomposing a standing ask names who wakes the pod while it spawns,
144
+ instead of leaving the tick to guess.
145
+
146
+ ### Fixed
147
+
148
+ - **A flag that arrives with no value is a usage error, not a word in the goal**
149
+ (issue #35). `spawn fix it --wake-budget` used to compose a goal containing the
150
+ literal flag and run on the default budget — a guardrail whose typo silently
151
+ disables it is not a guardrail. `parseFlags` now reports every value-taking
152
+ flag that arrived dangling.
153
+ - **The package no longer ships compiled Python.** `examples/feishu/__pycache__/*.pyc`
154
+ was in the published tarball and is now ignored.
155
+
156
+ Known gap, owned by #43: `spawn` still launches one wake-up immediately, so a
157
+ hybrid pod spawned at 15:00 does its first round at 15:00 and its next at 09:00.
158
+ One run is not a storm and the ceiling bounds what follows, so the guideline says
159
+ it out loud rather than claiming the clock owned the first one.
160
+
7
161
  ## [0.3.5] — 2026-11-04
8
162
 
9
163
  ### Fixed
package/README.md CHANGED
@@ -37,7 +37,11 @@ Sibling of [agents-cron](https://pi.dev/packages/agents-cron): **cron owns when
37
37
  work starts; pod owns keeping work alive.** Use agents-cron for recurring short
38
38
  tasks ("check CI every 30 minutes"). Use agents-pod for standing work
39
39
  ("refactor the auth layer — this may take days", "keep watching this incident",
40
- "serve this inbox — wake an agent per dropped task").
40
+ "serve this inbox — wake an agent per dropped task"). An ask that carries both
41
+ (「每天早上 9:00 汇总 CI,直到我说停」) is neither alone: cron owns the clock and
42
+ **hands the work over** — one Inbox Item when a per-firing input has to be
43
+ queued, or `agents-pod wake` when nothing does — and the pod's goal carries no
44
+ time rule ([ADR 0033](docs/adr/0033-a-hybrid-ask-decomposes-on-two-axes.md)).
41
45
 
42
46
  ## Why a separate plugin
43
47
 
@@ -140,36 +144,67 @@ package until both plugins demonstrably need one.
140
144
  agents-pod doctor [--probe] [--json] # what is missing, and the command that fixes it
141
145
  agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
142
146
  [--after <podId>] # wait for the upstream pod to reach done
147
+ [--admission intake|external|self | --external] # who may wake it (ADR 0030)
148
+ [--wake-budget <n>] # daily ceiling, default 24 (ADR 0032)
149
+ [--dormant] # write the record and launch nothing (ADR 0033)
143
150
  agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
144
151
  agents-pod project move <old> <new> [--apply] [--move-artifacts] # the tree was renamed: relocate records (ADR 0028)
145
- agents-pod list [--json]
152
+ agents-pod list [--all] [--json] # live pods; --all includes terminal ones
146
153
  agents-pod status <podId>
147
154
  agents-pod logs <podId> [--follow]
148
- agents-pod wake <podId> [--agent <id>] [--warm] # fresh by default; --warm resumes a lost wake-up's session
149
- agents-pod cancel <podId>
155
+ agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]
156
+ # fresh by default; --warm resumes a lost wake-up's session
157
+ agents-pod cancel <podId> # signals a live wake-up
158
+ agents-pod rm <podId> # discard a pod no wake-up owns, with its workspace (ADR 0035)
150
159
  agents-pod reconcile # the cron entry point; idempotent
151
160
  agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
152
161
  agents-pod agent edit <id> # $EDITOR on the stored definition
153
162
  agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
154
163
  agents-pod agent list [--json] # named agents: type, model, role
155
164
  agents-pod listener add (--inbox <dir|file> | --source <name>) (--pod <podId> | --template <file>)
156
- [--match <substr>] [--max-concurrent <n>] [--project <dir>]
165
+ [--match <substr>] [--chat <chat_id> ...] [--max-concurrent <n>] [--project <dir>]
166
+ agents-pod listener edit <listenerId> [--match <substr>] [--chat <chat_id> ... | --no-chat]
167
+ [--max-concurrent <n>] [--project <dir>]
157
168
  agents-pod listener list [--json]
158
169
  agents-pod listener rm <listenerId>
159
170
  agents-pod source add <name> --cmd <path> [--timeout <s>]
160
171
  agents-pod source list [--json]
161
172
  agents-pod source rm <name>
173
+ agents-pod source rewind <name> --to <cursor|""> # re-read past the cursor (ADR 0034)
162
174
  agents-pod deliver add <name> --cmd <path> [--timeout <s>]
163
175
  agents-pod deliver list [--json]
164
176
  agents-pod deliver rm <name>
165
177
  agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force] # copy the package's adapter, record its provenance (ADR 0029)
166
178
  ```
167
179
 
168
- `spawn` claims a pod id, writes the pod record, wakes the first agent, and
169
- returns immediately with the id. With `--after`, the first wake waits for the
180
+ `spawn` claims a pod id, writes the pod record (including who admits its
181
+ wake-ups — `--admission`, ADR 0030), wakes the first agent, and returns
182
+ immediately with the id. A goal that names a clock (`09:00`, `每天`, `每 30
183
+ 分钟`) is refused with exit 2 unless an outside owner is declared: starting work
184
+ at a time is a schedule's job, and a pod carrying a clock is the incident
185
+ ([`tests/reconcile.test.ts`](tests/reconcile.test.ts) replays it). With `--after`, the first wake waits for the
170
186
  upstream pod's `done`; a cancelled upstream never releases it. When the
171
187
  project's cap is full, the new pod likewise waits idle with its document store
172
188
  ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
189
+ With `--dormant` the record, the workspace and the owner are written and
190
+ **nothing is launched**: the first wake-up belongs to the owner, which is what a
191
+ hybrid pod needs so the schedule owns every firing, including the first one
192
+ ([ADR 0033 amendment](docs/adr/0033-a-hybrid-ask-decomposes-on-two-axes.md)).
193
+ `--dormant` is refused with `admission: self` — the tick admits an idle pod on
194
+ its next pass, so the stop would last a minute and the flag would lie. A manual
195
+ `wake` starts a dormant pod of any owner: the human is the override, not a
196
+ fourth admission owner, and the wake-up records `trigger: wake` rather than
197
+ claiming a batch arrived.
198
+
199
+ `list` shows the pods that are still live work: `done` and `cancelled` records
200
+ are hidden unless `--all` is passed, and the footer says how many are hidden.
201
+ `rm` is the record's end, not a stop ([ADR
202
+ 0035](docs/adr/0035-removal-is-garbage-collection.md)): it refuses `active` and
203
+ `spawning` pods (`cancel` first), and deletes the record plus
204
+ `<project>/.agents/pods/<podId>/` — documents, log and wake-up sessions
205
+ included. It refuses while a Listener is bound to the pod, while another pod's
206
+ `after` names it, or while its Outbox still holds an undelivered entry; clear
207
+ those first, because each would be left dangling.
173
208
  Exit codes follow the house rule: `0` success
174
209
  or nothing to do, `1` at least one pod failed (or `doctor` found something
175
210
  that blocks a channel), `2` configuration or storage error.
@@ -179,7 +214,7 @@ first, record before exiting.
179
214
 
180
215
  The Pi extension has two planes, gated by the env the wrapper injects
181
216
  ([ADR 0010](docs/adr/0010-two-extension-planes.md)). The **management
182
- plane** (`/pod list|status|logs|spawn|wake|cancel|agents|listeners` and the
217
+ plane** (`/pod list|status|logs|spawn|wake|cancel|rm|agents|listeners` and the
183
218
  `spawn_pod` tool) registers only in an interactive session: inside a pod
184
219
  those tools and commands are not registered at all, so fan-out is
185
220
  structurally impossible rather than forbidden. The **self plane** registers
@@ -198,22 +233,40 @@ tool is a guarantee on top of that contract, never a replacement for it.
198
233
  | --- | --- | --- |
199
234
  | `spawning` | record created, first wake-up not yet confirmed | `spawn` |
200
235
  | `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
201
- | `idle` | goal open, nothing live; documents current | wrapper / reconcile |
236
+ | `idle` | goal open, nothing live; documents current — `idle (awaiting)` when the last window declared itself satisfied, `idle (dormant: no wake-up yet)` when none has run | wrapper / reconcile |
202
237
  | `done` | goal reached; terminal | wrapper |
203
238
  | `cancelled` | user cancel completed; terminal | wrapper |
204
239
 
205
- A pod never exits — its wake-ups do. Wake-up outcomes: `more` (the goal
206
- needs another wake-up), `done` (goal reached), `failed`, `lost` (crash,
207
- reboot, orphan), `cancelled`. Outcomes are adjudicated in three layers —
240
+ A terminal pod is not deleted by reaching its state: it stays visible to
241
+ `status`, `list --all` and its documents until `rm` discards it, and it is never
242
+ re-woken (ADR 0035). `rm` accepts `done`, `cancelled` and `idle` — a pod that no
243
+ wake-up owns — and refuses `active`/`spawning`, a bound Listener, a dependent's
244
+ `after`, and an undelivered Outbox entry.
245
+
246
+ A pod never exits — its wake-ups do. Wake-up outcomes — declared in the
247
+ handoff's first line, in this order of preference: `more` (`status: rotating`,
248
+ the goal needs another wake-up now), `quiet` (`status: quiet`, this window is
249
+ satisfied and nothing is scheduled: wake me when work arrives), `done`
250
+ (`status: done`, goal reached) — plus `failed`, `lost` (crash, reboot, orphan)
251
+ and `cancelled`. `quiet` is what a standing goal says instead of lying `more` to
252
+ stay alive (ADR 0011 amendment, #36). Outcomes are adjudicated in three layers —
208
253
  the agent's HANDOFF.md declaration over the wrapper's event observation
209
254
  over OS exit facts — and a clean exit with no fresh handoff is `failed`
210
255
  ([ADR 0009](docs/adr/0009-wake-up-outcome-classification.md)). The wake
211
256
  policy turns outcomes into next actions
212
- ([ADR 0011](docs/adr/0011-minimal-wake-policy.md)): `more` re-wakes
213
- immediately; `failed` and `lost` back off exponentially (1m ×2, cap 15m,
214
- one streak, reset on `more`); three consecutive failures park the pod —
215
- still `idle`, auto-wake suspended — until a human wakes it. Reconcile
216
- policy, per record:
257
+ ([ADR 0011](docs/adr/0011-minimal-wake-policy.md), and [ADR 0031](docs/adr/0031-outcomes-do-not-schedule.md)
258
+ for the part that depends on the owner): a clean `more` re-wakes a pod **the
259
+ tick owns** immediately and schedules nothing for a pod somebody else wakes;
260
+ `failed` and `lost` back off exponentially (1m ×2, cap 15m, one streak, reset on
261
+ `more`) — again only for the tick's own pod, since a backoff would admit a pod
262
+ without its owner; three consecutive failures park the pod — still `idle`,
263
+ auto-wake suspended — until a human wakes it, whatever its owner. A **daily
264
+ ceiling** (`policy.wakeBudgetPerDay`, default 24, `spawn --wake-budget`) parks it
265
+ the same way — the same single notification — when the last 24 h hold more
266
+ wake-ups than that, and that stop is not lifted by an ordinary `wake` (only by a
267
+ deliberate `wake --over-budget`), because a ceiling any launcher can re-open is
268
+ not a ceiling ([ADR 0032](docs/adr/0032-provenance-and-a-daily-ceiling.md)).
269
+ Reconcile policy, per record:
217
270
 
218
271
  - active + wrapper alive + heartbeat fresh → no-op;
219
272
  - active + nothing alive (crash, reboot — boot-time mismatch means lost,
@@ -221,13 +274,20 @@ policy, per record:
221
274
  (death notifications in P1); the wake policy's backoff applies;
222
275
  - agent alive but wrapper dead (orphan) → kill the recorded process group,
223
276
  wake-up `lost`, pod → idle;
224
- - idle + goal open + not parked + wake eligible (past backoff) → wake the
225
- pod's default agent — which agent, which model, after how much backoff,
226
- is pod-record policy, written by reconcile from outcomes
277
+ - `admission: self` + idle + goal open + not parked + wake eligible (past
278
+ backoff — the only owner for which an outcome writes one) → wake the pod's default agent — which agent, which model, after how
279
+ much backoff, is pod-record policy, written by reconcile from outcomes
227
280
  ([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
228
- - idle + **bound by a listener** + nothing owed → no-op. An intake-driven pod
229
- is woken on the batch it owes (`policy.intakePending`), never on the tick, so
230
- a standing router with an empty inbox costs nothing
281
+ - **awaiting** (the last word was `quiet`) → no-op until work arrives: a new
282
+ intake batch, a human `wake`, or the removal of the Listener the claim was made
283
+ to ([ADR 0011 amendment](docs/adr/0011-minimal-wake-policy.md)). `status`
284
+ prints `idle (awaiting)`, and `doctor` notes an awaiting pod with no Listener,
285
+ because nothing can ever deliver work to it;
286
+ - `admission: external` → no-op, always: an outside clock or a human calls
287
+ `wake`, and the tick never launches it ([ADR 0030](docs/adr/0030-wake-admission-owners.md));
288
+ - `admission: intake` + nothing owed → no-op. An intake-driven pod is woken on
289
+ the batch it owes (`policy.intakePending`), never on the tick, so a standing
290
+ router with an empty inbox costs nothing
231
291
  ([ADR 0026 amendment](docs/adr/0026-affinity-root-message-and-delivery-address.md));
232
292
  - idle + parked (consecutive-failure stop) → no-op until a human `wake`
233
293
  clears it;
@@ -253,6 +313,16 @@ placeholders: `{{id}}` `{{title}}` `{{body}}` `{{path}}` and `{{reply_to}}`.
253
313
  The last placeholder carries the Delivery Address consumed by the outbound
254
314
  contract below (ADRs 0013, 0026).
255
315
 
316
+ One Source may feed several Listeners, so one channel can serve many
317
+ groups: `--chat <chat_id>` (repeatable) is a routing filter over the item's
318
+ `chat_id`, ANDed with `--match`, and the first Listener in `listeners.json`
319
+ order that holds claims the item (ADR 0034). A Source drained once, its items
320
+ split by their group to as many projects as there are Listeners. What no
321
+ Listener claims is counted, not silently dropped: `source list`/`doctor`
322
+ report `lastDrainUnrouted` and the pass writes one `cron.log` line. A
323
+ `--chat` filter needs an item that carries a group, so `listener add --chat`
324
+ against a directory inbox exits `2`; JSONL and Source items are filterable.
325
+
256
326
  For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
257
327
  section — below the agent-owned `## Waiting`, and appended only while no
258
328
  wake-up is live, then one batch, one wake on the ordinary wake path; the
@@ -262,6 +332,49 @@ the copied body (ADRs 0014, 0026). Between batches the pod is not merely idle
262
332
  but **asleep**: the tick alone does not wake a bound pod, which is what makes
263
333
  "serve this inbox" a shape that can stand for days (ADR 0026 amendment).
264
334
 
335
+ ### Who admits a wake-up
336
+
337
+ A pod record carries one owner ([ADR 0030](docs/adr/0030-wake-admission-owners.md)),
338
+ decided at spawn, printed by `status`, and read by the wake phase before it
339
+ launches anything. There are three shapes, and picking one is the operator's
340
+ decision — not a policy the tick guesses:
341
+
342
+ | `admission` | who wakes it | the shape |
343
+ | --- | --- | --- |
344
+ | `self` | the tick, after every `more` | a goal that reaches `done`: a refactor, an audit, a migration. Default when nobody says. |
345
+ | `intake` | the batch it owes — `listener add --pod` | a standing inbox server or router: idle between batches, and an empty inbox costs nothing |
346
+ | `external` | `agents-pod wake` — an outside clock or a human | a `crontab` line, a webhook, a CI trigger: the clock lives outside core |
347
+
348
+ `--external` is shorthand for `--admission external`. A Listener binding is
349
+ itself an intake declaration, so `spawn` → `listener add --inbox <dir> --pod
350
+ <id>` needs no flag; a pod recorded `external` refuses to be bound, because two
351
+ owners is zero owners when both fire. `doctor` names the two shapes that cost
352
+ money: a pod the tick owns whose goal names a clock, and a pod the tick owns
353
+ whose goal is open-ended (`常驻`, `监听`, `我说停才停`) — the record is legal,
354
+ the intent is not, and only the operator knows which owner it should have had.
355
+
356
+ A goal that names a clock is a misroute, not a pod: the schedule owns the time
357
+ and hands the work over. The canonical example is replayed in
358
+ [`tests/reconcile.test.ts`](tests/reconcile.test.ts) and
359
+ [`tests/fixtures/first-turn/`](tests/fixtures/first-turn/), where the incident's
360
+ own ask is the fixture.
361
+
362
+ The handover's first firing needs one more promise: `spawn --dormant` (or
363
+ `spawn_pod`'s `dormant: true`). A hybrid pod is written without its spawn
364
+ wake-up, so the schedule owns every firing and the first Inbox Item — or the
365
+ first `agents-pod wake` — is wake-up 1. `status` shows such a pod as
366
+ `idle (dormant: no wake-up yet)`, derived from an empty wake-up list rather than
367
+ stored: nothing has run, so nothing has to be cleared when it does.
368
+
369
+ ### Reading a rate
370
+
371
+ Every wake-up records `at` and `trigger` (`spawn` / `tick` / `wake`, ADR 0032), so
372
+ the question the incident could not answer — *why did this pod wake 41 times?* —
373
+ is one command per layer: `status <id>` prints `cadence: 41 in 24h, median
374
+ interval 2m, last trigger tick`; `doctor` prints a rate line for any pod above
375
+ 6 wake-ups an hour; `cron.log` records each launch as `wake <id> tick (admission
376
+ self)`. The daily ceiling parks the pod before a rhythm becomes a storm.
377
+
265
378
  Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
266
379
  after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
267
380
  with exit 0 and steals stale ones with an audit line; correctness lives in
@@ -287,6 +400,8 @@ a Source command, `source add`, `listener add`, `deliver add`, the cron line,
287
400
  ```text
288
401
  store ✓ 1 pods, 0 active
289
402
  pass ✓ last pass 41s ago
403
+ wake ? pod-c1329c: 41 wake-ups in the last hour, median interval 2m — above the 6/h ceiling
404
+ its goal names a clock ("9:00") and the tick admits it — that is the #31 shape
290
405
  intake ✗ source feishu: the last drain (2s ago) failed — exit 1
291
406
  run: agents-pod doctor --probe
292
407
  listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
@@ -335,6 +450,9 @@ name, removing an in-use Source, or adding a second deliverer returns `2`.
335
450
  `--pod <podId>`. With `--template`, `--project <dir>` selects the new Pods'
336
451
  project and defaults to the reconcile invocation's cwd; with `--pod` it is a
337
452
  configuration error (exit `2`), because the bound Pod keeps its own project.
453
+ `--chat` may repeat and replaces the whole filter; `listener edit` changes a
454
+ rule in place, claims included — `rm` + `add` is a fresh claim namespace and
455
+ the replay window hands the old ids back (ADR 0034).
338
456
 
339
457
  ### Writing a Source drain
340
458
 
@@ -31,6 +31,29 @@ behaviour alone cannot report it. A file you edited yourself is reported as a
31
31
  local edit and kept; `--force` is the only way to replace it. Plain `cp` still
32
32
  works; it just leaves nothing for `doctor` to compare against.
33
33
 
34
+ ## One app, one drain, many groups
35
+
36
+ A tenant usually runs several groups, one per project. That needs **one**
37
+ drain and one Listener per group — the drain already walks every chat the bot
38
+ sits in, and every item carries its `chat_id` (ADR 0034):
39
+
40
+ ```bash
41
+ agents-pod source add feishu --cmd ~/.agents/pods/adapters/feishu_drain.py --timeout 60
42
+ agents-pod listener add --source feishu --chat oc_aaaa --template ~/work/app-a/TASK.md --project ~/work/app-a
43
+ agents-pod listener add --source feishu --chat oc_bbbb --template ~/work/app-b/TASK.md --project ~/work/app-b
44
+ ```
45
+
46
+ One cursor, one poll per pass, one Pod per group's message; a reply goes back to
47
+ the group it came from because the Delivery Address travels with the item. A
48
+ group no Listener names is **counted as `unrouted`** and printed by `doctor` —
49
+ the bot is reading it and nothing wants it, which is worth being told. Add
50
+ `--chat` to an existing Listener with `listener edit`, never `rm` + `add`: the
51
+ claims live in the record, and re-adding replays the overlap window as new work.
52
+
53
+ `FEISHU_CHATS` is a different axis: the adapter-level allowlist limits what the
54
+ **drain** reads at all (and gives each group its own cursor if you run one copy
55
+ per group). Use it for hard isolation, `--chat` for routing.
56
+
34
57
  ## The one thing this cannot do for you: the app
35
58
 
36
59
  Feishu's console is a human surface. In order:
@@ -160,3 +183,45 @@ message is still subject to `FEISHU_REQUIRE_MENTION`.
160
183
  The pair shares a client file and nothing else. A different platform is a
161
184
  second pair of commands and needs nothing from core; a second Feishu app needs
162
185
  nothing from this directory but another `.env`.
186
+
187
+ ## Limits the code meets, and the road not taken (push)
188
+
189
+ Facts from Feishu's own docs, recorded because they bound the *next* change
190
+ rather than this one. Every claim names the page it came from; none of them is
191
+ in the code yet.
192
+
193
+ **Frequency.** Error `230020` is the rate limit: **5 QPS to one group**, shared
194
+ by every bot in it, and 5 QPS to one user. A chat walk is one call per chat, so
195
+ the per-group cap — not the per-tenant one — is what a wide `FEISHU_CHATS`
196
+ meets first. ([message list](https://open.feishu.cn/document/server-docs/im-v1/message/list))
197
+
198
+ **Reply payloads** (what `feishu_deliver.py` may send): `content` is a
199
+ *serialized JSON string* whose shape follows `msg_type`; text is capped at
200
+ 150 KB, cards and rich text at 30 KB; `uuid` is ≤50 characters with one hour of
201
+ idempotency. `reply_in_thread` refuses with `230071` (the group does not support
202
+ topic replies) and `230072` (aggregated messages). ([reply message](https://open.feishu.cn/document/server-docs/im-v1/message/reply))
203
+
204
+ **Message-list parameters** the drain does not pass yet: `sort_type`
205
+ (`ByCreateTimeAsc` default, fixed once paging starts), `page_size` 1–50
206
+ (default 20), `card_msg_content_type`, `only_thread_root_messages`,
207
+ `with_sender_name`. ([message list](https://open.feishu.cn/document/server-docs/im-v1/message/list))
208
+
209
+ **Push, if anyone ever wants it** (ADR 0024 leaves the door open; core stays
210
+ pull-only). The two transports share the at-least-once contract and the retry
211
+ schedule — 15 s, 5 min, 1 h, 6 h, at most four retries, ≈7 h — and differ in
212
+ everything else:
213
+
214
+ - *Webhook* needs a **public IPv4** URL, must echo `url_verification` within
215
+ **1 s** before the URL can even be saved, and must return HTTP 200 within
216
+ **3 s** (TCP connect 2 s + callback 3 s). A laptop that sleeps or changes
217
+ network fails that ack, and the retry window is the only recovery.
218
+ - *Long connection* (outbound WebSocket) needs no public address — only
219
+ outbound internet — and is **enterprise self-built apps only**. Success is the
220
+ handler returning within **3 s** without throwing. The hard limits: **at most
221
+ 50 connections per app**, each initialized client being one connection, and
222
+ delivery is **cluster mode with no broadcast** — with several clients of one
223
+ app, only one randomly chosen client receives each message. A shim is
224
+ therefore one connection, never a pool.
225
+
226
+ That last fact is why "push" here can only be a single resident process with a
227
+ file inbox behind it: any fan-out is the platform's, not ours.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,102 @@
1
+ import type { Admission, PodRecord } from "./registry.ts";
2
+
3
+ /**
4
+ * Who admits a Pod's wake-up (ADR 0030). ADR 0011's single wake rule —
5
+ * idle + goal open + not parked + due — is right for a bounded goal and an
6
+ * unbounded money pit for a standing one: the tick cannot tell "nobody feeds me"
7
+ * from "a crontab line feeds me at 09:00", so it assumed the second and woke a
8
+ * router 41 times in five hours. The missing fact was ownership, not a policy
9
+ * knob, so it is recorded as a fact on the Pod Record and read from there.
10
+ *
11
+ * Three owners, exactly one per Pod:
12
+ * intake — a Listener-bound Pod; the batch it owes (`policy.intakePending`)
13
+ * is its only admission. An empty inbox costs nothing.
14
+ * external — an outside clock or a human calls `agents-pod wake`; the tick
15
+ * never launches it.
16
+ * self — the tick admits it. Legal for a goal that reaches `done`, never
17
+ * for a standing one (`more` re-arms it on every pass).
18
+ */
19
+ export const ADMISSIONS = ["intake", "external", "self"] as const;
20
+
21
+ export function isAdmission(value: string): value is Admission {
22
+ return (ADMISSIONS as readonly string[]).includes(value);
23
+ }
24
+
25
+ /**
26
+ * Who admits this record. A Listener binding is itself an intake declaration —
27
+ * binding a Pod says "this is fed from an inbox", and it is the act the operator
28
+ * performs, not a state we ask them to restate. An unbound Pod is owned by the
29
+ * owner recorded at spawn, or by the tick when nobody recorded one (a record
30
+ * written before ADR 0030). The binding therefore never rewrites a record: it
31
+ * outranks it, and `listener add --pod` refuses a Pod recorded as `external`
32
+ * rather than quietly contradicting it.
33
+ */
34
+ export function admissionOf(
35
+ pod: Pick<PodRecord, "id" | "admission">,
36
+ intakeBound: ReadonlySet<string>,
37
+ ): Admission {
38
+ if (intakeBound.has(pod.id)) return "intake";
39
+ return pod.admission ?? "self";
40
+ }
41
+
42
+ /**
43
+ * The phrase that makes a goal a time rule, or `undefined` (issue #37: "a goal
44
+ * that names a clock is a misroute, not a pod"). Deliberately narrow — every
45
+ * match refuses a spawn on the creation side, so a false positive costs an
46
+ * operator a rejected command. `每天` / `每小时` / `每 N 分钟` / `HH:MM` are the
47
+ * shapes the incident was written in; `crontab` is NOT here, because "fix the
48
+ * crontab line" is a legitimate goal about a clock, not a goal with one.
49
+ */
50
+ const CLOCKS: ReadonlyArray<RegExp> = [
51
+ /\d{1,2}[::]\d{2}/, // 09:00, 9:30
52
+ /(?:每天|每日|每晚|每早|每周|每月|每年|每小时)/,
53
+ /每\s*\d+\s*(?:分钟|分|小时|钟头|天|日|周|星期|月|秒)/, // 每 30 分钟
54
+ /(?:早上|上午|中午|下午|晚上|凌晨|深夜)\s*\d{1,2}\s*点/,
55
+ /\d{1,2}\s*点(?:半|整|钟|\d{2})?/, // 9点, 9点30
56
+ /到点/,
57
+ /\bevery\s+(?:\d+\s*)?(?:minute|min|hour|hr|day|week|month|morning|night)/i,
58
+ /\b(?:daily|hourly|weekly|monthly|nightly)\b/i,
59
+ /\bat\s+\d{1,2}:\d{2}\b/i, // at 9:30 — a bare "at 3" is not a time ("look at 3 tests")
60
+ /\d{1,2}\s*(?:am|pm)\b/i,
61
+ ];
62
+
63
+ export function clockPhrase(goal: string): string | undefined {
64
+ for (const pattern of CLOCKS) {
65
+ const found = pattern.exec(goal);
66
+ if (found !== null) return found[0].trim();
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * Standing language: a goal that describes a role rather than a finish line
73
+ * (`常驻`, `监听`, `我说停才停`). Only `doctor` and the spawn warning read it, and
74
+ * only to name the shape — a Pod on `self` with an open-ended goal is woken
75
+ * after every `more`, with nothing new to work on.
76
+ */
77
+ const OPEN_ENDED: ReadonlyArray<RegExp> = [
78
+ /(?:常驻|长期|永久|无限期|永远|一直|监听|盯着|盯住|轮询|说停|直到.{0,6}停)/,
79
+ /\b(?:forever|indefinitely|standing)\b/i,
80
+ /\buntil\s+\w+\s+(?:says?|asks?)\s+(?:stop|enough)/i,
81
+ /\bkeep\s+(?:it\s+)?(?:running|watching|going)\b/i,
82
+ ];
83
+
84
+ export function openEnded(goal: string): boolean {
85
+ return OPEN_ENDED.some((pattern) => pattern.test(goal));
86
+ }
87
+
88
+ /**
89
+ * The refusal `spawn` prints when a goal carries a clock and nobody has said who
90
+ * admits the wake-ups: the schedule already owns the time, and the incident's
91
+ * pod was born exactly here (ADR 0030, issue #37).
92
+ */
93
+ export function clockRefusal(phrase: string): string[] {
94
+ return [
95
+ `a goal that names a clock is a misroute, not a pod: "${phrase}"`,
96
+ "starting work at a time is a schedule's job: call schedule_task (agents-cron) for the clock, and let the schedule hand the work to a pod",
97
+ "if this pod really is admitted from elsewhere, say so (exactly one owner, ADR 0030):",
98
+ " --admission external an outside clock or a human calls `agents-pod wake`",
99
+ " --admission intake a listener feeds it: listener add --inbox <dir> --pod <id>",
100
+ " --admission self the tick admits it — for a bounded goal that reaches done, never for a standing one",
101
+ ];
102
+ }