@awak-app/simy-cli 0.6.9 → 0.6.11

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. */ }
@@ -114,6 +148,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
114
148
  return { provider: item.provider, session: item.session, cwd: item.cwd,
115
149
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
116
150
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
151
+ ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
117
152
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
118
153
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
119
154
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
@@ -129,7 +164,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
129
164
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
130
165
  canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
131
166
  canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
132
- 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)) },
133
168
  reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
134
169
  codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
135
170
  };
@@ -150,9 +185,10 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
150
185
  export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
151
186
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
152
187
  if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
153
- return updateRecord(root, provider, session, async previous => {
188
+ const updated = await updateRecord(root, provider, session, async previous => {
154
189
  if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
155
- 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.');
156
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.');
157
193
  if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
158
194
  if (enabled && provider === 'claude') await requireClaudeChannel(previous,
@@ -171,6 +207,14 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
171
207
  if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
172
208
  return next;
173
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;
174
218
  }
175
219
 
176
220
  export async function readSessionAutoContinue({ root, provider, session }) {
@@ -194,6 +238,7 @@ export async function trackSession({ root, provider, session, tracked = true, op
194
238
  if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
195
239
  const cwd = await realpath(metadata.cwd);
196
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.');
197
242
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
198
243
  return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), tracked: true, monitor: true,
199
244
  admission: { ...(previous?.admission || {}), source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
@@ -214,6 +259,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
214
259
  if (!metadata) throw new Error('Exact provider session metadata is unavailable.');
215
260
  const cwd = await realpath(metadata.cwd);
216
261
  await updateRecord(root, provider, session, previous => {
262
+ if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling supervision.');
217
263
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
218
264
  return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
219
265
  admission: { source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
@@ -222,7 +268,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
222
268
  }
223
269
 
224
270
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
225
- maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, openClient = openCodexAppServer,
271
+ maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, openClient = openCodexAppServer,
226
272
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
227
273
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
228
274
  if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
@@ -232,7 +278,7 @@ export async function enrollSession({ root, provider, session, objective, accept
232
278
  metadata = await readClaude(session);
233
279
  const record = await readRecord(root, provider, session);
234
280
  await requireClaudeChannel(record, metadata, inspectProcess);
235
- 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.');
236
282
  await monitorSession({ root, provider, session, monitor: true, readClaude: async () => metadata, now });
237
283
  // A second check narrows the time between metadata admission and the
238
284
  // original provider's next Stop boundary. Process exit still remains a
@@ -246,7 +292,7 @@ export async function enrollSession({ root, provider, session, objective, accept
246
292
  const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
247
293
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
248
294
  const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
249
- 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.',
250
296
  maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
251
297
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
252
298
  managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
@@ -257,6 +303,14 @@ export async function enrollSession({ root, provider, session, objective, accept
257
303
  }
258
304
 
259
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
+ }
260
314
  return enrollSession({ ...options, objective: 'Understand and continue the selected conversation’s current user request.',
261
315
  acceptanceCriteria: [], contextBootstrap: true });
262
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
+ }
@@ -11,7 +11,7 @@ export function safeProviderDiagnostic(value) {
11
11
  if (SIGNALS.has(value.signal)) result.signal = value.signal;
12
12
  if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
13
13
  if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
14
- if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
14
+ if (typeof value.model === "string" && /^(?:gpt-6-(?:astra|sol)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
15
15
  return result;
16
16
  }
17
17
 
@@ -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";
@@ -20,7 +21,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
20
21
  const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
21
22
  // Keep analysis independent of interactive config. Availability varies by
22
23
  // account and CLI; only an explicit model rejection may try the next model.
23
- export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
24
+ // Background SQM and conversation triage use Sol models to limit usage.
25
+ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6.1-sol", "gpt-6-sol"]);
24
26
  export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
25
27
 
26
28
  export class SqmLocalReviewError extends Error {
@@ -108,6 +110,7 @@ export function createSqmLocalReviewWorker({
108
110
  now = () => Date.now(),
109
111
  providerFailureCooldownMs = 5 * 60_000,
110
112
  getRecoveryKey = () => null,
113
+ failureCircuit = createSqmFailureCircuit(),
111
114
  } = {}) {
112
115
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
113
116
  let activeJobId = null;
@@ -186,7 +189,6 @@ export function createSqmLocalReviewWorker({
186
189
  kind: "analysis", state: "running", provider, startedAt: new Date().toISOString(), webUrl: analysisTaskUrl(workspaceUrl, claim.job_id) });
187
190
  historyScopes.set(history[0], JSON.stringify(historyScope));
188
191
  history.splice(50);
189
- await persist(historyScope, history[0]);
190
192
  onChange();
191
193
  let heartbeatTimer = null;
192
194
  const heartbeat = () => {
@@ -202,6 +204,8 @@ export function createSqmLocalReviewWorker({
202
204
  const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
203
205
  ? { reported_executor_provider: review.provider || selectedProvider } : {};
204
206
  try {
207
+ await failureCircuit.begin(historyScope);
208
+ await persist(historyScope, history[0]);
205
209
  const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => request({
206
210
  action: "advance",
207
211
  ...leaseBody(claim, provider),
@@ -249,7 +253,11 @@ export function createSqmLocalReviewWorker({
249
253
  return {
250
254
  snapshot() {
251
255
  const pause = currentPause();
252
- 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,
253
261
  ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
254
262
  },
255
263
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
@@ -257,7 +265,7 @@ export function createSqmLocalReviewWorker({
257
265
  const item = visibleHistory().find(item => item.key === key);
258
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;
259
267
  },
260
- 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; },
261
269
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
262
270
  poll() {
263
271
  if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
@@ -278,6 +286,8 @@ export function createSqmLocalReviewWorker({
278
286
  const workspaceUrl = getWorkspaceUrl();
279
287
  reviewController = new AbortController();
280
288
  pollPromise = (async () => {
289
+ if (!await failureCircuit.canClaim(historyScope)) return null;
290
+ if (reviewController.signal.aborted) throw providerFailure("analysis_cancelled");
281
291
  const claim = await api.request({
282
292
  action: "claim",
283
293
  executor_provider: provider,
@@ -292,11 +302,16 @@ export function createSqmLocalReviewWorker({
292
302
  return null;
293
303
  }
294
304
  const result = await processClaim(claim, provider, historyScope, workspaceUrl);
305
+ try { await failureCircuit.success(historyScope); } catch (storageError) { onError(storageError); }
295
306
  pauses.delete(key);
296
307
  return result;
297
308
  })()
298
309
  .catch(async (error) => {
299
- 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); }
300
315
  pauseForFailure(error, provider, historyScope, recoveryKey);
301
316
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
302
317
  const scope = historyScope;