@addai/node 0.4.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.
Files changed (133) hide show
  1. package/README.md +96 -0
  2. package/dist/agent-auth.d.ts +9 -0
  3. package/dist/agent-auth.js +96 -0
  4. package/dist/attachments.d.ts +20 -0
  5. package/dist/attachments.js +164 -0
  6. package/dist/capabilities.d.ts +64 -0
  7. package/dist/capabilities.js +406 -0
  8. package/dist/claude-binary.d.ts +16 -0
  9. package/dist/claude-binary.js +145 -0
  10. package/dist/claude-config.d.ts +11 -0
  11. package/dist/claude-config.js +144 -0
  12. package/dist/claude-print.d.ts +35 -0
  13. package/dist/claude-print.js +312 -0
  14. package/dist/claude-spawn.d.ts +40 -0
  15. package/dist/claude-spawn.js +220 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +137 -0
  18. package/dist/codex-binary.d.ts +4 -0
  19. package/dist/codex-binary.js +70 -0
  20. package/dist/codex-spawn.d.ts +47 -0
  21. package/dist/codex-spawn.js +320 -0
  22. package/dist/command-runner.d.ts +9 -0
  23. package/dist/command-runner.js +592 -0
  24. package/dist/config.d.ts +7 -0
  25. package/dist/config.js +38 -0
  26. package/dist/control-server.d.ts +5 -0
  27. package/dist/control-server.js +292 -0
  28. package/dist/diskguard.d.ts +96 -0
  29. package/dist/diskguard.js +435 -0
  30. package/dist/error-classify.d.ts +20 -0
  31. package/dist/error-classify.js +159 -0
  32. package/dist/events.d.ts +26 -0
  33. package/dist/events.js +97 -0
  34. package/dist/gemini-binary.d.ts +4 -0
  35. package/dist/gemini-binary.js +64 -0
  36. package/dist/gemini-spawn.d.ts +52 -0
  37. package/dist/gemini-spawn.js +369 -0
  38. package/dist/git-identity.d.ts +18 -0
  39. package/dist/git-identity.js +104 -0
  40. package/dist/grok-binary.d.ts +4 -0
  41. package/dist/grok-binary.js +70 -0
  42. package/dist/grok-spawn.d.ts +41 -0
  43. package/dist/grok-spawn.js +495 -0
  44. package/dist/guardrails.d.ts +32 -0
  45. package/dist/guardrails.js +125 -0
  46. package/dist/hang-watchdog.d.ts +17 -0
  47. package/dist/hang-watchdog.js +48 -0
  48. package/dist/harness-registry.d.ts +55 -0
  49. package/dist/harness-registry.js +180 -0
  50. package/dist/heartbeat.d.ts +13 -0
  51. package/dist/heartbeat.js +95 -0
  52. package/dist/index.d.ts +12 -0
  53. package/dist/index.js +440 -0
  54. package/dist/install.d.ts +63 -0
  55. package/dist/install.js +467 -0
  56. package/dist/jsonl-tail.d.ts +20 -0
  57. package/dist/jsonl-tail.js +192 -0
  58. package/dist/kimi-binary.d.ts +4 -0
  59. package/dist/kimi-binary.js +68 -0
  60. package/dist/kimi-spawn.d.ts +47 -0
  61. package/dist/kimi-spawn.js +319 -0
  62. package/dist/lockfile.d.ts +30 -0
  63. package/dist/lockfile.js +169 -0
  64. package/dist/mcp-config.d.ts +23 -0
  65. package/dist/mcp-config.js +133 -0
  66. package/dist/memory-capture.d.ts +20 -0
  67. package/dist/memory-capture.js +45 -0
  68. package/dist/memory-pack.d.ts +34 -0
  69. package/dist/memory-pack.js +171 -0
  70. package/dist/model-filter.d.ts +4 -0
  71. package/dist/model-filter.js +34 -0
  72. package/dist/node-health.d.ts +18 -0
  73. package/dist/node-health.js +74 -0
  74. package/dist/pairing.d.ts +20 -0
  75. package/dist/pairing.js +129 -0
  76. package/dist/paths.d.ts +9 -0
  77. package/dist/paths.js +48 -0
  78. package/dist/precompact-hook.d.ts +27 -0
  79. package/dist/precompact-hook.js +136 -0
  80. package/dist/projects.d.ts +25 -0
  81. package/dist/projects.js +255 -0
  82. package/dist/prompt-keystrokes.d.ts +3 -0
  83. package/dist/prompt-keystrokes.js +56 -0
  84. package/dist/pty-helper.d.ts +26 -0
  85. package/dist/pty-helper.js +156 -0
  86. package/dist/request-pump.d.ts +12 -0
  87. package/dist/request-pump.js +249 -0
  88. package/dist/run-retry.d.ts +28 -0
  89. package/dist/run-retry.js +81 -0
  90. package/dist/self-update.d.ts +75 -0
  91. package/dist/self-update.js +192 -0
  92. package/dist/session-runner.d.ts +130 -0
  93. package/dist/session-runner.js +2271 -0
  94. package/dist/sleep-detector.d.ts +11 -0
  95. package/dist/sleep-detector.js +32 -0
  96. package/dist/state-migrate.d.ts +8 -0
  97. package/dist/state-migrate.js +91 -0
  98. package/dist/store.d.ts +5 -0
  99. package/dist/store.js +82 -0
  100. package/dist/supabase-client.d.ts +15 -0
  101. package/dist/supabase-client.js +119 -0
  102. package/dist/time-context.d.ts +10 -0
  103. package/dist/time-context.js +71 -0
  104. package/dist/tokens.d.ts +4 -0
  105. package/dist/tokens.js +33 -0
  106. package/dist/tui/app.d.ts +35 -0
  107. package/dist/tui/app.js +82 -0
  108. package/dist/tui/console-capture.d.ts +13 -0
  109. package/dist/tui/console-capture.js +62 -0
  110. package/dist/tui/dashboard.d.ts +42 -0
  111. package/dist/tui/dashboard.js +205 -0
  112. package/dist/tui/data.d.ts +57 -0
  113. package/dist/tui/data.js +62 -0
  114. package/dist/tui/harnesses.d.ts +16 -0
  115. package/dist/tui/harnesses.js +354 -0
  116. package/dist/tui/render.d.ts +21 -0
  117. package/dist/tui/render.js +116 -0
  118. package/dist/tui/requests.d.ts +18 -0
  119. package/dist/tui/requests.js +127 -0
  120. package/dist/tui/run.d.ts +13 -0
  121. package/dist/tui/run.js +178 -0
  122. package/dist/tui/transcript.d.ts +39 -0
  123. package/dist/tui/transcript.js +234 -0
  124. package/dist/tui.d.ts +1 -0
  125. package/dist/tui.js +314 -0
  126. package/dist/types.d.ts +65 -0
  127. package/dist/types.js +3 -0
  128. package/dist/win.d.ts +67 -0
  129. package/dist/win.js +317 -0
  130. package/package.json +60 -0
  131. package/scripts/fix-pty-helper.js +28 -0
  132. package/scripts/precompact-capture.js +292 -0
  133. package/scripts/smoke-test.sh +74 -0
@@ -0,0 +1,435 @@
1
+ "use strict";
2
+ // DiskGuard — CCTV-style disk-threshold auto-purge for the per-run Codex
3
+ // scratch dirs the runtime creates.
4
+ //
5
+ // Background: codex-spawn mints a throwaway CODEX_HOME per run under
6
+ // os.tmpdir() (entities-codex-<rand>). Codex writes its state_5.sqlite,
7
+ // logs, caches and shell snapshots there — 50-350 MB per run. On a
8
+ // dedicated runtime host that does thousands of runs, these pile up and
9
+ // fill the disk (observed: 3,006 dirs = ~158 GiB, hit 0 bytes free /
10
+ // ENOSPC). codex-spawn now removes its own dir on exit; DiskGuard is the
11
+ // safety net for the cases that skip that path — SIGKILL, crashes, and
12
+ // orphans left by a previous runtime process.
13
+ //
14
+ // Model (like a CCTV DVR): watch free space on the filesystem holding the
15
+ // temp root. When usage crosses a HIGH-water mark, delete the OLDEST
16
+ // INACTIVE scratch dirs, oldest-first, until usage drops below a LOW-water
17
+ // mark or no evictable dirs remain — leaving a safety buffer for the OS.
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.DiskGuard = void 0;
53
+ exports.loadDiskGuardConfig = loadDiskGuardConfig;
54
+ exports.runEvictionLoop = runEvictionLoop;
55
+ exports.makeSingleFlight = makeSingleFlight;
56
+ exports.removeSessionDir = removeSessionDir;
57
+ exports.getDiskGuard = getDiskGuard;
58
+ const fs = __importStar(require("fs"));
59
+ const os = __importStar(require("os"));
60
+ const path = __importStar(require("path"));
61
+ // ---------------------------------------------------------------------------
62
+ // Config
63
+ // ---------------------------------------------------------------------------
64
+ function numEnv(env, key, def) {
65
+ const raw = env[key];
66
+ if (raw === undefined || raw === '')
67
+ return def;
68
+ const n = Number(raw);
69
+ return Number.isFinite(n) ? n : def;
70
+ }
71
+ function boolEnv(env, key, def) {
72
+ const raw = env[key];
73
+ if (raw === undefined || raw === '')
74
+ return def;
75
+ return /^(1|true|yes|on)$/i.test(raw.trim());
76
+ }
77
+ function loadDiskGuardConfig(env = process.env) {
78
+ let high = numEnv(env, 'DISKGUARD_HIGH_WATER_PCT', 90);
79
+ let low = numEnv(env, 'DISKGUARD_LOW_WATER_PCT', 80);
80
+ // Clamp to sane bounds and keep low strictly below high — otherwise the
81
+ // eviction loop can't converge (it stops when usage <= low).
82
+ high = Math.min(99, Math.max(1, high));
83
+ low = Math.min(99, Math.max(1, low));
84
+ if (low >= high)
85
+ low = Math.max(1, high - 1);
86
+ const minAgeMin = Math.max(0, numEnv(env, 'DISKGUARD_MIN_AGE_MINUTES', 30));
87
+ const checkSec = Math.max(5, numEnv(env, 'DISKGUARD_CHECK_INTERVAL_SECONDS', 60));
88
+ const prefixEnv = (env.DISKGUARD_PREFIXES ?? 'entities-codex-').trim();
89
+ const prefixes = prefixEnv.split(',').map(s => s.trim()).filter(Boolean);
90
+ return {
91
+ enabled: boolEnv(env, 'DISKGUARD_ENABLED', true),
92
+ highWaterPct: high,
93
+ lowWaterPct: low,
94
+ minAgeMs: minAgeMin * 60_000,
95
+ checkIntervalMs: checkSec * 1000,
96
+ dryRun: boolEnv(env, 'DISKGUARD_DRY_RUN', false),
97
+ tempRoot: env.DISKGUARD_TEMP_ROOT || os.tmpdir(),
98
+ prefixes: prefixes.length > 0 ? prefixes : ['entities-codex-'],
99
+ };
100
+ }
101
+ async function runEvictionLoop(deps, opts) {
102
+ const used = await deps.readUsedPct();
103
+ if (used === null) {
104
+ // Fail safe: if we can't read usage, do nothing.
105
+ return { ran: false, usedBefore: null, usedAfter: null, evicted: [], bytesFreed: 0, skippedActive: 0, skippedYoung: 0, reason: 'usage-unreadable' };
106
+ }
107
+ if (used < opts.highWaterPct) {
108
+ return { ran: false, usedBefore: used, usedAfter: used, evicted: [], bytesFreed: 0, skippedActive: 0, skippedYoung: 0, reason: 'below-high-water' };
109
+ }
110
+ const candidates = await deps.listCandidates();
111
+ let skippedActive = 0;
112
+ let skippedYoung = 0;
113
+ const eligible = [];
114
+ for (const c of candidates) {
115
+ if (c.active) {
116
+ skippedActive++;
117
+ continue;
118
+ }
119
+ if (c.ageMs < opts.minAgeMs) {
120
+ skippedYoung++;
121
+ continue;
122
+ }
123
+ eligible.push(c);
124
+ }
125
+ // Oldest activity first — CCTV overwrites the oldest footage.
126
+ eligible.sort((a, b) => a.activityMs - b.activityMs);
127
+ const evicted = [];
128
+ if (opts.dryRun) {
129
+ // Can't observe real usage drop without deleting — estimate from sizes
130
+ // when we know the volume size; otherwise just report all eligible.
131
+ let est = used;
132
+ for (const c of eligible) {
133
+ if (deps.totalBytes && est <= opts.lowWaterPct)
134
+ break;
135
+ evicted.push(c.path);
136
+ if (deps.totalBytes)
137
+ est -= (c.sizeBytes / deps.totalBytes) * 100;
138
+ }
139
+ return {
140
+ ran: true, usedBefore: used, usedAfter: deps.totalBytes ? est : used,
141
+ evicted, bytesFreed: eligible.filter(c => evicted.includes(c.path)).reduce((s, c) => s + c.sizeBytes, 0),
142
+ skippedActive, skippedYoung, reason: 'dry-run',
143
+ };
144
+ }
145
+ let current = used;
146
+ for (const c of eligible) {
147
+ if (current <= opts.lowWaterPct)
148
+ break;
149
+ // A failed delete (EBUSY/EPERM — common on Windows when a handle is
150
+ // still open in the tree) must not abort the sweep or bubble up as an
151
+ // unhandled rejection: skip the dir and keep evicting the rest.
152
+ try {
153
+ await deps.deleteDir(c.path);
154
+ }
155
+ catch (err) {
156
+ console.error(`[diskguard] failed to evict ${c.path}: ${err.message}`);
157
+ continue;
158
+ }
159
+ evicted.push(c.path);
160
+ const after = await deps.readUsedPct();
161
+ if (after !== null)
162
+ current = after;
163
+ }
164
+ const bytesFreed = deps.totalBytes ? Math.max(0, ((used - current) / 100) * deps.totalBytes) : 0;
165
+ return {
166
+ ran: true, usedBefore: used, usedAfter: current, evicted, bytesFreed,
167
+ skippedActive, skippedYoung,
168
+ reason: evicted.length > 0 ? 'evicted' : 'no-eligible',
169
+ };
170
+ }
171
+ /** Single-flight wrapper: while a call is in flight, concurrent callers get
172
+ * the same promise instead of starting a second sweep (the mutex). */
173
+ function makeSingleFlight() {
174
+ let inFlight = null;
175
+ return (fn) => {
176
+ if (inFlight)
177
+ return inFlight;
178
+ inFlight = fn().finally(() => { inFlight = null; });
179
+ return inFlight;
180
+ };
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // Filesystem helpers
184
+ // ---------------------------------------------------------------------------
185
+ function safeRealpath(p) {
186
+ try {
187
+ return fs.realpathSync(p);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ function pidAlive(pid) {
194
+ if (!Number.isInteger(pid) || pid <= 0)
195
+ return false;
196
+ try {
197
+ // Signal 0 doesn't send a signal, just checks for existence/permission.
198
+ process.kill(pid, 0);
199
+ return true;
200
+ }
201
+ catch (e) {
202
+ // EPERM means the process exists but is owned by someone else — alive.
203
+ return e.code === 'EPERM';
204
+ }
205
+ }
206
+ /** Best-effort recursive dir size. Never follows symlinks (the scratch dir's
207
+ * `sessions`/`auth.json` are symlinks into the durable ~/.codex). */
208
+ async function dirSize(dir) {
209
+ let total = 0;
210
+ let entries;
211
+ try {
212
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
213
+ }
214
+ catch {
215
+ return 0;
216
+ }
217
+ for (const e of entries) {
218
+ if (e.isSymbolicLink())
219
+ continue;
220
+ const full = path.join(dir, e.name);
221
+ try {
222
+ if (e.isDirectory())
223
+ total += await dirSize(full);
224
+ else {
225
+ total += (await fs.promises.stat(full)).size;
226
+ }
227
+ }
228
+ catch { /* skip unreadable entry */ }
229
+ }
230
+ return total;
231
+ }
232
+ /** Remove a scratch dir. `recursive+force` unlinks symlink ENTRIES without
233
+ * following them, so the ~/.codex targets behind `sessions`/`auth.json`
234
+ * are never touched. ENOENT is swallowed by `force`. */
235
+ async function removeSessionDir(dir) {
236
+ await fs.promises.rm(dir, { recursive: true, force: true });
237
+ }
238
+ class DiskGuard {
239
+ config;
240
+ timer;
241
+ runExclusive = makeSingleFlight();
242
+ /** Realpath'd scratch dirs owned by a live in-process session. */
243
+ activeDirs = new Set();
244
+ constructor(overrides) {
245
+ this.config = { ...loadDiskGuardConfig(), ...overrides };
246
+ }
247
+ /** Start the periodic sweeper. No-op (beyond a log line) if disabled;
248
+ * cleanup-on-exit via beginSession still works regardless. */
249
+ start() {
250
+ if (!this.config.enabled) {
251
+ console.log('[diskguard] disabled (DISKGUARD_ENABLED=false) — codex scratch dirs still cleaned on exit');
252
+ return;
253
+ }
254
+ void this.sweep('startup').catch(err => console.error('[diskguard] startup sweep failed:', err.message));
255
+ this.timer = setInterval(() => void this.sweep('periodic').catch(err => console.error('[diskguard] sweep failed:', err.message)), this.config.checkIntervalMs);
256
+ this.timer.unref?.();
257
+ console.log(`[diskguard] enabled — root=${this.config.tempRoot} high=${this.config.highWaterPct}% ` +
258
+ `low=${this.config.lowWaterPct}% minAge=${Math.round(this.config.minAgeMs / 60_000)}m ` +
259
+ `interval=${Math.round(this.config.checkIntervalMs / 1000)}s${this.config.dryRun ? ' DRY-RUN' : ''}`);
260
+ }
261
+ stop() {
262
+ if (this.timer) {
263
+ clearInterval(this.timer);
264
+ this.timer = undefined;
265
+ }
266
+ }
267
+ /** Run one sweep. Concurrent calls share a single in-flight sweep. */
268
+ sweep(reason) {
269
+ return this.runExclusive(() => this.runSweep(reason));
270
+ }
271
+ async runSweep(reason) {
272
+ const totalBytes = this.readTotalBytes();
273
+ const res = await runEvictionLoop({
274
+ listCandidates: () => this.listCandidates(this.config.dryRun),
275
+ readUsedPct: async () => this.readUsedPct(),
276
+ deleteDir: (p) => removeSessionDir(p),
277
+ totalBytes,
278
+ }, this.config);
279
+ if (res.reason === 'usage-unreadable') {
280
+ console.warn(`[diskguard] ${reason}: could not read disk usage for ${this.config.tempRoot}; skipping`);
281
+ }
282
+ else if (res.ran && (res.evicted.length > 0 || this.config.dryRun)) {
283
+ const verb = this.config.dryRun ? 'would evict' : 'evicted';
284
+ console.log(`[diskguard] ${reason}: usage ${fmtPct(res.usedBefore)}→${fmtPct(res.usedAfter)}, ` +
285
+ `${verb} ${res.evicted.length} dir(s) (~${fmtMB(res.bytesFreed)} MB), ` +
286
+ `skipped ${res.skippedActive} active / ${res.skippedYoung} too-young`);
287
+ }
288
+ return res;
289
+ }
290
+ /** Register a live session: mark its dir active in-process and write a
291
+ * PID lock (with a periodic heartbeat) so a DIFFERENT runtime process
292
+ * can tell the dir is still owned and not evict it. */
293
+ beginSession(dir) {
294
+ const real = safeRealpath(dir) ?? dir;
295
+ this.activeDirs.add(real);
296
+ const lockPath = path.join(dir, 'session.lock');
297
+ const writeLock = () => {
298
+ try {
299
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, ts: Date.now() }), { mode: 0o600 });
300
+ }
301
+ catch { /* dir may already be gone */ }
302
+ };
303
+ writeLock();
304
+ const heartbeat = setInterval(writeLock, Math.max(30_000, Math.floor(this.config.checkIntervalMs / 2)));
305
+ heartbeat.unref?.();
306
+ let disposed = false;
307
+ return {
308
+ dispose: async (removeDir) => {
309
+ if (disposed)
310
+ return;
311
+ disposed = true;
312
+ clearInterval(heartbeat);
313
+ this.activeDirs.delete(real);
314
+ if (removeDir) {
315
+ try {
316
+ await removeSessionDir(dir);
317
+ }
318
+ catch { /* best-effort */ }
319
+ }
320
+ },
321
+ };
322
+ }
323
+ readUsedPct() {
324
+ try {
325
+ const st = fs.statfsSync(this.config.tempRoot);
326
+ const blocks = Number(st.blocks);
327
+ const bavail = Number(st.bavail);
328
+ if (!Number.isFinite(blocks) || blocks <= 0)
329
+ return null;
330
+ return (1 - bavail / blocks) * 100;
331
+ }
332
+ catch {
333
+ return null;
334
+ }
335
+ }
336
+ readTotalBytes() {
337
+ try {
338
+ const st = fs.statfsSync(this.config.tempRoot);
339
+ return Number(st.blocks) * Number(st.bsize);
340
+ }
341
+ catch {
342
+ return 0;
343
+ }
344
+ }
345
+ async listCandidates(measureSize) {
346
+ const root = this.config.tempRoot;
347
+ const realRoot = safeRealpath(root);
348
+ let names;
349
+ try {
350
+ names = await fs.promises.readdir(root);
351
+ }
352
+ catch {
353
+ return [];
354
+ }
355
+ const now = Date.now();
356
+ const out = [];
357
+ for (const name of names) {
358
+ if (!this.config.prefixes.some(p => name.startsWith(p)))
359
+ continue;
360
+ const full = path.join(root, name);
361
+ let lst;
362
+ try {
363
+ lst = await fs.promises.lstat(full);
364
+ }
365
+ catch {
366
+ continue;
367
+ }
368
+ // Only real directories — never a symlink masquerading as one.
369
+ if (!lst.isDirectory())
370
+ continue;
371
+ // Safety: the resolved path must still sit directly under the temp
372
+ // root, so we can never rm somewhere else via a swapped symlink.
373
+ const real = safeRealpath(full);
374
+ if (!real || (realRoot !== null && path.dirname(real) !== realRoot))
375
+ continue;
376
+ const { active, activityMs } = this.classifyActivity(full, lst.mtimeMs);
377
+ const sizeBytes = measureSize ? await dirSize(full) : 0;
378
+ out.push({ path: full, activityMs, ageMs: now - lst.mtimeMs, sizeBytes, active });
379
+ }
380
+ return out;
381
+ }
382
+ /** Decide whether a scratch dir is owned by a live session, and compute
383
+ * its last-activity time for oldest-first ordering. */
384
+ classifyActivity(dir, mtimeMs) {
385
+ const real = safeRealpath(dir);
386
+ if (real && this.activeDirs.has(real))
387
+ return { active: true, activityMs: Date.now() };
388
+ const lockPath = path.join(dir, 'session.lock');
389
+ let lockRaw;
390
+ try {
391
+ lockRaw = fs.readFileSync(lockPath, 'utf8');
392
+ }
393
+ catch {
394
+ return { active: false, activityMs: mtimeMs };
395
+ }
396
+ let lock;
397
+ try {
398
+ lock = JSON.parse(lockRaw);
399
+ }
400
+ catch {
401
+ return { active: false, activityMs: mtimeMs };
402
+ }
403
+ let lockMtime = mtimeMs;
404
+ try {
405
+ lockMtime = fs.statSync(lockPath).mtimeMs;
406
+ }
407
+ catch { /* keep dir mtime */ }
408
+ if (typeof lock.pid === 'number' && pidAlive(lock.pid)) {
409
+ return { active: true, activityMs: Math.max(lockMtime, lock.ts ?? 0) };
410
+ }
411
+ // Owning process is gone. Give a grace window after the last heartbeat
412
+ // (guards against PID reuse and races) before declaring it evictable.
413
+ const grace = Math.max(this.config.checkIntervalMs * 2, 120_000);
414
+ if (Date.now() - lockMtime < grace)
415
+ return { active: true, activityMs: lockMtime };
416
+ return { active: false, activityMs: Math.min(mtimeMs, lockMtime) };
417
+ }
418
+ }
419
+ exports.DiskGuard = DiskGuard;
420
+ function fmtPct(v) {
421
+ return v === null ? 'n/a' : `${v.toFixed(1)}%`;
422
+ }
423
+ function fmtMB(bytes) {
424
+ return (bytes / (1024 * 1024)).toFixed(0);
425
+ }
426
+ // ---------------------------------------------------------------------------
427
+ // Process-wide singleton so codex-spawn (session registration) and index.ts
428
+ // (the sweeper) share one activeDirs set.
429
+ // ---------------------------------------------------------------------------
430
+ let singleton = null;
431
+ function getDiskGuard() {
432
+ if (!singleton)
433
+ singleton = new DiskGuard();
434
+ return singleton;
435
+ }
@@ -0,0 +1,20 @@
1
+ export type ErrorClass = 'transient' | 'terminal';
2
+ /**
3
+ * True when the failure is a stale/invalid resume id — the run should clear
4
+ * the stored session id and retry ONCE fresh rather than fail terminally.
5
+ * Checked BEFORE classifyError's transient/terminal split (session-invalid
6
+ * needs the id cleared, which a plain transient requeue does not do).
7
+ */
8
+ export declare function isSessionInvalid(code?: string | null, message?: string | null): boolean;
9
+ export type Verdict = 'transient' | 'ladderable' | 'terminal';
10
+ /**
11
+ * Three-way verdict driving the resilience policy:
12
+ * transient → same-provider retry (retry_count budget)
13
+ * ladderable → hop to the next provider in the ladder
14
+ * terminal → give up (dead-letter); a hop would fail identically
15
+ * Order matters: transient capacity signals win over ladderable auth words,
16
+ * hard-terminal (content/context) wins over ladderable, and unknown → terminal.
17
+ */
18
+ export declare function classifyFailure(code?: string | null, message?: string | null): Verdict;
19
+ export declare function classifyError(code?: string | null, message?: string | null): ErrorClass;
20
+ export declare function retryDelayMs(retryCount: number): number;
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ // Classifies a failed run's error into 'transient' (worth an automatic
3
+ // same-provider retry: capacity, rate limits, network blips) vs 'terminal'
4
+ // (retrying would just repeat the failure: auth, bad input, content policy,
5
+ // context overflow). The default for anything unrecognized is 'terminal' —
6
+ // a wrong retry re-runs a whole agent turn (and any side effects), which is
7
+ // worse than a wrong give-up now that the failure reason is surfaced to the
8
+ // user with a retry path.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.isSessionInvalid = isSessionInvalid;
11
+ exports.classifyFailure = classifyFailure;
12
+ exports.classifyError = classifyError;
13
+ exports.retryDelayMs = retryDelayMs;
14
+ // Codes we assign ourselves that are never worth retrying, regardless of
15
+ // message content.
16
+ const TERMINAL_CODES = new Set([
17
+ 'no_prompt',
18
+ 'project_not_found',
19
+ 'project_not_ready',
20
+ 'watchdog_timeout',
21
+ 'codex_not_found',
22
+ ]);
23
+ const TERMINAL_CODE_SUFFIXES = ['_not_installed'];
24
+ // Codes we assign ourselves that ARE worth a retry regardless of message —
25
+ // e.g. a hang-watchdog kill: the child produced no output and we killed it,
26
+ // so there's no provider error text, but a stuck tool call may well succeed
27
+ // on a fresh attempt.
28
+ const TRANSIENT_CODES = new Set([
29
+ 'agent_hang',
30
+ ]);
31
+ // Checked FIRST: a concrete capacity/network signal wins over generic
32
+ // terminal-looking words that often ride along in the same error body.
33
+ const TRANSIENT_PATTERNS = [
34
+ /\b429\b/, /\brate.?limit/i, /too many requests/i,
35
+ /\b529\b/, /overloaded/i, /capacity/i,
36
+ /\b50[0234]\b/, /internal server error/i, /service unavailable/i, /bad gateway/i, /gateway timeout/i,
37
+ /\bETIMEDOUT\b/, /\bECONNRESET\b/, /\bECONNREFUSED\b/, /\bENOTFOUND\b/, /\bEAI_AGAIN\b/, /\bEPIPE\b/,
38
+ /fetch failed/i, /socket hang up/i, /network error/i, /timed? ?out/i,
39
+ /spawn (EAGAIN|ENOMEM)/, /\b(EAGAIN|ENOMEM)\b/,
40
+ ];
41
+ const TERMINAL_PATTERNS = [
42
+ /\b40[13]\b/, /unauthorized/i, /forbidden/i, /authentication/i, /api.?key/i, /log ?in/i, /credential/i,
43
+ /\bENOENT\b/, /not (installed|found|on PATH)/i,
44
+ /prompt is too long/i, /context (length|window|limit)/i, /too many tokens/i,
45
+ /content (filter|policy|blocked)/i, /\bpolicy\b/i, /invalid request/i, /bad request/i, /\b400\b/,
46
+ ];
47
+ // A stored resume session/thread id that the local agent CLI no longer has —
48
+ // recycled at 50 turns, cleared by a runtime switch, or created on another
49
+ // machine. Resuming it fails hard, but the run is NOT dead: dropping the stale
50
+ // id and retrying fresh self-heals. This is its OWN class, separate from
51
+ // transient, because a plain same-session retry would resume the SAME missing
52
+ // id and fail identically — the retry must clear the id first. Matched across
53
+ // every agent CLI's invalid-resume phrasing (all surface via *_stderr):
54
+ // claude → "No conversation found with session ID: <uuid>"
55
+ // codex → "session/thread not found" on `exec resume <id>`
56
+ // kimi → "session not found" on `--session <id>`
57
+ // grok → "no such session" / "session not found" on `--resume <id>`
58
+ // gemini → "Error resuming session: Invalid session identifier ..." (bad id)
59
+ // or "... No previous sessions found for this project." (empty/
60
+ // swept-then-recreated store) on `--resume <uuid>`
61
+ const SESSION_INVALID_PATTERNS = [
62
+ // Hang-watchdog kill of a run that was RESUMING and produced zero output:
63
+ // the resume itself is wedged (observed: grok loading a heavy session).
64
+ // Retrying the same resume wedges identically, so route it through the
65
+ // clear-session-and-retry path like any other invalid-session failure.
66
+ /silent resume hang/i,
67
+ /no conversation found with session id/i,
68
+ /no conversation found/i,
69
+ /\b(session|thread|conversation)\b[^.]{0,40}\bnot found\b/i,
70
+ /\bnot found\b[^.]{0,40}\b(session|thread|conversation)\b/i,
71
+ /\bno such (session|thread|conversation)\b/i,
72
+ /\b(unknown|invalid|missing|stale|expired) session( id)?\b/i,
73
+ /could not (find|resume|load)[^.]{0,40}\b(session|thread|conversation)\b/i,
74
+ /(session|thread) id .{0,60}\b(does not exist|doesn'?t exist|is invalid)\b/i,
75
+ // gemini stale-resume phrasings. The plural "sessions" in the empty-store
76
+ // message escapes the \bsession\b patterns above, so it needs its own line.
77
+ /invalid session identifier/i,
78
+ /no previous sessions found/i,
79
+ ];
80
+ /**
81
+ * True when the failure is a stale/invalid resume id — the run should clear
82
+ * the stored session id and retry ONCE fresh rather than fail terminally.
83
+ * Checked BEFORE classifyError's transient/terminal split (session-invalid
84
+ * needs the id cleared, which a plain transient requeue does not do).
85
+ */
86
+ function isSessionInvalid(code, message) {
87
+ const msg = message ?? '';
88
+ if (!msg.trim())
89
+ return false;
90
+ return SESSION_INVALID_PATTERNS.some(re => re.test(msg));
91
+ }
92
+ // Provider/infra failures where the SAME input on a DIFFERENT provider is
93
+ // likely to succeed: auth/token/credential, plan-or-usage limits, and
94
+ // capacity once same-provider transient retries are spent. These read as
95
+ // "terminal" to classifyError today; the ladder rescues them.
96
+ const LADDERABLE_PATTERNS = [
97
+ /\b40[13]\b/, /unauthorized/i, /forbidden/i, /authentication/i, /api.?key/i, /credential/i, /log ?in/i,
98
+ /session limit/i, /usage.{0,20}limit/i, /quota/i, /\bplan\b.{0,20}\blimit/i, /resets? \d/i,
99
+ ];
100
+ // Provider-independent failures — switching providers repeats them. These
101
+ // stay terminal (never ladder).
102
+ const HARD_TERMINAL_PATTERNS = [
103
+ /prompt is too long/i, /context (length|window|limit)/i, /too many tokens/i,
104
+ /content (filter|policy|blocked)/i,
105
+ /invalid request/i, /bad request/i, /\b400\b/,
106
+ /\bENOENT\b/, /not (installed|found|on PATH)/i,
107
+ ];
108
+ /**
109
+ * Three-way verdict driving the resilience policy:
110
+ * transient → same-provider retry (retry_count budget)
111
+ * ladderable → hop to the next provider in the ladder
112
+ * terminal → give up (dead-letter); a hop would fail identically
113
+ * Order matters: transient capacity signals win over ladderable auth words,
114
+ * hard-terminal (content/context) wins over ladderable, and unknown → terminal.
115
+ */
116
+ function classifyFailure(code, message) {
117
+ const c = (code ?? '').toLowerCase();
118
+ if (TRANSIENT_CODES.has(c))
119
+ return 'transient';
120
+ if (TERMINAL_CODES.has(c))
121
+ return 'terminal';
122
+ if (TERMINAL_CODE_SUFFIXES.some(s => c.endsWith(s)))
123
+ return 'terminal';
124
+ const msg = message ?? '';
125
+ if (!msg.trim())
126
+ return 'terminal';
127
+ if (TRANSIENT_PATTERNS.some(re => re.test(msg)))
128
+ return 'transient';
129
+ if (HARD_TERMINAL_PATTERNS.some(re => re.test(msg)))
130
+ return 'terminal';
131
+ if (LADDERABLE_PATTERNS.some(re => re.test(msg)))
132
+ return 'ladderable';
133
+ return 'terminal';
134
+ }
135
+ function classifyError(code, message) {
136
+ const c = (code ?? '').toLowerCase();
137
+ if (TRANSIENT_CODES.has(c))
138
+ return 'transient';
139
+ if (TERMINAL_CODES.has(c))
140
+ return 'terminal';
141
+ if (TERMINAL_CODE_SUFFIXES.some(s => c.endsWith(s)))
142
+ return 'terminal';
143
+ const msg = message ?? '';
144
+ if (!msg.trim())
145
+ return 'terminal';
146
+ if (TRANSIENT_PATTERNS.some(re => re.test(msg)))
147
+ return 'transient';
148
+ return 'terminal';
149
+ }
150
+ // Exponential backoff with ±25% jitter: 10s, 30s, capped at 60s. Retries
151
+ // must fit inside the server watchdog's 10-minute created_at ceiling for
152
+ // chat runs, so the schedule stays short.
153
+ const RETRY_BASE_MS = 10_000;
154
+ const RETRY_CAP_MS = 60_000;
155
+ function retryDelayMs(retryCount) {
156
+ const ideal = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * Math.pow(3, Math.max(0, retryCount)));
157
+ const jitter = 1 + (Math.random() * 0.5 - 0.25); // ±25%
158
+ return Math.min(RETRY_CAP_MS, Math.round(ideal * jitter));
159
+ }
@@ -0,0 +1,26 @@
1
+ import type { RuntimeEvent } from './types';
2
+ /**
3
+ * Event source for degenerate spawn handles (binary not installed / spawn
4
+ * threw synchronously). The old pattern — queueMicrotask over a listeners
5
+ * array — silently DROPPED the events: session-runner attaches onEvent only
6
+ * after `await setStatus(...)`, and the microtask fires at that first await,
7
+ * when the listeners array is still empty. The request then completed with
8
+ * no error ever surfaced to the chat.
9
+ *
10
+ * This buffers the events and delivers them (async, preserving the old
11
+ * post-return timing contract) to each listener when it attaches.
12
+ */
13
+ export declare function bufferedEvents(events: RuntimeEvent[]): {
14
+ onEvent: (cb: (e: RuntimeEvent) => void) => void;
15
+ };
16
+ /**
17
+ * Pure transform from one line of Claude's JSONL transcript into zero or
18
+ * more RuntimeEvent objects.
19
+ *
20
+ * - `user` lines: extract any tool_result content blocks (caller's own
21
+ * prompt echoes are dropped — they already know what they sent).
22
+ * - `assistant` lines: text blocks → assistant_text, tool_use blocks
23
+ * → tool_use. String content → single assistant_text.
24
+ * - Everything else → claude:<type> forward-compat passthrough.
25
+ */
26
+ export declare function normalizeJsonlLine(line: Record<string, unknown>): RuntimeEvent[];