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 +40 -0
- package/README.md +55 -5
- package/examples/feishu/README.md +65 -0
- package/package.json +1 -1
- package/src/cli.ts +521 -58
- package/src/doctor.ts +13 -0
- package/src/extension/management.ts +15 -4
- package/src/listeners.ts +14 -2
- package/src/sources.ts +40 -19
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.
|
|
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",
|