@awak-app/simy-cli 0.6.10 → 0.6.12

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.
@@ -1,7 +1,7 @@
1
1
  import { realpath } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { TERMINAL as TERMINAL_RUNS, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
- import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServerError } from './codex-app-server.js';
3
+ import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { listCodexSessions, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
5
5
  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';
@@ -55,25 +55,56 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
55
55
  }
56
56
 
57
57
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
58
- inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles } = {}) {
58
+ inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome } = {}) {
59
59
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
60
60
  assertActive();
61
61
  const records = await listRecords(root);
62
62
  const priorities = records.filter(retainedSession);
63
63
  let discovered = [], codexError = null, claude = [], claudeError = null;
64
- try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
65
- maxSessions: RECENT_SESSION_LIMIT,
66
- prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) }), signal); }
64
+ 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); }
67
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.'; }
68
73
  assertActive();
69
74
  try { claude = await listClaude({ signal, limit: RECENT_SESSION_LIMIT,
70
75
  prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
71
76
  catch { claudeError = 'Claude conversation discovery unavailable.'; }
72
77
  assertActive();
78
+ const activeCodex = new Set(discovered.map(item => item.session));
79
+ for (let index = 0; index < records.length; index++) {
80
+ assertActive();
81
+ const record = records[index];
82
+ if (record.provider !== 'codex') continue;
83
+ const isArchived = archiveStates.get(record.session) === true;
84
+ if (isArchived && record.providerArchived === true) continue;
85
+ if (!isArchived && !(record.providerArchived === true && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
86
+ records[index] = await updateRecord(root, record.provider, record.session, current => {
87
+ if (!current) return current;
88
+ current.providerArchived = isArchived;
89
+ if (isArchived) {
90
+ current.tracked = false; current.monitor = false; current.autoContinue = false;
91
+ current.autoContinueEpoch = randomUUID();
92
+ if (current.run && !TERMINAL_RUNS.has(current.run.state)) {
93
+ current.run.state = 'paused'; current.run.reason = 'The original provider conversation was archived.';
94
+ current.run.updatedAt = now;
95
+ }
96
+ }
97
+ return current;
98
+ });
99
+ }
100
+ assertActive();
73
101
  const byKey = new Map();
74
102
  const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
75
- for (const item of [...discovered, ...claude]) byKey.set(`${item.provider}:${item.session}`, item);
103
+ for (const item of [...discovered, ...claude]) {
104
+ if (recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
105
+ }
76
106
  for (const record of records) {
107
+ if (record.providerArchived === true) continue;
77
108
  const key = `${record.provider}:${record.session}`;
78
109
  if (!byKey.has(key) && (record.observedAt || record.admission || retainedSession(record))) byKey.set(key, {
79
110
  provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
@@ -99,14 +130,17 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
99
130
  ? { ready: false, code: 'session_metadata_unavailable', reason: claudeError }
100
131
  : await liveClaudeChannel(record, item, inspectProcess) : null;
101
132
  const liveHook = claudeChannel?.ready === true;
102
- const continuationIssue = liveHook && run ? !run.managed
133
+ const upgradeable = canUpgradeDelivery(run);
134
+ const continuationIssue = liveHook && run ? !run.managed && !upgradeable
103
135
  ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
104
136
  : TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
105
137
  let isRunning = item.isRunning;
106
- if (record?.runtime?.process?.identity && record.cwd === item.cwd) {
138
+ const hookIsNewer = Number.isFinite(record?.runtime?.observedAt) && Number.isFinite(item.lastActivityAt) && record.runtime.observedAt > item.lastActivityAt;
139
+ if ((isRunning === undefined || isRunning === false && hookIsNewer) && record?.runtime?.process?.identity && record.cwd === item.cwd) {
107
140
  try {
108
- // Running observations need the same live process, not just a stale
109
- // hook timestamp. Terminal observations do not override active RPCs.
141
+ // A hook newer than the persisted provider activity may describe a
142
+ // new turn. Require its same live process; older hooks cannot override
143
+ // a more recent completed provider turn.
110
144
  if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) isRunning = true;
111
145
  else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
112
146
  } catch { /* Unverified process leaves runtime status unknown. */ }
@@ -130,7 +164,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
130
164
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
131
165
  canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
132
166
  canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
133
- canAutoContinue: (supported || liveHook) && (!run || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
167
+ canAutoContinue: (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
134
168
  reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
135
169
  codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
136
170
  };
@@ -151,9 +185,10 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
151
185
  export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
152
186
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
153
187
  if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
154
- return updateRecord(root, provider, session, async previous => {
188
+ const updated = await updateRecord(root, provider, session, async previous => {
155
189
  if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
156
- if (enabled && previous.run && !previous.run.managed) throw new Error('Automatic continuation is unavailable for this conversation.');
190
+ 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.');
157
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.');
158
193
  if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
159
194
  if (enabled && provider === 'claude') await requireClaudeChannel(previous,
@@ -172,6 +207,14 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
172
207
  if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
173
208
  return next;
174
209
  });
210
+ if (enabled && updated.run && !updated.run.managed) {
211
+ // Enrollment checks this exact opt-in epoch under its state lock, so an
212
+ // overlapping Off action prevents the promotion from dispatching work.
213
+ await continueSession({ ...options, root, provider, session,
214
+ ...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch });
215
+ return readRecord(root, provider, session);
216
+ }
217
+ return updated;
175
218
  }
176
219
 
177
220
  export async function readSessionAutoContinue({ root, provider, session }) {
@@ -195,6 +238,7 @@ export async function trackSession({ root, provider, session, tracked = true, op
195
238
  if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
196
239
  const cwd = await realpath(metadata.cwd);
197
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.');
198
242
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
199
243
  return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), tracked: true, monitor: true,
200
244
  admission: { ...(previous?.admission || {}), source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
@@ -215,6 +259,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
215
259
  if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
216
260
  const cwd = await realpath(metadata.cwd);
217
261
  await updateRecord(root, provider, session, previous => {
262
+ if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling supervision.');
218
263
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
219
264
  return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
220
265
  admission: { source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
@@ -223,7 +268,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
223
268
  }
224
269
 
225
270
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
226
- maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, openClient = openCodexAppServer,
271
+ maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, openClient = openCodexAppServer,
227
272
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
228
273
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
229
274
  if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
@@ -233,7 +278,7 @@ export async function enrollSession({ root, provider, session, objective, accept
233
278
  metadata = await readClaude(session);
234
279
  const record = await readRecord(root, provider, session);
235
280
  await requireClaudeChannel(record, metadata, inspectProcess);
236
- if (record.run) throw new Error('This session already has a Delivery Loop.');
281
+ if (record.run && !upgradeRunId) throw new Error('This session already has a Delivery Loop.');
237
282
  await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
238
283
  // A second check narrows the time between metadata admission and the
239
284
  // original provider's next Stop boundary. Process exit still remains a
@@ -247,7 +292,7 @@ export async function enrollSession({ root, provider, session, objective, accept
247
292
  const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
248
293
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
249
294
  const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
250
- const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
295
+ const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
251
296
  maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
252
297
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
253
298
  managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
@@ -258,6 +303,14 @@ export async function enrollSession({ root, provider, session, objective, accept
258
303
  }
259
304
 
260
305
  export async function continueSession(options) {
306
+ const record = await readRecord(options.root, options.provider, options.session);
307
+ if (record?.run && !record.run.managed) {
308
+ // The user asked to continue this task. Adopt its goal and failure budget,
309
+ // rather than enrolling the generic context-bootstrap goal as a new run.
310
+ return enrollSession({ ...options, objective: record.run.objective, upgradeRunId: record.run.id,
311
+ maxFailures: record.run.maxFailures, contextBootstrap: false,
312
+ acceptanceCriteria: [{ id: 'original-goal', description: record.run.objective.slice(0, 500), check: { type: 'manual' } }] });
313
+ }
261
314
  return enrollSession({ ...options, objective: 'Understand and continue the selected conversation’s current user request.',
262
315
  acceptanceCriteria: [], contextBootstrap: true });
263
316
  }
@@ -6,81 +6,159 @@ import { join, relative, isAbsolute, sep } from 'node:path';
6
6
  const HOUR = 3_600_000;
7
7
  export const unavailableStatistics = () => ({ state: 'unavailable', hours: [], activity: [] });
8
8
 
9
- // Only usage envelopes and timestamps leave this reader, never message text.
10
- // Work time is an estimate from recorded activity, not a provider billing metric.
11
- export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
12
- const events = [], usage = new Map();
13
- let cumulative = 0, missing = truncated, matched = false;
14
- for (const line of text.split('\n')) {
15
- if (!line.trim()) continue;
9
+ const READ_BYTES = 1024 * 1024;
10
+ const MAX_LINE_BYTES = 4 * READ_BYTES;
11
+ const WINDOW_MS = 7 * 24 * HOUR;
12
+ // One selected transcript only; no raw messages or on-disk cache. Identity and
13
+ // ctime prevent reuse after replacement/truncation even when size is unchanged.
14
+ let cachedTranscript;
15
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs].join(':');
16
+
17
+ // Only usage envelopes and timestamps survive parsing, never message text.
18
+ // The reverse reducer attributes each cumulative difference to the newer row,
19
+ // retaining an older baseline even when it lies outside the displayed week.
20
+ class StatisticsAccumulator {
21
+ constructor(provider, session, now, reverse = false) {
22
+ Object.assign(this, { provider, session, scanNow: now, reverse, events: [], usage: new Map(), cumulative: 0,
23
+ pending: null, earliest: Infinity, missing: false, matched: false, wrongSession: false });
24
+ }
25
+ recordUsage(at, total, delta) {
26
+ if (at < this.scanNow - WINDOW_MS) return;
27
+ const key = `codex:${at}:${total}`;
28
+ if (delta > 0 || !this.usage.has(key)) this.usage.set(key, { at, tokens: delta });
29
+ }
30
+ add(line) {
31
+ if (!line.trim()) return;
16
32
  let row;
17
- try { row = JSON.parse(line); } catch { missing = true; continue; }
18
- if (provider === 'codex' && row.type === 'session_meta') {
19
- if (row.payload?.id !== session) return unavailableStatistics();
20
- matched = true;
33
+ try { row = JSON.parse(line); } catch { this.missing = true; return; }
34
+ if (!row || typeof row !== 'object' || Array.isArray(row)) { this.missing = true; return; }
35
+ if (this.provider === 'codex' && row.type === 'session_meta') {
36
+ if (row.payload?.id !== this.session) this.wrongSession = true;
37
+ else this.matched = true;
21
38
  }
22
- if (provider === 'claude') {
23
- if (row.sessionId !== session || row.isSidechain === true) continue;
24
- matched = true;
39
+ if (this.provider === 'claude') {
40
+ if (row.sessionId !== this.session || row.isSidechain === true) return;
41
+ this.matched = true;
25
42
  }
26
43
  const at = Date.parse(row.timestamp);
27
- if (!Number.isFinite(at) || at > now) continue;
28
- const active = provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
44
+ if (!Number.isFinite(at)) return;
45
+ const inWindow = at >= this.scanNow - WINDOW_MS;
46
+ const active = this.provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
29
47
  : ['response_item', 'event_msg'].includes(row.type) && row.payload?.type !== 'token_count';
30
- if (active) events.push(at);
31
- if (provider === 'codex' && row.type === 'event_msg' && row.payload?.type === 'token_count') {
48
+ if (active) {
49
+ this.earliest = Math.min(this.earliest, at);
50
+ if (inWindow) this.events.push(at);
51
+ }
52
+ if (this.provider === 'codex' && row.type === 'event_msg' && row.payload?.type === 'token_count') {
32
53
  const total = row.payload.info?.total_token_usage?.total_tokens;
33
- if (!Number.isSafeInteger(total) || total < 0) continue;
34
- if (total < cumulative) { missing = true; cumulative = total; continue; }
35
- const delta = total - cumulative; cumulative = total;
36
- const key = `codex:${at}:${total}`;
37
- if (delta > 0 || !usage.has(key)) usage.set(key, { at, tokens: delta });
38
- } else if (provider === 'claude' && row.type === 'assistant' && row.message?.id) {
54
+ if (!Number.isSafeInteger(total) || total < 0) return;
55
+ this.earliest = Math.min(this.earliest, at);
56
+ if (this.reverse) {
57
+ if (this.pending) {
58
+ const delta = this.pending.total - total;
59
+ if (delta < 0) this.missing = true;
60
+ else this.recordUsage(this.pending.at, this.pending.total, delta);
61
+ }
62
+ this.pending = { at, total };
63
+ } else {
64
+ if (at > this.scanNow) return;
65
+ if (total < this.cumulative) { this.missing = true; this.cumulative = total; return; }
66
+ this.recordUsage(at, total, total - this.cumulative);
67
+ this.cumulative = total;
68
+ }
69
+ } else if (this.provider === 'claude' && row.type === 'assistant' && row.message?.id && (inWindow || !this.reverse || this.usage.has(row.message.id))) {
39
70
  const u = row.message.usage;
40
- if (!u || ![u.input_tokens, u.output_tokens].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
41
- // Cached input is separate in Claude. Count it once with uncached input.
42
- if (![u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
71
+ if (!u || ![u.input_tokens, u.output_tokens, u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0]
72
+ .every(x => Number.isSafeInteger(x) && x >= 0)) return;
43
73
  const tokens = u.input_tokens + u.output_tokens + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
44
- if (!Number.isSafeInteger(tokens) || tokens < 0) continue;
45
- const previous = usage.get(row.message.id);
46
- if (!previous || tokens > previous.tokens) usage.set(row.message.id, { at, tokens });
74
+ if (!Number.isSafeInteger(tokens)) return;
75
+ const previous = this.usage.get(row.message.id);
76
+ if (!previous || tokens > previous.tokens || tokens === previous.tokens && at < previous.at) this.usage.set(row.message.id, { at, tokens });
47
77
  }
48
78
  }
49
- if (!matched) return unavailableStatistics();
50
- const timestamps = [...events, ...[...usage.values()].map(x => x.at)].sort((a, b) => a - b);
51
- if (!timestamps.length) return unavailableStatistics();
52
- const start = Math.max(timestamps[0], now - 7 * 24 * HOUR);
53
- const end = now;
54
- const hours = new Map();
55
- for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
56
- for (const item of usage.values()) {
57
- if (item.at < start || item.at > end) continue;
58
- const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
59
- bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
79
+ finish(now, incomplete = false, coverage = {}) {
80
+ if (!this.matched || this.wrongSession || this.earliest > now) return unavailableStatistics();
81
+ const missing = this.missing || incomplete;
82
+ const start = Math.max(this.earliest, now - WINDOW_MS), end = now;
83
+ const hours = new Map();
84
+ 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;
89
+ const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
90
+ bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
91
+ }
92
+ const activity = [];
93
+ for (const at of this.events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
94
+ const previous = activity.at(-1);
95
+ if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
96
+ else activity.push({ start: at, end: at });
97
+ }
98
+ const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
99
+ const elapsedMs = end - start;
100
+ return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
101
+ 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,
104
+ coverage: { ...coverage, complete: !missing, scope: 'rolling_7_days' },
105
+ activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
60
106
  }
61
- const activity = [];
62
- for (const at of events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
63
- const previous = activity.at(-1);
64
- if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
65
- else activity.push({ start: at, end: at });
107
+ }
108
+
109
+ export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
110
+ const accumulator = new StatisticsAccumulator(provider, session, now);
111
+ for (const line of text.split('\n')) accumulator.add(line);
112
+ return accumulator.finish(now, truncated);
113
+ }
114
+
115
+ async function readReverse(handle, size, accumulator) {
116
+ let position = size, carry = Buffer.alloc(0), skipping = false, bytesRead = 0;
117
+ while (position > 0) {
118
+ const length = Math.min(position, READ_BYTES); position -= length;
119
+ const buffer = Buffer.allocUnsafe(length);
120
+ let filled = 0;
121
+ while (filled < length) {
122
+ const read = await handle.read(buffer, filled, length - filled, position + filled);
123
+ if (!read.bytesRead) break;
124
+ filled += read.bytesRead;
125
+ }
126
+ bytesRead += filled;
127
+ if (filled !== length) accumulator.missing = true;
128
+ const data = Buffer.concat([buffer.subarray(0, filled), carry]);
129
+ let end = data.length;
130
+ for (let index = data.lastIndexOf(10); index >= 0; index = data.lastIndexOf(10, index - 1)) {
131
+ const line = data.subarray(index + 1, end);
132
+ if (!skipping && line.length <= MAX_LINE_BYTES) accumulator.add(line.toString('utf8'));
133
+ else accumulator.missing = true;
134
+ skipping = false; end = index;
135
+ if (index === 0) break;
136
+ }
137
+ if (end > MAX_LINE_BYTES || skipping) { carry = Buffer.alloc(0); skipping = true; accumulator.missing = true; }
138
+ else carry = Buffer.from(data.subarray(0, end));
66
139
  }
67
- const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
68
- const elapsedMs = end - start;
69
- return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
70
- tokens: [...hours.values()].some(h => h.tokens !== null) ? [...hours.values()].reduce((sum, h) => sum + (h.tokens || 0), 0) : null,
71
- activeMs, idleMs: Math.max(0, elapsedMs - activeMs), utilization: elapsedMs ? activeMs / elapsedMs : null,
72
- activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
140
+ if (!skipping) accumulator.add(carry.toString('utf8'));
141
+ return bytesRead;
73
142
  }
74
143
 
75
- export async function statisticsFromHandle(handle, info, provider, session, options) {
76
- const maximum = 32 * 1024 * 1024;
77
- // Keep the prefix for identity and cumulative usage. Never invent a baseline
78
- // from a truncated tail or claim the unobserved remainder as zero.
79
- const buffer = Buffer.alloc(Math.min(info.size, maximum));
80
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
144
+ export async function statisticsFromHandle(handle, info, provider, session, { now = Date.now(), ...options } = {}) {
145
+ const key = `${provider}:${session}:${fingerprint(info)}`;
146
+ // Rebuild time-dependent buckets from cached envelopes, never cache wall-clock
147
+ // idle time. A backwards clock or a new file fingerprint requires a new scan.
148
+ if (cachedTranscript?.key === key && now >= cachedTranscript.accumulator.scanNow) {
149
+ const cached = cachedTranscript;
150
+ const changed = fingerprint(await handle.stat()) !== fingerprint(info);
151
+ if (changed) cachedTranscript = undefined;
152
+ return cached.accumulator.finish(now, changed || options.truncated, cached.coverage);
153
+ }
154
+ const accumulator = new StatisticsAccumulator(provider, session, now, true);
155
+ const bytesRead = await readReverse(handle, info.size, accumulator);
81
156
  const after = await handle.stat();
82
- return transcriptStatistics(buffer.subarray(0, bytesRead).toString('utf8'), provider, session,
83
- { ...options, truncated: info.size > maximum || after.size !== info.size || after.mtimeMs !== info.mtimeMs });
157
+ const changed = fingerprint(after) !== fingerprint(info);
158
+ const coverage = { bytesRead, totalBytes: info.size };
159
+ if (!changed && !accumulator.missing && !options.truncated) cachedTranscript = { key, accumulator, coverage };
160
+ else cachedTranscript = undefined;
161
+ return accumulator.finish(now, changed || options.truncated, coverage);
84
162
  }
85
163
 
86
164
  export async function readCodexStatistics(client, candidate, { home = process.env.CODEX_HOME || join(homedir(), '.codex'), ...options } = {}) {
@@ -0,0 +1,105 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { compareVersions } from "../backend-executable.js";
5
+ import { CLI_VERSION } from "../cli-contract.js";
6
+ import { defaultSessionRoot } from "../session-store.js";
7
+
8
+ export const SQM_FAILURE_PAUSE_THRESHOLD = 6;
9
+ const scopeKey = scope => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId]);
10
+ const validVersion = value => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
11
+
12
+ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {}) {
13
+ const directory = join(root, "sqm-failure-circuit");
14
+ const path = scope => {
15
+ if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) throw new Error("SQM failure state requires an organization scope.");
16
+ return join(directory, `${createHash("sha256").update(scopeKey(scope)).digest("hex")}.json`);
17
+ };
18
+ return {
19
+ async read(scope) {
20
+ try {
21
+ const state = JSON.parse(await readFile(path(scope), "utf8"));
22
+ if (!validVersion(state.version) || !Number.isInteger(state.failures) || state.failures < 0 || state.failures > SQM_FAILURE_PAUSE_THRESHOLD || (state.inFlight !== undefined && typeof state.inFlight !== "boolean"))
23
+ throw new Error("Invalid saved SQM failure state.");
24
+ return state;
25
+ } catch (error) { if (error.code === "ENOENT") return null; throw error; }
26
+ },
27
+ async write(scope, state) {
28
+ const target = path(scope), temporary = `${target}.${randomUUID()}.tmp`;
29
+ await mkdir(directory, { recursive: true, mode: 0o700 });
30
+ try {
31
+ await writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
32
+ await rename(temporary, target);
33
+ } finally { await rm(temporary, { force: true }); }
34
+ },
35
+ };
36
+ }
37
+
38
+ // One counter per organization on this machine, shared by both local engines.
39
+ // Neither reconnecting nor an empty claim demonstrates successful analysis.
40
+ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null } = {}) {
41
+ if (!validVersion(version)) throw new Error("SQM failure circuit requires a SIMY version.");
42
+ const states = new Map();
43
+ const unreadableScopes = new Set();
44
+ const save = async (scope, state) => {
45
+ try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true }); state.storageError = false; }
46
+ catch (error) { state.storageError = true; throw error; }
47
+ };
48
+ const load = async scope => {
49
+ const key = scopeKey(scope);
50
+ let state = states.get(key);
51
+ if (!state) {
52
+ let saved;
53
+ try { saved = await store?.read(scope); unreadableScopes.delete(key); }
54
+ catch (error) { unreadableScopes.add(key); throw error; }
55
+ state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true } : { version, failures: 0, inFlight: false };
56
+ states.set(key, state);
57
+ // An unfinished review from this same version means the last host exited
58
+ // without a result or graceful cancellation. Count it once on restart.
59
+ if (state.inFlight && compareVersions(version, state.version) <= 0) {
60
+ state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
61
+ state.inFlight = false;
62
+ await save(scope, state);
63
+ }
64
+ }
65
+ if (compareVersions(version, state.version) > 0) {
66
+ state.version = version;
67
+ state.failures = 0;
68
+ state.inFlight = false;
69
+ await save(scope, state);
70
+ } else if (state.storageError) await save(scope, state);
71
+ return state;
72
+ };
73
+ return {
74
+ async canClaim(scope) { return (await load(scope)).failures < SQM_FAILURE_PAUSE_THRESHOLD; },
75
+ async begin(scope) {
76
+ const state = await load(scope);
77
+ state.inFlight = true;
78
+ await save(scope, state);
79
+ },
80
+ async cancel(scope) {
81
+ const state = await load(scope);
82
+ state.inFlight = false;
83
+ await save(scope, state);
84
+ },
85
+ async failure(scope) {
86
+ const state = await load(scope);
87
+ state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
88
+ state.inFlight = false;
89
+ await save(scope, state);
90
+ },
91
+ async success(scope) {
92
+ const state = await load(scope);
93
+ state.failures = 0;
94
+ state.inFlight = false;
95
+ await save(scope, state);
96
+ },
97
+ snapshot(scope) {
98
+ const state = states.get(scopeKey(scope));
99
+ return { consecutive_failures: state?.failures || 0,
100
+ paused_until_update: (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD,
101
+ paused_at_version: (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD ? state.version : null,
102
+ storage_unavailable: state?.storageError === true || unreadableScopes.has(scopeKey(scope)) };
103
+ },
104
+ };
105
+ }
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
7
  import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
8
+ import { createSqmFailureCircuit } from "./failure-circuit.js";
8
9
  import { inspectProviderStatus } from "../desktop-status.js";
9
10
  import { inspectDesktopExecutor } from "../desktop-executor.js";
10
11
  import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
@@ -109,6 +110,7 @@ export function createSqmLocalReviewWorker({
109
110
  now = () => Date.now(),
110
111
  providerFailureCooldownMs = 5 * 60_000,
111
112
  getRecoveryKey = () => null,
113
+ failureCircuit = createSqmFailureCircuit(),
112
114
  } = {}) {
113
115
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
114
116
  let activeJobId = null;
@@ -187,7 +189,6 @@ export function createSqmLocalReviewWorker({
187
189
  kind: "analysis", state: "running", provider, startedAt: new Date().toISOString(), webUrl: analysisTaskUrl(workspaceUrl, claim.job_id) });
188
190
  historyScopes.set(history[0], JSON.stringify(historyScope));
189
191
  history.splice(50);
190
- await persist(historyScope, history[0]);
191
192
  onChange();
192
193
  let heartbeatTimer = null;
193
194
  const heartbeat = () => {
@@ -203,6 +204,8 @@ export function createSqmLocalReviewWorker({
203
204
  const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
204
205
  ? { reported_executor_provider: review.provider || selectedProvider } : {};
205
206
  try {
207
+ await failureCircuit.begin(historyScope);
208
+ await persist(historyScope, history[0]);
206
209
  const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => request({
207
210
  action: "advance",
208
211
  ...leaseBody(claim, provider),
@@ -250,7 +253,11 @@ export function createSqmLocalReviewWorker({
250
253
  return {
251
254
  snapshot() {
252
255
  const pause = currentPause();
253
- return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
256
+ const circuit = failureCircuit.snapshot(getHistoryScope());
257
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended,
258
+ paused: pausedEnvironment !== null || circuit.paused_until_update || circuit.storage_unavailable,
259
+ pause_reason: circuit.paused_until_update ? "consecutive_failures_until_update" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
260
+ ...circuit, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
254
261
  ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
255
262
  },
256
263
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
@@ -258,7 +265,7 @@ export function createSqmLocalReviewWorker({
258
265
  const item = visibleHistory().find(item => item.key === key);
259
266
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
260
267
  },
261
- retry() { if (closed || suspended || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
268
+ retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).paused_until_update) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
262
269
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
263
270
  poll() {
264
271
  if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
@@ -279,6 +286,8 @@ export function createSqmLocalReviewWorker({
279
286
  const workspaceUrl = getWorkspaceUrl();
280
287
  reviewController = new AbortController();
281
288
  pollPromise = (async () => {
289
+ if (!await failureCircuit.canClaim(historyScope)) return null;
290
+ if (reviewController.signal.aborted) throw providerFailure("analysis_cancelled");
282
291
  const claim = await api.request({
283
292
  action: "claim",
284
293
  executor_provider: provider,
@@ -293,11 +302,16 @@ export function createSqmLocalReviewWorker({
293
302
  return null;
294
303
  }
295
304
  const result = await processClaim(claim, provider, historyScope, workspaceUrl);
305
+ try { await failureCircuit.success(historyScope); } catch (storageError) { onError(storageError); }
296
306
  pauses.delete(key);
297
307
  return result;
298
308
  })()
299
309
  .catch(async (error) => {
300
- if (error.code === "analysis_cancelled") return null;
310
+ if (error.code === "analysis_cancelled") {
311
+ try { await failureCircuit.cancel(historyScope); } catch (storageError) { onError(storageError); }
312
+ return null;
313
+ }
314
+ try { await failureCircuit.failure(historyScope); } catch (storageError) { onError(storageError); }
301
315
  pauseForFailure(error, provider, historyScope, recoveryKey);
302
316
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
303
317
  const scope = historyScope;
@@ -1,3 +1,6 @@
1
+ import { findReusableConnection } from "../connection-discovery.js";
2
+ import { connectionScope } from "../connection-diagnostics.js";
3
+ import { CLI_VERSION, CLI_API_CONTRACT_VERSION } from "../cli-contract.js";
1
4
  import { startAgent } from "../agent.js";
2
5
  import { openAuthorizationUrl } from "../browser.js";
3
6
  import { isSessionValid, readSession } from "../session-store.js";
@@ -16,6 +19,7 @@ export async function ensureSqmSession(
16
19
  readSessionImpl = readSession,
17
20
  isSessionValidImpl = isSessionValid,
18
21
  startAgentImpl = startAgent,
22
+ findConnectionImpl = findReusableConnection,
19
23
  openAuthorizationUrlImpl = openAuthorizationUrl,
20
24
  createAgentShutdownImpl = createAgentShutdown,
21
25
  expectedScope,
@@ -31,8 +35,11 @@ export async function ensureSqmSession(
31
35
  return session;
32
36
  }
33
37
 
34
- const agent = await startAgentImpl({ requestedPort: 0, webOrigin, quiet: true });
35
- const shutdown = createAgentShutdownImpl(agent);
38
+ const existing = await findConnectionImpl({ webOrigin, cliVersion: CLI_VERSION,
39
+ apiContractVersion: CLI_API_CONTRACT_VERSION, expectedScope: connectionScope(session, webOrigin),
40
+ authorize: true, reauthorize: session?.token === rejectedToken && Boolean(rejectedToken) });
41
+ const agent = existing || await startAgentImpl({ requestedPort: 0, webOrigin, quiet: true });
42
+ const shutdown = existing ? async () => {} : createAgentShutdownImpl(agent);
36
43
  try {
37
44
  const loginUrl = agent.loginUrl;
38
45
  if (!loginUrl) throw new Error("SIMY could not create an SQM authorization session.");