@awak-app/simy-cli 0.5.59 → 0.5.61

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.59",
3
+ "version": "0.5.61",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.59";
16
+ export const CLI_VERSION = "0.5.61";
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 };
@@ -1,7 +1,7 @@
1
1
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
2
2
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
3
3
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
4
- import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
4
+ import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
5
5
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
6
6
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
7
7
  import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
@@ -10,7 +10,7 @@ import { analyzeContinuation, readContinuationContext } from "./session-supervis
10
10
  import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
11
11
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
12
12
  import { spawn, execFile } from "node:child_process";
13
- import { promisify } from "node:util";
13
+ import { promisify, isDeepStrictEqual } from "node:util";
14
14
  import { createInterface } from "node:readline";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { realpathSync } from "node:fs";
@@ -59,6 +59,19 @@ function desktopActionError(action, error) {
59
59
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
60
60
  dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
61
61
  const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
62
+ // A heartbeat remains a complete native frame. Only its observation time is
63
+ // volatile; reuse the encoded body after comparing freshly authorized data.
64
+ // Detach the comparison value because registries may mutate nested objects.
65
+ let encodedStatus = null, lastStatus = null;
66
+ const emitStatus = status => {
67
+ const { sampledAt, ...content } = status;
68
+ if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
69
+ encodedStatus = JSON.stringify(content).slice(1, -1);
70
+ lastStatus = structuredClone(content);
71
+ }
72
+ const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
73
+ output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
74
+ };
62
75
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
63
76
  const webOrigin = resolveWebOrigin(null, env);
64
77
  const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
@@ -67,8 +80,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
67
80
  }
68
81
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
69
82
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
83
+ const deliveryRoot = join(root, "delivery-hooks");
84
+ const recordReader = createRecordReader(deliveryRoot);
70
85
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
71
- const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
86
+ const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
72
87
  let actionAssociations = makeActionAssociations();
73
88
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
74
89
  const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
@@ -76,6 +91,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
76
91
  const previousScope = accessScope(currentAccess);
77
92
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
78
93
  if (previousScope !== accessScope(currentAccess)) {
94
+ discoveryController?.abort();
95
+ sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
79
96
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
80
97
  sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
81
98
  await analysisManager?.update([]);
@@ -99,28 +116,37 @@ export async function startDesktopHost({ input = process.stdin, output = process
99
116
 
100
117
  const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
101
118
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
102
- authorizeDispatch: authorizeWork,
119
+ authorizeDispatch: authorizeWork, readRecords: () => recordReader.list(),
103
120
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
104
121
  let shuttingDown = null;
105
122
  let commands;
106
123
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
107
- let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
124
+ let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
125
+ const stopDiscovery = async ({ drain = false } = {}) => {
126
+ discoveryController?.abort();
127
+ sessionRefreshAgain = false;
128
+ if (drain && !dependencies.discoverSessions) await sessionRefresh;
129
+ };
108
130
  const refreshSessions = (force = false) => {
109
- if (!desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
131
+ if (discoveryStopped && !closed && !relay) return sessionStatus;
132
+ if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
110
133
  if (force && sessionRefresh) sessionRefreshAgain = true;
111
134
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
112
135
  const requestedScope = accessScope(currentAccess);
113
- sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
136
+ const controller = discoveryController = new AbortController();
137
+ sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal }); })
114
138
  .then(async value => {
139
+ if (closed || relay || controller.signal.aborted) return;
115
140
  const latestAccess = await accessSnapshot();
141
+ if (closed || relay || controller.signal.aborted) return;
116
142
  if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
117
143
  await analysisManager.update([]);
118
144
  sessionRefreshAgain = desktopAccessReady(latestAccess);
119
145
  return;
120
146
  }
121
147
  await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
122
- .catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
123
- .finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
148
+ .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
149
+ .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
124
150
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
125
151
  }
126
152
  return sessionStatus;
@@ -171,8 +197,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
171
197
  if (!scheduledPublish) {
172
198
  let resolve, reject;
173
199
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
174
- scheduledPublish = { promise };
175
- setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75);
200
+ scheduledPublish = { promise, resolve, timer: setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75) };
176
201
  }
177
202
  return scheduledPublish.promise;
178
203
  };
@@ -180,6 +205,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
180
205
  if (closed || relay || !agent) return Promise.resolve();
181
206
  if (publishing) { publishAgain = true; return publishing; }
182
207
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
208
+ if (closed || relay) return;
183
209
  await setAccessSnapshot(status.access);
184
210
  status.access = currentAccess;
185
211
  const requestedScope = accessScope(currentAccess);
@@ -191,36 +217,45 @@ export async function startDesktopHost({ input = process.stdin, output = process
191
217
  status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
192
218
  status.deliveryHooks = { runs: [], providers: {} };
193
219
  status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
194
- if (!closed && !relay) emit({ type: 'status', status });
220
+ if (!closed && !relay) emitStatus(status);
195
221
  return;
196
222
  }
197
- status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
198
- const projection = await actionAssociations.beginProjection?.();
199
- await actionAssociations.decorate(status.deliveryHooks);
223
+ const records = await recordReader.list();
224
+ const projection = await actionAssociations.beginProjection?.({ records });
225
+ status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
226
+ await actionAssociations.decorate(status.deliveryHooks, projection);
200
227
  const discovery = refreshSessions();
201
228
  automaticContinuation?.tick(discovery.sessions);
202
229
  const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
203
230
  await actionAssociations.decorateSessions?.(sessions, projection);
231
+ const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
204
232
  for (const session of sessions) {
205
233
  const context = await actionAssociations.deadlineContext?.(session, projection);
206
- if (context) session.actionDeadline = await deadlines.reconcile(context);
234
+ if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
207
235
  else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
208
- const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
236
+ const run = runsBySession.get(`${session.provider}:${session.session}`);
209
237
  if (run) run.actionDeadline = session.actionDeadline;
210
238
  }
211
239
  status.sessionSupervision = { ...discovery, sessions };
212
240
  const latestAccess = await accessSnapshot();
213
- if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) { publishAgain = true; return; }
214
- if (!closed && !relay) emit({ type: "status", status });
241
+ if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
242
+ actionAssociations.validateProjection && !await actionAssociations.validateProjection(projection)) { publishAgain = true; return; }
243
+ if (!closed && !relay) emitStatus(status);
215
244
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
216
245
  return publishing;
217
246
  };
218
247
  const stopAgent = async () => {
219
248
  clearInterval(timer);
249
+ discoveryStopped = true;
250
+ await stopDiscovery({ drain: true });
251
+ encodedStatus = null; lastStatus = null;
252
+ if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
253
+ await publishing?.catch(() => {});
220
254
  await actionAssociations.close();
221
255
  await automaticContinuation?.close();
222
256
  await analysisManager?.close();
223
257
  await managedSupervisor.close();
258
+ await recordReader.close();
224
259
  await stopWorkflows();
225
260
  if (agent) await createAgentShutdown(agent)();
226
261
  };
@@ -262,6 +297,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
262
297
  updater.on?.("change", () => { void publish().catch(() => {}); });
263
298
  updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
264
299
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
300
+ discoveryStopped = true;
301
+ await stopDiscovery({ drain: true });
265
302
  // The candidate shares this cache. Stop inference before it starts so a
266
303
  // signed handoff cannot create two analysis writers or duplicate calls.
267
304
  await automaticContinuation.close();
@@ -276,7 +313,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
276
313
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
277
314
  }); } catch (error) {
278
315
  actionAssociations = makeActionAssociations();
279
- analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
316
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
280
317
  }
281
318
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
282
319
  let accepted = false;
@@ -321,18 +358,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
321
358
  lines.close(); await stopUpdateCandidate(child);
322
359
  if (!closed && !relay) {
323
360
  actionAssociations = makeActionAssociations();
324
- analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
361
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
325
362
  }
326
363
  throw error;
327
364
  }
328
365
  } });
329
366
  await refreshPlugins();
330
367
  await publish();
331
- void actionAssociations.tick().then(publish).catch(() => {});
368
+ const maintenance = new Map();
369
+ const tick = worker => {
370
+ if (closed || relay || maintenance.has(worker)) return;
371
+ const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
372
+ maintenance.set(worker, pending);
373
+ };
374
+ tick(actionAssociations);
375
+ tick(managedSupervisor);
332
376
  timer = setInterval(() => {
333
377
  if (desktopAccessReady(currentAccess)) {
334
- void actionAssociations.tick().then(publish).catch(() => {});
335
- void managedSupervisor.tick().then(publish).catch(() => {});
378
+ tick(actionAssociations);
379
+ tick(managedSupervisor);
336
380
  }
337
381
  void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
338
382
  }, 3_000);
@@ -43,7 +43,7 @@ async function boundedJson(response) {
43
43
  // login cannot associate work in the newly selected account.
44
44
  export function createActionAssociationApi({ webOrigin, sessionRoot,
45
45
  fetchImpl = fetch, readSessionImpl = readSession, now = Date.now, timeoutMs = 10_000 } = {}) {
46
- const identity = session => createHash('sha256').update(`${webOrigin}\0${session.token}`).digest('hex');
46
+ const identity = session => createHash('sha256').update(JSON.stringify([webOrigin, session.token, session.organization_id ?? null, session.user_id ?? null, session.account_id ?? null, session.device_id ?? null, session.api_base_url ?? null, session.expires_at])).digest('hex');
47
47
  const currentSession = async () => {
48
48
  const session = await readSessionImpl(webOrigin, sessionRoot);
49
49
  if (!isSessionValid(session, now(), webOrigin)) throw new ActionAssociationApiError('signed_out');