agents-pod 0.1.0 → 0.3.0
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 +68 -0
- package/README.md +283 -25
- package/examples/feishu/.env.example +23 -0
- package/examples/feishu/README.md +92 -0
- package/examples/feishu/feishu_client.py +220 -0
- package/examples/feishu/feishu_deliver.py +136 -0
- package/examples/feishu/feishu_drain.py +305 -0
- package/package.json +2 -1
- package/src/cli.ts +472 -47
- package/src/doctor.ts +551 -0
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +15 -0
- package/src/sources.ts +312 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,74 @@ 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.0] — 2026-11-04
|
|
8
|
+
|
|
9
|
+
A machine whose IM surface has never been exercised can now say so. Discovery
|
|
10
|
+
was the one thing ADRs 0024/0025 left without a home: credentials and sockets
|
|
11
|
+
moved into operator commands, so core could not check any of it, and every
|
|
12
|
+
failure mode — a drain that runs and yields nothing, a missing deliverer, no
|
|
13
|
+
channels, no cron line — was exit-`0`-silent by design (issue #19).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`agents-pod doctor [--probe] [--json]`** — a read-only Health Report over
|
|
18
|
+
the store (ADR 0027): store and registry, the last pass, each Source (command,
|
|
19
|
+
cursor, drain age and yield, last failure), each Listener (Source or inbox,
|
|
20
|
+
template, project, bound Pod), the deliverer with pending and dead Outbox
|
|
21
|
+
entries, and the notification config including a `passwordEnv` that is not
|
|
22
|
+
set. `✓` nothing to do, `✗` blocks a channel, `?` worth knowing; exit `0`
|
|
23
|
+
clean, `1` otherwise, `2` for usage errors.
|
|
24
|
+
- **The first run, printed** — with no Source, no deliverer and no channel,
|
|
25
|
+
`doctor` prints the ordered recipe instead of a report, shrinking to the
|
|
26
|
+
missing steps once anything exists. A bare `agents-pod` now names `doctor`
|
|
27
|
+
instead of answering `Unknown command`.
|
|
28
|
+
- **`<cmd> --check`** — the operator command's own self-check (exit `0`
|
|
29
|
+
healthy, `3` none here, anything else unhealthy), run by `doctor --probe`
|
|
30
|
+
under the ten-second budget and quoted verbatim. Core still holds no
|
|
31
|
+
credentials; the side that owns them does the checking.
|
|
32
|
+
- **`examples/feishu/`** — a worked Source and deliverer pair for Feishu with
|
|
33
|
+
`--check` implemented, a `.env.example`, and the platform facts measured
|
|
34
|
+
while wiring it to a real tenant: the four scopes, `230027`/`99991672`, the
|
|
35
|
+
group that must be created by a human, and the endpoints that are not there.
|
|
36
|
+
- **Two facts that end the silence**: `sources.json` now records
|
|
37
|
+
`lastDrainItems`, `lastError` and `lastFailureAt` (a zero-yield drain is
|
|
38
|
+
visible; a failure is reportable without tailing `cron.log`), and every pass
|
|
39
|
+
writes `last-pass.json`, so "cron is not installed" and "cron runs and finds
|
|
40
|
+
nothing" read differently.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- A failed drain's record keeps the failure fact; the cursor, drain horizon and
|
|
45
|
+
yield still do not move (ADR 0024, amended in ADR 0027).
|
|
46
|
+
|
|
47
|
+
## [0.2.0] — 2026-11-04
|
|
48
|
+
|
|
49
|
+
Work that arrives in a chat can now be picked up there, and answered there,
|
|
50
|
+
without a resident process holding a socket: a **Source** drains a conversation
|
|
51
|
+
into a pod once per pass, a **Deliverer** posts a pod's **Outbox** back, and a
|
|
52
|
+
**Delivery Address** rides the Goal so a Worker can reply without asking anyone.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **IM intake and outbound — Source, Deliverer, Outbox, and Delivery Address** —
|
|
57
|
+
a pull-based intake and file-based reply path, with platform credentials and
|
|
58
|
+
API behavior kept in operator commands.
|
|
59
|
+
- **Sources and drains** — `source add|list|rm`, cursor-based JSONL drains with
|
|
60
|
+
platform ids and routing fields, bounded execution, claim-before-cursor
|
|
61
|
+
commits, and two-generation claim retention.
|
|
62
|
+
- **Source Listeners and standing Pod intake** — `listener add --source`, batched
|
|
63
|
+
`TASKS.md` Inbox delivery with Waiting state, the `{{reply_to}}` template
|
|
64
|
+
placeholder, and `--project` for template-materialized Pods.
|
|
65
|
+
- **Outbox delivery** — `deliver add|list|rm`, one operator deliverer, named
|
|
66
|
+
per-wake-up entry files carrying `{to, text}`, stable delivery uuids, bounded
|
|
67
|
+
wrapper and reconcile flushes, same-uuid retries, one-hour dead-lettering,
|
|
68
|
+
and retained `*.dead` entries for expired or invalid input.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- Wake-up outcomes are recorded under the registry lock, so concurrent passes
|
|
73
|
+
and wrappers no longer overwrite each other's registry updates.
|
|
74
|
+
|
|
7
75
|
## [0.1.0] — 2026-11-03
|
|
8
76
|
|
|
9
77
|
First release: one pod, many wake-ups, and the machinery that keeps a standing
|
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@ Supervision for long-running coding agents: spawn a **pod** — a standing
|
|
|
4
4
|
unit that outlives every context window — and wake **agents** inside it,
|
|
5
5
|
again and again, each wake-up fresh, documents carrying the work across.
|
|
6
6
|
|
|
7
|
-
> **Status: `0.
|
|
8
|
-
> logs, reconcile, listeners, notifications) and the
|
|
9
|
-
> planes.
|
|
7
|
+
> **Status: `0.2.0` — IM intake and outbound.** The CLI runs (spawn, wake,
|
|
8
|
+
> cancel, logs, reconcile, listeners, sources, deliverers, notifications) and the
|
|
9
|
+
> Pi extension ships its two planes. A pod can now be fed from a chat and answer
|
|
10
|
+
> into it, through operator commands that keep credentials and sockets out of
|
|
11
|
+
> core. Wake-ups are **Pi only** in this release. Interfaces may still move
|
|
10
12
|
> before `1.0`. The design lives in `CONTEXT.md` and `docs/adr/`; a reader's
|
|
11
13
|
> companion — the design principles and an explained vocabulary — in
|
|
12
14
|
> [docs/DESIGN.md](docs/DESIGN.md).
|
|
@@ -73,11 +75,11 @@ agent (planner, coder, reviewer; pi, claude, codex) across its life
|
|
|
73
75
|
|
|
74
76
|
**Wake-ups are the context unit; the pod isolates them.** One **wake-up** =
|
|
75
77
|
one agent, one fresh context window: read the pod documents first, work,
|
|
76
|
-
record (handoff, decisions, task state), exit with an outcome.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
record (handoff, decisions, task state), exit with an outcome. Agents in a
|
|
79
|
+
pod share its documents, never one another's windows; pending Outbox entries
|
|
80
|
+
are files, not context. Context exhaustion ends a wake-up, never the pod;
|
|
81
|
+
waking again is routine, not recovery. The store's contract is three slots,
|
|
82
|
+
two hard lines ([ADR 0008](docs/adr/0008-document-store-contract.md)).
|
|
81
83
|
|
|
82
84
|
**A wrapper owns each wake-up's liveness.** Every wake-up gets a tiny
|
|
83
85
|
**wrapper** — detached, session and process-group leader — that spawns the
|
|
@@ -135,6 +137,7 @@ package until both plugins demonstrably need one.
|
|
|
135
137
|
## CLI
|
|
136
138
|
|
|
137
139
|
```text
|
|
140
|
+
agents-pod doctor [--probe] [--json] # what is missing, and the command that fixes it
|
|
138
141
|
agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
|
|
139
142
|
[--after <podId>] # wait for the upstream pod to reach done
|
|
140
143
|
agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
|
|
@@ -148,10 +151,16 @@ agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instruction
|
|
|
148
151
|
agents-pod agent edit <id> # $EDITOR on the stored definition
|
|
149
152
|
agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
|
|
150
153
|
agents-pod agent list [--json] # named agents: type, model, role
|
|
151
|
-
agents-pod listener add --inbox <dir|file>
|
|
152
|
-
[--match <substr>] [--max-concurrent <n>]
|
|
154
|
+
agents-pod listener add (--inbox <dir|file> | --source <name>) (--pod <podId> | --template <file>)
|
|
155
|
+
[--match <substr>] [--max-concurrent <n>] [--project <dir>]
|
|
153
156
|
agents-pod listener list [--json]
|
|
154
157
|
agents-pod listener rm <listenerId>
|
|
158
|
+
agents-pod source add <name> --cmd <path> [--timeout <s>]
|
|
159
|
+
agents-pod source list [--json]
|
|
160
|
+
agents-pod source rm <name>
|
|
161
|
+
agents-pod deliver add <name> --cmd <path> [--timeout <s>]
|
|
162
|
+
agents-pod deliver list [--json]
|
|
163
|
+
agents-pod deliver rm <name>
|
|
155
164
|
```
|
|
156
165
|
|
|
157
166
|
`spawn` claims a pod id, writes the pod record, wakes the first agent, and
|
|
@@ -160,8 +169,8 @@ upstream pod's `done`; a cancelled upstream never releases it. When the
|
|
|
160
169
|
project's cap is full, the new pod likewise waits idle with its document store
|
|
161
170
|
ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
|
|
162
171
|
Exit codes follow the house rule: `0` success
|
|
163
|
-
or nothing to do, `1` at least one pod failed
|
|
164
|
-
error.
|
|
172
|
+
or nothing to do, `1` at least one pod failed (or `doctor` found something
|
|
173
|
+
that blocks a channel), `2` configuration or storage error.
|
|
165
174
|
|
|
166
175
|
Every goal prompt carries the wake-up protocol: read the pod documents
|
|
167
176
|
first, record before exiting.
|
|
@@ -222,21 +231,28 @@ policy, per record:
|
|
|
222
231
|
- terminal → no-op (terminal is terminal: `wake` refuses; continuation is
|
|
223
232
|
a new pod).
|
|
224
233
|
|
|
225
|
-
After pods, the same pass materializes listeners:
|
|
226
|
-
items by id (registry-first, so a lost tick
|
|
227
|
-
either spawn a pod from the listener's template
|
|
228
|
-
|
|
234
|
+
After pods, the same pass materializes listeners: use each Source's drained
|
|
235
|
+
items or scan each inbox, claim new items by id (registry-first, so a lost tick
|
|
236
|
+
cannot double-claim), then either spawn a pod from the listener's template —
|
|
237
|
+
bounded by `--max-concurrent` and the per-project pod cap — or append a batch
|
|
238
|
+
to the bound standing pod and use the ordinary capacity-checked wake path.
|
|
229
239
|
|
|
230
240
|
An inbox is a directory (one item per top-level non-dot file; claimable once
|
|
231
241
|
its mtime and size hold across two passes) or one append-only JSONL file
|
|
232
|
-
(`body` required; malformed lines skipped with a warning).
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
(`body` required, `title` optional; malformed lines skipped with a warning).
|
|
243
|
+
A JSONL line may also carry the routing fields `chat_id`, `root_id` and
|
|
244
|
+
`thread_id`; any other key is ignored. Item ids are filenames or line
|
|
245
|
+
numbers — never content — claimed per inbox; templates bind five
|
|
246
|
+
placeholders: `{{id}}` `{{title}}` `{{body}}` `{{path}}` and `{{reply_to}}`.
|
|
247
|
+
The last placeholder carries the Delivery Address consumed by the outbound
|
|
248
|
+
contract below (ADRs 0013, 0026).
|
|
235
249
|
|
|
236
250
|
For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
|
|
237
|
-
section —
|
|
238
|
-
on the ordinary wake path; the
|
|
239
|
-
checkboxes
|
|
251
|
+
section — below the agent-owned `## Waiting`, and appended only while no
|
|
252
|
+
wake-up is live, then one batch, one wake on the ordinary wake path; the
|
|
253
|
+
agent serves entries and flips their checkboxes. A block with a Delivery
|
|
254
|
+
Address carries ` — to: <Delivery Address>` on its checkbox line, never in
|
|
255
|
+
the copied body (ADRs 0014, 0026).
|
|
240
256
|
|
|
241
257
|
Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
|
|
242
258
|
after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
|
|
@@ -247,7 +263,228 @@ registry-lock CAS on every state transition, so two passes — or a human
|
|
|
247
263
|
Notifications (P1): two events only — park (with the failure verdict)
|
|
248
264
|
and done — through the copied channels (webhook/email/system) in
|
|
249
265
|
`~/.agents/pods/config.json`; once per episode; individual outcomes
|
|
250
|
-
never notify (ADR 0021).
|
|
266
|
+
never notify (ADR 0021). A store with no channels at all stays silent by
|
|
267
|
+
design, so `agents-pod doctor` reports it as a `?` line rather than letting
|
|
268
|
+
silence look like health.
|
|
269
|
+
|
|
270
|
+
## First run
|
|
271
|
+
|
|
272
|
+
`agents-pod doctor` answers one question, read-only: what does this machine
|
|
273
|
+
still lack for a conversation to reach a Pod, and a reply to leave one? With
|
|
274
|
+
nothing configured it prints the ordered first run instead of a report — write
|
|
275
|
+
a Source command, `source add`, `listener add`, `deliver add`, the cron line,
|
|
276
|
+
`doctor` again — and exits `1` until every line is `✓`
|
|
277
|
+
([ADR 0027](docs/adr/0027-discovery-doctor-and-the-self-check.md)).
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
store ✓ 1 pods, 0 active
|
|
281
|
+
pass ✓ last pass 41s ago
|
|
282
|
+
intake ✗ source feishu: the last drain (2s ago) failed — exit 1
|
|
283
|
+
run: agents-pod doctor --probe
|
|
284
|
+
listeners ✓ listener-4f1a: source feishu → template triage.md → /work/app
|
|
285
|
+
outbound ✓ deliverer feishu-reply: ~/.agents/pods/adapters/feishu_deliver.py
|
|
286
|
+
notify ? no notification channels: parks and dones stay silent
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`✓` nothing to do, `✗` the channel cannot work, `?` worth knowing — a quiet
|
|
290
|
+
chat, no channels, a pass that has not run. Exit `0` when nothing is `✗`, `1`
|
|
291
|
+
otherwise, `2` for store or usage errors; `--json` prints the same report as
|
|
292
|
+
data. Two small records make the silent cases visible: `sources.json` keeps
|
|
293
|
+
the last drain's yield and its last failure, and every pass marks
|
|
294
|
+
`last-pass.json`, so "cron is not installed" and "cron runs and finds nothing"
|
|
295
|
+
stop looking identical.
|
|
296
|
+
|
|
297
|
+
**Credentials belong to the operator command, so only it can check them.** The
|
|
298
|
+
convention is `<cmd> --check`: exit `0` healthy, `3` no self-check here,
|
|
299
|
+
anything else unhealthy. `doctor --probe` runs it once for each Source and
|
|
300
|
+
once for the deliverer and quotes its lines verbatim — which is how a missing
|
|
301
|
+
platform scope, with its console URL, reaches the person who can grant it:
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
checks ✗ source feishu: self-check failed (exit 1)
|
|
305
|
+
read failed in 联调: code=230027 msg=Lack of necessary permissions, ext=need scope: im:message.group_msg
|
|
306
|
+
https://open.feishu.cn/app/cli_…/auth?q=im:message.group_msg&op_from=openapi&token_type=tenant
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Plain `doctor` never executes operator code. `examples/feishu/` is a worked
|
|
310
|
+
pair that implements `--check` and records the platform facts measured while
|
|
311
|
+
wiring it up: the four scopes, the error codes, and the two traps that cost an
|
|
312
|
+
afternoon.
|
|
313
|
+
|
|
314
|
+
## IM intake and outbound
|
|
315
|
+
|
|
316
|
+
The operator owns platform I/O. A **Source** is an executable pull drain; the
|
|
317
|
+
single **deliverer** is an executable push command. Source definitions and
|
|
318
|
+
their committed cursors live in `~/.agents/pods/sources.json`; Listener claims
|
|
319
|
+
live in `~/.agents/pods/listeners.json`; the deliverer lives in
|
|
320
|
+
`~/.agents/pods/deliverer.json`. `source add` and `deliver add` resolve `--cmd`
|
|
321
|
+
against the add command's cwd and require an executable regular file.
|
|
322
|
+
`--timeout` is a positive number of seconds, default `10`. Their add/list/rm
|
|
323
|
+
commands return `0` on success; invalid arguments, an unknown or duplicate
|
|
324
|
+
name, removing an in-use Source, or adding a second deliverer returns `2`.
|
|
325
|
+
|
|
326
|
+
`listener add --source <name>` takes either `--template <file>` or
|
|
327
|
+
`--pod <podId>`. With `--template`, `--project <dir>` selects the new Pods'
|
|
328
|
+
project and defaults to the reconcile invocation's cwd; with `--pod` it is a
|
|
329
|
+
configuration error (exit `2`), because the bound Pod keeps its own project.
|
|
330
|
+
|
|
331
|
+
### Writing a Source drain
|
|
332
|
+
|
|
333
|
+
Each reconcile pass invokes every distinct Source consumed by a Listener once,
|
|
334
|
+
before any Pod work and before taking the registry lock. The command receives
|
|
335
|
+
no arguments. Its cwd is the reconcile invocation's project (its cwd, not a
|
|
336
|
+
Listener's `--project`), and it inherits that invocation's environment plus:
|
|
337
|
+
|
|
338
|
+
- `AGENTS_POD_SOURCE_ID` — the registered Source name;
|
|
339
|
+
- `AGENTS_POD_CURSOR` — the last committed cursor, or the empty string when no
|
|
340
|
+
cursor has been committed.
|
|
341
|
+
|
|
342
|
+
The command writes JSONL to stdout; stdin is closed and stderr is ignored. A
|
|
343
|
+
successful stream contains zero or more items followed by an optional cursor
|
|
344
|
+
as the last non-empty line:
|
|
345
|
+
|
|
346
|
+
```jsonl
|
|
347
|
+
{"id":"message-123","body":"Please review this","title":"Review request","chat_id":"chat-1","root_id":"root-1","thread_id":"thread-1"}
|
|
348
|
+
{"cursor":"next-page-token"}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
- `id` is a non-empty string and `body` is a string; both are required. The id
|
|
352
|
+
is the platform's stable deduplication key.
|
|
353
|
+
- `title` is optional. A missing or blank title defaults to the trimmed first
|
|
354
|
+
line of `body`.
|
|
355
|
+
- `chat_id`, `root_id` and `thread_id` are optional routing strings. Non-empty
|
|
356
|
+
values are trimmed; blank or non-string values are ignored. They feed
|
|
357
|
+
`{{reply_to}}`. Source items have no filesystem path, so `{{path}}` is empty.
|
|
358
|
+
Unknown keys are ignored.
|
|
359
|
+
- The final `{"cursor":"…"}` line takes a string. Omitting it leaves the
|
|
360
|
+
stored cursor unchanged. Blank lines are ignored. A malformed JSON line, an
|
|
361
|
+
invalid item, or an invalid final cursor is skipped with one
|
|
362
|
+
`~/.agents/pods/cron.log` warning and is not claimed; other valid lines still
|
|
363
|
+
count.
|
|
364
|
+
|
|
365
|
+
Exit `0` commits the successful drain. Claims use `source:<name>` as their
|
|
366
|
+
inbox namespace and the item's platform id, shared by every Listener on that
|
|
367
|
+
Source. Claims are written to `listeners.json` before the Source's cursor is
|
|
368
|
+
atomically written to `sources.json`, so a crash between those writes replays
|
|
369
|
+
into id deduplication. A committed drain also advances the record's
|
|
370
|
+
`lastDrainAt` and records its yield as `lastDrainItems` — zero is a fact, not a
|
|
371
|
+
gap, and it is what makes a pull that is pointed at nothing visible to
|
|
372
|
+
`agents-pod doctor` (ADR 0027). Claims from the current and previous
|
|
373
|
+
successful drain generations are retained and older timestamped claims are
|
|
374
|
+
pruned. Claims with
|
|
375
|
+
no generation timestamp are retained. For a Source bound to a standing Pod,
|
|
376
|
+
the claimed batch must also be appended to `TASKS.md` before cursor and
|
|
377
|
+
`lastDrainAt` commit; a live wake-up holds that append, so the next pass drains
|
|
378
|
+
from the unchanged cursor again.
|
|
379
|
+
|
|
380
|
+
A non-zero exit, termination by a signal, spawn failure, timeout, or total
|
|
381
|
+
stdout larger than **1 MiB** abandons the whole drain: no items are claimed,
|
|
382
|
+
neither cursor nor `lastDrainAt` nor `lastDrainItems` moves, no claims are
|
|
383
|
+
pruned, and exactly one `cron.log` line records the failure. The record also
|
|
384
|
+
keeps `lastError` and `lastFailureAt`, cleared by the next success, so the
|
|
385
|
+
report can name a Source that fails every pass without tailing the log
|
|
386
|
+
(ADR 0027). Reconcile still exits `0`; the next pass retries the whole drain
|
|
387
|
+
from the same cursor (ADR 0024).
|
|
388
|
+
|
|
389
|
+
A Source command may also answer `<cmd> --check` (exit `0` healthy, `3` no
|
|
390
|
+
self-check here, anything else unhealthy) with its own diagnosis on
|
|
391
|
+
stdout/stderr; `agents-pod doctor --probe` runs it and quotes it (ADR 0027).
|
|
392
|
+
The drain invocation never passes an argument — the check is a second calling
|
|
393
|
+
convention, opted into by the command that implements it.
|
|
394
|
+
|
|
395
|
+
### Writing the deliverer
|
|
396
|
+
|
|
397
|
+
Exactly one deliverer may be configured; remove it before adding a replacement.
|
|
398
|
+
A Wake-up writes one JSON file per outgoing message in its own Pod:
|
|
399
|
+
|
|
400
|
+
```text
|
|
401
|
+
<project>/.agents/pods/<podId>/outbox/<wakeup>-<seq>-<name>.json
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`<wakeup>` is the positive integer wake-up number, `<seq>` is a zero-padded
|
|
405
|
+
increasing sequence such as `001`, `002`, and `<name>` is a filename-safe slug.
|
|
406
|
+
Files are delivered serially in filename order. Each file contains a JSON
|
|
407
|
+
object with a non-empty string `to` and a string `text`:
|
|
408
|
+
|
|
409
|
+
```json
|
|
410
|
+
{"to":"<Delivery Address>","text":"The reply text"}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`to` is the **Delivery Address** rendered by `{{reply_to}}` or carried on a
|
|
414
|
+
standing Pod's Inbox checkbox line. The worker copies it verbatim; the
|
|
415
|
+
deliverer parses it; core never reads it back. Its shape is:
|
|
416
|
+
|
|
417
|
+
```text
|
|
418
|
+
chat=<chat_id>;affinity=<root_id ?? item id>[;thread=<thread_id>]
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
An item with no `chat_id` renders the empty string, which is not a valid `to`
|
|
422
|
+
and cannot be delivered. When `thread` is present, the deliverer can use it to
|
|
423
|
+
reply in that thread.
|
|
424
|
+
|
|
425
|
+
The Wrapper invokes the deliverer once per current-wake-up entry after the
|
|
426
|
+
agent exits and before the Pod becomes idle. Reconcile retries entries for
|
|
427
|
+
every non-active Pod, outside the registry lock. Each invocation receives the
|
|
428
|
+
entry file's bytes verbatim on stdin, takes no arguments, runs with the Pod's
|
|
429
|
+
project as cwd, and ignores stdout and stderr. It inherits the operator
|
|
430
|
+
environment plus exactly these core-provided values:
|
|
431
|
+
|
|
432
|
+
- `AGENTS_POD_POD_ID` — the owning Pod id;
|
|
433
|
+
- `AGENTS_POD_WAKEUP_N` — the wake-up number parsed from the filename;
|
|
434
|
+
- `AGENTS_POD_OUTBOX_ENTRY` — the entry filename, not its full path;
|
|
435
|
+
- `AGENTS_POD_OUTBOX_UUID` — the 50-character lowercase hexadecimal idempotency
|
|
436
|
+
key derived from Pod id, wake-up number, and filename. Pass this same value
|
|
437
|
+
to the platform on every retry.
|
|
438
|
+
|
|
439
|
+
One flush per Pod makes at most **10 delivery attempts** within a shared
|
|
440
|
+
**10-second budget**, in filename order. The configured per-invocation timeout
|
|
441
|
+
defaults to 10 seconds and is additionally capped by the flush's remaining
|
|
442
|
+
budget. Entries left beyond either bound wait for a later pass.
|
|
443
|
+
|
|
444
|
+
Exit `0` is the only acknowledgement: core deletes the entry and writes no
|
|
445
|
+
completion marker. A non-zero exit, signal, spawn failure, or timeout leaves
|
|
446
|
+
the file unchanged, writes one `cron.log` line, and lets the next pass retry
|
|
447
|
+
with the same uuid. Delivery failure does not change the Wake-up outcome,
|
|
448
|
+
failure streak, or notifications.
|
|
449
|
+
|
|
450
|
+
An entry whose mtime is more than **one hour** old is not delivered: core
|
|
451
|
+
renames it by appending `.dead`, logs one line, and keeps its contents
|
|
452
|
+
unchanged. A malformed entry, a filename with no valid leading wake-up number,
|
|
453
|
+
or a future-wake-up entry seen by a Wrapper is likewise dead-lettered on first
|
|
454
|
+
sight. `*.dead` files are never scanned again or automatically removed. With
|
|
455
|
+
no deliverer configured, the Wrapper flush is a no-op; each reconcile pass
|
|
456
|
+
writes one `cron.log` line per Pod with waiting entries, and the same one-hour
|
|
457
|
+
aging still applies (ADR 0025).
|
|
458
|
+
|
|
459
|
+
A deliverer may answer `<cmd> --check` on the same convention as a Source
|
|
460
|
+
command (exit `0` healthy, `3` no self-check here, anything else unhealthy);
|
|
461
|
+
`agents-pod doctor --probe` runs it, and `agents-pod doctor` reports any
|
|
462
|
+
entries waiting in Pod outboxes while no deliverer is configured (ADR 0027).
|
|
463
|
+
|
|
464
|
+
### Role Instructions for replying agents
|
|
465
|
+
|
|
466
|
+
Save the following as the replying Agent's instructions file and pass it to
|
|
467
|
+
`agent add --instructions <file>`:
|
|
468
|
+
|
|
469
|
+
```text
|
|
470
|
+
When you reply to a Conversation, write one JSON file per reply into your own
|
|
471
|
+
Pod's outbox at .agents/pods/$AGENTS_POD_POD_ID/outbox/ under the Pod project.
|
|
472
|
+
Name each file $AGENTS_POD_WAKEUP_N-<seq>-<name>.json, using a zero-padded
|
|
473
|
+
increasing <seq> (001, 002, ...) and a filename-safe <name>. Write
|
|
474
|
+
{"to":"<Delivery Address>","text":"<reply>"}; copy the address verbatim from
|
|
475
|
+
Reply-To in the Goal or from "to:" on the item's TASKS.md Inbox checkbox line.
|
|
476
|
+
Do not write an entry when that address is empty.
|
|
477
|
+
|
|
478
|
+
If you ask a question in a thread, reply in that same thread by reusing its
|
|
479
|
+
Delivery Address, including the thread component. Ask at most two clarification
|
|
480
|
+
rounds per uncertainty; then proceed on a stated assumption and record that
|
|
481
|
+
assumption in HANDOFF.md.
|
|
482
|
+
|
|
483
|
+
Core deletes an entry only after the deliverer exits 0. A failed delivery keeps
|
|
484
|
+
the file, logs one line, and is retried on the next pass with the same uuid; an
|
|
485
|
+
entry older than one hour is renamed with a .dead suffix and kept. Do not
|
|
486
|
+
rename, delete, or rewrite pending or dead entries yourself.
|
|
487
|
+
```
|
|
251
488
|
|
|
252
489
|
## Storage
|
|
253
490
|
|
|
@@ -256,12 +493,17 @@ never notify (ADR 0021).
|
|
|
256
493
|
├── pods.json # global pod registry: definitions + state, 0600
|
|
257
494
|
├── agents.json # named agents: adapter type, model, role instructions, 0600
|
|
258
495
|
├── listeners.json # listeners, bounds, claimed item ids, 0600
|
|
496
|
+
├── sources.json # named drain commands, committed cursors, lastDrainAt
|
|
497
|
+
│ # + lastDrainItems and the last failure (ADR 0027)
|
|
498
|
+
├── deliverer.json # the single outbound delivery command
|
|
499
|
+
├── last-pass.json # when a pass last finished, so "never ran" is a fact
|
|
259
500
|
├── inboxes/<id>/ # default listener inboxes; any dir/file may serve instead
|
|
260
501
|
└── registry.lock # short-lived registry write lock, 0600
|
|
261
502
|
|
|
262
503
|
<project>/.agents/pods/
|
|
263
504
|
└── <podId>/
|
|
264
|
-
├── docs/ # HANDOFF.md · DECISIONS.md · TASKS.md —
|
|
505
|
+
├── docs/ # HANDOFF.md · DECISIONS.md · TASKS.md — cross-wake work memory (ADR 0008)
|
|
506
|
+
├── outbox/ # pending <wakeup>-<seq>-<name>.json replies and retained *.dead entries
|
|
265
507
|
├── output.log # every wake-up, append-only, wake-up delimited; trimmed idle by
|
|
266
508
|
│ # reconcile: cap 10 MB, keep 2 MB, oldest sections dropped (ADR 0016), 0600
|
|
267
509
|
├── heartbeat # touched by the live wake-up's wrapper every 30s; mtime = last beat;
|
|
@@ -317,7 +559,18 @@ adapted from agents-cron); listeners — inbox directory and JSONL file
|
|
|
317
559
|
sources, per-item pods from templates, bound standing pods; wake admission
|
|
318
560
|
honouring `--after` dependencies and the per-project cap; the Pi extension's
|
|
319
561
|
two planes (ADR 0010) — `/pod` commands, `spawn_pod`, and `pod_handoff`.
|
|
320
|
-
No default timeout
|
|
562
|
+
No default Wake-up timeout — liveness is heartbeat-based or it is nothing.
|
|
563
|
+
|
|
564
|
+
**Shipped in `0.2.0`.** Chat as an intake and an outlet, both as operator
|
|
565
|
+
commands rather than a daemon: sources (`source add|list|rm`) drained once per
|
|
566
|
+
pass with a committed cursor and claim-before-cursor ordering; source-fed
|
|
567
|
+
Listeners, batched `## Inbox` delivery to a standing pod, and `## Waiting` above
|
|
568
|
+
it; outbox delivery (`deliver add|list|rm`) at wake-up exit and in every pass,
|
|
569
|
+
with stable uuids, one-hour dead-lettering and retained `*.dead` entries; the
|
|
570
|
+
Affinity Key and the `{{reply_to}}` Delivery Address riding the Goal or an Inbox
|
|
571
|
+
line; `listener add --project` for tuning intake and workers apart. A wrapper's
|
|
572
|
+
outcome is now recorded under the registry lock, so a concurrent pass or wrapper
|
|
573
|
+
cannot overwrite it and force the same wake-up to run twice (ADR 0018).
|
|
321
574
|
|
|
322
575
|
**Next.** The Claude Code and Codex adapters (`--type` is refused today, not
|
|
323
576
|
silently downgraded). Default pod expiry and guarded fan-out — the
|
|
@@ -342,3 +595,8 @@ is refused at the surface and at launch. The extension lives in
|
|
|
342
595
|
`src/extension/` (one module per plane, plus `handoff.ts` for the document
|
|
343
596
|
rendering), with `index.mjs` at the package root as Pi's entry so the footer
|
|
344
597
|
label stays `agents-pod`.
|
|
598
|
+
|
|
599
|
+
`examples/` ships operator commands that core never imports — today the Feishu
|
|
600
|
+
Source/deliverer pair — as copyable starting points. `src/doctor.ts` is the
|
|
601
|
+
read-only Health Report; it is tested through the CLI like everything else,
|
|
602
|
+
with fake operator commands that answer `--check` on each exit code.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# A copy of this file is your own: `cp .env.example .env` (0600), then fill in.
|
|
2
|
+
# Nothing here is ever committed; core never reads these — the commands do
|
|
3
|
+
# (ADRs 0024, 0025), and `agents-pod doctor --probe` asks the commands.
|
|
4
|
+
|
|
5
|
+
# Self-built app credentials: open platform → app → 凭证与基础信息.
|
|
6
|
+
FEISHU_APP_ID=
|
|
7
|
+
FEISHU_APP_SECRET=
|
|
8
|
+
|
|
9
|
+
# Comma-separated chat_id allowlist; empty = every group chat the app is in.
|
|
10
|
+
# Read a chat_id from state.json's known_chats after one successful drain.
|
|
11
|
+
FEISHU_CHATS=
|
|
12
|
+
|
|
13
|
+
# 1 = only messages that @-mention the bot (a mixed-use group); 0 = everything.
|
|
14
|
+
FEISHU_REQUIRE_MENTION=0
|
|
15
|
+
|
|
16
|
+
# First-run window when there is no cursor yet, and the re-read overlap behind
|
|
17
|
+
# an existing one, in seconds.
|
|
18
|
+
FEISHU_LOOKBACK_SECONDS=3600
|
|
19
|
+
FEISHU_OVERLAP_SECONDS=60
|
|
20
|
+
|
|
21
|
+
# Per-pass item cap; the cursor stops at the last item printed, so a bigger
|
|
22
|
+
# burst simply arrives over several passes.
|
|
23
|
+
FEISHU_MAX_ITEMS=40
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# A Feishu command pair for agents-pod
|
|
2
|
+
|
|
3
|
+
Two operator commands that hold every platform concern — app credentials, token
|
|
4
|
+
rotation, chat and thread paging, message flattening, reply addressing — so
|
|
5
|
+
core stays credential- and socket-free (ADRs [0024](../../docs/adr/0024-sources-pull-drain-and-cursor.md),
|
|
6
|
+
[0025](../../docs/adr/0025-outbox-operator-delivery-and-entry-files.md)).
|
|
7
|
+
|
|
8
|
+
| file | role | registered as |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `feishu_drain.py` | Source: chats + threads → JSONL items + a cursor line | `agents-pod source add feishu --cmd <path>` |
|
|
11
|
+
| `feishu_deliver.py` | Deliverer: one Outbox entry → one Feishu reply | `agents-pod deliver add feishu-reply --cmd <path>` |
|
|
12
|
+
| `feishu_client.py` | shared client: token cache, paging, message flattening | — |
|
|
13
|
+
| `.env.example` | the switchboard; copy to `.env` (0600) and fill | — |
|
|
14
|
+
|
|
15
|
+
Copy all four next to each other anywhere outside this repository — the pair is
|
|
16
|
+
meant to be *yours*, not core's. `chmod +x feishu_drain.py feishu_deliver.py`.
|
|
17
|
+
`state.json` and `.token.json` appear on first use; both are rebuilt if lost.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cp examples/feishu/*.py ~/.agents/pods/adapters/ # or wherever
|
|
21
|
+
cp examples/feishu/.env.example ~/.agents/pods/adapters/.env && chmod 600 …
|
|
22
|
+
agents-pod source add feishu --cmd ~/.agents/pods/adapters/feishu_drain.py --timeout 60
|
|
23
|
+
agents-pod deliver add feishu-reply --cmd ~/.agents/pods/adapters/feishu_deliver.py --timeout 20
|
|
24
|
+
agents-pod doctor --probe # the pair answers for itself; fix what it names
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The one thing this cannot do for you: the app
|
|
28
|
+
|
|
29
|
+
Feishu's console is a human surface. In order:
|
|
30
|
+
|
|
31
|
+
1. **Create a self-built app** (自建应用) and take `App ID` / `App Secret` from
|
|
32
|
+
凭证与基础信息 into `.env`.
|
|
33
|
+
2. **Grant four tenant permissions**, then **publish a version** (权限变更 only
|
|
34
|
+
takes effect with a new version; sensitive ones need an admin's approval):
|
|
35
|
+
|
|
36
|
+
| scope | why | measured failure without it |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `im:message.group_msg` | read group history | `GET /im/v1/messages` → `code=230027 lack of necessary permissions` |
|
|
39
|
+
| `im:message:readonly` | read messages generally | same class of refusal |
|
|
40
|
+
| `im:message:send_as_bot` | post as the bot | send refused |
|
|
41
|
+
| `im:chat:readonly` | enumerate the chats the bot is in | chat list comes back empty |
|
|
42
|
+
|
|
43
|
+
3. **Add the bot to a group chat** — 群设置 → 群机器人 → 添加机器人. Create that
|
|
44
|
+
group yourself, in the Feishu app: see the trap table below.
|
|
45
|
+
4. **Say one thing in the group**, then run the smoke test below.
|
|
46
|
+
|
|
47
|
+
## Smoke test
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
cd ~/.agents/pods/adapters
|
|
51
|
+
env AGENTS_POD_SOURCE_ID=feishu AGENTS_POD_CURSOR= ./feishu_drain.py # JSONL items, then {"cursor":"…"}
|
|
52
|
+
printf '%s' '{"to":"chat=oc_xxx;affinity=om_xxx","text":"通道自检"}' | ./feishu_deliver.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`exit 0` on both is the wiring. A drain that prints nothing **and** exits `0`
|
|
56
|
+
is not proof of health — it is the shape of "the pull is pointed at nothing",
|
|
57
|
+
which is why `sources.json` records `lastDrainItems` and why
|
|
58
|
+
`agents-pod doctor` prints a `?` line for a zero-yield drain (ADR 0027).
|
|
59
|
+
|
|
60
|
+
Both commands answer `--check` (exit `0` healthy, `1` not) with their own
|
|
61
|
+
diagnosis, including the console URL for a missing scope; `agents-pod doctor
|
|
62
|
+
--probe` runs those and quotes them.
|
|
63
|
+
|
|
64
|
+
## Measured traps
|
|
65
|
+
|
|
66
|
+
Facts observed while wiring this pair to a real tenant, in the order they bite:
|
|
67
|
+
|
|
68
|
+
| trap | what actually happens |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **Send works while read is denied** | The bot can post into a group and cannot read a word of it, so a half-granted app looks alive. `im:message.group_msg` is the one that blocks intake (`230027`), and the error message carries its own console link. |
|
|
71
|
+
| **An app-created chat is invisible to humans** | `POST /im/v1/chats` succeeds (so does the local test above), but the group is created *by the app*: you do not see it in Feishu, and you cannot join it without another write scope. Create the group in the client and add the bot — that is the only shape a human can also type into. |
|
|
72
|
+
| **A new chat can take a moment to appear** | `GET /im/v1/chats` may return `items: []` for a chat that exists; a retry a few seconds later lists it. Treat one empty page as inconclusive before concluding the bot is in no group. |
|
|
73
|
+
| **p2p is not enumerable** | The chat list excludes p2p conversations, so a pull drain cannot see a direct message (ADR 0024). Group chats are the supported intake shape. |
|
|
74
|
+
| **`/im/v1/chats/:chat_id/link` is 404** | Not every endpoint in older notes exists on the tenant's version; do not build an invite flow on it. |
|
|
75
|
+
| **A version must be published** | Granting scopes without 版本管理与发布 changes nothing at runtime, and the refusal looks identical to "scope not granted". |
|
|
76
|
+
| **Chat anonymity hides history** | A group with 保密模式 or hidden history answers intake with `230050`/`231203` and no item ever arrives — a configuration fact, not a bug. |
|
|
77
|
+
| **Tokens and dedup windows are hours long** | `tenant_access_token` lives 2 h (`.token.json` caches it), so a 1-minute pass is cheap; the reply `uuid` window is 1 h, which is exactly what core's dead-lettering assumes (ADR 0025). |
|
|
78
|
+
|
|
79
|
+
## Switchboard (`.env`, or the environment — the environment wins)
|
|
80
|
+
|
|
81
|
+
- `FEISHU_CHATS` — comma-separated `chat_id` allowlist; empty = every chat.
|
|
82
|
+
- `FEISHU_REQUIRE_MENTION` — `1` reads only messages that @-mention the bot.
|
|
83
|
+
- `FEISHU_LOOKBACK_SECONDS` (3600) / `FEISHU_OVERLAP_SECONDS` (60) /
|
|
84
|
+
`FEISHU_MAX_ITEMS` (40) — first-run window, re-read overlap behind the
|
|
85
|
+
cursor, per-pass cap. The cap never loses messages: the cursor stops at the
|
|
86
|
+
last item printed.
|
|
87
|
+
|
|
88
|
+
## Two independent commands, one platform
|
|
89
|
+
|
|
90
|
+
The pair shares a client file and nothing else. A different platform is a
|
|
91
|
+
second pair of commands and needs nothing from core; a second Feishu app needs
|
|
92
|
+
nothing from this directory but another `.env`.
|