agents-pod 0.0.0-stage → 0.1.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 +55 -0
- package/LICENSE +661 -0
- package/README.md +343 -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 +1512 -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 +155 -0
- package/src/listeners.ts +78 -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/paths.ts +57 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/workspace.ts +124 -0
- package/src/wrapper.ts +484 -0
package/README.md
CHANGED
|
@@ -1,3 +1,344 @@
|
|
|
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.1.0` — first release.** The CLI runs (spawn, wake, cancel,
|
|
8
|
+
> logs, reconcile, listeners, notifications) and the Pi extension ships its two
|
|
9
|
+
> planes. Wake-ups are **Pi only** in this release. Interfaces may still move
|
|
10
|
+
> before `1.0`. The design lives in `CONTEXT.md` and `docs/adr/`; a reader's
|
|
11
|
+
> companion — the design principles and an explained vocabulary — in
|
|
12
|
+
> [docs/DESIGN.md](docs/DESIGN.md).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g agents-pod # CLI; needs Node >= 22.19.0
|
|
18
|
+
pi install npm:agents-pod # Pi extension: /pod and spawn_pod
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
agents-pod spawn "refactor the auth layer — this may take days"
|
|
23
|
+
agents-pod status <podId>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then put one line on a crontab so idle pods get woken (see [Storage](#storage)):
|
|
27
|
+
|
|
28
|
+
```cron
|
|
29
|
+
* * * * * agents-pod reconcile >> "$HOME/.agents/pods/cron.log" 2>&1
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The package ships TypeScript and runs it as-is — no build step, no `dist/`.
|
|
33
|
+
|
|
34
|
+
Sibling of [agents-cron](https://pi.dev/packages/agents-cron): **cron owns when
|
|
35
|
+
work starts; pod owns keeping work alive.** Use agents-cron for recurring short
|
|
36
|
+
tasks ("check CI every 30 minutes"). Use agents-pod for standing work
|
|
37
|
+
("refactor the auth layer — this may take days", "keep watching this incident",
|
|
38
|
+
"serve this inbox — wake an agent per dropped task").
|
|
39
|
+
|
|
40
|
+
## Why a separate plugin
|
|
41
|
+
|
|
42
|
+
agents-cron runs each task as a short, synchronous headless run: the runner
|
|
43
|
+
process waits for the agent to exit while holding a project-wide run lock,
|
|
44
|
+
output is buffered in memory, a 30-minute timeout reclaims stuck runs, and a
|
|
45
|
+
dead run restarts from zero. That model is right for cron-scale work and wrong
|
|
46
|
+
for multi-day agents:
|
|
47
|
+
|
|
48
|
+
- one long run holds the lock for days and starves every other task in its
|
|
49
|
+
project;
|
|
50
|
+
- a reboot (or, on WSL, any Windows restart) orphans the agent and loses the
|
|
51
|
+
work — nothing can watch, cancel, or resume a live run;
|
|
52
|
+
- recurring schedules and long runs are semantically incompatible
|
|
53
|
+
(`nextRunAt` only advances after a run finishes, so "every 30m" silently
|
|
54
|
+
becomes "every 2 days").
|
|
55
|
+
|
|
56
|
+
The decision, its alternatives, and the reuse plan are recorded in
|
|
57
|
+
[ADR 0001](docs/adr/0001-separate-plugin-not-an-agents-cron-extension.md).
|
|
58
|
+
|
|
59
|
+
## Design
|
|
60
|
+
|
|
61
|
+
**No daemon. Time is cron's; lifetime is ours.** Nothing in agents-pod stays
|
|
62
|
+
resident — not even intake: a listener is a record the pass materializes. A
|
|
63
|
+
pod is an OS-detached process tree; supervision is `agents-pod reconcile`, an
|
|
64
|
+
idempotent pass ticked by cron once a minute. Any tick can be lost — the next
|
|
65
|
+
tick sees the same world
|
|
66
|
+
([ADR 0002](docs/adr/0002-no-daemon-detached-pods-and-cron-ticked-reconcile.md)).
|
|
67
|
+
|
|
68
|
+
**A pod outlives its agents.** The pod is the standing unit: one **Goal**,
|
|
69
|
+
one workspace, one **document store**, one supervision identity. Agents are
|
|
70
|
+
workers woken inside it — and a pod accepts different **types and roles** of
|
|
71
|
+
agent (planner, coder, reviewer; pi, claude, codex) across its life
|
|
72
|
+
([ADR 0006](docs/adr/0006-agents-are-woken-pods-outlive-them.md)).
|
|
73
|
+
|
|
74
|
+
**Wake-ups are the context unit; the pod isolates them.** One **wake-up** =
|
|
75
|
+
one agent, one fresh context window: read the pod documents first, work,
|
|
76
|
+
record (handoff, decisions, task state), exit with an outcome. Nothing
|
|
77
|
+
crosses wake-ups except documents — agents in a pod share its documents,
|
|
78
|
+
never one another's windows. Context exhaustion ends a wake-up, never the
|
|
79
|
+
pod; waking again is routine, not recovery. The store's contract is three
|
|
80
|
+
slots, two hard lines ([ADR 0008](docs/adr/0008-document-store-contract.md)).
|
|
81
|
+
|
|
82
|
+
**A wrapper owns each wake-up's liveness.** Every wake-up gets a tiny
|
|
83
|
+
**wrapper** — detached, session and process-group leader — that spawns the
|
|
84
|
+
agent CLI, streams stdout/stderr to the pod log, touches a heartbeat, and
|
|
85
|
+
records the outcome. Between wake-ups the pod is **idle**: a registry fact,
|
|
86
|
+
not a process. The agent runtime never knows it is supervised
|
|
87
|
+
([ADR 0003](docs/adr/0003-wrapper-process-owns-liveness.md), scope narrowed
|
|
88
|
+
by ADR 0006).
|
|
89
|
+
|
|
90
|
+
**The record follows reality, not heuristics.** Liveness is a pid plus a
|
|
91
|
+
heartbeat written by our own wrapper — never "started N minutes ago". A
|
|
92
|
+
record that says active while nothing is alive is a **ghost record**, and
|
|
93
|
+
reconcile marks the wake-up lost and the pod idle. An agent process alive
|
|
94
|
+
with no wrapper is an **orphan**, and reconcile reaps it. Kubernetes
|
|
95
|
+
vocabulary on purpose: desired state, observed state, reconcile.
|
|
96
|
+
|
|
97
|
+
**Standing intake is a record, not a process.** A **listener** is a registry
|
|
98
|
+
entry: an inbox (a directory or file anyone — human, CI, a crontab line —
|
|
99
|
+
may write), a target (a pod template, or a bound standing pod), and bounds.
|
|
100
|
+
Each pass scans inboxes, claims new items by id, and materializes work. A
|
|
101
|
+
pod whose goal is "serve this inbox" is a resident agent with no resident
|
|
102
|
+
process ([ADR 0007](docs/adr/0007-listeners-standing-intake-without-a-daemon.md)).
|
|
103
|
+
|
|
104
|
+
**Cancel and logs are first-class.** `cancel` signals the live wake-up's
|
|
105
|
+
whole process group, then kills it after a grace period; the wrapper records
|
|
106
|
+
the cancellation. `logs --follow` tails the stream the wrappers have been
|
|
107
|
+
writing since spawn — every wake-up, in order, never buffered in memory.
|
|
108
|
+
|
|
109
|
+
**Borrow, don't link.** The agent adapter layer, registry and locking
|
|
110
|
+
patterns, notifications, and the packaging story are copied from agents-cron
|
|
111
|
+
and adapted (spawn returns a pid and streams; nothing buffers). No shared
|
|
112
|
+
package until both plugins demonstrably need one.
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
you (or a Pi session) one pod, standing for days cron, every minute
|
|
116
|
+
────────────────────────── ─────────────────────────────── ───────────────────────
|
|
117
|
+
agents-pod spawn <goal> → Pod: Goal · documents · wake-ups agents-pod reconcile
|
|
118
|
+
agents-pod status | logs │ │ per pod record:
|
|
119
|
+
agents-pod wake <podId> ▼ wake: an agent (type · role) │ active: pid? beat?
|
|
120
|
+
agents-pod cancel Wrapper + agent CLI, fresh window │ idle + goal open?
|
|
121
|
+
├─ streams stdout/stderr → output.log │ → wake next agent
|
|
122
|
+
├─ writes into the document store │ listener inbox:
|
|
123
|
+
└─ outcome → pod idle, or done │ route or spawn
|
|
124
|
+
▼
|
|
125
|
+
no-op · mark lost ·
|
|
126
|
+
┌───────────────────────────────────────────────────────────────┐ reap orphan · wake next
|
|
127
|
+
│ ~/.agents/pods/pods.json pods: state + wake-ups │
|
|
128
|
+
│ ~/.agents/pods/projects.json per-project wake-up caps │
|
|
129
|
+
│ ~/.agents/pods/agents.json named agents: type · role │
|
|
130
|
+
│ ~/.agents/pods/listeners.json listeners + claimed ids │
|
|
131
|
+
│ <project>/.agents/pods/<podId>/ docs · log · wake sessions │
|
|
132
|
+
└───────────────────────────────────────────────────────────────┘
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## CLI
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
agents-pod spawn <goal prompt> [--project <dir>] [--agent <id>] [--model <id>] [--cwd <dir>]
|
|
139
|
+
[--after <podId>] # wait for the upstream pod to reach done
|
|
140
|
+
agents-pod project cap [n|unlimited] [--project <dir>] # per-project live wake-up cap
|
|
141
|
+
agents-pod list [--json]
|
|
142
|
+
agents-pod status <podId>
|
|
143
|
+
agents-pod logs <podId> [--follow]
|
|
144
|
+
agents-pod wake <podId> [--agent <id>] [--warm] # fresh by default; --warm resumes a lost wake-up's session
|
|
145
|
+
agents-pod cancel <podId>
|
|
146
|
+
agents-pod reconcile # the cron entry point; idempotent
|
|
147
|
+
agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instructions <file>]
|
|
148
|
+
agents-pod agent edit <id> # $EDITOR on the stored definition
|
|
149
|
+
agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
|
|
150
|
+
agents-pod agent list [--json] # named agents: type, model, role
|
|
151
|
+
agents-pod listener add --inbox <dir|file> [--pod <podId> | --template <file>]
|
|
152
|
+
[--match <substr>] [--max-concurrent <n>]
|
|
153
|
+
agents-pod listener list [--json]
|
|
154
|
+
agents-pod listener rm <listenerId>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`spawn` claims a pod id, writes the pod record, wakes the first agent, and
|
|
158
|
+
returns immediately with the id. With `--after`, the first wake waits for the
|
|
159
|
+
upstream pod's `done`; a cancelled upstream never releases it. When the
|
|
160
|
+
project's cap is full, the new pod likewise waits idle with its document store
|
|
161
|
+
ready. Manual `wake` overrides park/backoff, never dependencies or capacity.
|
|
162
|
+
Exit codes follow the house rule: `0` success
|
|
163
|
+
or nothing to do, `1` at least one pod failed, `2` configuration or storage
|
|
164
|
+
error.
|
|
165
|
+
|
|
166
|
+
Every goal prompt carries the wake-up protocol: read the pod documents
|
|
167
|
+
first, record before exiting.
|
|
168
|
+
|
|
169
|
+
The Pi extension has two planes, gated by the env the wrapper injects
|
|
170
|
+
([ADR 0010](docs/adr/0010-two-extension-planes.md)). The **management
|
|
171
|
+
plane** (`/pod list|status|logs|spawn|wake|cancel|agents|listeners` and the
|
|
172
|
+
`spawn_pod` tool) registers only in an interactive session: inside a pod
|
|
173
|
+
those tools and commands are not registered at all, so fan-out is
|
|
174
|
+
structurally impossible rather than forbidden. The **self plane** registers
|
|
175
|
+
only inside a pod — `pod_handoff`, which writes `docs/HANDOFF.md` from the
|
|
176
|
+
template with the `status:` first line guaranteed and returns
|
|
177
|
+
`terminate: true`, making write-then-exit one call instead of a ritual the
|
|
178
|
+
agent can skip. The gate is `AGENTS_POD_POD_ID`, with `AGENTS_POD_WAKEUP_N`
|
|
179
|
+
and `AGENTS_POD_DOCS_DIR` beside it; a `Symbol.for` flag keeps a second
|
|
180
|
+
installed copy of the extension inert. Claude and Codex wake-ups are not
|
|
181
|
+
shipped in `0.1.0`, so the file convention is the contract there: the
|
|
182
|
+
tool is a guarantee on top of that contract, never a replacement for it.
|
|
183
|
+
|
|
184
|
+
## Pod lifecycle
|
|
185
|
+
|
|
186
|
+
| State | Meaning | Written by |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| `spawning` | record created, first wake-up not yet confirmed | `spawn` |
|
|
189
|
+
| `active` | a wake-up is live: wrapper alive, heartbeat fresh | wake / reconcile |
|
|
190
|
+
| `idle` | goal open, nothing live; documents current | wrapper / reconcile |
|
|
191
|
+
| `done` | goal reached; terminal | wrapper |
|
|
192
|
+
| `cancelled` | user cancel completed; terminal | wrapper |
|
|
193
|
+
|
|
194
|
+
A pod never exits — its wake-ups do. Wake-up outcomes: `more` (the goal
|
|
195
|
+
needs another wake-up), `done` (goal reached), `failed`, `lost` (crash,
|
|
196
|
+
reboot, orphan), `cancelled`. Outcomes are adjudicated in three layers —
|
|
197
|
+
the agent's HANDOFF.md declaration over the wrapper's event observation
|
|
198
|
+
over OS exit facts — and a clean exit with no fresh handoff is `failed`
|
|
199
|
+
([ADR 0009](docs/adr/0009-wake-up-outcome-classification.md)). The wake
|
|
200
|
+
policy turns outcomes into next actions
|
|
201
|
+
([ADR 0011](docs/adr/0011-minimal-wake-policy.md)): `more` re-wakes
|
|
202
|
+
immediately; `failed` and `lost` back off exponentially (1m ×2, cap 15m,
|
|
203
|
+
one streak, reset on `more`); three consecutive failures park the pod —
|
|
204
|
+
still `idle`, auto-wake suspended — until a human wakes it. Reconcile
|
|
205
|
+
policy, per record:
|
|
206
|
+
|
|
207
|
+
- active + wrapper alive + heartbeat fresh → no-op;
|
|
208
|
+
- active + nothing alive (crash, reboot — boot-time mismatch means lost,
|
|
209
|
+
no pid probed, ADR 0020) → wake-up `lost`, pod → idle
|
|
210
|
+
(death notifications in P1); the wake policy's backoff applies;
|
|
211
|
+
- agent alive but wrapper dead (orphan) → kill the recorded process group,
|
|
212
|
+
wake-up `lost`, pod → idle;
|
|
213
|
+
- idle + goal open + not parked + wake eligible (past backoff) → wake the
|
|
214
|
+
pod's default agent — which agent, which model, after how much backoff,
|
|
215
|
+
is pod-record policy, written by reconcile from outcomes
|
|
216
|
+
([ADR 0011](docs/adr/0011-minimal-wake-policy.md));
|
|
217
|
+
- idle + parked (consecutive-failure stop) → no-op until a human `wake`
|
|
218
|
+
clears it;
|
|
219
|
+
- live but progressless (no document change for 60 min) → killed via the
|
|
220
|
+
cancel path, recorded `failed — progressless`, feeding the streak like
|
|
221
|
+
any failure (ADR 0019, P1);
|
|
222
|
+
- terminal → no-op (terminal is terminal: `wake` refuses; continuation is
|
|
223
|
+
a new pod).
|
|
224
|
+
|
|
225
|
+
After pods, the same pass materializes listeners: scan each inbox, claim new
|
|
226
|
+
items by id (registry-first, so a lost tick cannot double-claim), then
|
|
227
|
+
either spawn a pod from the listener's template or wake the bound standing
|
|
228
|
+
pod — bounded by `--max-concurrent` and the per-project pod cap.
|
|
229
|
+
|
|
230
|
+
An inbox is a directory (one item per top-level non-dot file; claimable once
|
|
231
|
+
its mtime and size hold across two passes) or one append-only JSONL file
|
|
232
|
+
(`body` required; malformed lines skipped with a warning). Item ids are
|
|
233
|
+
filenames or line numbers — never content — claimed per inbox; templates
|
|
234
|
+
bind `{{id}}` `{{title}}` `{{body}}` `{{path}}` (ADR 0013).
|
|
235
|
+
|
|
236
|
+
For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
|
|
237
|
+
section — appended only while no wake-up is live, then one batch, one wake
|
|
238
|
+
on the ordinary wake path; the agent serves entries and flips their
|
|
239
|
+
checkboxes (ADR 0014).
|
|
240
|
+
|
|
241
|
+
Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
|
|
242
|
+
after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
|
|
243
|
+
with exit 0 and steals stale ones with an audit line; correctness lives in
|
|
244
|
+
registry-lock CAS on every state transition, so two passes — or a human
|
|
245
|
+
`wake` and a pass — can never double-wake a pod (ADR 0018).
|
|
246
|
+
|
|
247
|
+
Notifications (P1): two events only — park (with the failure verdict)
|
|
248
|
+
and done — through the copied channels (webhook/email/system) in
|
|
249
|
+
`~/.agents/pods/config.json`; once per episode; individual outcomes
|
|
250
|
+
never notify (ADR 0021).
|
|
251
|
+
|
|
252
|
+
## Storage
|
|
253
|
+
|
|
254
|
+
```text
|
|
255
|
+
~/.agents/pods/
|
|
256
|
+
├── pods.json # global pod registry: definitions + state, 0600
|
|
257
|
+
├── agents.json # named agents: adapter type, model, role instructions, 0600
|
|
258
|
+
├── listeners.json # listeners, bounds, claimed item ids, 0600
|
|
259
|
+
├── inboxes/<id>/ # default listener inboxes; any dir/file may serve instead
|
|
260
|
+
└── registry.lock # short-lived registry write lock, 0600
|
|
261
|
+
|
|
262
|
+
<project>/.agents/pods/
|
|
263
|
+
└── <podId>/
|
|
264
|
+
├── docs/ # HANDOFF.md · DECISIONS.md · TASKS.md — the only cross-wake state (ADR 0008)
|
|
265
|
+
├── output.log # every wake-up, append-only, wake-up delimited; trimmed idle by
|
|
266
|
+
│ # reconcile: cap 10 MB, keep 2 MB, oldest sections dropped (ADR 0016), 0600
|
|
267
|
+
├── heartbeat # touched by the live wake-up's wrapper every 30s; mtime = last beat;
|
|
268
|
+
│ # stale 3m = lost signal, live-but-stale 2 passes = wedged (ADR 0017), 0600
|
|
269
|
+
└── wakeups/<n>/ # per wake-up: events.jsonl, handoff snapshot, session file, resolved agent
|
|
270
|
+
# snapshot (type, model, instructions hash — ADR 0012), outcome detail
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Wake-ups inherit reconcile's (cron) environment unchanged except a
|
|
274
|
+
propped `PATH`; `AGENTS_POD_PI_BIN`-style overrides resolve first; HOME is
|
|
275
|
+
never scrubbed — CLI credentials live there (ssh-agent sockets do not
|
|
276
|
+
survive cron: use HTTPS + credential helper for private repos). No per-pod
|
|
277
|
+
env files (ADR 0015).
|
|
278
|
+
|
|
279
|
+
agents-pod never reads or writes `.agents/cron/`
|
|
280
|
+
([ADR 0004](docs/adr/0004-own-store.md)). Both plugins install side by side,
|
|
281
|
+
and one crontab line can tick both:
|
|
282
|
+
|
|
283
|
+
```cron
|
|
284
|
+
* * * * * AGENTS_POD_PI_BIN=/absolute/path/to/pi agents-cron run-due --all && agents-pod reconcile >> "$HOME/.agents/pods/cron.log" 2>&1
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Non-goals
|
|
288
|
+
|
|
289
|
+
- **No scheduling.** Starting work at a time is agents-cron's job (or a plain
|
|
290
|
+
crontab line calling `spawn`). Pods have no `nextRunAt`; a listener's
|
|
291
|
+
trigger is an event already recorded as data, never a time
|
|
292
|
+
([ADR 0005](docs/adr/0005-boundary-with-agents-cron.md),
|
|
293
|
+
[ADR 0007](docs/adr/0007-listeners-standing-intake-without-a-daemon.md)).
|
|
294
|
+
- **No daemon, no resident watcher.** A standing pod idles as a registry
|
|
295
|
+
record; a listener is materialized by the pass. An HTTP inbox would be a
|
|
296
|
+
daemon — rejected.
|
|
297
|
+
- **No shared context.** Agents in a pod share documents, never windows;
|
|
298
|
+
isolation between agents is the product, not a limitation.
|
|
299
|
+
- **No arbitrary shell supervision.** Wake-ups run agent CLIs through
|
|
300
|
+
adapters — Pi in `0.1.0`, Claude Code and Codex copied next — never raw
|
|
301
|
+
shell commands. An adapter that is not installed is refused at the surface
|
|
302
|
+
and again at launch, never approximated by running Pi in its name.
|
|
303
|
+
- **No in-session orchestration.** Subagents inside a live Pi session are
|
|
304
|
+
[pi-subagents](https://pi.dev)' job; agents-pod supervises OS processes that
|
|
305
|
+
outlive any session.
|
|
306
|
+
|
|
307
|
+
## Roadmap
|
|
308
|
+
|
|
309
|
+
**Shipped in `0.1.0`.** spawn/claim/detach; the idle → wake → outcome loop;
|
|
310
|
+
wrapper per wake-up (heartbeat, streaming, outcome recording); document store
|
|
311
|
+
with the HANDOFF.md convention; named agents; reconcile (ghost records,
|
|
312
|
+
orphans, idle → wake per the policy: backoff after `failed`/`lost`,
|
|
313
|
+
consecutive-failure stop, manual `wake` as the human override); `logs
|
|
314
|
+
--follow`; `cancel`; reboot reconciliation; progress signals with forced
|
|
315
|
+
re-wake of live-but-progressless wake-ups; death notifications (notify layer
|
|
316
|
+
adapted from agents-cron); listeners — inbox directory and JSONL file
|
|
317
|
+
sources, per-item pods from templates, bound standing pods; wake admission
|
|
318
|
+
honouring `--after` dependencies and the per-project cap; the Pi extension's
|
|
319
|
+
two planes (ADR 0010) — `/pod` commands, `spawn_pod`, and `pod_handoff`.
|
|
320
|
+
No default timeout anywhere — liveness is heartbeat-based or it is nothing.
|
|
321
|
+
|
|
322
|
+
**Next.** The Claude Code and Codex adapters (`--type` is refused today, not
|
|
323
|
+
silently downgraded). Default pod expiry and guarded fan-out — the
|
|
324
|
+
load-bearing guardrails for standing pods, shipped only together with
|
|
325
|
+
fan-out. Dashboard (adapted). Richer listener sources (git refs).
|
|
326
|
+
Wake-policy sequences across roles.
|
|
327
|
+
|
|
328
|
+
## License
|
|
329
|
+
|
|
330
|
+
AGPL-3.0-or-later. See [LICENSE](LICENSE). Running agents-pod as a network
|
|
331
|
+
service means the source of that service is available to its users.
|
|
332
|
+
|
|
333
|
+
## Development
|
|
334
|
+
|
|
335
|
+
The packaging story is copied from agents-cron: TypeScript shipped and
|
|
336
|
+
executed as-is (erasable syntax only, `erasableSyntaxOnly` in tsconfig), a bin
|
|
337
|
+
loader hook for files under `node_modules`, `npm run check` = typecheck +
|
|
338
|
+
tests including a packed-tarball smoke test. `src/agents-store.ts` holds the
|
|
339
|
+
named-agent definitions and the adapter vocabulary; the launch itself is
|
|
340
|
+
`src/wrapper.ts` — in `0.1.0` it always runs Pi, and an unsupported `--type`
|
|
341
|
+
is refused at the surface and at launch. The extension lives in
|
|
342
|
+
`src/extension/` (one module per plane, plus `handoff.ts` for the document
|
|
343
|
+
rendering), with `index.mjs` at the package root as Pi's entry so the footer
|
|
344
|
+
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-pod",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agents-pod": "bin/agents-pod.mjs"
|
|
9
|
+
},
|
|
10
|
+
"pi": {
|
|
11
|
+
"extensions": [
|
|
12
|
+
"./index.mjs"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"index.mjs",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.19.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test \"tests/**/*.test.ts\"",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"check": "npm run typecheck && npm test"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
32
|
+
"@types/node": "^24.0.0",
|
|
33
|
+
"typebox": "^1.4.3",
|
|
34
|
+
"typescript": "^5.9.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "*",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"typebox": "*"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { agentsFile } from "./paths.ts";
|
|
5
|
+
|
|
6
|
+
/** Adapter types are types, not agents: every wake-up names an agent (ADR 0012). */
|
|
7
|
+
export type AgentType = "pi" | "claude" | "codex";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A definition is five fields and nothing else in P0 (ADR 0012): id, type,
|
|
11
|
+
* optional model, inline role instructions, optional display name. Role
|
|
12
|
+
* instructions are text, never a live path — a standing pod must not starve
|
|
13
|
+
* because a file moved.
|
|
14
|
+
*/
|
|
15
|
+
export interface AgentDefinition {
|
|
16
|
+
id: string;
|
|
17
|
+
type: AgentType;
|
|
18
|
+
model?: string;
|
|
19
|
+
instructions: string;
|
|
20
|
+
displayName?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `default` is a pre-seeded ordinary row, not a reserved word (ADR 0012): the
|
|
25
|
+
* user may edit or delete it, and a missing row falls back to this built-in.
|
|
26
|
+
*/
|
|
27
|
+
export const BUILTIN_DEFAULT: AgentDefinition = {
|
|
28
|
+
id: "default",
|
|
29
|
+
type: "pi",
|
|
30
|
+
instructions: "",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function isAgentType(value: string): value is AgentType {
|
|
34
|
+
return value === "pi" || value === "claude" || value === "codex";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Adapter coverage is Pi-only in 0.1.0, and the type is a promise the wrapper
|
|
39
|
+
* has to keep: it launches one command line — pi's `--print --mode json
|
|
40
|
+
* --session` — whatever the definition says. Accepting `--type claude` and
|
|
41
|
+
* then running pi would be a silent wrong agent, so the type is refused at the
|
|
42
|
+
* surface and again at launch, where a store written by an older build or by
|
|
43
|
+
* hand cannot slip through (ADR 0023).
|
|
44
|
+
*/
|
|
45
|
+
export const UNSUPPORTED_AGENT_TYPE =
|
|
46
|
+
"0.1.0 wakes pods with pi only: claude and codex adapters land in a later release";
|
|
47
|
+
|
|
48
|
+
export function agentTypeRefusal(type: AgentType): string | undefined {
|
|
49
|
+
return type === "pi" ? undefined : UNSUPPORTED_AGENT_TYPE;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The one resolution rule (ADR 0012): an exact id match in the store; a
|
|
54
|
+
* missing `default` falls back to the built-in; anything else is unknown.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveAgentDefinition(
|
|
57
|
+
agents: AgentDefinition[],
|
|
58
|
+
id: string,
|
|
59
|
+
): AgentDefinition | undefined {
|
|
60
|
+
const found = agents.find((agent) => agent.id === id);
|
|
61
|
+
if (found) return found;
|
|
62
|
+
return id === BUILTIN_DEFAULT.id ? { ...BUILTIN_DEFAULT } : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readAgents(): Promise<AgentDefinition[]> {
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(await readFile(agentsFile(), "utf8"));
|
|
68
|
+
return Array.isArray(parsed) ? (parsed as AgentDefinition[]) : [];
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// A store that has never been written is seeded here — the init-time
|
|
71
|
+
// pre-seed, observable without a separate init command (ADR 0012).
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
73
|
+
const seeded = [{ ...BUILTIN_DEFAULT }];
|
|
74
|
+
await writeAgents(seeded);
|
|
75
|
+
return seeded;
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Atomic 0600 write: a crash mid-write never leaves a half-written store. */
|
|
82
|
+
export async function writeAgents(agents: AgentDefinition[]): Promise<void> {
|
|
83
|
+
const file = agentsFile();
|
|
84
|
+
await mkdir(dirname(file), { recursive: true });
|
|
85
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
86
|
+
await writeFile(tmp, `${JSON.stringify(agents, null, 2)}\n`, { mode: 0o600 });
|
|
87
|
+
await rename(tmp, file);
|
|
88
|
+
}
|