agent-accelerator 0.5.6 → 0.5.8

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.
Files changed (57) hide show
  1. package/README.md +10 -17
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +92 -57
  4. package/src/agent/architecture.md +25 -0
  5. package/src/agent/context.ts +7 -3
  6. package/src/agent/delegation.ts +30 -96
  7. package/src/agent/loop.ts +54 -56
  8. package/src/agent/subagent.ts +4 -4
  9. package/src/agent/worker.ts +1 -3
  10. package/src/architecture.md +28 -0
  11. package/src/index.ts +13 -80
  12. package/src/models/architecture.md +14 -0
  13. package/src/models/catalog-cache.ts +10 -7
  14. package/src/models/catalog.ts +20 -13
  15. package/src/providers/architecture.md +24 -0
  16. package/src/providers/google.ts +24 -4
  17. package/src/providers/openai-compat.ts +29 -7
  18. package/src/providers/openai.ts +22 -6
  19. package/src/providers/openrouter.ts +14 -2
  20. package/src/providers/registry.ts +1 -0
  21. package/src/providers/shared.ts +4 -4
  22. package/src/providers.ts +9 -53
  23. package/src/scheduler/architecture.md +22 -0
  24. package/src/scheduler/scheduler.ts +131 -56
  25. package/src/scheduler/store.ts +274 -54
  26. package/src/scheduler/time.ts +2 -0
  27. package/src/session/architecture.md +19 -0
  28. package/src/session/store.ts +37 -22
  29. package/src/session/usage.ts +1 -0
  30. package/src/streaming/architecture.md +13 -0
  31. package/src/streaming/event-stream.ts +16 -11
  32. package/src/telemetry-file.ts +74 -24
  33. package/src/telemetry.ts +3 -2
  34. package/src/tools/architecture.md +19 -0
  35. package/src/tools/executor.ts +14 -1
  36. package/src/tools/schema.ts +1 -0
  37. package/src/tools/subagent-result.ts +1 -0
  38. package/src/tools/tool.ts +8 -8
  39. package/src/types/agent.ts +22 -20
  40. package/src/types/architecture.md +20 -0
  41. package/src/types/response.ts +8 -8
  42. package/src/types/scheduler.ts +2 -2
  43. package/src/types/tool.ts +5 -5
  44. package/src/update-models.ts +2 -2
  45. package/src/utils/architecture.md +25 -0
  46. package/src/utils/base64.ts +2 -0
  47. package/src/utils/cache.ts +6 -4
  48. package/src/utils/durable.ts +78 -3
  49. package/src/utils/env.ts +1 -1
  50. package/src/utils/errors.ts +4 -0
  51. package/src/utils/headers.ts +7 -4
  52. package/src/utils/home.ts +1 -1
  53. package/src/utils/media.ts +1 -0
  54. package/src/utils/raw-capture.ts +3 -0
  55. package/src/utils/retry.ts +2 -1
  56. package/src/utils/safe-io.ts +4 -0
  57. package/src/utils/session.ts +10 -166
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * Disk layer for scheduled actions. Everything lives under one home folder:
3
3
  *
4
- * <home>/scheduled_actions/tasks.json every action, one file
5
- * <home>/scheduled_actions/deliveries.json saved results waiting for their delivery time
6
- * <home>/scheduled_actions/<id>/<date>.md one file per run
4
+ * <home>/scheduled/<id>/task.json the action (`{ version: 2, task }`)
5
+ * <home>/scheduled/<id>/delivery.json its saved results waiting for their delivery time (`{ version: 2, pending }`)
6
+ * <home>/scheduled/<id>/<date>.md one file per run
7
+ * <home>/scheduled/<id>/.mutex short lock held while this action's files change
8
+ * <home>/scheduled/<id>/.heartbeat written while a process is running the action (pid, host; its mtime is refreshed)
7
9
  *
8
- * The files are the source of truth: every write re-reads first (never writes
9
- * from a stale copy) and goes through `writeFileDurable` (temp file, fsync, `.bak`, rename).
10
- * Sync `fs`, like the session store.
10
+ * One folder per action, so processes working on different actions never touch the same file. The files are the
11
+ * source of truth: every change takes the action's mutex, re-reads first (never writes from a stale copy) and goes
12
+ * through `writeFileDurable` (temp file, fsync, `.bak`, rename). Sync `fs`, like the session store.
11
13
  */
12
14
  import * as fs from "node:fs";
15
+ import * as os from "node:os";
13
16
  import * as path from "node:path";
14
17
  import { z } from "zod";
15
- import { readWithBackup, writeFileDurable } from "../utils/durable.ts";
18
+ import { readWithBackup, withFileMutex, writeFileDurable } from "../utils/durable.ts";
16
19
  import { telemetryBus } from "../telemetry.ts";
17
20
  import { resolveHome } from "../utils/home.ts";
18
21
  export { resolveHome };
@@ -26,10 +29,10 @@ export const TASK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
26
29
  /** 48-char slug + `-` + 6 random chars. */
27
30
  export const MAX_TASK_ID_LENGTH = 55;
28
31
 
29
- /** A saved run waiting for its delivery time (one entry of `deliveries.json`). */
32
+ /** A saved run waiting for its delivery time (one entry of an action's `delivery.json`). */
30
33
  export interface PendingDelivery {
31
34
  id: string;
32
- /** Run file path, relative to `scheduled_actions/`. */
35
+ /** Run file path, relative to `scheduled/`. */
33
36
  file: string;
34
37
  status: ScheduledRunStatus;
35
38
  readyAt: string;
@@ -72,31 +75,105 @@ export interface RunHeader {
72
75
  error?: string;
73
76
  }
74
77
 
75
- const baseDir = (home: string): string => path.join(home, "scheduled_actions");
76
- const tasksFile = (home: string): string => path.join(baseDir(home), "tasks.json");
77
- const deliveriesFile = (home: string): string => path.join(baseDir(home), "deliveries.json");
78
+ // The `scheduled` folder of a home; in: the path helpers, out: its path.
79
+ const baseDir = (home: string): string => path.join(home, "scheduled");
78
80
 
79
- /** Absolute path of a run file given its `scheduled_actions/`-relative path. */
80
- export function runPath(home: string, relativeFile: string): string {
81
- return path.resolve(baseDir(home), relativeFile);
81
+ // The folder of one action; refuses anything that is not an action id, so a hand-edited id can never point outside `scheduled/`; in: every per-action helper, out: its path.
82
+ function taskDir(home: string, id: string): string {
83
+ if (typeof id !== "string" || !TASK_ID_PATTERN.test(id) || id.length > MAX_TASK_ID_LENGTH) {
84
+ throw new ValidationError(`${JSON.stringify(id)} is not a scheduled action id.`);
85
+ }
86
+ return path.join(baseDir(home), id);
87
+ }
88
+ // Path of an action's record; in: the task helpers, out: its path.
89
+ const taskFile = (dir: string): string => path.join(dir, "task.json");
90
+ // Path of an action's saved deliveries; in: the delivery helpers, out: its path.
91
+ const deliveryFile = (dir: string): string => path.join(dir, "delivery.json");
92
+ // Path of the short mutex that serialises every change to one action (record and deliveries); in: updateTask/updateDeliveries/deleteTask, out: its path.
93
+ const mutexFile = (dir: string): string => path.join(dir, ".mutex");
94
+
95
+ // Path of the file a running action's owner keeps fresh; in: the heartbeat helpers, out: its path.
96
+ const heartbeatFile = (dir: string): string => path.join(dir, ".heartbeat");
97
+
98
+ /** Who is running an action and how long ago they last said so. */
99
+ export interface Heartbeat {
100
+ pid: number;
101
+ host: string;
102
+ /** Identifies the scheduler instance inside that process. */
103
+ token: string;
104
+ ageMs: number;
105
+ }
106
+
107
+ /** Marks an action as running in this process (pid, host, token); written before the record flips to `cooking`; in: startCook, out: `<id>/.heartbeat`. */
108
+ export function writeHeartbeat(home: string, id: string, token: string): void {
109
+ fs.writeFileSync(heartbeatFile(taskDir(home, id)), JSON.stringify({ pid: process.pid, host: os.hostname(), token }), { mode: 0o600 });
110
+ }
111
+
112
+ /** Refreshes the heartbeat's modification time; false when the file is gone; in: the scheduler tick for each run in flight. */
113
+ export function touchHeartbeat(home: string, id: string): boolean {
114
+ try {
115
+ const now = new Date();
116
+ fs.utimesSync(heartbeatFile(taskDir(home, id)), now, now);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /** The heartbeat of an action, or undefined when it has none or the file is unreadable; in: the dead-owner checks and `delete`. */
124
+ export function readHeartbeat(home: string, id: string): Heartbeat | undefined {
125
+ try {
126
+ const file = heartbeatFile(taskDir(home, id));
127
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Heartbeat>;
128
+ if (typeof raw.pid !== "number" || typeof raw.host !== "string" || typeof raw.token !== "string") return undefined;
129
+ return { pid: raw.pid, host: raw.host, token: raw.token, ageMs: Date.now() - fs.statSync(file).mtimeMs };
130
+ } catch {
131
+ return undefined;
132
+ }
82
133
  }
83
134
 
84
- /** Renames the pre-0.5.0 `scheduled_tasks/` folder to `scheduled_actions/` (once, only when the new one does not exist). */
85
- export function migrateLegacyFolder(home: string): void {
86
- const legacy = path.join(home, "scheduled_tasks");
135
+ /** Removes the heartbeat when a run ends; in: the scheduler after a run, out: nothing. */
136
+ export function clearHeartbeat(home: string, id: string): void {
87
137
  try {
88
- if (fs.existsSync(legacy) && !fs.existsSync(baseDir(home))) fs.renameSync(legacy, baseDir(home));
138
+ fs.rmSync(heartbeatFile(taskDir(home, id)), { force: true });
89
139
  } catch {
90
- // why: best effort; the old folder simply stays where it is.
140
+ // why: a leftover heartbeat of a finished run is harmless (it is only read for actions that are cooking).
91
141
  }
92
142
  }
93
143
 
144
+ // True for the error of a folder that was removed while we waited for its lock; in: the catch blocks of the mutating helpers, out: a boolean.
145
+ const isMissingFolder = (err: unknown): boolean => (err as { code?: string } | null)?.code === "ENOENT";
146
+
147
+ // Deletes the leftover temp files of a task.json / delivery.json write that never finished (a killed process); only called while holding the action's mutex, which is what makes it safe (run files are written outside the mutex, so they are never touched); in: the three mutating helpers, out: nothing.
148
+ function sweepShardTemps(dir: string): void {
149
+ try {
150
+ for (const name of fs.readdirSync(dir)) if (/^(task|delivery)\.json\.tmp-\d+-[0-9a-z]+$/.test(name)) fs.rmSync(path.join(dir, name), { force: true });
151
+ } catch {
152
+ // why: an unreadable folder or an undeletable leftover is harmless.
153
+ }
154
+ }
155
+
156
+ /** Absolute path of a run file given its `scheduled/`-relative path. */
157
+ export function runPath(home: string, relativeFile: string): string {
158
+ return path.resolve(baseDir(home), relativeFile);
159
+ }
160
+
94
161
  type Raw = Record<string, unknown>;
95
162
 
96
- // Parses a `{ version, <key>: [...] }` file into its object entries; undefined when the text is not that shape; in: raw file text, out: readList.
97
- function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
163
+ // Parses `{ version, task: {...} }` into the record; undefined when the text is not that shape; in: readShard, out: the record.
164
+ function parseTask(text: string): Raw | undefined {
98
165
  try {
99
- const list = (JSON.parse(text) as Raw | null)?.[key];
166
+ const task = (JSON.parse(text) as Raw | null)?.["task"];
167
+ return task && typeof task === "object" && !Array.isArray(task) ? (task as Raw) : undefined;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+
173
+ // Parses `{ version, pending: [...] }` into its object entries; undefined when the text is not that shape; in: readShard, out: the entries.
174
+ function parsePending(text: string): Raw[] | undefined {
175
+ try {
176
+ const list = (JSON.parse(text) as Raw | null)?.["pending"];
100
177
  if (!Array.isArray(list)) return undefined;
101
178
  return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
102
179
  } catch {
@@ -105,21 +182,21 @@ function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
105
182
  }
106
183
 
107
184
  /**
108
- * Reads a `{ version, <key>: [...] }` file. Missing = empty. A damaged file is renamed `*.corrupt-<ts>`; if its `.bak`
109
- * (the last good version) is valid it is restored, else the list starts empty.
185
+ * Reads one JSON file of an action. Missing = undefined. A damaged file is renamed `*.corrupt-<ts>`; if its `.bak`
186
+ * (the last good version) is valid it is restored and returned, else undefined (the action is treated as absent).
110
187
  */
111
- function readList(file: string, key: "tasks" | "pending"): Raw[] {
112
- const read = readWithBackup(file, (t) => parseList(t, key) !== undefined);
113
- if (!read) return [];
114
- const list = parseList(read.text, key);
115
- if (list && !read.fromBackup) return list;
188
+ function readShard<T>(file: string, parse: (text: string) => T | undefined): T | undefined {
189
+ const read = readWithBackup(file, (t) => parse(t) !== undefined);
190
+ if (!read) return undefined;
191
+ const value = parse(read.text);
192
+ if (value !== undefined && !read.fromBackup) return value;
116
193
  const moved = `${file}.corrupt-${Date.now()}`;
117
194
  try {
118
195
  fs.renameSync(file, moved);
119
196
  } catch {
120
197
  // why: quarantine is best-effort; the file is ignored either way.
121
198
  }
122
- if (list) {
199
+ if (value !== undefined) {
123
200
  try {
124
201
  writeFileDurable(file, read.text, { backup: false }); // keep the good .bak where it is
125
202
  } catch {
@@ -128,39 +205,181 @@ function readList(file: string, key: "tasks" | "pending"): Raw[] {
128
205
  }
129
206
  telemetryBus.emitWarning(
130
207
  "scheduled_action.store_corrupt",
131
- `[Agent Accelerator] ${path.basename(file)} is not valid; moved to ${path.basename(moved)} and ${list ? "restored from its backup" : "starting empty"}.`,
132
- { file: moved, restored: !!list }
208
+ `[Agent Accelerator] ${path.relative(baseDir(path.dirname(path.dirname(file))), file)} is not valid; moved to ${path.basename(moved)} and ${value !== undefined ? "restored from its backup" : "ignored"}.`,
209
+ { file: moved, restored: value !== undefined }
133
210
  );
134
- return list ?? [];
211
+ return value;
135
212
  }
136
213
 
137
- function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
138
- writeFileDurable(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
214
+ // Names of the action folders of a home (every folder not starting with a dot), sorted; in: readTasks/readDeliveries, out: folder names.
215
+ function actionFolders(home: string): string[] {
216
+ try {
217
+ return fs
218
+ .readdirSync(baseDir(home), { withFileTypes: true })
219
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
220
+ .map((e) => e.name)
221
+ .sort();
222
+ } catch {
223
+ return [];
224
+ }
139
225
  }
140
226
 
141
- /** Every record as stored. Records may be invalid: check with {@link validateRecord} before acting on one. */
227
+ /** Folder/id mismatches already reported, so a bad hand edit warns once per process. */
228
+ const reportedMismatch = new Set<string>();
229
+ // True when a record or delivery names the folder it sits in; otherwise warns once and says no; in: readTasks/readDeliveries, out: a boolean.
230
+ function inOwnFolder(file: string, folder: string, id: unknown): boolean {
231
+ if (id === folder) return true;
232
+ const key = `${file}:${String(id)}`;
233
+ if (!reportedMismatch.has(key)) {
234
+ reportedMismatch.add(key);
235
+ telemetryBus.emitWarning(
236
+ "scheduled_action.invalid",
237
+ `[Agent Accelerator] ${folder}/${path.basename(file)} names the id ${JSON.stringify(id)} but sits in the folder "${folder}" and is skipped.`,
238
+ { id: String(id), problem: "id: does not match its folder", file: path.basename(file) }
239
+ );
240
+ }
241
+ return false;
242
+ }
243
+
244
+ // Orders records by creation time, then id, so listings are stable; in: readTasks, out: a comparison number.
245
+ function byCreation(a: ScheduledActionRecord, b: ScheduledActionRecord): number {
246
+ return (a.createdAt ?? "").localeCompare(b.createdAt ?? "") || a.id.localeCompare(b.id);
247
+ }
248
+
249
+ /** One action as stored (`task.json`), or undefined when it does not exist or `id` is not an action id. May be invalid: check with {@link validateRecord}. */
250
+ export function readTask(home: string, id: string): ScheduledActionRecord | undefined {
251
+ let dir: string;
252
+ try {
253
+ dir = taskDir(home, id);
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ const raw = readShard(taskFile(dir), parseTask);
258
+ return raw && inOwnFolder(taskFile(dir), id, raw["id"]) ? (raw as unknown as ScheduledActionRecord) : undefined;
259
+ }
260
+
261
+ /** Every record as stored, oldest first. Records may be invalid: check with {@link validateRecord} before acting on one. */
142
262
  export function readTasks(home: string): ScheduledActionRecord[] {
143
- return readList(tasksFile(home), "tasks") as unknown as ScheduledActionRecord[];
263
+ const out: ScheduledActionRecord[] = [];
264
+ for (const folder of actionFolders(home)) {
265
+ if (!TASK_ID_PATTERN.test(folder) || folder.length > MAX_TASK_ID_LENGTH) continue; // not an action folder
266
+ const rec = readTask(home, folder);
267
+ if (rec) out.push(rec);
268
+ }
269
+ return out.sort(byCreation);
270
+ }
271
+
272
+ /**
273
+ * Changes one action under its own short mutex: re-reads `task.json`, calls `fn` (which may mutate the record in place;
274
+ * it gets `undefined` when the action does not exist) and writes the record back if it changed. If `fn` throws nothing
275
+ * is written. Other actions are never locked, so processes working on different actions do not wait for each other.
276
+ */
277
+ export function updateTask<T>(home: string, id: string, fn: (rec: ScheduledActionRecord | undefined) => T): T {
278
+ let dir: string;
279
+ try {
280
+ dir = taskDir(home, id);
281
+ } catch {
282
+ return fn(undefined); // not an action id: nothing to change
283
+ }
284
+ if (!fs.existsSync(dir)) return fn(undefined);
285
+ try {
286
+ return withFileMutex(mutexFile(dir), () => {
287
+ sweepShardTemps(dir);
288
+ const rec = readTask(home, id);
289
+ const before = rec ? JSON.stringify(rec) : "";
290
+ const result = fn(rec);
291
+ if (rec && JSON.stringify(rec) !== before) writeFileDurable(taskFile(dir), JSON.stringify({ version: 2, task: rec }, null, 2) + "\n");
292
+ return result;
293
+ });
294
+ } catch (err) {
295
+ if (!isMissingFolder(err)) throw err;
296
+ return fn(undefined); // another process deleted the action while we waited for its lock
297
+ }
144
298
  }
145
299
 
146
- /** Re-reads `tasks.json`, applies `fn` (mutating the array/records in place) and writes it back. */
147
- export function updateTasks<T>(home: string, fn: (tasks: ScheduledActionRecord[]) => T): T {
148
- const tasks = readTasks(home);
149
- const result = fn(tasks);
150
- writeList(tasksFile(home), "tasks", tasks as unknown as Raw[]);
151
- return result;
300
+ /**
301
+ * Creates an action in a new folder. The folder is made with a create-only `mkdir`, so two processes can never take the
302
+ * same id; `nextId` is called again until a free one is found, then `build` makes the record for it.
303
+ */
304
+ export function createTask(home: string, nextId: () => string, build: (id: string) => ScheduledActionRecord): ScheduledActionRecord {
305
+ fs.mkdirSync(baseDir(home), { recursive: true, mode: 0o700 });
306
+ for (let attempt = 0; attempt < 100; attempt++) {
307
+ const id = nextId();
308
+ const dir = taskDir(home, id);
309
+ try {
310
+ fs.mkdirSync(dir, { mode: 0o700 });
311
+ } catch (err) {
312
+ if ((err as { code?: string }).code === "EEXIST") continue;
313
+ throw err;
314
+ }
315
+ const rec = build(id);
316
+ withFileMutex(mutexFile(dir), () => {
317
+ sweepShardTemps(dir);
318
+ writeFileDurable(taskFile(dir), JSON.stringify({ version: 2, task: rec }, null, 2) + "\n");
319
+ });
320
+ return rec;
321
+ }
322
+ throw new ValidationError("Could not find a free scheduled action id.");
152
323
  }
153
324
 
325
+ /** Removes an action's record and saved deliveries (run files stay); true when there was something to remove. An emptied folder is removed too. */
326
+ export function deleteTask(home: string, id: string): boolean {
327
+ const dir = taskDir(home, id);
328
+ if (!fs.existsSync(dir)) return false;
329
+ let removed: boolean;
330
+ try {
331
+ removed = withFileMutex(mutexFile(dir), () => {
332
+ const had = fs.existsSync(taskFile(dir)) || fs.existsSync(deliveryFile(dir));
333
+ for (const f of [taskFile(dir), deliveryFile(dir)]) fs.rmSync(f, { force: true }), fs.rmSync(`${f}.bak`, { force: true });
334
+ fs.rmSync(heartbeatFile(dir), { force: true });
335
+ return had;
336
+ });
337
+ } catch (err) {
338
+ if (isMissingFolder(err)) return false; // another process deleted it first
339
+ throw err;
340
+ }
341
+ try {
342
+ fs.rmdirSync(dir); // only succeeds when nothing is left in it
343
+ } catch {
344
+ // why: run files (or another process's new action) keep the folder.
345
+ }
346
+ return removed;
347
+ }
348
+
349
+ /** Every saved delivery of every action as stored, soonest first (entries may be invalid: check with validateDelivery). */
154
350
  export function readDeliveries(home: string): PendingDelivery[] {
155
- return readList(deliveriesFile(home), "pending") as unknown as PendingDelivery[];
351
+ const out: PendingDelivery[] = [];
352
+ for (const folder of actionFolders(home)) {
353
+ if (!TASK_ID_PATTERN.test(folder) || folder.length > MAX_TASK_ID_LENGTH) continue;
354
+ const file = deliveryFile(taskDir(home, folder));
355
+ for (const entry of readShard(file, parsePending) ?? []) if (inOwnFolder(file, folder, entry["id"])) out.push(entry as unknown as PendingDelivery);
356
+ }
357
+ return out.sort((a, b) => String(a.deliverAt).localeCompare(String(b.deliverAt)));
156
358
  }
157
359
 
158
- /** Re-reads `deliveries.json`, applies `fn` (mutating in place) and writes it back. */
159
- export function updateDeliveries<T>(home: string, fn: (pending: PendingDelivery[]) => T): T {
160
- const pending = readDeliveries(home);
161
- const result = fn(pending);
162
- writeList(deliveriesFile(home), "pending", pending as unknown as Raw[]);
163
- return result;
360
+ /** Changes the saved deliveries of ONE action under its short mutex (`fn` mutates the array in place); the file is removed when the list ends up empty. */
361
+ export function updateDeliveries<T>(home: string, id: string, fn: (pending: PendingDelivery[]) => T): T {
362
+ const dir = taskDir(home, id);
363
+ for (let attempt = 1; ; attempt++) {
364
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
365
+ try {
366
+ return withFileMutex(mutexFile(dir), () => {
367
+ sweepShardTemps(dir);
368
+ const file = deliveryFile(dir);
369
+ const pending = (readShard(file, parsePending) ?? []) as unknown as PendingDelivery[];
370
+ const result = fn(pending);
371
+ if (pending.length === 0) {
372
+ fs.rmSync(file, { force: true });
373
+ fs.rmSync(`${file}.bak`, { force: true });
374
+ } else {
375
+ writeFileDurable(file, JSON.stringify({ version: 2, pending }, null, 2) + "\n");
376
+ }
377
+ return result;
378
+ });
379
+ } catch (err) {
380
+ if (!isMissingFolder(err) || attempt >= 3) throw err; // the folder was removed between making it and locking it: make it again
381
+ }
382
+ }
164
383
  }
165
384
 
166
385
  // ---------------------------------------------------------------------------
@@ -219,7 +438,7 @@ const deliverySchema = z.object({
219
438
  late: z.boolean().optional(),
220
439
  });
221
440
 
222
- /** `undefined` when a `deliveries.json` entry is usable, else a one-line reason. Never throws. */
441
+ /** `undefined` when a `delivery.json` entry is usable, else a one-line reason. Never throws. */
223
442
  export function validateDelivery(raw: unknown): string | undefined {
224
443
  const parsed = deliverySchema.safeParse(raw);
225
444
  return parsed.success ? undefined : parsed.error.issues.map((i) => `${i.path.join(".") || "entry"}: ${i.message}`).join("; ");
@@ -229,6 +448,7 @@ export function validateDelivery(raw: unknown): string | undefined {
229
448
  // Run files
230
449
  // ---------------------------------------------------------------------------
231
450
 
451
+ // Renders a run file: the `---` header lines, a blank line, the result text; in: writeRunFile, out: the file text.
232
452
  function renderRun(header: RunHeader, body: string): string {
233
453
  const lines = ["---"];
234
454
  for (const [key, value] of Object.entries(header)) {
@@ -240,7 +460,7 @@ function renderRun(header: RunHeader, body: string): string {
240
460
 
241
461
  /**
242
462
  * Writes one run under its final name (`27-october-2026.md`, `-2`, `-3` on a
243
- * same-day clash) and returns its path relative to `scheduled_actions/`.
463
+ * same-day clash) and returns its path relative to `scheduled/`.
244
464
  */
245
465
  export function writeRunFile(home: string, id: string, deliverAt: Date, header: RunHeader, body: string): string {
246
466
  const dir = path.join(baseDir(home), id);
@@ -287,7 +507,7 @@ export function findRunFile(home: string, id: string, deliverAt: Date, startedAt
287
507
  return undefined;
288
508
  }
289
509
 
290
- /** Deletes an action's folder (`purge`). Only ever inside `scheduled_actions/`. */
510
+ /** Deletes an action's folder (`purge`). Only ever inside `scheduled/`. */
291
511
  export function removeTaskFolder(home: string, id: string): void {
292
512
  const root = baseDir(home);
293
513
  const dir = path.resolve(root, id);
@@ -15,7 +15,9 @@ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
15
15
  const DDHHMM = /^(\d{2,}):(\d{2}):(\d{2})$/;
16
16
  const LOCAL_ISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
17
17
 
18
+ // Two-digit zero padding; in: date formatting here, out: a string.
18
19
  const pad2 = (n: number): string => String(n).padStart(2, "0");
20
+ // JSON text of a value for error messages; in: the validators, out: a string.
19
21
  const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
20
22
 
21
23
  /** Parses `DD:HH:MM` (DD >= 2 digits, MM < 60). HH is range-checked by the caller. */
@@ -0,0 +1,19 @@
1
+ # src/session/
2
+
3
+ Saving a conversation, its spending and its tool logs.
4
+
5
+ | File | What it is |
6
+ | :-- | :-- |
7
+ | `store.ts` | Snapshot format (`session.json`, one line per field and per turn), load validation, `SessionTelemetry`, media extraction, journal replay (`replayJournal`), and `priceUsage` (the one cost model, integer micro-USD). |
8
+ | `journal.ts` | `SessionJournal`: after each step appends one fsynced line to `journal.ndjson`; writes a fresh snapshot (new `epoch`) when history changed any other way or the journal passed 500 lines / 4 MB. |
9
+ | `usage.ts` | Pure spending accounting: `UsageTotals`, `usageLine`, `buildUsageReport`. No Agent import. |
10
+ | `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
11
+
12
+ ## Invariants
13
+ - Layout: `<home>/sessions/<id>/{session.json, journal.ndjson, telemetry.ndjson, media/, tool-logs/, workers/}`. `telemetry.ndjson` is written by `telemetry-file.ts` (opt-in), not by the session code; a folder holding only that file is not a session.
14
+ - A journal whose header epoch differs from the snapshot's is ignored on load.
15
+ - Journal record types: `base`, `msg`, `meta`, `run`, `end`, `tool`, `pending`, `usage`. Replay ignores unknown types, drops a torn last line, and stops at earlier damage with `session.journal_corrupt`.
16
+ - Media is content-addressed (`media/<kind>-h<hash>.<ext>`), written once, never deleted.
17
+ - Valid sessions over the load limits throw `SessionLoadError` and are never quarantined; only corrupt files are renamed `*.corrupt-<ts>`.
18
+ - Pricing: a provider-reported cost wins and is never rescaled; unknown pricing stays unknown, never $0; cache reads and writes are subsets of input.
19
+ - Never call `saveSessionDir` on a folder an Agent persists to.