@awak-app/simy-cli 0.5.60 → 0.5.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -631,3 +631,20 @@ cross-view consistency. The workflow requires independent design, implementation
631
631
  To-Be and PR reviews, plus explicitly opted-in SQM before and after PR creation.
632
632
  The [workflow reference](src/orchestrator/delivery/references/shared-system-specifications.md)
633
633
  explains baseline updates, historical regression coverage and evidence limits.
634
+
635
+
636
+ ### Desktop SQM history
637
+
638
+ SQM Tasks has separate **Analysis** and **Checks** lists on macOS and Windows.
639
+ Analysis retains local review summaries; Checks retains both `simy sqm min-check`
640
+ and `simy sqm check` outcomes, including checks run by the Delivery Loop.
641
+ Each list keeps the latest 100 runs under `SIMY_HOME/sqm-history` (default
642
+ `~/.simy/sqm-history`), isolated by web origin, account and organization.
643
+ History remains visible with local analysis off and after restarting. A saved
644
+ in-flight analysis is shown as Interrupted until this client claims it again;
645
+ it does not contribute to the running count. The web workspace owns retry status.
646
+
647
+ Only summary metadata is retained, without analysis prompts, source inputs,
648
+ model responses or credentials. Historical summaries are not a replacement for
649
+ signed proof files or delivery acceptance. Records from versions that kept
650
+ analysis only in memory cannot be recovered after that process exits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.60",
3
+ "version": "0.5.62",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
41
41
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
42
42
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
43
- "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
43
+ "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
44
44
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
45
45
  "check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
46
46
  "check:package": "node ./scripts/check-package-contents.js",
package/src/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createSqmHistoryStore, projectSqmHistory, sqmHistoryScope } from "./sqm/history-store.js";
1
2
  import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
2
3
  import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
3
4
  import { createServer } from "node:http";
@@ -287,6 +288,7 @@ export async function startAgent({
287
288
  };
288
289
  refreshAvailableCapabilities();
289
290
  let durableLocalTaskWorker = null;
291
+ const sqmHistory = createSqmHistoryStore({ root: sessionRoot });
290
292
  let sqmLocalReviewWorker = null;
291
293
  let codexWorkPatternSync = null;
292
294
  let codexWorkPatternSyncInFlight = null;
@@ -541,7 +543,7 @@ export async function startAgent({
541
543
  const remoteRun = restoreRun({
542
544
  snapshot, session, apiOrigin,
543
545
  localPath: repository?.local_path || null,
544
- localStateRoot: codingRunStateRoot,
546
+ localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
545
547
  });
546
548
  Object.assign(localRun, {
547
549
  request: remoteRun.request,
@@ -565,7 +567,7 @@ export async function startAgent({
565
567
  session,
566
568
  apiOrigin,
567
569
  localPath: repository?.local_path || null,
568
- localStateRoot: codingRunStateRoot,
570
+ localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
569
571
  });
570
572
  registry.create(restoredRun);
571
573
  restored = true;
@@ -597,7 +599,7 @@ export async function startAgent({
597
599
  const run = restoreRun({
598
600
  snapshot, session, apiOrigin,
599
601
  localPath: repository?.local_path || null,
600
- localStateRoot: codingRunStateRoot,
602
+ localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
601
603
  });
602
604
  // Keep the persisted timestamp until the Web ledger has been compared.
603
605
  // Publishing recovery here would make an older local record look newest.
@@ -836,6 +838,9 @@ export async function startAgent({
836
838
  return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch });
837
839
  },
838
840
  onChange: notifyDesktopStatusChange,
841
+ getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
842
+ recordHistory: (scope, item) => sqmHistory.record(scope, item),
843
+ onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
839
844
  getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
840
845
  (provider) => availableCapabilities?.backends?.[provider] === true &&
841
846
  providerStatuses[provider]?.state === "ready",
@@ -1869,7 +1874,7 @@ export async function startAgent({
1869
1874
  },
1870
1875
  session,
1871
1876
  apiOrigin,
1872
- localStateRoot: codingRunStateRoot,
1877
+ localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
1873
1878
  });
1874
1879
  try {
1875
1880
  await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
@@ -2213,6 +2218,12 @@ export async function startAgent({
2213
2218
  server.on("close", () => {
2214
2219
  void shutdown().catch((error) => reportDurableLocalTaskError(error, quiet));
2215
2220
  });
2221
+ const currentHistoryScope = () => isSessionValid(session, Date.now(), apiOrigin) ? sqmHistoryScope(apiOrigin, session) : null;
2222
+ const historyForCurrentScope = async () => {
2223
+ const scope = currentHistoryScope();
2224
+ const items = await sqmHistory.items(scope).catch(() => []);
2225
+ return JSON.stringify(scope) === JSON.stringify(currentHistoryScope()) ? items : [];
2226
+ };
2216
2227
  const getDesktopStatus = async () => {
2217
2228
  const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
2218
2229
  if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
@@ -2240,7 +2251,7 @@ export async function startAgent({
2240
2251
  activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2241
2252
  issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2242
2253
  providerReady: [codex, claude].some(provider => provider?.state === "ready") },
2243
- sqmTasks: sqmLocalReviewWorker?.desktopTasks?.() ?? { activeCount: 0, items: [] },
2254
+ ...projectSqmHistory(await historyForCurrentScope(), sqmLocalReviewWorker?.desktopTasks?.()),
2244
2255
  update: updateManager?.snapshot?.() ?? null,
2245
2256
  codex,
2246
2257
  claude,
@@ -2286,7 +2297,16 @@ export async function startAgent({
2286
2297
  void heartbeat().catch(error => reportHeartbeatError(error, quiet));
2287
2298
  },
2288
2299
  getDesktopStatus,
2289
- getDesktopTask: (key) => sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? desktopTaskDetail([registry, directRegistry, localTaskRegistry], key),
2300
+ getDesktopTask: async (key) => {
2301
+ if (key?.startsWith("sqm:") || key?.startsWith("sqm-check:")) {
2302
+ const scope = currentHistoryScope();
2303
+ if (!scope) return null;
2304
+ const detail = await sqmHistory.detail(scope, key).catch(() => null);
2305
+ if (JSON.stringify(scope) !== JSON.stringify(currentHistoryScope())) return null;
2306
+ return sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? detail;
2307
+ }
2308
+ return desktopTaskDetail([registry, directRegistry, localTaskRegistry], key);
2309
+ },
2290
2310
  async refreshDesktopStatus() {
2291
2311
  if (isSessionValid(session, Date.now(), apiOrigin)) {
2292
2312
  await heartbeat().catch((error) => reportHeartbeatError(error, quiet));
@@ -2350,7 +2370,7 @@ export async function startAgent({
2350
2370
  });
2351
2371
  const runId = String(remoteRun?.id || "");
2352
2372
  if (!runId) throw new Error("SIMY Web did not return an Agentic Loop run id.");
2353
- const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot });
2373
+ const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot });
2354
2374
  await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
2355
2375
  registry.create(run);
2356
2376
  await authorizeDesktopWork(scope);
@@ -25,11 +25,15 @@ export async function resolveBackendExecutable(backend, options = {}) {
25
25
 
26
26
  export async function inspectBackendExecutable(
27
27
  backend,
28
- { spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 5_000 } = {},
28
+ { spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 5_000, signal } = {},
29
29
  ) {
30
+ const assertActive = () => { if (signal?.aborted) throw Object.assign(new Error('Backend inspection was cancelled.'), { code: 'cancelled' }); };
31
+ assertActive();
30
32
  const policy = versionPolicy(backend);
31
33
  for (const candidate of [...new Set(candidates.filter(Boolean))]) {
32
- const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs });
34
+ assertActive();
35
+ const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs, signal });
36
+ assertActive();
33
37
  if (!probe.available) continue;
34
38
  const installedVersion = parseBackendVersion(probe.output);
35
39
  if (!installedVersion) {
@@ -115,31 +119,42 @@ function numericVersionParts(value) {
115
119
  return match.slice(1).map(Number);
116
120
  }
117
121
 
118
- function probeExecutable(command, { spawnImpl, timeoutMs }) {
122
+ function probeExecutable(command, { spawnImpl, timeoutMs, signal }) {
119
123
  return new Promise((resolve) => {
120
- let settled = false;
121
- let timeout;
122
- let output = "";
124
+ let settled = false, terminating = false;
125
+ let timeout, force, limit;
126
+ let output = Buffer.alloc(0);
123
127
  const child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
124
- child.stdout?.setEncoding?.("utf8");
125
- child.stderr?.setEncoding?.("utf8");
126
- child.stdout?.on?.("data", (chunk) => {
127
- output += chunk;
128
- });
129
- child.stderr?.on?.("data", (chunk) => {
130
- output += chunk;
131
- });
132
- const finish = (available) => {
128
+ const capture = chunk => {
129
+ // Version probes need a tiny prefix. Drain noisy executables without
130
+ // retaining unbounded stdout/stderr in the desktop background process.
131
+ const remaining = 64 * 1024 - output.length;
132
+ if (remaining > 0) {
133
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk).slice(0, remaining));
134
+ output = Buffer.concat([output, bytes.subarray(0, remaining)]);
135
+ }
136
+ };
137
+ const finish = available => {
133
138
  if (settled) return;
134
139
  settled = true;
135
- if (timeout) clearTimeout(timeout);
136
- resolve({ available, output: output.trim() });
140
+ clearTimeout(timeout); clearTimeout(force); clearTimeout(limit);
141
+ signal?.removeEventListener('abort', cancel);
142
+ resolve({ available: available && !terminating, output: output.toString('utf8').trim() });
137
143
  };
138
- timeout = setTimeout(() => {
144
+ const cancel = () => {
145
+ if (settled || terminating) return;
146
+ terminating = true; clearTimeout(timeout);
147
+ // Install timers before kill because a fake/fast child may close inline.
148
+ force = setTimeout(() => { child.kill?.("SIGKILL"); }, 2_000);
149
+ limit = setTimeout(() => finish(false), 3_000);
139
150
  child.kill?.("SIGTERM");
140
- finish(false);
141
- }, timeoutMs);
151
+ };
152
+ child.stdout?.on?.("data", capture);
153
+ child.stderr?.on?.("data", capture);
142
154
  child.on("error", () => finish(false));
143
- child.on("close", (code) => finish(code === 0));
155
+ child.on("close", code => finish(code === 0));
156
+ timeout = setTimeout(cancel, timeoutMs);
157
+ signal?.addEventListener('abort', cancel, { once: true });
158
+ if (signal?.aborted) cancel();
144
159
  });
145
160
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.60";
16
+ export const CLI_VERSION = "0.5.62";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Standalone by design: this exact module is shipped to both provider plugins.
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
6
  import { isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { execFile } from 'node:child_process';
@@ -28,8 +28,22 @@ export function recordPath(root, provider, session) {
28
28
  return join(root, `${digest(`${provider}\0${session}`)}.json`);
29
29
  }
30
30
  async function read(file) {
31
- try { const raw = await readFile(file, 'utf8'); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('State exceeds limit.'); return JSON.parse(raw); }
32
- catch (e) { if (e.code === 'ENOENT') return null; throw e; }
31
+ let handle;
32
+ try {
33
+ handle = await open(file, 'r');
34
+ // Read a bounded allocation even when an external writer supplies a huge
35
+ // file. The extra byte distinguishes the limit from a truncated document.
36
+ const bytes = Buffer.alloc(MAX_BYTES + 1);
37
+ let size = 0;
38
+ while (size < bytes.length) {
39
+ const result = await handle.read(bytes, size, bytes.length - size, null);
40
+ if (!result.bytesRead) break;
41
+ size += result.bytesRead;
42
+ }
43
+ if (size > MAX_BYTES) throw new Error('State exceeds limit.');
44
+ return JSON.parse(bytes.subarray(0, size).toString('utf8'));
45
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
46
+ finally { await handle?.close(); }
33
47
  }
34
48
  async function lockOwner(lock) {
35
49
  try {
@@ -51,8 +65,20 @@ async function ownerDefinitelyDead(owner, inspectProcess) {
51
65
  async function releaseStateLock(lock, token) {
52
66
  if ((await lockOwner(lock))?.token === token) await rm(lock, { recursive: true, force: true });
53
67
  }
68
+ let ownProcessIdentity = null, ownProcessProbe = null;
69
+ async function stateWriterIdentity(inspectProcess) {
70
+ if (inspectProcess !== processIdentity) return inspectProcess(process.pid);
71
+ if (ownProcessIdentity) return ownProcessIdentity;
72
+ if (!ownProcessProbe) ownProcessProbe = processIdentity(process.pid).then(identity => {
73
+ // Only this running process's positive lifetime identity is immutable.
74
+ // Unknown results and every external/lock-owner inspection remain fresh.
75
+ if (identity) ownProcessIdentity = identity;
76
+ return identity;
77
+ }).finally(() => { ownProcessProbe = null; });
78
+ return ownProcessProbe;
79
+ }
54
80
  async function acquireStateLock(lock, inspectProcess, depth = 0) {
55
- const owner = { pid: process.pid, identity: await inspectProcess(process.pid), token: randomUUID() };
81
+ const owner = { pid: process.pid, identity: await stateWriterIdentity(inspectProcess), token: randomUUID() };
56
82
  const create = async (reclaiming = false) => {
57
83
  await mkdir(lock, { mode: 0o700 });
58
84
  try { await writeFile(join(lock, 'owner.json'), JSON.stringify(owner), { mode: 0o600, flag: 'wx' }); }
@@ -102,7 +128,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
102
128
  const lockToken = await acquireStateLock(lock, inspectProcess);
103
129
  let temporary;
104
130
  try {
105
- const next = await mutate(await read(file));
131
+ const previous = await read(file);
132
+ const previousBytes = previous === null ? null : JSON.stringify(previous);
133
+ const next = await mutate(previous);
106
134
  if (next) {
107
135
  // Older file_equals drafts used `path`, which an older verifier could
108
136
  // mistake for file_exists. Persist only the distinct exactPath field.
@@ -112,9 +140,10 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
112
140
  delete criterion.check.path;
113
141
  }
114
142
  }
115
- temporary = `${file}.${randomUUID()}.tmp`;
116
143
  const serialized = JSON.stringify(next);
117
144
  if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('Delivery state exceeds 64 KiB; reduce task details.');
145
+ if (serialized === previousBytes) return next;
146
+ temporary = `${file}.${randomUUID()}.tmp`;
118
147
  await writeFile(temporary, serialized, { mode: 0o600 });
119
148
  await rename(temporary, file);
120
149
  }
@@ -128,13 +157,68 @@ export async function readRecord(root = rootDirectory(), provider, session) {
128
157
  return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? record : null;
129
158
  } catch { return null; } // Match listRecords' treatment of unreadable records.
130
159
  }
131
- export async function listRecords(root = rootDirectory()) {
132
- let names; try { names = await readdir(root); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
133
- const results = await Promise.all(names.filter(n => /^[a-f0-9]{64}\.json$/.test(n)).slice(0, 500).map(async n => {
134
- try { const r = await read(join(root, n)); return r?.schemaVersion === 1 && recordPath(root, r.provider, r.session) === join(root, n) ? r : null; } catch { return null; }
160
+ const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
161
+ const RECORD_CACHE_LIMIT = 500;
162
+ const READ_CONCURRENCY = 8;
163
+ async function recordNames(root) {
164
+ try { return (await readdir(root)).filter(name => RECORD_NAME.test(name)).sort(); }
165
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
166
+ }
167
+ async function mapRecordNames(names, visit) {
168
+ const results = new Array(names.length);
169
+ let cursor = 0;
170
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, names.length) }, async () => {
171
+ while (cursor < names.length) { const index = cursor++; results[index] = await visit(names[index]); }
135
172
  }));
136
173
  return results.filter(Boolean);
137
174
  }
175
+ async function readNamedRecord(root, name) {
176
+ try {
177
+ const path = join(root, name), record = await read(path);
178
+ return record?.schemaVersion === 1 && recordPath(root, record.provider, record.session) === path ? record : null;
179
+ } catch { return null; }
180
+ }
181
+ export async function listRecords(root = rootDirectory()) {
182
+ return mapRecordNames(await recordNames(root), name => readNamedRecord(root, name));
183
+ }
184
+
185
+ // A shared candidate/display index, never mutation authority. Every list checks
186
+ // metadata so external hooks, removals and direct writes invalidate cached JSON.
187
+ // Fresh execution reads and updateRecord locks remain independent of this index.
188
+ export function createRecordReader(root = rootDirectory()) {
189
+ const cache = new Map();
190
+ let inFlight = null, closed = false;
191
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
192
+ const scan = async () => {
193
+ const names = await recordNames(root), live = new Set(names.slice(0, RECORD_CACHE_LIMIT));
194
+ for (const name of cache.keys()) if (!live.has(name)) cache.delete(name);
195
+ return mapRecordNames(names, async name => {
196
+ const path = join(root, name);
197
+ try {
198
+ const before = await stat(path, { bigint: true }), key = fingerprint(before);
199
+ const cached = cache.get(name);
200
+ if (cached?.key === key) return cached.record;
201
+ const record = before.size <= BigInt(MAX_BYTES) ? await readNamedRecord(root, name) : null;
202
+ // Do not retain a value when a write races the read; the next list must
203
+ // inspect it again. This also prevents malformed transient JSON sticking.
204
+ const after = await stat(path, { bigint: true });
205
+ if (live.has(name) && fingerprint(after) === key) cache.set(name, { key, record });
206
+ else cache.delete(name);
207
+ return record;
208
+ } catch { cache.delete(name); return null; }
209
+ });
210
+ };
211
+ return {
212
+ async list() {
213
+ if (closed) return [];
214
+ if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
215
+ // Consumers can decorate/mutate their own snapshots without corrupting the
216
+ // cached records or a simultaneous caller's view.
217
+ return structuredClone(await inFlight);
218
+ },
219
+ async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); },
220
+ };
221
+ }
138
222
  function positive(value, fallback, max) {
139
223
  if (value === undefined) return fallback;
140
224
  if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid delivery budget.');
@@ -1064,12 +1148,16 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1064
1148
  }
1065
1149
  }
1066
1150
  }
1067
- export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity } = {}) {
1068
- const records = await listRecords(root);
1151
+ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity, records: suppliedRecords } = {}) {
1152
+ const records = suppliedRecords || await listRecords(root);
1153
+ const reconciled = [];
1069
1154
  for (const record of records) {
1070
- if (!record.run || TERMINAL.has(record.run.state)) continue;
1155
+ // Paused runs have no reconciliation transition: budget enforcement excludes
1156
+ // them, process-exit checks only apply to active runs, and no notification
1157
+ // is generated. Resume/control still reread and validate the exact record.
1158
+ if (!record.run || TERMINAL.has(record.run.state) || record.run.state === 'paused') { reconciled.push(record); continue; }
1071
1159
  const observed = record.process ? await inspectProcess(record.process.pid) : null;
1072
- await updateRecord(root, record.provider, record.session, async current => {
1160
+ const next = await updateRecord(root, record.provider, record.session, async current => {
1073
1161
  if (!current?.run || TERMINAL.has(current.run.state)) return current;
1074
1162
  const run = current.run;
1075
1163
  if (run.state === 'verification_pending' && run.managed) {
@@ -1084,9 +1172,10 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1084
1172
  }
1085
1173
  notifyState(run, now);
1086
1174
  return current;
1087
- }).catch(() => {});
1175
+ }).catch(() => readRecord(root, record.provider, record.session));
1176
+ if (next) reconciled.push(next);
1088
1177
  }
1089
- return listRecords(root);
1178
+ return reconciled;
1090
1179
  }
1091
1180
  export async function main(provider) {
1092
1181
  let raw = '';
@@ -19,8 +19,10 @@ function associationStatus(association) {
19
19
  projectId: binding.action.projectId, projectName: binding.action.projectName } : null } : null };
20
20
  }
21
21
 
22
- export async function deliveryHookStatus({ root = rootDirectory(), now = Date.now() } = {}) {
23
- const records = await reconcileRecords({ root, now });
22
+ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.now(), records: suppliedRecords } = {}) {
23
+ // Display callers can reuse the host frame snapshot. Execution maintenance
24
+ // owns reconciliation; the standalone status command retains its fresh path.
25
+ const records = suppliedRecords ?? await reconcileRecords({ root, now });
24
26
  return {
25
27
  providers: Object.fromEntries(['codex', 'claude'].map(provider => {
26
28
  const latest = records.filter(r => r.provider === provider && Number.isFinite(r.observedAt)).sort((a, b) => b.observedAt - a.observedAt)[0];
@@ -18,6 +18,20 @@ function providerFailureReason(code) {
18
18
  if (code === 'turn_failed') return 'The Codex turn did not complete; inspect the original conversation.';
19
19
  return 'Codex recovery process failed; inspect its checkpoint and original session before manual resume.';
20
20
  }
21
+ // One fresh lease read at a time; stopping the watch also fences late reads so
22
+ // a completed provider result cannot be changed by an old watchdog callback.
23
+ function watchLease(root, provider, session, inspect, onFailure) {
24
+ let reading = false, stopped = false;
25
+ const timer = setInterval(() => {
26
+ if (reading || stopped) return;
27
+ reading = true;
28
+ void readRecord(root, provider, session).then(record => {
29
+ if (!stopped) inspect(record);
30
+ }).catch(() => { if (!stopped) onFailure(); }).finally(() => { reading = false; });
31
+ }, 500);
32
+ timer.unref();
33
+ return () => { stopped = true; clearInterval(timer); };
34
+ }
21
35
  async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
22
36
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
23
37
  const current = await readRecord(root, provider, session);
@@ -54,7 +68,7 @@ export function parseAgentCheckpoint(output, expectedSession) {
54
68
 
55
69
  // The provider CLI is a new process. It is used only for an exact Codex UUID
56
70
  // enrolled for this recovery channel after a corroborated process exit.
57
- export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {} } = {}) {
71
+ export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {}, leaseMonitored = false } = {}) {
58
72
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
59
73
  const binary = await executable('codex');
60
74
  if (!binary) throw new Error('Codex executable unavailable.');
@@ -94,12 +108,9 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
94
108
  forceKill.unref();
95
109
  };
96
110
  signal?.addEventListener('abort', terminate, { once: true });
97
- const watch = setInterval(() => {
98
- void readRecord(root, record.provider, record.session).then(current => {
99
- if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
100
- }).catch(terminate);
101
- }, 500);
102
- watch.unref();
111
+ const stopWatch = leaseMonitored ? () => {} : watchLease(root, record.provider, record.session, current => {
112
+ if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
113
+ }, terminate);
103
114
  child.stdout.on('data', chunk => {
104
115
  onActivity();
105
116
  const lines = (pending + chunk).split('\n');
@@ -107,7 +118,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
107
118
  for (const line of lines) consumeLine(line);
108
119
  });
109
120
  child.stderr.on('data', chunk => { error = (error + chunk).slice(-MAX_OUTPUT); });
110
- const cleanup = () => { clearInterval(watch); clearTimeout(forceKill); signal?.removeEventListener('abort', terminate); };
121
+ const cleanup = () => { stopWatch(); clearTimeout(forceKill); signal?.removeEventListener('abort', terminate); };
111
122
  child.once('error', failure => { spawnError = failure; terminate(); });
112
123
  child.once('close', code => {
113
124
  cleanup(); if (pending) consumeLine(pending);
@@ -118,11 +129,12 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
118
129
  });
119
130
  }
120
131
 
121
- export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch } = {}) {
132
+ export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords } = {}) {
122
133
  const tickWallStart = Date.now();
123
134
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
124
135
  if (signal?.aborted) return;
125
- const records = await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}) });
136
+ const records = await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}),
137
+ ...(readRecords ? { records: await readRecords() } : {}) });
126
138
  if (signal?.aborted) return;
127
139
  for (let record of records) {
128
140
  if (signal?.aborted) break;
@@ -209,13 +221,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
209
221
  if (signal?.aborted) controller.abort();
210
222
  else signal?.addEventListener('abort', abort, { once: true });
211
223
  const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
212
- const watch = setInterval(() => {
213
- void readRecord(root, record.provider, record.session).then(current => {
214
- if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
215
- managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
216
- }).catch(() => controller.abort());
217
- }, 500);
218
- watch.unref();
224
+ const stopWatch = watchLease(root, record.provider, record.session, current => {
225
+ if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
226
+ managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
227
+ }, () => controller.abort());
219
228
  try {
220
229
  if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
221
230
  if (controller.signal.aborted) result = { code: -2, cancelled: true };
@@ -249,7 +258,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
249
258
  if (!result) {
250
259
  await authorizeClaim();
251
260
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
252
- result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
261
+ result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime, leaseMonitored: true,
253
262
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
254
263
  onUsage: observed => {
255
264
  usage = observed;
@@ -287,7 +296,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
287
296
  ? { code: -3, busy: true, accessDenied: true }
288
297
  : { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
289
298
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
290
- finally { clearTimeout(timeout); clearInterval(watch); signal?.removeEventListener('abort', abort); }
299
+ finally { clearTimeout(timeout); stopWatch(); signal?.removeEventListener('abort', abort); }
291
300
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
292
301
  if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
293
302
  result = { ...result, usageUnavailable: true };