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/src/sources.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import type { BadInboxLine, InboxItem } from "./inbox.ts";
|
|
6
|
+
import type { ListenerRecord } from "./listeners.ts";
|
|
7
|
+
import { sourcesFile } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
/** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
|
|
10
|
+
export interface SourceRecord {
|
|
11
|
+
name: string;
|
|
12
|
+
cmd: string;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
cursor?: string;
|
|
15
|
+
/** The previous successful drain is the claim-retention horizon (ADR 0024). */
|
|
16
|
+
lastDrainAt?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SourceDrain {
|
|
20
|
+
name: string;
|
|
21
|
+
items: InboxItem[];
|
|
22
|
+
bad: BadInboxLine[];
|
|
23
|
+
cursor?: string;
|
|
24
|
+
/** Successful drains carry their generation and the preceding one's boundary. */
|
|
25
|
+
drainedAt?: number;
|
|
26
|
+
previousDrainAt?: number;
|
|
27
|
+
/** A failed drain contributes nothing and leaves the cursor untouched. */
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One MiB is the whole successful stdout budget (ADR 0024). */
|
|
32
|
+
export const DRAIN_STDOUT_CAP = 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export async function readSources(): Promise<SourceRecord[]> {
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(await readFile(sourcesFile(), "utf8"));
|
|
37
|
+
return Array.isArray(parsed) ? (parsed as SourceRecord[]) : [];
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The cursor's own atomic write, separate from listeners.json (ADR 0024). */
|
|
45
|
+
export async function writeSources(sources: SourceRecord[]): Promise<void> {
|
|
46
|
+
const file = sourcesFile();
|
|
47
|
+
await mkdir(dirname(file), { recursive: true });
|
|
48
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
49
|
+
await writeFile(tmp, `${JSON.stringify(sources, null, 2)}\n`);
|
|
50
|
+
await rename(tmp, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function routingField(value: unknown): string | undefined {
|
|
54
|
+
if (typeof value !== "string") return undefined;
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
return trimmed === "" ? undefined : trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A stream item is complete when its line parses; no two-pass guard applies. */
|
|
60
|
+
export function parseDrainOutput(name: string, text: string): SourceDrain {
|
|
61
|
+
const items: InboxItem[] = [];
|
|
62
|
+
const bad: BadInboxLine[] = [];
|
|
63
|
+
let cursor: string | undefined;
|
|
64
|
+
const lines = text.split("\n")
|
|
65
|
+
.map((raw, index) => ({ raw, line: index + 1 }))
|
|
66
|
+
.filter(({ raw }) => raw.trim() !== "");
|
|
67
|
+
|
|
68
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
69
|
+
const { raw, line } = lines[index]!;
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
bad.push({ line, reason: "invalid JSON" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
78
|
+
bad.push({ line, reason: "invalid JSON (not an object)" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const record = parsed as {
|
|
82
|
+
id?: unknown;
|
|
83
|
+
body?: unknown;
|
|
84
|
+
title?: unknown;
|
|
85
|
+
cursor?: unknown;
|
|
86
|
+
chat_id?: unknown;
|
|
87
|
+
root_id?: unknown;
|
|
88
|
+
thread_id?: unknown;
|
|
89
|
+
};
|
|
90
|
+
if (
|
|
91
|
+
index === lines.length - 1 &&
|
|
92
|
+
"cursor" in record &&
|
|
93
|
+
record.id === undefined &&
|
|
94
|
+
record.body === undefined
|
|
95
|
+
) {
|
|
96
|
+
if (typeof record.cursor !== "string") {
|
|
97
|
+
bad.push({ line, reason: "invalid cursor" });
|
|
98
|
+
} else {
|
|
99
|
+
cursor = record.cursor;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (typeof record.id !== "string" || record.id.trim() === "") {
|
|
104
|
+
bad.push({ line, reason: "no id" });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof record.body !== "string") {
|
|
108
|
+
bad.push({ line, reason: "no body" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const title =
|
|
112
|
+
typeof record.title === "string" && record.title.trim() !== ""
|
|
113
|
+
? record.title
|
|
114
|
+
: record.body.split("\n", 1)[0]!.trim();
|
|
115
|
+
const chatId = routingField(record.chat_id);
|
|
116
|
+
const rootId = routingField(record.root_id);
|
|
117
|
+
const threadId = routingField(record.thread_id);
|
|
118
|
+
items.push({
|
|
119
|
+
id: record.id,
|
|
120
|
+
title,
|
|
121
|
+
body: record.body,
|
|
122
|
+
path: "",
|
|
123
|
+
mtimeMs: 0,
|
|
124
|
+
size: 0,
|
|
125
|
+
...(chatId === undefined ? {} : { chatId }),
|
|
126
|
+
...(rootId === undefined ? {} : { rootId }),
|
|
127
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return { name, items, bad, ...(cursor === undefined ? {} : { cursor }) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function drainOne(source: SourceRecord, project: string): Promise<SourceDrain> {
|
|
134
|
+
const empty = { name: source.name, items: [], bad: [] };
|
|
135
|
+
const result = await new Promise<{ stdout?: string; error?: string }>((resolve) => {
|
|
136
|
+
const child = spawn(source.cmd, [], {
|
|
137
|
+
cwd: project,
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
AGENTS_POD_SOURCE_ID: source.name,
|
|
141
|
+
AGENTS_POD_CURSOR: source.cursor ?? "",
|
|
142
|
+
},
|
|
143
|
+
// A shell drain can leave a child holding stdout after the shell dies.
|
|
144
|
+
// Its own process group makes the timeout and output cap real bounds.
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
147
|
+
});
|
|
148
|
+
const kill = (): void => {
|
|
149
|
+
if (child.pid === undefined) {
|
|
150
|
+
child.kill("SIGKILL");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
process.kill(-child.pid, "SIGKILL");
|
|
155
|
+
} catch {
|
|
156
|
+
child.kill("SIGKILL");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const chunks: Buffer[] = [];
|
|
160
|
+
let bytes = 0;
|
|
161
|
+
let oversized = false;
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
let spawnError: string | undefined;
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
timedOut = true;
|
|
166
|
+
kill();
|
|
167
|
+
}, source.timeoutMs);
|
|
168
|
+
|
|
169
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
170
|
+
bytes += chunk.length;
|
|
171
|
+
if (bytes > DRAIN_STDOUT_CAP) {
|
|
172
|
+
if (!oversized) {
|
|
173
|
+
oversized = true;
|
|
174
|
+
kill();
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
chunks.push(chunk);
|
|
179
|
+
});
|
|
180
|
+
child.on("error", (error) => {
|
|
181
|
+
spawnError = error.message;
|
|
182
|
+
});
|
|
183
|
+
child.on("close", (code, signal) => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
if (oversized) return resolve({ error: "stdout exceeded 1 MiB" });
|
|
186
|
+
if (timedOut) return resolve({ error: `timed out after ${source.timeoutMs / 1000}s` });
|
|
187
|
+
if (spawnError !== undefined) return resolve({ error: spawnError });
|
|
188
|
+
if (code !== 0) {
|
|
189
|
+
return resolve({
|
|
190
|
+
error: signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
resolve({ stdout: Buffer.concat(chunks).toString("utf8") });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
if (result.error !== undefined) return { ...empty, error: result.error };
|
|
197
|
+
// Distinct generations need distinct boundaries even when two local drains
|
|
198
|
+
// finish within one wall-clock millisecond.
|
|
199
|
+
const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
|
|
200
|
+
return {
|
|
201
|
+
...parseDrainOutput(source.name, result.stdout ?? ""),
|
|
202
|
+
drainedAt,
|
|
203
|
+
...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Drain each distinct Source once, before the registry lock or any Pod work (ADR 0024). */
|
|
208
|
+
export async function drainSources(
|
|
209
|
+
project: string,
|
|
210
|
+
listeners: ListenerRecord[],
|
|
211
|
+
): Promise<SourceDrain[]> {
|
|
212
|
+
const sources = await readSources();
|
|
213
|
+
const names = [...new Set(listeners.flatMap((listener) => listener.source ?? []))];
|
|
214
|
+
const drained: SourceDrain[] = [];
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
217
|
+
drained.push(
|
|
218
|
+
source === undefined
|
|
219
|
+
? { name, items: [], bad: [], error: "source record is missing" }
|
|
220
|
+
: await drainOne(source, project),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return drained;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Two generations are enough: after this drain, anything older than the prior
|
|
228
|
+
* successful one is behind the committed cursor and cannot arrive again. The
|
|
229
|
+
* store is transformed without I/O; the caller writes it inside the registry
|
|
230
|
+
* lock before advancing the Source records (ADR 0024). Legacy claims without a
|
|
231
|
+
* timestamp are retained because their generation cannot be proven.
|
|
232
|
+
*/
|
|
233
|
+
export function pruneSourceClaims(
|
|
234
|
+
listeners: ListenerRecord[],
|
|
235
|
+
drained: SourceDrain[],
|
|
236
|
+
): { listeners: ListenerRecord[]; changed: boolean } {
|
|
237
|
+
const horizons = new Map<string, number>();
|
|
238
|
+
for (const drain of drained) {
|
|
239
|
+
if (drain.error === undefined && drain.previousDrainAt !== undefined) {
|
|
240
|
+
horizons.set(`source:${drain.name}`, drain.previousDrainAt);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (horizons.size === 0) return { listeners, changed: false };
|
|
244
|
+
|
|
245
|
+
let changed = false;
|
|
246
|
+
const pruned = listeners.map((listener) => {
|
|
247
|
+
const claimed = listener.claimed.filter((claim) => {
|
|
248
|
+
const horizon = horizons.get(claim.inbox);
|
|
249
|
+
return (
|
|
250
|
+
horizon === undefined || claim.claimedAt === undefined || claim.claimedAt >= horizon
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
if (claimed.length === listener.claimed.length) return listener;
|
|
254
|
+
changed = true;
|
|
255
|
+
return { ...listener, claimed };
|
|
256
|
+
});
|
|
257
|
+
return { listeners: pruned, changed };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Claims and pruning land before this cursor/retention-horizon write (ADR 0024). */
|
|
261
|
+
export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
|
|
262
|
+
const sources = await readSources();
|
|
263
|
+
let changed = false;
|
|
264
|
+
for (const drain of drained) {
|
|
265
|
+
if (drain.error !== undefined || drain.drainedAt === undefined) continue;
|
|
266
|
+
const source = sources.find((candidate) => candidate.name === drain.name);
|
|
267
|
+
if (source === undefined) continue;
|
|
268
|
+
if (drain.cursor !== undefined && source.cursor !== drain.cursor) {
|
|
269
|
+
source.cursor = drain.cursor;
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
if (source.lastDrainAt !== drain.drainedAt) {
|
|
273
|
+
source.lastDrainAt = drain.drainedAt;
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (changed) await writeSources(sources);
|
|
278
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { deliveryAddress, type InboxItem } from "./inbox.ts";
|
|
5
|
+
import { podDir } from "./paths.ts";
|
|
6
|
+
import type { PodRecord } from "./registry.ts";
|
|
7
|
+
|
|
8
|
+
/** The document store's three slots: the only progress signal P1 trusts (ADR 0019). */
|
|
9
|
+
export const DOC_FILES = ["HANDOFF.md", "DECISIONS.md", "TASKS.md"] as const;
|
|
10
|
+
|
|
11
|
+
/** A document at a moment: progress is mtime or size moving (ADR 0019). */
|
|
12
|
+
export interface DocFacts {
|
|
13
|
+
mtimeMs: number;
|
|
14
|
+
size: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The progress baseline (ADR 0019): the wake-up's start records what the
|
|
19
|
+
* documents looked like, so a later pass can prove they never moved. An absent
|
|
20
|
+
* file is simply not recorded — its appearing or vanishing is a change.
|
|
21
|
+
*/
|
|
22
|
+
export async function snapshotDocs(docsDir: string): Promise<Record<string, DocFacts>> {
|
|
23
|
+
const facts: Record<string, DocFacts> = {};
|
|
24
|
+
for (const name of DOC_FILES) {
|
|
25
|
+
const info = await stat(join(docsDir, name)).catch(() => undefined);
|
|
26
|
+
if (info !== undefined) facts[name] = { mtimeMs: info.mtimeMs, size: info.size };
|
|
27
|
+
}
|
|
28
|
+
return facts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The document store's three slots, created from templates at spawn (ADR 0008).
|
|
33
|
+
* The wording is free markdown; the only machine surface P0 depends on is the
|
|
34
|
+
* HANDOFF first line and TASKS checkboxes, both grepped, never parsed.
|
|
35
|
+
*
|
|
36
|
+
* `## Inbox` is last so reconcile's append is an EOF append (ADR 0014).
|
|
37
|
+
*/
|
|
38
|
+
const TEMPLATES: Record<string, string> = {
|
|
39
|
+
"HANDOFF.md": [
|
|
40
|
+
"status: rotating",
|
|
41
|
+
"",
|
|
42
|
+
"No wake-up has written a handoff yet.",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
"DECISIONS.md": [
|
|
46
|
+
"# Decisions",
|
|
47
|
+
"",
|
|
48
|
+
"One line per decision, with its reason. Append-only.",
|
|
49
|
+
"",
|
|
50
|
+
].join("\n"),
|
|
51
|
+
"TASKS.md": ["# Tasks", "", "## Plan", "", "## Waiting", "", "## Inbox", ""].join("\n"),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The one section reconcile may write (ADR 0008, 0014). */
|
|
55
|
+
const INBOX_HEADING = "## Inbox";
|
|
56
|
+
|
|
57
|
+
export async function createPodWorkspace(pod: PodRecord): Promise<string> {
|
|
58
|
+
const dir = podDir(pod.project, pod.id);
|
|
59
|
+
const docs = join(dir, "docs");
|
|
60
|
+
await mkdir(docs, { recursive: true });
|
|
61
|
+
// The outbox is beside the three-slot Document Store, never a fourth slot
|
|
62
|
+
// (ADRs 0008, 0025).
|
|
63
|
+
await mkdir(join(dir, "outbox"), { recursive: true });
|
|
64
|
+
for (const [name, body] of Object.entries(TEMPLATES)) {
|
|
65
|
+
await writeFile(join(docs, name), body);
|
|
66
|
+
}
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One Inbox entry block (ADR 0014): `- [ ] <id> — <title>`, then the body
|
|
72
|
+
* copied in as an indented list continuation. The id leads so counting and
|
|
73
|
+
* audit have an anchor; the body is copied because the source file belongs to
|
|
74
|
+
* its writer and may be deleted once the item is claimed.
|
|
75
|
+
*/
|
|
76
|
+
export function inboxBlock(
|
|
77
|
+
item: Pick<InboxItem, "id" | "title" | "body" | "chatId" | "rootId" | "threadId">,
|
|
78
|
+
): string {
|
|
79
|
+
const title = item.title.split("\n", 1)[0]!.trim();
|
|
80
|
+
const address = deliveryAddress(item);
|
|
81
|
+
const suffix = address === "" ? "" : ` — to: ${address}`;
|
|
82
|
+
const lines = [`- [ ] ${item.id} — ${title}${suffix}`];
|
|
83
|
+
const body = item.body.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
84
|
+
if (body.length > 0) {
|
|
85
|
+
for (const line of body.split("\n")) {
|
|
86
|
+
// A blank line stays blank: an indented empty line is trailing whitespace.
|
|
87
|
+
lines.push(line.trim().length === 0 ? "" : ` ${line}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return `${lines.join("\n")}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function escapeRegExp(text: string): string {
|
|
94
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The id's checkbox line, checked or not: the document is the truth (ADR 0014). */
|
|
98
|
+
function alreadyInboxed(text: string, id: string): boolean {
|
|
99
|
+
return new RegExp(`^- \\[[ xX]\\] ${escapeRegExp(id)} —`, "m").test(text);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Append a batch to a pod's `## Inbox` (ADR 0014) — reconcile's only write into
|
|
104
|
+
* a pod's documents (ADR 0008), and only while no wake-up is live (the caller's
|
|
105
|
+
* gate). Idempotent by document, not by marker: an id that already has a
|
|
106
|
+
* checkbox line is skipped, so a crash between this write and the claim's
|
|
107
|
+
* `appended` marker repairs on the next pass instead of duplicating the block.
|
|
108
|
+
* An older pod without the section gets the heading at EOF on its first append.
|
|
109
|
+
*/
|
|
110
|
+
export async function appendInbox(docsDir: string, items: readonly InboxItem[]): Promise<void> {
|
|
111
|
+
const file = join(docsDir, "TASKS.md");
|
|
112
|
+
const existing = await readFile(file, "utf8").catch(() => "");
|
|
113
|
+
const fresh = items.filter((item) => !alreadyInboxed(existing, item.id));
|
|
114
|
+
if (fresh.length === 0) return; // a repair pass: the document already has it
|
|
115
|
+
|
|
116
|
+
let text = existing.length === 0 ? "# Tasks\n" : existing;
|
|
117
|
+
if (!/^##\s+Inbox\s*$/m.test(text)) {
|
|
118
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
119
|
+
if (!text.endsWith("\n\n")) text += "\n";
|
|
120
|
+
text += `${INBOX_HEADING}\n`;
|
|
121
|
+
}
|
|
122
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
123
|
+
if (!text.endsWith("\n\n")) text += "\n";
|
|
124
|
+
text += fresh.map(inboxBlock).join("\n");
|
|
125
|
+
|
|
126
|
+
// The registry's atomic write, applied to the one shared file: a crash never
|
|
127
|
+
// leaves a half-written TASKS.md for the next wake-up to read.
|
|
128
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
129
|
+
await writeFile(tmp, text);
|
|
130
|
+
await rename(tmp, file);
|
|
131
|
+
}
|