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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The Handoff document, rendered once and written once (ADR 0008, 0010).
3
+ *
4
+ * The only machine surface is the first line — `status: rotating` or
5
+ * `status: done` — because outcome classification greps it and the wrapper's
6
+ * `freshDeclaration()` refuses anything else. Everything after it is free
7
+ * markdown for the successor, so the sections are the ones CONTEXT.md names:
8
+ * goal restated, decisions and reasons, done, next, pointers.
9
+ */
10
+ export type HandoffStatus = "rotating" | "done";
11
+
12
+ export interface HandoffInput {
13
+ status: HandoffStatus;
14
+ /** The goal as this wake-up understood it, not as the registry spelled it. */
15
+ goal?: string;
16
+ /** Why, one line each — the part a successor cannot re-derive. */
17
+ decisions?: string[];
18
+ done?: string[];
19
+ next?: string[];
20
+ /** Files, commands, links worth opening first. */
21
+ pointers?: string[];
22
+ }
23
+
24
+ function bulletList(items: string[] | undefined): string[] {
25
+ const lines = (items ?? []).map((item) => item.trim()).filter((item) => item.length > 0);
26
+ return lines.map((item) => (item.startsWith("- ") ? item : `- ${item}`));
27
+ }
28
+
29
+ function section(heading: string, items: string[] | undefined, empty?: string): string[] {
30
+ const lines = bulletList(items);
31
+ if (lines.length === 0) return empty === undefined ? [] : [heading, "", empty, ""];
32
+ return [heading, "", ...lines, ""];
33
+ }
34
+
35
+ /**
36
+ * The hard line first, always: an agent that passes nothing but a status still
37
+ * produces a legal (if thin) handoff, and the successor is told so rather than
38
+ * waking up to a half-written template it cannot tell from a real one.
39
+ */
40
+ export function renderHandoff(input: HandoffInput, now: Date = new Date()): string {
41
+ const lines = [`status: ${input.status}`, "", "# Handoff", "", `written: ${now.toISOString()}`, ""];
42
+ const goal = input.goal?.trim();
43
+ lines.push("## Goal", "", goal && goal.length > 0 ? goal : "(not restated)", "");
44
+ lines.push(...section("## Decisions and reasons", input.decisions));
45
+ lines.push(...section("## Done", input.done));
46
+ // Next is the one section a successor cannot live without, so it is always
47
+ // present — an empty one says so instead of disappearing.
48
+ lines.push(
49
+ ...section(
50
+ "## Next",
51
+ input.next,
52
+ input.status === "done" ? "(nothing — the goal is reached)" : "(unstated)",
53
+ ),
54
+ );
55
+ lines.push(...section("## Pointers", input.pointers));
56
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
57
+ }
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerManagementCommands, registerManagementTools } from "./management.ts";
3
+ import { registerSelf } from "./self.ts";
4
+
5
+ /**
6
+ * The gate is wrapper-injected env, not a flag and not a config file
7
+ * (ADR 0010, 0015): the wrapper sets `AGENTS_POD_POD_ID` for every wake-up,
8
+ * and nothing else on the machine does. Present → this process is an agent
9
+ * inside a pod → self plane only. Absent → an interactive session → the
10
+ * management plane.
11
+ */
12
+ export const POD_ID_ENV = "AGENTS_POD_POD_ID";
13
+
14
+ /**
15
+ * Set by whichever copy of this extension loads first in a process. `pi
16
+ * install` and a project-local install can both resolve here, and pi would
17
+ * then load two copies that fight over the same tool and command names;
18
+ * `Symbol.for` is shared across module instances, so the second copy sees the
19
+ * flag and stays inert no matter which path loaded first.
20
+ */
21
+ const LOADED_FLAG = Symbol.for("agents-pod.extension.loaded");
22
+
23
+ export default function (pi: ExtensionAPI): void {
24
+ const scope = globalThis as Record<symbol, boolean>;
25
+ if (scope[LOADED_FLAG]) return;
26
+ scope[LOADED_FLAG] = true;
27
+
28
+ if (process.env[POD_ID_ENV] !== undefined) {
29
+ registerSelf(pi);
30
+ return;
31
+ }
32
+ registerManagementCommands(pi);
33
+ registerManagementTools(pi);
34
+ }
@@ -0,0 +1,149 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "typebox";
4
+ import { main } from "../cli.ts";
5
+
6
+ /**
7
+ * The management plane (ADR 0010): supervising other people's work — spawn,
8
+ * wake, cancel, listeners, and reading the registry. Registered only in an
9
+ * interactive session; the factory does not call this when pod env is present,
10
+ * so inside a pod these tools and commands do not exist at all (ADR 0005: an
11
+ * unregistered tool answers "no such tool" instead of inviting a workaround).
12
+ *
13
+ * Everything here is the CLI called in-process: one implementation, one set of
14
+ * exit codes, and no second opinion about what `spawn` means.
15
+ */
16
+
17
+ export interface CliOutcome {
18
+ code: number;
19
+ lines: string[];
20
+ }
21
+
22
+ const LOG_TAIL_LINES = 200;
23
+
24
+ function clip(lines: string[], limit = LOG_TAIL_LINES): string[] {
25
+ if (lines.length <= limit) return lines;
26
+ const dropped = lines.length - limit;
27
+ return [`… ${dropped} earlier line(s) omitted`, ...lines.slice(dropped)];
28
+ }
29
+
30
+ export async function runCli(argv: string[], cwd: string): Promise<CliOutcome> {
31
+ const lines: string[] = [];
32
+ const code = await main(argv, {
33
+ cwd,
34
+ stdout: (line) => lines.push(line),
35
+ stderr: (line) => lines.push(line),
36
+ });
37
+ return { code, lines };
38
+ }
39
+
40
+ function show(ctx: Pick<ExtensionContext, "ui">, outcome: CliOutcome): void {
41
+ const text = outcome.lines.length === 0 ? "(no output)" : clip(outcome.lines).join("\n");
42
+ ctx.ui.notify(text, outcome.code === 0 ? "info" : "error");
43
+ }
44
+
45
+ const POD_HELP = [
46
+ "/pod list pods (needs-human first)",
47
+ "/pod status <podId> state, liveness, wake-up trail",
48
+ "/pod logs <podId> the tail of the pod log",
49
+ "/pod spawn <goal…> spawn a pod in this project",
50
+ "/pod wake <podId> wake an idle pod now",
51
+ "/pod cancel <podId> cancel a live wake-up",
52
+ "/pod agents named agents",
53
+ "/pod listeners listeners and their claims",
54
+ ].join("\n");
55
+
56
+ /** The `/pod <action> …` command: the human's side of the management plane. */
57
+ export function registerManagementCommands(pi: ExtensionAPI): void {
58
+ pi.registerCommand("pod", {
59
+ description: "Supervise pods — /pod help shows usage",
60
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
61
+ const words = args.trim().split(/\s+/).filter((word) => word.length > 0);
62
+ const action = words[0] ?? "list";
63
+ const rest = words.slice(1);
64
+ const cwd = ctx.cwd;
65
+
66
+ if (action === "help") {
67
+ ctx.ui.notify(POD_HELP, "info");
68
+ return;
69
+ }
70
+ const needsPodId = ["status", "logs", "wake", "cancel", "spawn"].includes(action);
71
+ if (needsPodId && rest.length === 0) {
72
+ ctx.ui.notify(POD_HELP, "info");
73
+ return;
74
+ }
75
+
76
+ const argv =
77
+ action === "spawn"
78
+ ? ["spawn", ...rest, "--project", cwd]
79
+ : action === "logs"
80
+ ? ["logs", ...rest]
81
+ : action === "agents"
82
+ ? ["agent", "list"]
83
+ : action === "listeners"
84
+ ? ["listener", "list"]
85
+ : [action, ...rest];
86
+ show(ctx, await runCli(argv, cwd));
87
+ },
88
+ });
89
+ }
90
+
91
+ /**
92
+ * `spawn_pod` is the whole fan-out surface (ADR 0010): the model can start a
93
+ * pod, and the guardrails that make fan-out safe — the per-project cap and the
94
+ * `--after` dependency — are the same ones the CLI enforces, because it is the
95
+ * CLI doing the enforcing.
96
+ */
97
+ export function registerManagementTools(pi: ExtensionAPI): void {
98
+ pi.registerTool({
99
+ name: "spawn_pod",
100
+ label: "Spawn Pod",
101
+ description:
102
+ "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, again and again, until its goal is done. Returns the pod id.",
103
+ promptSnippet: "Spawn a standing pod for long-running work",
104
+ promptGuidelines: [
105
+ "Use spawn_pod when the user wants work to keep going across sessions — a multi-day refactor, a standing watch, an inbox to serve — not for a task this session can finish.",
106
+ "spawn_pod returns a pod id; report that id back to the user so they can run /pod status on it.",
107
+ "A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
108
+ "Do not spawn a second pod for the same goal while an existing pod covers it; check /pod list first.",
109
+ ],
110
+ parameters: Type.Object({
111
+ goal: Type.String({
112
+ description:
113
+ "The standing goal, in the user's terms. This is the only context a wake-up gets, so make it self-contained.",
114
+ }),
115
+ agent: Type.Optional(Type.String({ description: "Named agent id to wake; default is `default`." })),
116
+ model: Type.Optional(Type.String({ description: "Model id pinned for this pod's wake-ups." })),
117
+ after: Type.Optional(
118
+ Type.String({ description: "Pod id whose `done` releases this pod's first wake-up." }),
119
+ ),
120
+ cwd: Type.Optional(
121
+ Type.String({ description: "Working directory for wake-ups, relative to this project." }),
122
+ ),
123
+ }),
124
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
125
+ const argv = ["spawn", params.goal, "--project", ctx.cwd];
126
+ if (params.agent !== undefined) argv.push("--agent", params.agent);
127
+ if (params.model !== undefined) argv.push("--model", params.model);
128
+ if (params.after !== undefined) argv.push("--after", params.after);
129
+ if (params.cwd !== undefined) argv.push("--cwd", params.cwd);
130
+
131
+ const outcome = await runCli(argv, ctx.cwd);
132
+ const podId = outcome.lines.join("\n").trim();
133
+ if (outcome.code !== 0 || podId.length === 0) {
134
+ // Throwing is the error signal the model can act on (a bad agent id is
135
+ // a configuration error, exactly as the CLI reports it).
136
+ throw new Error(outcome.lines.join("\n") || `spawn failed with exit ${outcome.code}`);
137
+ }
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text" as const,
142
+ text: `Spawned pod ${podId}. Wake-ups run detached; /pod status ${podId} shows progress.`,
143
+ },
144
+ ],
145
+ details: { podId },
146
+ };
147
+ },
148
+ });
149
+ }
@@ -0,0 +1,80 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
+ import { StringEnum } from "@earendil-works/pi-ai";
6
+ import { Type } from "typebox";
7
+ import { renderHandoff, type HandoffStatus } from "./handoff.ts";
8
+
9
+ /** The wrapper's gate var for the docs directory (ADR 0010, 0015). */
10
+ export const DOCS_DIR_ENV = "AGENTS_POD_DOCS_DIR";
11
+
12
+ const handoffSchema = Type.Object({
13
+ status: StringEnum(["rotating", "done"] as const, {
14
+ description:
15
+ "rotating: the goal is open, a successor continues. done: the goal is reached and the pod ends.",
16
+ }),
17
+ goal: Type.Optional(Type.String({ description: "The goal as this wake-up understood it." })),
18
+ decisions: Type.Optional(
19
+ Type.Array(Type.String(), {
20
+ description: "One entry per decision, each with its reason.",
21
+ }),
22
+ ),
23
+ done: Type.Optional(Type.Array(Type.String(), { description: "What this wake-up finished." })),
24
+ next: Type.Optional(
25
+ Type.Array(Type.String(), { description: "What the next wake-up should do, in order." }),
26
+ ),
27
+ pointers: Type.Optional(
28
+ Type.Array(Type.String(), { description: "Files, commands, or links to open first." }),
29
+ ),
30
+ });
31
+
32
+ /**
33
+ * The self plane (ADR 0010): one tool, live only inside a pod. It can end the
34
+ * calling agent's own wake-up well; it can never create, destroy, or
35
+ * reconfigure work — the management plane is not registered here at all.
36
+ *
37
+ * The write is the whole guarantee: a fresh HANDOFF.md with a legal first line
38
+ * is what the wrapper's adjudication believes (ADR 0009), so this tool makes
39
+ * "write-then-exit" one call instead of a ritual the agent can skip.
40
+ */
41
+ export function registerSelf(pi: ExtensionAPI): void {
42
+ pi.registerTool({
43
+ name: "pod_handoff",
44
+ label: "Pod Handoff",
45
+ description:
46
+ "Write this wake-up's handoff to docs/HANDOFF.md and end the wake-up cleanly. Call it as the last action of every wake-up.",
47
+ promptSnippet: "Write the pod handoff and end this wake-up",
48
+ promptGuidelines: [
49
+ "Call pod_handoff as the last action of a wake-up, after the work is recorded in the documents.",
50
+ "Use pod_handoff with status done only when the pod's goal is reached; rotating means the goal is open and the next wake-up continues from the handoff.",
51
+ "An exit without a fresh pod_handoff is classified as failed — pod_handoff is the clean exit, not a summary you may skip.",
52
+ ],
53
+ parameters: handoffSchema,
54
+ async execute(_toolCallId, params) {
55
+ const docsDir = process.env[DOCS_DIR_ENV]?.trim();
56
+ if (!docsDir) {
57
+ // Escaping a pod by unsetting the env would make the tool lie; a hard
58
+ // error is the honest answer, and it never writes outside the pod.
59
+ throw new Error(`pod_handoff requires ${DOCS_DIR_ENV}: this session is not a pod wake-up`);
60
+ }
61
+ const file = join(docsDir, "HANDOFF.md");
62
+ // The schema admits exactly these two words; narrowing here keeps the
63
+ // hard line legal even if a provider reorders the enum.
64
+ const status: HandoffStatus = params.status === "done" ? "done" : "rotating";
65
+ const body = renderHandoff({ ...params, status });
66
+ // The agent may be editing HANDOFF.md in the same turn: one queue per file.
67
+ await withFileMutationQueue(file, () => writeFile(file, body));
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text" as const,
72
+ text: `Handoff written to ${file} (status: ${status}). The wake-up ends here.`,
73
+ },
74
+ ],
75
+ details: { status, path: file },
76
+ terminate: true,
77
+ };
78
+ },
79
+ });
80
+ }
package/src/inbox.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * An Inbox Item (ADR 0013): one positional id, the values a Pod Template
5
+ * binds, and the facts the two-pass stability guard compares. `{{path}}` is the
6
+ * item file's absolute path (directory inbox) or the JSONL path plus line.
7
+ *
8
+ * The optional routing fields are the platform's passengers (ADR 0026): they
9
+ * never join the id or the body, they only feed the Delivery Address.
10
+ */
11
+ export interface InboxItem {
12
+ /** Directory: filename. File: line number. Source: the platform's id (ADRs 0013, 0024). */
13
+ id: string;
14
+ title: string;
15
+ body: string;
16
+ /** Empty for a Source item, which has no filesystem attachment (ADR 0024). */
17
+ path: string;
18
+ /** Inbox stability facts only; placeholders are zero for a Source item. */
19
+ mtimeMs: number;
20
+ size: number;
21
+ /** The chat a reply has to land in; without it there is no address. */
22
+ chatId?: string;
23
+ /** The root message: the Conversation's key, propagated by the platform. */
24
+ rootId?: string;
25
+ /** The topic thread, when the platform gave the message one. */
26
+ threadId?: string;
27
+ }
28
+
29
+ /**
30
+ * The Delivery Address (ADR 0026): the Conversation's key, the chat, and the
31
+ * optional thread as one opaque token — the string an outbox entry's `to`
32
+ * takes verbatim and a deliverer parses. Core never reads it back. An item with
33
+ * no chat has nowhere to be answered, so it renders the empty string rather
34
+ * than a token with a `chat=` hole; root and thread are passengers, never a
35
+ * substitute for the chat.
36
+ *
37
+ * The affinity is `root_id ?? item id`, exactly as ADR 0026 states it. For a
38
+ * directory or JSONL inbox the id is the item's position, not a platform
39
+ * message id; a Source (ADR 0024) is what makes the id the platform's, and
40
+ * therefore the fallback usable by a deliverer.
41
+ */
42
+ export function deliveryAddress(
43
+ item: Pick<InboxItem, "id" | "chatId" | "rootId" | "threadId">,
44
+ ): string {
45
+ if (item.chatId === undefined) return "";
46
+ const affinity = item.rootId ?? item.id;
47
+ const thread = item.threadId === undefined ? "" : `;thread=${item.threadId}`;
48
+ return `chat=${item.chatId};affinity=${affinity}${thread}`;
49
+ }
50
+
51
+ /** A routing field is a non-empty, trimmed string; anything else is as ignored as an unknown key. */
52
+ function routingField(value: unknown): string | undefined {
53
+ if (typeof value !== "string") return undefined;
54
+ const trimmed = value.trim();
55
+ return trimmed === "" ? undefined : trimmed;
56
+ }
57
+
58
+ /** A malformed JSONL line: skipped, unclaimed, warned on every pass (ADR 0013). */
59
+ export interface BadInboxLine {
60
+ line: number;
61
+ reason: string;
62
+ }
63
+
64
+ export interface InboxScan {
65
+ items: InboxItem[];
66
+ bad: BadInboxLine[];
67
+ /** Set when the inbox itself cannot be read; the pass degrades, never throws. */
68
+ error?: string;
69
+ }
70
+
71
+ /** The body's first line is the default title (ADR 0013). */
72
+ function firstLine(body: string): string {
73
+ return body.split("\n", 1)[0]!.trim();
74
+ }
75
+
76
+ /** Filename without extension: "notes.md" → "notes" (ADR 0013). */
77
+ function titleFromFilename(name: string): string {
78
+ const dot = name.lastIndexOf(".");
79
+ return dot > 0 ? name.slice(0, dot) : name;
80
+ }
81
+
82
+ /**
83
+ * A directory inbox holds one item per top-level non-dot regular file, any
84
+ * extension. Subdirectories (writers' WIP parking), dotfiles and symlinks are
85
+ * ignored — `isFile()` is false for a dirent that is a symlink.
86
+ */
87
+ async function scanDirectory(dir: string): Promise<InboxScan> {
88
+ let entries;
89
+ try {
90
+ entries = await readdir(dir, { withFileTypes: true });
91
+ } catch (error) {
92
+ return { items: [], bad: [], error: (error as NodeJS.ErrnoException).code ?? "unreadable" };
93
+ }
94
+ const items: InboxItem[] = [];
95
+ for (const entry of entries) {
96
+ if (!entry.isFile() || entry.name.startsWith(".")) continue;
97
+ const file = join(dir, entry.name);
98
+ const info = await stat(file).catch(() => undefined);
99
+ if (info === undefined || !info.isFile()) continue;
100
+ const body = await readFile(file, "utf8").catch(() => "");
101
+ items.push({
102
+ id: entry.name,
103
+ title: titleFromFilename(entry.name),
104
+ body,
105
+ path: file,
106
+ mtimeMs: info.mtimeMs,
107
+ size: info.size,
108
+ });
109
+ }
110
+ // Arrival order is the writer's priority; filename breaks mtime ties (ADR 0013).
111
+ items.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
112
+ return { items, bad: [] };
113
+ }
114
+
115
+ /**
116
+ * A file inbox is JSONL only (ADR 0013): `body` is required, `title` optional,
117
+ * `chat_id`/`root_id`/`thread_id` are read as the item's routing fields
118
+ * (ADR 0026), and every other key is ignored. A line that does not parse, or
119
+ * carries no string body, is skipped and reported — never claimed, never fatal
120
+ * to the pass.
121
+ */
122
+ async function scanJsonl(file: string): Promise<InboxScan> {
123
+ const info = await stat(file).catch(() => undefined);
124
+ if (info === undefined) return { items: [], bad: [], error: "ENOENT" };
125
+ const text = await readFile(file, "utf8").catch(() => undefined);
126
+ if (text === undefined) return { items: [], bad: [], error: "unreadable" };
127
+
128
+ const items: InboxItem[] = [];
129
+ const bad: BadInboxLine[] = [];
130
+ const lines = text.split("\n");
131
+ for (let index = 0; index < lines.length; index += 1) {
132
+ const raw = lines[index]!;
133
+ if (raw.trim() === "") continue; // a trailing newline is not an item
134
+ const line = index + 1;
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ } catch {
139
+ bad.push({ line, reason: "invalid JSON" });
140
+ continue;
141
+ }
142
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
143
+ bad.push({ line, reason: "invalid JSON (not an object)" });
144
+ continue;
145
+ }
146
+ const record = parsed as {
147
+ body?: unknown;
148
+ title?: unknown;
149
+ chat_id?: unknown;
150
+ root_id?: unknown;
151
+ thread_id?: unknown;
152
+ };
153
+ if (typeof record.body !== "string") {
154
+ bad.push({ line, reason: "no body" });
155
+ continue;
156
+ }
157
+ const title =
158
+ typeof record.title === "string" && record.title.trim() !== ""
159
+ ? record.title
160
+ : firstLine(record.body);
161
+ const chatId = routingField(record.chat_id);
162
+ const rootId = routingField(record.root_id);
163
+ const threadId = routingField(record.thread_id);
164
+ items.push({
165
+ id: String(line),
166
+ title,
167
+ body: record.body,
168
+ path: `${file}#${line}`,
169
+ mtimeMs: info.mtimeMs,
170
+ size: info.size,
171
+ ...(chatId === undefined ? {} : { chatId }),
172
+ ...(rootId === undefined ? {} : { rootId }),
173
+ ...(threadId === undefined ? {} : { threadId }),
174
+ });
175
+ }
176
+ return { items, bad };
177
+ }
178
+
179
+ /** Shape follows the path's own `stat`: a directory or a JSONL file (ADR 0013). */
180
+ export async function scanInbox(inbox: string): Promise<InboxScan> {
181
+ const info = await stat(inbox).catch(() => undefined);
182
+ if (info === undefined) return { items: [], bad: [], error: "ENOENT" };
183
+ return info.isDirectory() ? scanDirectory(inbox) : scanJsonl(inbox);
184
+ }
185
+
186
+ /**
187
+ * The five template placeholders (ADR 0013, amended by ADR 0026), each one a
188
+ * renderer: adding a sixth is one row, not a set plus two casts.
189
+ */
190
+ const PLACEHOLDERS: Record<string, (item: InboxItem) => string> = {
191
+ id: (item) => item.id,
192
+ title: (item) => item.title,
193
+ body: (item) => item.body,
194
+ path: (item) => item.path,
195
+ // The address is derived, not a field: an item without routing fields
196
+ // renders empty, so a template stays valid for both kinds of item.
197
+ reply_to: deliveryAddress,
198
+ };
199
+
200
+ /**
201
+ * Render a Pod Template against one item. An unknown `{{...}}` is a
202
+ * configuration error: the caller fails this item's materialization, keeps the
203
+ * claim (already written), warns to cron.log, and the pass carries on.
204
+ */
205
+ export function renderTemplate(
206
+ template: string,
207
+ item: InboxItem,
208
+ ): { goal: string } | { error: string } {
209
+ const unknown: string[] = [];
210
+ const goal = template.replace(/\{\{([^{}]*)\}\}/g, (whole: string, raw: string) => {
211
+ const name = raw.trim();
212
+ const render = PLACEHOLDERS[name];
213
+ if (render === undefined) {
214
+ unknown.push(name);
215
+ return whole;
216
+ }
217
+ return render(item);
218
+ });
219
+ if (unknown.length > 0) return { error: `unknown placeholder {{${unknown[0]}}}` };
220
+ return { goal };
221
+ }
@@ -0,0 +1,84 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { listenersFile } from "./paths.ts";
5
+
6
+ /**
7
+ * A Listener is a registry record, never a process (ADR 0007): an inbox, an
8
+ * optional match rule, one target, and a bound on concurrent wake-ups. The pass
9
+ * scans it; nothing ever runs on its behalf between passes.
10
+ */
11
+ export type ListenerMode = "template" | "pod";
12
+
13
+ /**
14
+ * A consumed item, by position for an inbox or platform id for a Source
15
+ * (ADRs 0013, 0024): the claim namespace is the origin, so `inbox` + `item` is
16
+ * the key across listeners. `spawnedPodId` is what the template materialized;
17
+ * `blocked` is a reason materialization will not happen
18
+ * (a configuration error, or an item that vanished). `appended` is ADR 0014's
19
+ * one boolean for a bound standing pod: the block is in the pod's `## Inbox`.
20
+ * Beyond that the document is the truth — there is no per-item lifecycle.
21
+ */
22
+ export interface ClaimRecord {
23
+ inbox: string;
24
+ item: string;
25
+ /** Source claims retain the current and previous successful drain (ADR 0024). */
26
+ claimedAt?: number;
27
+ spawnedPodId?: string;
28
+ blocked?: string;
29
+ appended?: boolean;
30
+ }
31
+
32
+ /** The two-pass stability guard's memory: last scan's mtime and size (ADR 0013). */
33
+ export interface SeenFact {
34
+ item: string;
35
+ mtimeMs: number;
36
+ size: number;
37
+ }
38
+
39
+ export interface ListenerRecord {
40
+ id: string;
41
+ /** Exactly one origin: an absolute inbox path or a Source name (ADR 0024). */
42
+ inbox?: string;
43
+ source?: string;
44
+ /** P0's rule: a plain substring; absent means every item matches. */
45
+ match?: string;
46
+ mode: ListenerMode;
47
+ /** Template mode: the goal template file. */
48
+ template?: string;
49
+ /** Template mode: materialize under this project instead of the tick's cwd (ADR 0007). */
50
+ project?: string;
51
+ /** Pod mode: the standing pod whose `## Inbox` the items join (ADR 0014). */
52
+ podId?: string;
53
+ maxConcurrent: number;
54
+ claimed: ClaimRecord[];
55
+ seen: SeenFact[];
56
+ }
57
+
58
+ export function newListenerId(): string {
59
+ return `listener-${randomBytes(3).toString("hex")}`;
60
+ }
61
+
62
+ /** The claim key is inbox + position, never the listener (ADR 0013). */
63
+ export function claimKey(inbox: string, item: string): string {
64
+ return `${inbox}\u0000${item}`;
65
+ }
66
+
67
+ export async function readListeners(): Promise<ListenerRecord[]> {
68
+ try {
69
+ const parsed: unknown = JSON.parse(await readFile(listenersFile(), "utf8"));
70
+ return Array.isArray(parsed) ? (parsed as ListenerRecord[]) : [];
71
+ } catch (error) {
72
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /** Atomic write, one unique temp name per writer (the pods.json rule). */
78
+ export async function writeListeners(listeners: ListenerRecord[]): Promise<void> {
79
+ const file = listenersFile();
80
+ await mkdir(dirname(file), { recursive: true });
81
+ const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
82
+ await writeFile(tmp, `${JSON.stringify(listeners, null, 2)}\n`);
83
+ await rename(tmp, file);
84
+ }