@awak-app/simy-cli 0.6.16 → 0.6.18

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 (42) hide show
  1. package/README.md +99 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +123 -28
  4. package/src/auto-update.js +15 -3
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
  8. package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
  9. package/src/codex-plugins/simy/hooks/hooks.json +2 -2
  10. package/src/codex-work-pattern-sync.js +37 -9
  11. package/src/delivery-hooks/command.js +9 -2
  12. package/src/delivery-hooks/desktop.js +10 -12
  13. package/src/delivery-hooks/evidence.js +50 -0
  14. package/src/delivery-hooks/supervisor.js +8 -2
  15. package/src/desktop-access.js +4 -3
  16. package/src/desktop-host.js +669 -73
  17. package/src/doctor.js +2 -1
  18. package/src/host-exit-diagnostics.js +28 -0
  19. package/src/index.js +1 -0
  20. package/src/native-development-report-completion.js +143 -0
  21. package/src/native-development-reports.js +137 -0
  22. package/src/orchestrator/checklist-continuation.js +5 -0
  23. package/src/orchestrator/delivery/WORKFLOW.md +4 -0
  24. package/src/orchestrator/delivery/references/state-model.md +187 -0
  25. package/src/orchestrator/loop.js +119 -34
  26. package/src/provider-links.js +74 -0
  27. package/src/runner.js +31 -2
  28. package/src/session-supervision/automatic-continuation.js +31 -2
  29. package/src/session-supervision/claude-sessions.js +25 -15
  30. package/src/session-supervision/codex-app-server.js +27 -4
  31. package/src/session-supervision/index.js +163 -42
  32. package/src/session-supervision/statistics.js +76 -15
  33. package/src/sqm/command.js +58 -7
  34. package/src/sqm/evidence-client.js +11 -1
  35. package/src/sqm/failure-circuit.js +28 -6
  36. package/src/sqm/history-store.js +49 -18
  37. package/src/sqm/provider-diagnostics.js +1 -0
  38. package/src/sqm/review-worker.js +70 -21
  39. package/src/sqm/workflow-update.js +12 -3
  40. package/src/windows-bootstrap.js +2 -1
  41. package/src/windows-desktop-updater.js +6 -1
  42. package/src/windows-inbound.js +9 -0
@@ -6,19 +6,64 @@ import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
6
6
  import { originalSessionTarget } from './original-session-target.js';
7
7
  import { readCodexTitles, sessionTitle } from './session-titles.js';
8
8
  import { RECENT_SESSION_LIMIT, recentSessionWindow, retainedSession } from './discovery-window.js';
9
+ import { requireProviderLink } from '../provider-links.js';
9
10
 
10
- function autoContinueState(record, isRunning, supported) {
11
+ // Only product-authored eligibility messages may pass through the desktop
12
+ // error sanitizer; native provider errors never acquire this type.
13
+ export class AutoContinueUnavailableError extends Error {
14
+ constructor(availability) {
15
+ super(availability.reason || 'Automatic continuation is unavailable for this conversation.');
16
+ this.code = availability.reasonCode;
17
+ }
18
+ }
19
+
20
+ // Opt-in is independent of a busy provider turn. Dispatch still requires a
21
+ // settled context and the original provider's execution boundary.
22
+ function autoContinueAvailability(run, channel) {
23
+ const issue = !channel.ready ? channel : run && !run.managed && !canUpgradeDelivery(run)
24
+ ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
25
+ : TERMINAL_RUNS.has(run?.state)
26
+ ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null;
27
+ return { canEnable: !issue, reasonCode: issue?.code || null, reason: issue?.reason || null };
28
+ }
29
+
30
+ function codexContinuationChannel(source, model, issue = null) {
31
+ if (issue) return { ready: false, ...issue };
32
+ if (source !== 'codex_app_server') return { ready: false, code: 'session_metadata_unavailable',
33
+ reason: 'Exact Codex session metadata is unavailable. Refresh the original conversation.' };
34
+ if (!model) return { ready: false, code: 'model_metadata_unavailable',
35
+ reason: 'Exact Codex model metadata is unavailable. Configure the model in the original conversation and refresh.' };
36
+ return { ready: true, code: 'ready', reason: null };
37
+ }
38
+
39
+ function autoContinueState(record, isRunning, availability) {
11
40
  if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
12
41
  const state = record.run?.state;
13
42
  if (state === 'budget_exhausted') return { autoContinueStatus: 'needs_user', autoContinueReason: 'Local execution limits have been removed. Resume this same conversation to continue.' };
14
43
  if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Continuation finished for this conversation.' };
15
44
  const managed = record.run?.managed;
16
45
  const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
17
- if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause || managed.pendingApproval)) return {
18
- autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.',
19
- };
20
- if (['paused', 'blocked', 'stopped', 'failed', 'verification_pending'].includes(state)) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.' };
21
- if (!supported) return { autoContinueStatus: 'unavailable', autoContinueReason: 'The original provider continuation channel is unavailable.' };
46
+ if (managed?.pendingApproval) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention: review the pending approval in the original conversation before continuing.' };
47
+ if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause)) {
48
+ const recoveryReason = {
49
+ process_exit: 'the original provider process exited. Reopen the original conversation and explicitly resume.',
50
+ session_end: 'the original provider session ended. Reopen it and review why it ended before resuming.',
51
+ provider_error: 'the provider reported an error. Review the original conversation before resuming.',
52
+ shutdown_cancelled: 'continuation was interrupted when SIMY shut down. Review the task and explicitly resume.',
53
+ hook_owner_mismatch: 'the original Claude hook process changed. Refresh the original conversation before resuming.',
54
+ policy_change: 'the continuation policy changed. Resume the original conversation for the selected action.',
55
+ }[record.run.recoveryCause] || 'automatic recovery is unavailable. Review the task and explicitly resume through its existing controls.';
56
+ return { autoContinueStatus: 'needs_user', autoContinueReason: `Existing supervision needs your attention: ${recoveryReason}` };
57
+ }
58
+ const intervention = {
59
+ paused: 'resume the paused task through its existing controls.',
60
+ blocked: 'resolve the task blocker before continuing.',
61
+ stopped: 'review the stopped task through its existing controls.',
62
+ failed: 'review the failed task through its existing controls.',
63
+ verification_pending: 'complete the pending verification before continuing.',
64
+ }[state];
65
+ if (intervention) return { autoContinueStatus: 'needs_user', autoContinueReason: `Existing supervision needs your attention: ${intervention}` };
66
+ if (!availability.canEnable) return { autoContinueStatus: 'unavailable', autoContinueReason: availability.reason };
22
67
  return { autoContinueStatus: record.run ? 'supervising' : isRunning ? 'waiting' : 'watching',
23
68
  autoContinueReason: record.run ? 'Continuing the original task with progress and approval checks.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
24
69
  }
@@ -55,25 +100,34 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
55
100
  }
56
101
 
57
102
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
58
- inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome } = {}) {
103
+ inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome, connectedProviders = ['codex', 'claude'] } = {}) {
59
104
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
60
105
  assertActive();
61
- const records = await listRecords(root);
106
+ const connected = new Set(connectedProviders);
107
+ const records = (await listRecords(root)).filter(record => connected.has(record.provider));
62
108
  const priorities = records.filter(retainedSession);
63
- let discovered = [], codexError = null, claude = [], claudeError = null;
109
+ let discovered = [], codexError = null, codexReasonCode = null, claude = [], claudeError = null;
64
110
  let archiveStates = new Map();
65
- try { discovered = await withClient(openClient, async client => {
66
- const active = await listCodexSessions({ client, inspectState: true, maxSessions: RECENT_SESSION_LIMIT,
67
- prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
68
- const ids = new Set(active.map(item => item.session));
69
- archiveStates = await readCodexArchiveStates(client, records.filter(record => record.provider === 'codex' && !ids.has(record.session)), { codexHome });
70
- return active;
71
- }, signal); }
72
- catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
73
- assertActive();
74
- try { claude = await listClaude({ signal, limit: RECENT_SESSION_LIMIT,
75
- prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
76
- catch { claudeError = 'Claude conversation discovery unavailable.'; }
111
+ await Promise.all([
112
+ (async () => {
113
+ try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
114
+ const active = await listCodexSessions({ client, inspectState: true, maxSessions: RECENT_SESSION_LIMIT, codexHome,
115
+ prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
116
+ const ids = new Set(active.map(item => item.session));
117
+ archiveStates = await readCodexArchiveStates(client, records.filter(record => record.provider === 'codex' && !ids.has(record.session)), { codexHome });
118
+ return active;
119
+ }, signal); }
120
+ catch (error) {
121
+ codexReasonCode = error?.code === 'unavailable' ? 'provider_unavailable' : error?.code === 'unsupported' ? 'provider_unsupported' : 'session_metadata_unavailable';
122
+ codexError = error?.code === 'unavailable' ? 'Codex executable unavailable. Configure a compatible Codex installation and refresh.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable. Refresh the conversation list.';
123
+ }
124
+ })(),
125
+ (async () => {
126
+ try { if (connected.has('claude')) claude = await listClaude({ signal, limit: RECENT_SESSION_LIMIT,
127
+ prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
128
+ catch { claudeError = 'Claude conversation discovery unavailable.'; }
129
+ })(),
130
+ ]);
77
131
  assertActive();
78
132
  const activeCodex = new Set(discovered.map(item => item.session));
79
133
  for (let index = 0; index < records.length; index++) {
@@ -106,7 +160,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
106
160
  for (const record of records) {
107
161
  if (record.providerArchived === true) continue;
108
162
  const key = `${record.provider}:${record.session}`;
109
- if (!byKey.has(key) && (record.observedAt || record.admission || retainedSession(record))) byKey.set(key, {
163
+ // A hook observation can belong to an AI-created worker. Only explicit
164
+ // supervision may restore a conversation absent from provider discovery.
165
+ if (!byKey.has(key) && retainedSession(record)) byKey.set(key, {
110
166
  provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
111
167
  lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
112
168
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
@@ -125,11 +181,20 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
125
181
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
126
182
  const run = record?.run;
127
183
  const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
128
- const supported = item.provider === 'codex' && item.observation.source === 'codex_app_server' && Boolean(item.model) && !codexError;
184
+ const codexChannel = codexContinuationChannel(item.observation.source, item.model, codexError ? { code: codexReasonCode, reason: codexError } : null);
185
+ const supported = item.provider === 'codex' && codexChannel.ready;
129
186
  const claudeChannel = item.provider === 'claude' ? claudeError
130
187
  ? { ready: false, code: 'session_metadata_unavailable', reason: claudeError }
131
188
  : await liveClaudeChannel(record, item, inspectProcess) : null;
132
189
  const liveHook = claudeChannel?.ready === true;
190
+ let continuationAdmission;
191
+ if (supported || liveHook) {
192
+ try {
193
+ continuationAdmission = { provider: item.provider, session: item.session,
194
+ cwd: await realpath(item.cwd), source: item.observation.source, model: item.model, validatedAt: now };
195
+ } catch { /* Missing workspaces cannot grant an admission. */ }
196
+ }
197
+ const availability = autoContinueAvailability(run, claudeChannel || codexChannel);
133
198
  const upgradeable = canUpgradeDelivery(run);
134
199
  const continuationIssue = liveHook && run ? !run.managed && !upgradeable
135
200
  ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
@@ -154,13 +219,15 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
154
219
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
155
220
  autoContinue: record?.autoContinue === true,
156
221
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
157
- ...autoContinueState(record, isRunning, supported || liveHook),
222
+ autoContinueAvailability: availability,
223
+ ...autoContinueState(record, isRunning, availability),
158
224
  lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
159
225
  taskId: run?.managed?.taskId || record?.associationTaskId || null,
160
226
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
161
227
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
162
228
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
163
- ...(claudeChannel ? { continuationReasonCode: continuationIssue?.code || claudeChannel.code } : {}),
229
+ ...(continuationAdmission ? { continuationAdmission } : {}),
230
+ continuationReasonCode: availability.reasonCode || claudeChannel?.code || 'ready',
164
231
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
165
232
  canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
166
233
  canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
@@ -178,21 +245,69 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
178
245
  }));
179
246
  assertActive();
180
247
  projected.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
181
- return { sessions: projected, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
248
+ return { sessions: projected, providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
249
+ }
250
+
251
+ // Saving consent needs an exact admitted identity, not another provider RPC.
252
+ // Execution separately rechecks the settled context, live channel and approvals.
253
+ function hasAutoContinueAdmission(record, provider, session) {
254
+ const admission = record?.admission;
255
+ return record?.provider === provider && record.session === session && typeof record.cwd === 'string' && record.cwd &&
256
+ admission?.provider === provider && admission.session === session && admission.cwd === record.cwd &&
257
+ admission.source === (provider === 'codex' ? 'codex_app_server' : 'claude_local_index') &&
258
+ (provider !== 'codex' || typeof admission.model === 'string' && admission.model.trim());
259
+ }
260
+ function requireAutoContinueTask(record) {
261
+ if (record?.run && !record.run.managed && !canUpgradeDelivery(record.run)) throw Object.assign(new Error('This conversation already has a Delivery Loop. Use its existing controls.'), { code: 'existing_loop' });
262
+ if (TERMINAL_RUNS.has(record?.run?.state)) throw new AutoContinueUnavailableError({ reason: 'This supervised task has settled. Review its existing status before continuing.', reasonCode: 'task_settled' });
182
263
  }
183
264
 
184
265
  // Explicit execution authority: legacy monitor/tracked records stay read-only.
185
266
  export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
186
267
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
187
- if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
268
+ const link = enabled ? await requireProviderLink({ root, provider, expectedEpoch: options.expectedProviderEpoch }) : null;
269
+ let discoveredAdmission;
270
+ if (enabled) {
271
+ const record = await readRecord(root, provider, session);
272
+ if (record?.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation.'), { code: 'session_archived' });
273
+ requireAutoContinueTask(record);
274
+ if (!hasAutoContinueAdmission(record, provider, session)) {
275
+ // Only the host's verified discovery catalog supplies this option; command
276
+ // payloads must never provide admission. First opt-in needs no remote RPC.
277
+ const supplied = options.admission;
278
+ if (!record?.admission && hasAutoContinueAdmission({ provider, session, cwd: supplied?.cwd, admission: supplied }, provider, session)) {
279
+ try {
280
+ const cwd = await realpath(supplied.cwd);
281
+ if (cwd === supplied.cwd) discoveredAdmission = { provider, session, cwd, source: supplied.source,
282
+ model: supplied.model, validatedAt: supplied.validatedAt };
283
+ } catch { /* Fall back to exact provider metadata if the catalog is stale. */ }
284
+ }
285
+ try { if (!discoveredAdmission) await trackSession({ root, provider, session, tracked: true, ...options, expectedProviderEpoch: link.epoch }); }
286
+ catch (error) {
287
+ if (['session_archived', 'session_workspace_changed', 'provider_link_disconnected', 'provider_link_changed'].includes(error?.code)) throw error;
288
+ throw Object.assign(new Error('SIMY could not verify this conversation. Refresh its provider and conversation list, then try again.'), { code: 'session_metadata_unavailable' });
289
+ }
290
+ }
291
+ }
188
292
  const updated = await updateRecord(root, provider, session, async previous => {
293
+ if (enabled) await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
294
+ if (enabled && discoveredAdmission) {
295
+ if (previous && previous.cwd !== discoveredAdmission.cwd) throw Object.assign(new Error('Session workspace changed; refresh its metadata.'), { code: 'session_workspace_changed' });
296
+ // An admission written while saving is authoritative. Never overwrite its
297
+ // identity with the earlier catalog snapshot, including a mismatch.
298
+ previous = previous?.admission ? previous : { ...(previous || { schemaVersion: 1, provider, session, cwd: discoveredAdmission.cwd, run: null }), admission: discoveredAdmission };
299
+ }
189
300
  if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
190
301
  if (enabled && previous.providerArchived === true) throw new Error('Restore the original provider conversation before enabling automatic continuation.');
191
- if (enabled && previous.run && !previous.run.managed && !canUpgradeDelivery(previous.run)) throw new Error('Resolve the existing loop blocker before enabling automatic continuation.');
192
- if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('This conversation is settled. Resume an eligible existing task after refreshing its status.');
193
- if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
194
- if (enabled && provider === 'claude') await requireClaudeChannel(previous,
195
- { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity);
302
+ if (enabled) {
303
+ const channel = provider === 'claude' ? await liveClaudeChannel(previous,
304
+ { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)
305
+ : codexContinuationChannel(previous.admission?.source, previous.admission?.model);
306
+ const availability = autoContinueAvailability(previous.run, channel);
307
+ if (!availability.canEnable) throw new AutoContinueUnavailableError(availability);
308
+ if (!hasAutoContinueAdmission(previous, provider, session)) throw Object.assign(new Error('SIMY could not verify this conversation. Refresh its provider and conversation list, then try again.'), { code: 'session_metadata_unavailable' });
309
+ }
310
+ if (enabled) await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
196
311
  const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
197
312
  // Settings persisted by older versions may carry removed time, turn and
198
313
  // token caps; only the fields that still govern a task are forwarded.
@@ -211,7 +326,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
211
326
  // Enrollment checks this exact opt-in epoch under its state lock, so an
212
327
  // overlapping Off action prevents the promotion from dispatching work.
213
328
  await continueSession({ ...options, root, provider, session,
214
- ...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch });
329
+ ...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch, expectedProviderEpoch: link.epoch });
215
330
  return readRecord(root, provider, session);
216
331
  }
217
332
  return updated;
@@ -225,7 +340,7 @@ export async function readSessionAutoContinue({ root, provider, session }) {
225
340
 
226
341
  // Tracking is local observation metadata. It neither starts nor stops the
227
342
  // original provider task and never grants automatic continuation authority.
228
- export async function trackSession({ root, provider, session, tracked = true, openClient = openCodexAppServer, readClaude = readClaudeSession, now = Date.now() }) {
343
+ export async function trackSession({ root, provider, session, tracked = true, openClient = openCodexAppServer, readClaude = readClaudeSession, expectedProviderEpoch, now = Date.now() }) {
229
344
  if (!['codex', 'claude'].includes(provider) || typeof tracked !== 'boolean') throw new Error('Unsupported tracking request.');
230
345
  if (!tracked) {
231
346
  await updateRecord(root, provider, session, previous => {
@@ -234,18 +349,20 @@ export async function trackSession({ root, provider, session, tracked = true, op
234
349
  });
235
350
  return;
236
351
  }
352
+ const link = await requireProviderLink({ root, provider, expectedEpoch: expectedProviderEpoch });
237
353
  const metadata = provider === 'codex' ? await withClient(openClient, client => readCodexSession(client, session)) : await readClaude(session);
238
354
  if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
239
355
  const cwd = await realpath(metadata.cwd);
240
- await updateRecord(root, provider, session, previous => {
241
- if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling automatic continuation or tracking.');
356
+ await updateRecord(root, provider, session, async previous => {
357
+ await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
358
+ if (previous?.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation or tracking.'), { code: 'session_archived' });
242
359
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
243
360
  return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), tracked: true, monitor: true,
244
361
  admission: { ...(previous?.admission || {}), source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
245
362
  });
246
363
  }
247
364
 
248
- export async function monitorSession({ root, provider, session, monitor = true, openClient = openCodexAppServer, readClaude = readClaudeSession, now = Date.now() }) {
365
+ export async function monitorSession({ root, provider, session, monitor = true, openClient = openCodexAppServer, readClaude = readClaudeSession, expectedProviderEpoch, now = Date.now() }) {
249
366
  if (!['codex', 'claude'].includes(provider)) throw new Error('Unsupported provider.');
250
367
  if (!monitor) {
251
368
  await updateRecord(root, provider, session, previous => {
@@ -255,10 +372,12 @@ export async function monitorSession({ root, provider, session, monitor = true,
255
372
  });
256
373
  return null;
257
374
  }
375
+ const link = await requireProviderLink({ root, provider, expectedEpoch: expectedProviderEpoch });
258
376
  const metadata = provider === 'codex' ? await withClient(openClient, client => readCodexSession(client, session)) : await readClaude(session);
259
377
  if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
260
378
  const cwd = await realpath(metadata.cwd);
261
- await updateRecord(root, provider, session, previous => {
379
+ await updateRecord(root, provider, session, async previous => {
380
+ await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
262
381
  if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling supervision.');
263
382
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
264
383
  return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
@@ -268,9 +387,10 @@ export async function monitorSession({ root, provider, session, monitor = true,
268
387
  }
269
388
 
270
389
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
271
- maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, openClient = openCodexAppServer,
390
+ maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, expectedProviderEpoch, openClient = openCodexAppServer,
272
391
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
273
392
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
393
+ const link = await requireProviderLink({ root, provider, expectedEpoch: expectedProviderEpoch });
274
394
  if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
275
395
  if (!['autonomous', 'balanced', 'strict'].includes(autonomyPolicy)) throw new Error('Choose an autonomy policy.');
276
396
  let metadata;
@@ -279,20 +399,21 @@ export async function enrollSession({ root, provider, session, objective, accept
279
399
  const record = await readRecord(root, provider, session);
280
400
  await requireClaudeChannel(record, metadata, inspectProcess);
281
401
  if (record.run && !upgradeRunId) throw new Error('This session already has a Delivery Loop.');
282
- await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
402
+ await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, expectedProviderEpoch: link.epoch, now });
283
403
  // A second check narrows the time between metadata admission and the
284
404
  // original provider's next Stop boundary. Process exit still remains a
285
405
  // manual recovery condition; SIMY never starts claude -r.
286
406
  await requireClaudeChannel(await readRecord(root, provider, session), metadata, inspectProcess);
287
407
  } else {
288
- metadata = await monitorSession({ root, provider, session, monitor: true, openClient, now });
408
+ metadata = await monitorSession({ root, provider, session, monitor: true, openClient, expectedProviderEpoch: link.epoch, now });
289
409
  if (!metadata.model) throw new Error('Codex did not expose this conversation’s model; automatic continuation is unavailable.');
290
410
  }
291
411
  const cwd = await realpath(metadata.cwd);
292
412
  const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
293
413
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
294
414
  const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
295
- const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
415
+ await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
416
+ const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, providerLinkEpoch: link.epoch, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
296
417
  maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
297
418
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
298
419
  managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
@@ -2,6 +2,7 @@ import { constants } from 'node:fs';
2
2
  import { lstat, open, realpath } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join, relative, isAbsolute, sep } from 'node:path';
5
+ import { setImmediate as yieldToHost } from 'node:timers/promises';
5
6
 
6
7
  const HOUR = 3_600_000;
7
8
  export const unavailableStatistics = () => ({ state: 'unavailable', hours: [], activity: [] });
@@ -14,6 +15,32 @@ const WINDOW_MS = 7 * 24 * HOUR;
14
15
  let cachedTranscript;
15
16
  const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs].join(':');
16
17
 
18
+ // One global Array.sort can freeze the host after the asynchronous file scan.
19
+ // Sort bounded runs and merge cooperatively without discarding any timestamps.
20
+ function* sortedActivityEvents(events, start, end) {
21
+ const batchSize = 2048;
22
+ let runs = [];
23
+ for (let index = 0; index < events.length; index += batchSize) {
24
+ runs.push(events.slice(index, index + batchSize).filter(at => at >= start && at <= end).sort((a, b) => a - b));
25
+ yield;
26
+ }
27
+ while (runs.length > 1) {
28
+ const mergedRuns = [];
29
+ for (let index = 0; index < runs.length; index += 2) {
30
+ if (!runs[index + 1]) { mergedRuns.push(runs[index]); continue; }
31
+ const left = runs[index], right = runs[index + 1], merged = [];
32
+ let a = 0, b = 0;
33
+ while (a < left.length || b < right.length) {
34
+ merged.push(b >= right.length || a < left.length && left[a] <= right[b] ? left[a++] : right[b++]);
35
+ if (merged.length % batchSize === 0) yield;
36
+ }
37
+ mergedRuns.push(merged);
38
+ }
39
+ runs = mergedRuns;
40
+ }
41
+ return runs[0] || [];
42
+ }
43
+
17
44
  // Only usage envelopes and timestamps survive parsing, never message text.
18
45
  // The reverse reducer attributes each cumulative difference to the newer row,
19
46
  // retaining an older baseline even when it lies outside the displayed week.
@@ -76,34 +103,57 @@ class StatisticsAccumulator {
76
103
  if (!previous || tokens > previous.tokens || tokens === previous.tokens && at < previous.at) this.usage.set(row.message.id, { at, tokens });
77
104
  }
78
105
  }
79
- finish(now, incomplete = false, coverage = {}) {
106
+ *finishSteps(now, incomplete = false, coverage = {}) {
80
107
  if (!this.matched || this.wrongSession || this.earliest > now) return unavailableStatistics();
81
108
  const missing = this.missing || incomplete;
82
109
  const start = Math.max(this.earliest, now - WINDOW_MS), end = now;
83
110
  const hours = new Map();
84
111
  for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
85
- const usage = [...this.usage.values()];
86
- if (this.reverse && this.pending) usage.push({ at: this.pending.at, tokens: this.pending.total });
87
- for (const item of usage) {
88
- if (item.at < start || item.at > end) continue;
112
+ let batch = 0;
113
+ const addUsage = item => {
114
+ if (item.at < start || item.at > end) return;
89
115
  const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
90
116
  bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
117
+ };
118
+ for (const item of this.usage.values()) {
119
+ addUsage(item);
120
+ if (++batch % 2048 === 0) yield;
91
121
  }
122
+ if (this.reverse && this.pending) addUsage({ at: this.pending.at, tokens: this.pending.total });
92
123
  const activity = [];
93
- for (const at of this.events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
124
+ let observedActiveMs = 0;
125
+ for (const at of (yield* sortedActivityEvents(this.events, start, end))) {
94
126
  const previous = activity.at(-1);
95
- if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
127
+ if (previous && at - previous.end <= 5 * 60_000) { observedActiveMs += at - previous.end; previous.end = at; }
96
128
  else activity.push({ start: at, end: at });
129
+ if (++batch % 2048 === 0) yield;
97
130
  }
98
- const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
131
+ // Usage counters alone cannot establish time spent working. A newly
132
+ // observed single event is a real zero-duration sample; no events is unknown.
133
+ const activeMs = activity.length ? observedActiveMs : null;
99
134
  const elapsedMs = end - start;
100
135
  return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
101
136
  tokens: [...hours.values()].some(h => h.tokens !== null) ? [...hours.values()].reduce((sum, h) => sum + (h.tokens || 0), 0) : null,
102
- activeMs, idleMs: missing ? null : Math.max(0, elapsedMs - activeMs),
103
- utilization: missing || !elapsedMs ? null : activeMs / elapsedMs,
137
+ activeMs, idleMs: missing || activeMs === null ? null : Math.max(0, elapsedMs - activeMs),
138
+ utilization: missing || activeMs === null || !elapsedMs ? null : activeMs / elapsedMs,
104
139
  coverage: { ...coverage, complete: !missing, scope: 'rolling_7_days' },
105
140
  activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
106
141
  }
142
+ finish(...args) {
143
+ const steps = this.finishSteps(...args);
144
+ let step;
145
+ do { step = steps.next(); } while (!step.done);
146
+ return step.value;
147
+ }
148
+ async finishAsync(now, incomplete, coverage, signal) {
149
+ const steps = this.finishSteps(now, incomplete, coverage);
150
+ for (;;) {
151
+ signal?.throwIfAborted();
152
+ const step = steps.next();
153
+ if (step.done) return step.value;
154
+ await yieldToHost();
155
+ }
156
+ }
107
157
  }
108
158
 
109
159
  export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
@@ -112,9 +162,10 @@ export function transcriptStatistics(text, provider, session, { now = Date.now()
112
162
  return accumulator.finish(now, truncated);
113
163
  }
114
164
 
115
- async function readReverse(handle, size, accumulator) {
165
+ async function readReverse(handle, size, accumulator, signal) {
116
166
  let position = size, carry = Buffer.alloc(0), skipping = false, bytesRead = 0;
117
167
  while (position > 0) {
168
+ signal?.throwIfAborted();
118
169
  const length = Math.min(position, READ_BYTES); position -= length;
119
170
  const buffer = Buffer.allocUnsafe(length);
120
171
  let filled = 0;
@@ -127,11 +178,19 @@ async function readReverse(handle, size, accumulator) {
127
178
  if (filled !== length) accumulator.missing = true;
128
179
  const data = Buffer.concat([buffer.subarray(0, filled), carry]);
129
180
  let end = data.length;
181
+ let batch = 0, yieldedAt = performance.now();
130
182
  for (let index = data.lastIndexOf(10); index >= 0; index = data.lastIndexOf(10, index - 1)) {
131
183
  const line = data.subarray(index + 1, end);
132
184
  if (!skipping && line.length <= MAX_LINE_BYTES) accumulator.add(line.toString('utf8'));
133
185
  else accumulator.missing = true;
134
186
  skipping = false; end = index;
187
+ // Disk I/O alone does not yield while parsing a dense page. Keep status,
188
+ // selection and cancellation responsive even for huge cached transcripts.
189
+ if (++batch >= 256 || performance.now() - yieldedAt >= 8) {
190
+ await yieldToHost();
191
+ signal?.throwIfAborted();
192
+ batch = 0; yieldedAt = performance.now();
193
+ }
135
194
  if (index === 0) break;
136
195
  }
137
196
  if (end > MAX_LINE_BYTES || skipping) { carry = Buffer.alloc(0); skipping = true; accumulator.missing = true; }
@@ -142,6 +201,7 @@ async function readReverse(handle, size, accumulator) {
142
201
  }
143
202
 
144
203
  export async function statisticsFromHandle(handle, info, provider, session, { now = Date.now(), ...options } = {}) {
204
+ options.signal?.throwIfAborted();
145
205
  const key = `${provider}:${session}:${fingerprint(info)}`;
146
206
  // Rebuild time-dependent buckets from cached envelopes, never cache wall-clock
147
207
  // idle time. A backwards clock or a new file fingerprint requires a new scan.
@@ -149,16 +209,17 @@ export async function statisticsFromHandle(handle, info, provider, session, { no
149
209
  const cached = cachedTranscript;
150
210
  const changed = fingerprint(await handle.stat()) !== fingerprint(info);
151
211
  if (changed) cachedTranscript = undefined;
152
- return cached.accumulator.finish(now, changed || options.truncated, cached.coverage);
212
+ return cached.accumulator.finishAsync(now, changed || options.truncated, cached.coverage, options.signal);
153
213
  }
154
214
  const accumulator = new StatisticsAccumulator(provider, session, now, true);
155
- const bytesRead = await readReverse(handle, info.size, accumulator);
215
+ const bytesRead = await readReverse(handle, info.size, accumulator, options.signal);
216
+ options.signal?.throwIfAborted();
156
217
  const after = await handle.stat();
157
218
  const changed = fingerprint(after) !== fingerprint(info);
158
219
  const coverage = { bytesRead, totalBytes: info.size };
159
220
  if (!changed && !accumulator.missing && !options.truncated) cachedTranscript = { key, accumulator, coverage };
160
221
  else cachedTranscript = undefined;
161
- return accumulator.finish(now, changed || options.truncated, coverage);
222
+ return accumulator.finishAsync(now, changed || options.truncated, coverage, options.signal);
162
223
  }
163
224
 
164
225
  export async function readCodexStatistics(client, candidate, { home = process.env.CODEX_HOME || join(homedir(), '.codex'), ...options } = {}) {
@@ -175,6 +236,6 @@ export async function readCodexStatistics(client, candidate, { home = process.en
175
236
  const info = await handle.stat();
176
237
  if (info.ino !== before.ino || info.dev !== before.dev || await realpath(thread.path) !== path) return unavailableStatistics();
177
238
  return await statisticsFromHandle(handle, info, 'codex', candidate.session, options);
178
- } catch { return unavailableStatistics(); }
239
+ } catch (error) { if (options.signal?.aborted) throw error; return unavailableStatistics(); }
179
240
  finally { await handle?.close(); }
180
241
  }