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/README.md CHANGED
@@ -1,3 +1,344 @@
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.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
+ }
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }