agents-pod 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,531 @@
1
- # Temporary Holding Version
1
+ # agents-pod
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Supervision for long-running coding agents: spawn a **pod** — a standing
4
+ unit that outlives every context window — and wake **agents** inside it,
5
+ again and again, each wake-up fresh, documents carrying the work across.
6
+
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
12
+ > before `1.0`. The design lives in `CONTEXT.md` and `docs/adr/`; a reader's
13
+ > companion — the design principles and an explained vocabulary — in
14
+ > [docs/DESIGN.md](docs/DESIGN.md).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g agents-pod # CLI; needs Node >= 22.19.0
20
+ pi install npm:agents-pod # Pi extension: /pod and spawn_pod
21
+ ```
22
+
23
+ ```bash
24
+ agents-pod spawn "refactor the auth layer — this may take days"
25
+ agents-pod status <podId>
26
+ ```
27
+
28
+ Then put one line on a crontab so idle pods get woken (see [Storage](#storage)):
29
+
30
+ ```cron
31
+ * * * * * agents-pod reconcile >> "$HOME/.agents/pods/cron.log" 2>&1
32
+ ```
33
+
34
+ The package ships TypeScript and runs it as-is — no build step, no `dist/`.
35
+
36
+ Sibling of [agents-cron](https://pi.dev/packages/agents-cron): **cron owns when
37
+ work starts; pod owns keeping work alive.** Use agents-cron for recurring short
38
+ tasks ("check CI every 30 minutes"). Use agents-pod for standing work
39
+ ("refactor the auth layer — this may take days", "keep watching this incident",
40
+ "serve this inbox — wake an agent per dropped task").
41
+
42
+ ## Why a separate plugin
43
+
44
+ agents-cron runs each task as a short, synchronous headless run: the runner
45
+ process waits for the agent to exit while holding a project-wide run lock,
46
+ output is buffered in memory, a 30-minute timeout reclaims stuck runs, and a
47
+ dead run restarts from zero. That model is right for cron-scale work and wrong
48
+ for multi-day agents:
49
+
50
+ - one long run holds the lock for days and starves every other task in its
51
+ project;
52
+ - a reboot (or, on WSL, any Windows restart) orphans the agent and loses the
53
+ work — nothing can watch, cancel, or resume a live run;
54
+ - recurring schedules and long runs are semantically incompatible
55
+ (`nextRunAt` only advances after a run finishes, so "every 30m" silently
56
+ becomes "every 2 days").
57
+
58
+ The decision, its alternatives, and the reuse plan are recorded in
59
+ [ADR 0001](docs/adr/0001-separate-plugin-not-an-agents-cron-extension.md).
60
+
61
+ ## Design
62
+
63
+ **No daemon. Time is cron's; lifetime is ours.** Nothing in agents-pod stays
64
+ resident — not even intake: a listener is a record the pass materializes. A
65
+ pod is an OS-detached process tree; supervision is `agents-pod reconcile`, an
66
+ idempotent pass ticked by cron once a minute. Any tick can be lost — the next
67
+ tick sees the same world
68
+ ([ADR 0002](docs/adr/0002-no-daemon-detached-pods-and-cron-ticked-reconcile.md)).
69
+
70
+ **A pod outlives its agents.** The pod is the standing unit: one **Goal**,
71
+ one workspace, one **document store**, one supervision identity. Agents are
72
+ workers woken inside it — and a pod accepts different **types and roles** of
73
+ agent (planner, coder, reviewer; pi, claude, codex) across its life
74
+ ([ADR 0006](docs/adr/0006-agents-are-woken-pods-outlive-them.md)).
75
+
76
+ **Wake-ups are the context unit; the pod isolates them.** One **wake-up** =
77
+ one agent, one fresh context window: read the pod documents first, work,
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)).
83
+
84
+ **A wrapper owns each wake-up's liveness.** Every wake-up gets a tiny
85
+ **wrapper** — detached, session and process-group leader — that spawns the
86
+ agent CLI, streams stdout/stderr to the pod log, touches a heartbeat, and
87
+ records the outcome. Between wake-ups the pod is **idle**: a registry fact,
88
+ not a process. The agent runtime never knows it is supervised
89
+ ([ADR 0003](docs/adr/0003-wrapper-process-owns-liveness.md), scope narrowed
90
+ by ADR 0006).
91
+
92
+ **The record follows reality, not heuristics.** Liveness is a pid plus a
93
+ heartbeat written by our own wrapper — never "started N minutes ago". A
94
+ record that says active while nothing is alive is a **ghost record**, and
95
+ reconcile marks the wake-up lost and the pod idle. An agent process alive
96
+ with no wrapper is an **orphan**, and reconcile reaps it. Kubernetes
97
+ vocabulary on purpose: desired state, observed state, reconcile.
98
+
99
+ **Standing intake is a record, not a process.** A **listener** is a registry
100
+ entry: an inbox (a directory or file anyone — human, CI, a crontab line —
101
+ may write), a target (a pod template, or a bound standing pod), and bounds.
102
+ Each pass scans inboxes, claims new items by id, and materializes work. A
103
+ pod whose goal is "serve this inbox" is a resident agent with no resident
104
+ process ([ADR 0007](docs/adr/0007-listeners-standing-intake-without-a-daemon.md)).
105
+
106
+ **Cancel and logs are first-class.** `cancel` signals the live wake-up's
107
+ whole process group, then kills it after a grace period; the wrapper records
108
+ the cancellation. `logs --follow` tails the stream the wrappers have been
109
+ writing since spawn — every wake-up, in order, never buffered in memory.
110
+
111
+ **Borrow, don't link.** The agent adapter layer, registry and locking
112
+ patterns, notifications, and the packaging story are copied from agents-cron
113
+ and adapted (spawn returns a pid and streams; nothing buffers). No shared
114
+ package until both plugins demonstrably need one.
115
+
116
+ ```text
117
+ you (or a Pi session) one pod, standing for days cron, every minute
118
+ ────────────────────────── ─────────────────────────────── ───────────────────────
119
+ agents-pod spawn <goal> → Pod: Goal · documents · wake-ups agents-pod reconcile
120
+ agents-pod status | logs │ │ per pod record:
121
+ agents-pod wake <podId> ▼ wake: an agent (type · role) │ active: pid? beat?
122
+ agents-pod cancel Wrapper + agent CLI, fresh window │ idle + goal open?
123
+ ├─ streams stdout/stderr → output.log │ → wake next agent
124
+ ├─ writes into the document store │ listener inbox:
125
+ └─ outcome → pod idle, or done │ route or spawn
126
+ ▼
127
+ no-op · mark lost ·
128
+ ┌───────────────────────────────────────────────────────────────┐ reap orphan · wake next
129
+ │ ~/.agents/pods/pods.json pods: state + wake-ups │
130
+ │ ~/.agents/pods/projects.json per-project wake-up caps │
131
+ │ ~/.agents/pods/agents.json named agents: type · role │
132
+ │ ~/.agents/pods/listeners.json listeners + claimed ids │
133
+ │ <project>/.agents/pods/<podId>/ docs · log · wake sessions │
134
+ └───────────────────────────────────────────────────────────────┘
135
+ ```
136
+
137
+ ## CLI
138
+
139
+ ```text
140
+ agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
141
+ [--after <podId>] # wait for the upstream pod to reach done
142
+ agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
143
+ agents-pod list [--json]
144
+ agents-pod status <podId>
145
+ agents-pod logs <podId> [--follow]
146
+ agents-pod wake <podId> [--agent <id>] [--warm] # fresh by default; --warm resumes a lost wake-up's session
147
+ agents-pod cancel <podId>
148
+ agents-pod reconcile # the cron entry point; idempotent
149
+ agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
150
+ agents-pod agent edit <id> # $EDITOR on the stored definition
151
+ agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
152
+ agents-pod agent list [--json] # named agents: type, model, role
153
+ agents-pod listener add (--inbox <dir|file> | --source <name>) (--pod <podId> | --template <file>)
154
+ [--match <substr>] [--max-concurrent <n>] [--project <dir>]
155
+ agents-pod listener list [--json]
156
+ agents-pod listener rm <listenerId>
157
+ agents-pod source add <name> --cmd <path> [--timeout <s>]
158
+ agents-pod source list [--json]
159
+ agents-pod source rm <name>
160
+ agents-pod deliver add <name> --cmd <path> [--timeout <s>]
161
+ agents-pod deliver list [--json]
162
+ agents-pod deliver rm <name>
163
+ ```
164
+
165
+ `spawn` claims a pod id, writes the pod record, wakes the first agent, and
166
+ returns immediately with the id. With `--after`, the first wake waits for the
167
+ upstream pod's `done`; a cancelled upstream never releases it. When the
168
+ project's cap is full, the new pod likewise waits idle with its document store
169
+ ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
170
+ Exit codes follow the house rule: `0` success
171
+ or nothing to do, `1` at least one pod failed, `2` configuration or storage
172
+ error.
173
+
174
+ Every goal prompt carries the wake-up protocol: read the pod documents
175
+ first, record before exiting.
176
+
177
+ The Pi extension has two planes, gated by the env the wrapper injects
178
+ ([ADR 0010](docs/adr/0010-two-extension-planes.md)). The **management
179
+ plane** (`/pod list|status|logs|spawn|wake|cancel|agents|listeners` and the
180
+ `spawn_pod` tool) registers only in an interactive session: inside a pod
181
+ those tools and commands are not registered at all, so fan-out is
182
+ structurally impossible rather than forbidden. The **self plane** registers
183
+ only inside a pod — `pod_handoff`, which writes `docs/HANDOFF.md` from the
184
+ template with the `status:` first line guaranteed and returns
185
+ `terminate: true`, making write-then-exit one call instead of a ritual the
186
+ agent can skip. The gate is `AGENTS_POD_POD_ID`, with `AGENTS_POD_WAKEUP_N`
187
+ and `AGENTS_POD_DOCS_DIR` beside it; a `Symbol.for` flag keeps a second
188
+ installed copy of the extension inert. Claude and Codex wake-ups are not
189
+ shipped in `0.1.0`, so the file convention is the contract there: the
190
+ tool is a guarantee on top of that contract, never a replacement for it.
191
+
192
+ ## Pod lifecycle
193
+
194
+ | State | Meaning | Written by |
195
+ | --- | --- | --- |
196
+ | `spawning` | record created, first wake-up not yet confirmed | `spawn` |
197
+ | `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
198
+ | `idle` | goal open, nothing live; documents current | wrapper / reconcile |
199
+ | `done` | goal reached; terminal | wrapper |
200
+ | `cancelled` | user cancel completed; terminal | wrapper |
201
+
202
+ A pod never exits — its wake-ups do. Wake-up outcomes: `more` (the goal
203
+ needs another wake-up), `done` (goal reached), `failed`, `lost` (crash,
204
+ reboot, orphan), `cancelled`. Outcomes are adjudicated in three layers —
205
+ the agent's HANDOFF.md declaration over the wrapper's event observation
206
+ over OS exit facts — and a clean exit with no fresh handoff is `failed`
207
+ ([ADR 0009](docs/adr/0009-wake-up-outcome-classification.md)). The wake
208
+ policy turns outcomes into next actions
209
+ ([ADR 0011](docs/adr/0011-minimal-wake-policy.md)): `more` re-wakes
210
+ immediately; `failed` and `lost` back off exponentially (1m ×2, cap 15m,
211
+ one streak, reset on `more`); three consecutive failures park the pod —
212
+ still `idle`, auto-wake suspended — until a human wakes it. Reconcile
213
+ policy, per record:
214
+
215
+ - active + wrapper alive + heartbeat fresh → no-op;
216
+ - active + nothing alive (crash, reboot — boot-time mismatch means lost,
217
+ no pid probed, ADR 0020) → wake-up `lost`, pod → idle
218
+ (death notifications in P1); the wake policy's backoff applies;
219
+ - agent alive but wrapper dead (orphan) → kill the recorded process group,
220
+ wake-up `lost`, pod → idle;
221
+ - idle + goal open + not parked + wake eligible (past backoff) → wake the
222
+ pod's default agent — which agent, which model, after how much backoff,
223
+ is pod-record policy, written by reconcile from outcomes
224
+ ([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
225
+ - idle + parked (consecutive-failure stop) → no-op until a human `wake`
226
+ clears it;
227
+ - live but progressless (no document change for 60 min) → killed via the
228
+ cancel path, recorded `failed — progressless`, feeding the streak like
229
+ any failure (ADR 0019, P1);
230
+ - terminal → no-op (terminal is terminal: `wake` refuses; continuation is
231
+ a new pod).
232
+
233
+ After pods, the same pass materializes listeners: use each Source's drained
234
+ items or scan each inbox, claim new items by id (registry-first, so a lost tick
235
+ cannot double-claim), then either spawn a pod from the listener's template —
236
+ bounded by `--max-concurrent` and the per-project pod cap — or append a batch
237
+ to the bound standing pod and use the ordinary capacity-checked wake path.
238
+
239
+ An inbox is a directory (one item per top-level non-dot file; claimable once
240
+ its mtime and size hold across two passes) or one append-only JSONL file
241
+ (`body` required, `title` optional; malformed lines skipped with a warning).
242
+ A JSONL line may also carry the routing fields `chat_id`, `root_id` and
243
+ `thread_id`; any other key is ignored. Item ids are filenames or line
244
+ numbers — never content — claimed per inbox; templates bind five
245
+ placeholders: `{{id}}` `{{title}}` `{{body}}` `{{path}}` and `{{reply_to}}`.
246
+ The last placeholder carries the Delivery Address consumed by the outbound
247
+ contract below (ADRs 0013, 0026).
248
+
249
+ For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
250
+ section — below the agent-owned `## Waiting`, and appended only while no
251
+ wake-up is live, then one batch, one wake on the ordinary wake path; the
252
+ agent serves entries and flips their checkboxes. A block with a Delivery
253
+ Address carries ` — to: <Delivery Address>` on its checkbox line, never in
254
+ the copied body (ADRs 0014, 0026).
255
+
256
+ Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
257
+ after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
258
+ with exit 0 and steals stale ones with an audit line; correctness lives in
259
+ registry-lock CAS on every state transition, so two passes — or a human
260
+ `wake` and a pass — can never double-wake a pod (ADR 0018).
261
+
262
+ Notifications (P1): two events only — park (with the failure verdict)
263
+ and done — through the copied channels (webhook/email/system) in
264
+ `~/.agents/pods/config.json`; once per episode; individual outcomes
265
+ never notify (ADR 0021).
266
+
267
+ ## IM intake and outbound
268
+
269
+ The operator owns platform I/O. A **Source** is an executable pull drain; the
270
+ single **deliverer** is an executable push command. Source definitions and
271
+ their committed cursors live in `~/.agents/pods/sources.json`; Listener claims
272
+ live in `~/.agents/pods/listeners.json`; the deliverer lives in
273
+ `~/.agents/pods/deliverer.json`. `source add` and `deliver add` resolve `--cmd`
274
+ against the add command's cwd and require an executable regular file.
275
+ `--timeout` is a positive number of seconds, default `10`. Their add/list/rm
276
+ commands return `0` on success; invalid arguments, an unknown or duplicate
277
+ name, removing an in-use Source, or adding a second deliverer returns `2`.
278
+
279
+ `listener add --source <name>` takes either `--template <file>` or
280
+ `--pod <podId>`. With `--template`, `--project <dir>` selects the new Pods'
281
+ project and defaults to the reconcile invocation's cwd; with `--pod` it is a
282
+ configuration error (exit `2`), because the bound Pod keeps its own project.
283
+
284
+ ### Writing a Source drain
285
+
286
+ Each reconcile pass invokes every distinct Source consumed by a Listener once,
287
+ before any Pod work and before taking the registry lock. The command receives
288
+ no arguments. Its cwd is the reconcile invocation's project (its cwd, not a
289
+ Listener's `--project`), and it inherits that invocation's environment plus:
290
+
291
+ - `AGENTS_POD_SOURCE_ID` — the registered Source name;
292
+ - `AGENTS_POD_CURSOR` — the last committed cursor, or the empty string when no
293
+ cursor has been committed.
294
+
295
+ The command writes JSONL to stdout; stdin is closed and stderr is ignored. A
296
+ successful stream contains zero or more items followed by an optional cursor
297
+ as the last non-empty line:
298
+
299
+ ```jsonl
300
+ {"id":"message-123","body":"Please review this","title":"Review request","chat_id":"chat-1","root_id":"root-1","thread_id":"thread-1"}
301
+ {"cursor":"next-page-token"}
302
+ ```
303
+
304
+ - `id` is a non-empty string and `body` is a string; both are required. The id
305
+ is the platform's stable deduplication key.
306
+ - `title` is optional. A missing or blank title defaults to the trimmed first
307
+ line of `body`.
308
+ - `chat_id`, `root_id` and `thread_id` are optional routing strings. Non-empty
309
+ values are trimmed; blank or non-string values are ignored. They feed
310
+ `{{reply_to}}`. Source items have no filesystem path, so `{{path}}` is empty.
311
+ Unknown keys are ignored.
312
+ - The final `{"cursor":"…"}` line takes a string. Omitting it leaves the
313
+ stored cursor unchanged. Blank lines are ignored. A malformed JSON line, an
314
+ invalid item, or an invalid final cursor is skipped with one
315
+ `~/.agents/pods/cron.log` warning and is not claimed; other valid lines still
316
+ count.
317
+
318
+ Exit `0` commits the successful drain. Claims use `source:<name>` as their
319
+ inbox namespace and the item's platform id, shared by every Listener on that
320
+ Source. Claims are written to `listeners.json` before the Source's cursor is
321
+ atomically written to `sources.json`, so a crash between those writes replays
322
+ into id deduplication. A committed drain also advances the record's
323
+ `lastDrainAt`; claims from the current and previous successful drain
324
+ generations are retained and older timestamped claims are pruned. Claims with
325
+ no generation timestamp are retained. For a Source bound to a standing Pod,
326
+ the claimed batch must also be appended to `TASKS.md` before cursor and
327
+ `lastDrainAt` commit; a live wake-up holds that append, so the next pass drains
328
+ from the unchanged cursor again.
329
+
330
+ A non-zero exit, termination by a signal, spawn failure, timeout, or total
331
+ stdout larger than **1 MiB** abandons the whole drain: no items are claimed,
332
+ neither cursor nor `lastDrainAt` moves, no claims are pruned, and exactly one
333
+ `cron.log` line records the failure. Reconcile still exits `0`; the next pass
334
+ retries the whole drain from the same cursor (ADR 0024).
335
+
336
+ ### Writing the deliverer
337
+
338
+ Exactly one deliverer may be configured; remove it before adding a replacement.
339
+ A Wake-up writes one JSON file per outgoing message in its own Pod:
340
+
341
+ ```text
342
+ <project>/.agents/pods/<podId>/outbox/<wakeup>-<seq>-<name>.json
343
+ ```
344
+
345
+ `<wakeup>` is the positive integer wake-up number, `<seq>` is a zero-padded
346
+ increasing sequence such as `001`, `002`, and `<name>` is a filename-safe slug.
347
+ Files are delivered serially in filename order. Each file contains a JSON
348
+ object with a non-empty string `to` and a string `text`:
349
+
350
+ ```json
351
+ {"to":"<Delivery Address>","text":"The reply text"}
352
+ ```
353
+
354
+ `to` is the **Delivery Address** rendered by `{{reply_to}}` or carried on a
355
+ standing Pod's Inbox checkbox line. The worker copies it verbatim; the
356
+ deliverer parses it; core never reads it back. Its shape is:
357
+
358
+ ```text
359
+ chat=<chat_id>;affinity=<root_id ?? item id>[;thread=<thread_id>]
360
+ ```
361
+
362
+ An item with no `chat_id` renders the empty string, which is not a valid `to`
363
+ and cannot be delivered. When `thread` is present, the deliverer can use it to
364
+ reply in that thread.
365
+
366
+ The Wrapper invokes the deliverer once per current-wake-up entry after the
367
+ agent exits and before the Pod becomes idle. Reconcile retries entries for
368
+ every non-active Pod, outside the registry lock. Each invocation receives the
369
+ entry file's bytes verbatim on stdin, takes no arguments, runs with the Pod's
370
+ project as cwd, and ignores stdout and stderr. It inherits the operator
371
+ environment plus exactly these core-provided values:
372
+
373
+ - `AGENTS_POD_POD_ID` — the owning Pod id;
374
+ - `AGENTS_POD_WAKEUP_N` — the wake-up number parsed from the filename;
375
+ - `AGENTS_POD_OUTBOX_ENTRY` — the entry filename, not its full path;
376
+ - `AGENTS_POD_OUTBOX_UUID` — the 50-character lowercase hexadecimal idempotency
377
+ key derived from Pod id, wake-up number, and filename. Pass this same value
378
+ to the platform on every retry.
379
+
380
+ One flush per Pod makes at most **10 delivery attempts** within a shared
381
+ **10-second budget**, in filename order. The configured per-invocation timeout
382
+ defaults to 10 seconds and is additionally capped by the flush's remaining
383
+ budget. Entries left beyond either bound wait for a later pass.
384
+
385
+ Exit `0` is the only acknowledgement: core deletes the entry and writes no
386
+ completion marker. A non-zero exit, signal, spawn failure, or timeout leaves
387
+ the file unchanged, writes one `cron.log` line, and lets the next pass retry
388
+ with the same uuid. Delivery failure does not change the Wake-up outcome,
389
+ failure streak, or notifications.
390
+
391
+ An entry whose mtime is more than **one hour** old is not delivered: core
392
+ renames it by appending `.dead`, logs one line, and keeps its contents
393
+ unchanged. A malformed entry, a filename with no valid leading wake-up number,
394
+ or a future-wake-up entry seen by a Wrapper is likewise dead-lettered on first
395
+ sight. `*.dead` files are never scanned again or automatically removed. With
396
+ no deliverer configured, the Wrapper flush is a no-op; each reconcile pass
397
+ writes one `cron.log` line per Pod with waiting entries, and the same one-hour
398
+ aging still applies (ADR 0025).
399
+
400
+ ### Role Instructions for replying agents
401
+
402
+ Save the following as the replying Agent's instructions file and pass it to
403
+ `agent add --instructions <file>`:
404
+
405
+ ```text
406
+ When you reply to a Conversation, write one JSON file per reply into your own
407
+ Pod's outbox at .agents/pods/$AGENTS_POD_POD_ID/outbox/ under the Pod project.
408
+ Name each file $AGENTS_POD_WAKEUP_N-<seq>-<name>.json, using a zero-padded
409
+ increasing <seq> (001, 002, ...) and a filename-safe <name>. Write
410
+ {"to":"<Delivery Address>","text":"<reply>"}; copy the address verbatim from
411
+ Reply-To in the Goal or from "to:" on the item's TASKS.md Inbox checkbox line.
412
+ Do not write an entry when that address is empty.
413
+
414
+ If you ask a question in a thread, reply in that same thread by reusing its
415
+ Delivery Address, including the thread component. Ask at most two clarification
416
+ rounds per uncertainty; then proceed on a stated assumption and record that
417
+ assumption in HANDOFF.md.
418
+
419
+ Core deletes an entry only after the deliverer exits 0. A failed delivery keeps
420
+ the file, logs one line, and is retried on the next pass with the same uuid; an
421
+ entry older than one hour is renamed with a .dead suffix and kept. Do not
422
+ rename, delete, or rewrite pending or dead entries yourself.
423
+ ```
424
+
425
+ ## Storage
426
+
427
+ ```text
428
+ ~/.agents/pods/
429
+ ├── pods.json # global pod registry: definitions + state, 0600
430
+ ├── agents.json # named agents: adapter type, model, role instructions, 0600
431
+ ├── listeners.json # listeners, bounds, claimed item ids, 0600
432
+ ├── sources.json # named drain commands, committed cursors, lastDrainAt
433
+ ├── deliverer.json # the single outbound delivery command
434
+ ├── inboxes/<id>/ # default listener inboxes; any dir/file may serve instead
435
+ └── registry.lock # short-lived registry write lock, 0600
436
+
437
+ <project>/.agents/pods/
438
+ └── <podId>/
439
+ ├── docs/ # HANDOFF.md · DECISIONS.md · TASKS.md — cross-wake work memory (ADR 0008)
440
+ ├── outbox/ # pending <wakeup>-<seq>-<name>.json replies and retained *.dead entries
441
+ ├── output.log # every wake-up, append-only, wake-up delimited; trimmed idle by
442
+ │ # reconcile: cap 10 MB, keep 2 MB, oldest sections dropped (ADR 0016), 0600
443
+ ├── heartbeat # touched by the live wake-up's wrapper every 30s; mtime = last beat;
444
+ │ # stale 3m = lost signal, live-but-stale 2 passes = wedged (ADR 0017), 0600
445
+ └── wakeups/<n>/ # per wake-up: events.jsonl, handoff snapshot, session file, resolved agent
446
+ # snapshot (type, model, instructions hash — ADR 0012), outcome detail
447
+ ```
448
+
449
+ Wake-ups inherit reconcile's (cron) environment unchanged except a
450
+ propped `PATH`; `AGENTS_POD_PI_BIN`-style overrides resolve first; HOME is
451
+ never scrubbed — CLI credentials live there (ssh-agent sockets do not
452
+ survive cron: use HTTPS + credential helper for private repos). No per-pod
453
+ env files (ADR 0015).
454
+
455
+ agents-pod never reads or writes `.agents/cron/`
456
+ ([ADR 0004](docs/adr/0004-own-store.md)). Both plugins install side by side,
457
+ and one crontab line can tick both:
458
+
459
+ ```cron
460
+ * * * * * AGENTS_POD_PI_BIN=/absolute/path/to/pi agents-cron run-due --all && agents-pod reconcile >> "$HOME/.agents/pods/cron.log" 2>&1
461
+ ```
462
+
463
+ ## Non-goals
464
+
465
+ - **No scheduling.** Starting work at a time is agents-cron's job (or a plain
466
+ crontab line calling `spawn`). Pods have no `nextRunAt`; a listener's
467
+ trigger is an event already recorded as data, never a time
468
+ ([ADR 0005](docs/adr/0005-boundary-with-agents-cron.md),
469
+ [ADR 0007](docs/adr/0007-listeners-standing-intake-without-a-daemon.md)).
470
+ - **No daemon, no resident watcher.** A standing pod idles as a registry
471
+ record; a listener is materialized by the pass. An HTTP inbox would be a
472
+ daemon — rejected.
473
+ - **No shared context.** Agents in a pod share documents, never windows;
474
+ isolation between agents is the product, not a limitation.
475
+ - **No arbitrary shell supervision.** Wake-ups run agent CLIs through
476
+ adapters — Pi in `0.1.0`, Claude Code and Codex copied next — never raw
477
+ shell commands. An adapter that is not installed is refused at the surface
478
+ and again at launch, never approximated by running Pi in its name.
479
+ - **No in-session orchestration.** Subagents inside a live Pi session are
480
+ [pi-subagents](https://pi.dev)' job; agents-pod supervises OS processes that
481
+ outlive any session.
482
+
483
+ ## Roadmap
484
+
485
+ **Shipped in `0.1.0`.** spawn/claim/detach; the idle → wake → outcome loop;
486
+ wrapper per wake-up (heartbeat, streaming, outcome recording); document store
487
+ with the HANDOFF.md convention; named agents; reconcile (ghost records,
488
+ orphans, idle → wake per the policy: backoff after `failed`/`lost`,
489
+ consecutive-failure stop, manual `wake` as the human override); `logs
490
+ --follow`; `cancel`; reboot reconciliation; progress signals with forced
491
+ re-wake of live-but-progressless wake-ups; death notifications (notify layer
492
+ adapted from agents-cron); listeners — inbox directory and JSONL file
493
+ sources, per-item pods from templates, bound standing pods; wake admission
494
+ honouring `--after` dependencies and the per-project cap; the Pi extension's
495
+ two planes (ADR 0010) — `/pod` commands, `spawn_pod`, and `pod_handoff`.
496
+ No default Wake-up timeout — liveness is heartbeat-based or it is nothing.
497
+
498
+ **Shipped in `0.2.0`.** Chat as an intake and an outlet, both as operator
499
+ commands rather than a daemon: sources (`source add|list|rm`) drained once per
500
+ pass with a committed cursor and claim-before-cursor ordering; source-fed
501
+ Listeners, batched `## Inbox` delivery to a standing pod, and `## Waiting` above
502
+ it; outbox delivery (`deliver add|list|rm`) at wake-up exit and in every pass,
503
+ with stable uuids, one-hour dead-lettering and retained `*.dead` entries; the
504
+ Affinity Key and the `{{reply_to}}` Delivery Address riding the Goal or an Inbox
505
+ line; `listener add --project` for tuning intake and workers apart. A wrapper's
506
+ outcome is now recorded under the registry lock, so a concurrent pass or wrapper
507
+ cannot overwrite it and force the same wake-up to run twice (ADR 0018).
508
+
509
+ **Next.** The Claude Code and Codex adapters (`--type` is refused today, not
510
+ silently downgraded). Default pod expiry and guarded fan-out — the
511
+ load-bearing guardrails for standing pods, shipped only together with
512
+ fan-out. Dashboard (adapted). Richer listener sources (git refs).
513
+ Wake-policy sequences across roles.
514
+
515
+ ## License
516
+
517
+ AGPL-3.0-or-later. See [LICENSE](LICENSE). Running agents-pod as a network
518
+ service means the source of that service is available to its users.
519
+
520
+ ## Development
521
+
522
+ The packaging story is copied from agents-cron: TypeScript shipped and
523
+ executed as-is (erasable syntax only, `erasableSyntaxOnly` in tsconfig), a bin
524
+ loader hook for files under `node_modules`, `npm run check` = typecheck +
525
+ tests including a packed-tarball smoke test. `src/agents-store.ts` holds the
526
+ named-agent definitions and the adapter vocabulary; the launch itself is
527
+ `src/wrapper.ts` — in `0.1.0` it always runs Pi, and an unsupported `--type`
528
+ is refused at the surface and at launch. The extension lives in
529
+ `src/extension/` (one module per plane, plus `handoff.ts` for the document
530
+ rendering), with `index.mjs` at the package root as Pi's entry so the footer
531
+ label stays `agents-pod`.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point. Cron invokes this with a bare `node`, and the CLI itself is
4
+ * TypeScript, so register the stripping loader before importing it: Node will
5
+ * not strip types for files under `node_modules`, where an installed package
6
+ * lives. There is no build and no `dist/`; the published package ships the
7
+ * sources it runs.
8
+ */
9
+ import { register } from "node:module";
10
+
11
+ register("./ts-loader.mjs", import.meta.url);
12
+
13
+ const { main } = await import("../src/cli.ts");
14
+
15
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Type-stripping loader for this package's own `.ts` sources.
3
+ *
4
+ * Node strips types for `.ts` files everywhere except under `node_modules`,
5
+ * which is exactly where an installed package lives. Pi's extension loader
6
+ * does not care (it compiles TypeScript itself through jiti), but the CLI
7
+ * runs under a bare `node` from cron, so it needs this hook to run the same
8
+ * sources.
9
+ *
10
+ * Two rules keep this hook polite:
11
+ *
12
+ * - It only touches `.ts` files inside this package's own directory, resolved
13
+ * through the symlink a package manager may put in front of `bin/`. Every
14
+ * other specifier — including any other package's TypeScript — falls through
15
+ * to Node and to whatever hooks the host process registered.
16
+ * - Stripping is `mode: "strip"`, so the sources must stay erasable;
17
+ * `erasableSyntaxOnly` in `tsconfig.json` enforces that at typecheck time,
18
+ * which is what keeps this loader from being a runtime surprise.
19
+ */
20
+ import { readFile, realpath } from "node:fs/promises";
21
+ import { stripTypeScriptTypes } from "node:module";
22
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
+
24
+ // This package's root directory as a `file:` URL with a trailing slash, so a
25
+ // plain startsWith() decides whether a URL belongs to us.
26
+ const packageRoot = `${pathToFileURL(
27
+ await realpath(fileURLToPath(new URL("../", import.meta.url))),
28
+ ).href}/`;
29
+
30
+ /**
31
+ * `stripTypeScriptTypes` is experimental and warns on first use. The warning
32
+ * would come from this hook, so mute exactly that one and only while
33
+ * stripping — harnesses that spawn this CLI read its stderr, and a stray
34
+ * ExperimentalWarning reads like a failure. Everything else keeps Node's
35
+ * default treatment, and the override never outlives the call.
36
+ */
37
+ function strip(source, url) {
38
+ const emitWarning = process.emitWarning;
39
+ try {
40
+ process.emitWarning = (warning, ...rest) =>
41
+ String(warning).includes("stripTypeScriptTypes")
42
+ ? undefined
43
+ : emitWarning.call(process, warning, ...rest);
44
+ return stripTypeScriptTypes(source, { mode: "strip", sourceUrl: url });
45
+ } finally {
46
+ process.emitWarning = emitWarning;
47
+ }
48
+ }
49
+
50
+ /** @type {import("node:module").LoadHook} */
51
+ export async function load(url, context, nextLoad) {
52
+ if (
53
+ !url.startsWith("file:") ||
54
+ !url.endsWith(".ts") ||
55
+ !url.startsWith(packageRoot)
56
+ ) {
57
+ return nextLoad(url, context);
58
+ }
59
+
60
+ const source = await readFile(new URL(url), "utf8");
61
+ return {
62
+ format: "module",
63
+ source: strip(source, url),
64
+ shortCircuit: true,
65
+ };
66
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Wrapper entry point: same loader trick as the CLI — the wrapper runs under a
4
+ * bare `node`, detached by the CLI, and its sources are TypeScript.
5
+ */
6
+ import { register } from "node:module";
7
+
8
+ register("./ts-loader.mjs", import.meta.url);
9
+
10
+ const { runWrapper } = await import("../src/wrapper.ts");
11
+
12
+ process.exit(await runWrapper(process.argv.slice(2)));
package/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Pi loads this file as the package's extension entry (package.json `pi`).
3
+ * The entry file must be named `index.*` at the package root: pi's compact
4
+ * label logic only drops the file path when the entry basename is `index`,
5
+ * otherwise the footer shows `agents-pod:extension.mjs` instead of just
6
+ * `agents-pod`. The planes themselves live in `src/extension/`; this file only
7
+ * re-exports them.
8
+ */
9
+ import extension from "./src/extension/index.ts";
10
+
11
+ export default extension;