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/CHANGELOG.md +83 -0
- package/LICENSE +661 -0
- package/README.md +530 -2
- package/bin/agents-pod.mjs +15 -0
- package/bin/ts-loader.mjs +66 -0
- package/bin/wrapper.mjs +12 -0
- package/index.mjs +11 -0
- package/package.json +39 -4
- package/src/agents-store.ts +88 -0
- package/src/cli.ts +1864 -0
- package/src/extension/handoff.ts +57 -0
- package/src/extension/index.ts +34 -0
- package/src/extension/management.ts +149 -0
- package/src/extension/self.ts +80 -0
- package/src/inbox.ts +221 -0
- package/src/listeners.ts +84 -0
- package/src/liveness.ts +191 -0
- package/src/locks.ts +74 -0
- package/src/notify/config.ts +125 -0
- package/src/notify/dispatch.ts +63 -0
- package/src/notify/email.ts +72 -0
- package/src/notify/format.ts +42 -0
- package/src/notify/system.ts +79 -0
- package/src/notify/types.ts +96 -0
- package/src/notify/webhook.ts +39 -0
- package/src/outbox.ts +294 -0
- package/src/paths.ts +67 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +131 -0
- package/src/wrapper.ts +533 -0
package/README.md
CHANGED
|
@@ -1,3 +1,531 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agents-pod
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/bin/wrapper.mjs
ADDED
|
@@ -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;
|