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/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ 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
+
7
47
  ## [0.3.6] — 2026-11-05
8
48
 
9
49
  One pod woke 41 times in five hours, and the store could not say why. Every
package/README.md CHANGED
@@ -146,26 +146,31 @@ agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [
146
146
  [--after <podId>] # wait for the upstream pod to reach done
147
147
  [--admission intake|external|self | --external] # who may wake it (ADR 0030)
148
148
  [--wake-budget <n>] # daily ceiling, default 24 (ADR 0032)
149
+ [--dormant] # write the record and launch nothing (ADR 0033)
149
150
  agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
150
151
  agents-pod project move <old> <new> [--apply] [--move-artifacts] # the tree was renamed: relocate records (ADR 0028)
151
- agents-pod list [--json]
152
+ agents-pod list [--all] [--json] # live pods; --all includes terminal ones
152
153
  agents-pod status <podId>
153
154
  agents-pod logs <podId> [--follow]
154
155
  agents-pod wake <podId> [--agent <id>] [--warm] [--over-budget]
155
156
  # fresh by default; --warm resumes a lost wake-up's session
156
- agents-pod cancel <podId>
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)
157
159
  agents-pod reconcile # the cron entry point; idempotent
158
160
  agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
159
161
  agents-pod agent edit <id> # $EDITOR on the stored definition
160
162
  agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
161
163
  agents-pod agent list [--json] # named agents: type, model, role
162
164
  agents-pod listener add (--inbox <dir|file> | --source <name>) (--pod <podId> | --template <file>)
163
- [--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>]
164
168
  agents-pod listener list [--json]
165
169
  agents-pod listener rm <listenerId>
166
170
  agents-pod source add <name> --cmd <path> [--timeout <s>]
167
171
  agents-pod source list [--json]
168
172
  agents-pod source rm <name>
173
+ agents-pod source rewind <name> --to <cursor|""> # re-read past the cursor (ADR 0034)
169
174
  agents-pod deliver add <name> --cmd <path> [--timeout <s>]
170
175
  agents-pod deliver list [--json]
171
176
  agents-pod deliver rm <name>
@@ -181,6 +186,25 @@ at a time is a schedule's job, and a pod carrying a clock is the incident
181
186
  upstream pod's `done`; a cancelled upstream never releases it. When the
182
187
  project's cap is full, the new pod likewise waits idle with its document store
183
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.
184
208
  Exit codes follow the house rule: `0` success
185
209
  or nothing to do, `1` at least one pod failed (or `doctor` found something
186
210
  that blocks a channel), `2` configuration or storage error.
@@ -190,7 +214,7 @@ first, record before exiting.
190
214
 
191
215
  The Pi extension has two planes, gated by the env the wrapper injects
192
216
  ([ADR 0010](docs/adr/0010-two-extension-planes.md)). The **management
193
- 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
194
218
  `spawn_pod` tool) registers only in an interactive session: inside a pod
195
219
  those tools and commands are not registered at all, so fan-out is
196
220
  structurally impossible rather than forbidden. The **self plane** registers
@@ -209,10 +233,16 @@ tool is a guarantee on top of that contract, never a replacement for it.
209
233
  | --- | --- | --- |
210
234
  | `spawning` | record created, first wake-up not yet confirmed | `spawn` |
211
235
  | `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
212
- | `idle` | goal open, nothing live; documents current — `idle (awaiting)` when the last window declared itself satisfied | 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 |
213
237
  | `done` | goal reached; terminal | wrapper |
214
238
  | `cancelled` | user cancel completed; terminal | wrapper |
215
239
 
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
+
216
246
  A pod never exits — its wake-ups do. Wake-up outcomes — declared in the
217
247
  handoff's first line, in this order of preference: `more` (`status: rotating`,
218
248
  the goal needs another wake-up now), `quiet` (`status: quiet`, this window is
@@ -283,6 +313,16 @@ placeholders: `{{id}}` `{{title}}` `{{body}}` `{{path}}` and `{{reply_to}}`.
283
313
  The last placeholder carries the Delivery Address consumed by the outbound
284
314
  contract below (ADRs 0013, 0026).
285
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
+
286
326
  For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
287
327
  section — below the agent-owned `## Waiting`, and appended only while no
288
328
  wake-up is live, then one batch, one wake on the ordinary wake path; the
@@ -319,6 +359,13 @@ and hands the work over. The canonical example is replayed in
319
359
  [`tests/fixtures/first-turn/`](tests/fixtures/first-turn/), where the incident's
320
360
  own ask is the fixture.
321
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
+
322
369
  ### Reading a rate
323
370
 
324
371
  Every wake-up records `at` and `trigger` (`spawn` / `tick` / `wake`, ADR 0032), so
@@ -403,6 +450,9 @@ name, removing an in-use Source, or adding a second deliverer returns `2`.
403
450
  `--pod <podId>`. With `--template`, `--project <dir>` selects the new Pods'
404
451
  project and defaults to the reconcile invocation's cwd; with `--pod` it is a
405
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).
406
456
 
407
457
  ### Writing a Source drain
408
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.6",
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",