@awak-app/simy-cli 0.5.59 → 0.5.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,24 @@ const MAX_TRANSCRIPTS = 4096;
10
10
  const MAX_PREFIX_BYTES = 256 * 1024;
11
11
  const MAX_LINES = 256;
12
12
  const MAX_FILE_BYTES = 64 * 1024 * 1024;
13
+ // Metadata is observation only. Exact-session/context readers always open the
14
+ // transcript again; cached values contain no conversation message bodies.
15
+ const metadataCache = new Map();
16
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
17
+ function rememberMetadata(path, value) {
18
+ metadataCache.delete(path); metadataCache.set(path, value);
19
+ while (metadataCache.size > MAX_TRANSCRIPTS) metadataCache.delete(metadataCache.keys().next().value);
20
+ }
21
+ function candidateMetadata(id, metadata, lastActivityAt, observedAt) {
22
+ return {
23
+ provider: 'claude', session: id, cwd: metadata.cwd, title: metadata.title,
24
+ source: 'claude_cli', lastActivityAt,
25
+ observation: { source: 'claude_local_index', observedAt },
26
+ availability: 'unsupported', model: null,
27
+ capabilities: { canMonitor: true, canEnroll: false, canAutoContinue: false },
28
+ reason: 'Claude session ownership and safe exact-session takeover are unverified; monitoring only.',
29
+ };
30
+ }
13
31
 
14
32
  function within(parent, child) {
15
33
  const path = relative(parent, child);
@@ -53,7 +71,7 @@ function metadataFromPrefix(bytes, id) {
53
71
  return found ? { cwd, title } : null;
54
72
  }
55
73
 
56
- async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false) {
74
+ async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false) {
57
75
  const id = fileName.slice(0, -'.jsonl'.length);
58
76
  if (!UUID.test(id)) return null;
59
77
  const project = join(root, projectName);
@@ -62,8 +80,19 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
62
80
  let handle;
63
81
  try {
64
82
  const before = await lstat(path);
65
- if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_FILE_BYTES) return null;
66
- if (!within(root, await realpath(path))) return null;
83
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_FILE_BYTES) { metadataCache.delete(path); return null; }
84
+ if (!within(root, await realpath(path))) { metadataCache.delete(path); return null; }
85
+ let key;
86
+ if (cacheMetadata) {
87
+ const info = await lstat(path, { bigint: true });
88
+ if (!info.isFile() || info.isSymbolicLink() || info.dev !== BigInt(before.dev) || info.ino !== BigInt(before.ino)) return null;
89
+ key = fingerprint(info);
90
+ const cached = metadataCache.get(path);
91
+ if (cached?.key === key) {
92
+ rememberMetadata(path, cached);
93
+ return cached.metadata ? candidateMetadata(id, cached.metadata, cached.lastActivityAt, observedAt) : null;
94
+ }
95
+ }
67
96
  handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
68
97
  const opened = await handle.stat();
69
98
  if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null;
@@ -75,22 +104,22 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
75
104
  const buffer = Buffer.allocUnsafe(Math.min(opened.size, MAX_PREFIX_BYTES));
76
105
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
77
106
  const metadata = metadataFromPrefix(buffer.subarray(0, bytesRead), id);
107
+ if (cacheMetadata) {
108
+ const [after, pathAfter] = await Promise.all([handle.stat({ bigint: true }), lstat(path, { bigint: true })]);
109
+ if (fingerprint(after) === key && fingerprint(pathAfter) === key && pathAfter.isFile() && !pathAfter.isSymbolicLink())
110
+ rememberMetadata(path, { key, metadata, lastActivityAt: opened.mtimeMs });
111
+ else metadataCache.delete(path);
112
+ }
78
113
  if (!metadata) return null;
79
- const candidate = {
80
- provider: 'claude', session: id, cwd: metadata.cwd, title: metadata.title,
81
- source: 'claude_cli', lastActivityAt: opened.mtimeMs,
82
- observation: { source: 'claude_local_index', observedAt },
83
- availability: 'unsupported', model: null,
84
- capabilities: { canMonitor: true, canEnroll: false, canAutoContinue: false },
85
- reason: 'Claude session ownership and safe exact-session takeover are unverified; monitoring only.',
86
- };
114
+ const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
87
115
  if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
88
116
  return candidate;
89
- } catch { return null; }
117
+ } catch { metadataCache.delete(path); return null; }
90
118
  finally { await handle?.close(); }
91
119
  }
92
120
 
93
- async function collect({ home = homedir(), limit = 100, session = null, includeContext = false } = {}) {
121
+ async function collect({ home = homedir(), limit = 100, session = null, includeContext = false, cacheMetadata = false, signal } = {}) {
122
+ if (signal?.aborted) return [];
94
123
  const root = await projectsRoot(home);
95
124
  if (!root) return [];
96
125
  const maximum = Number.isInteger(limit) ? Math.max(1, Math.min(limit, 200)) : 100;
@@ -100,16 +129,18 @@ async function collect({ home = homedir(), limit = 100, session = null, includeC
100
129
  let projects;
101
130
  try { projects = await readdir(root, { withFileTypes: true }); } catch { return []; }
102
131
  for (const entry of projects.filter(item => item.isDirectory() && !item.isSymbolicLink()).slice(0, MAX_PROJECTS)) {
132
+ if (signal?.aborted) return [];
103
133
  const project = join(root, entry.name);
104
134
  let files;
105
135
  try { files = await readdir(project, { withFileTypes: true }); } catch { continue; }
106
136
  for (const file of files) {
137
+ if (signal?.aborted) return [];
107
138
  if (inspected >= MAX_TRANSCRIPTS) break;
108
139
  if (!file.isFile() || file.isSymbolicLink() || !UUID.test(file.name.replace(/\.jsonl$/, '')) || !file.name.endsWith('.jsonl')) continue;
109
140
  const id = file.name.slice(0, -'.jsonl'.length);
110
141
  if (session && id !== session) continue;
111
142
  inspected++;
112
- const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext);
143
+ const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext, cacheMetadata);
113
144
  if (!candidate) continue;
114
145
  const key = `${candidate.session}\0${candidate.cwd}${includeContext ? `\0${entry.name}\0${file.name}` : ''}`;
115
146
  if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
@@ -120,8 +151,8 @@ async function collect({ home = homedir(), limit = 100, session = null, includeC
120
151
  return session ? sorted : sorted.slice(0, maximum);
121
152
  }
122
153
 
123
- export async function listClaudeSessions({ home, limit } = {}) {
124
- return collect({ home, limit });
154
+ export async function listClaudeSessions({ home, limit, signal } = {}) {
155
+ return collect({ home, limit, signal, cacheMetadata: true });
125
156
  }
126
157
 
127
158
  export async function readClaudeSession(session, { home } = {}) {
@@ -163,7 +194,7 @@ async function conversationFromHandle(handle, opened, session, cwd) {
163
194
  export async function readClaudeConversation(candidate, { home, signal } = {}) {
164
195
  if (signal?.aborted) return boundConversation(null);
165
196
  if (!UUID.test(candidate?.session || '') || typeof candidate?.cwd !== 'string') return boundConversation(null);
166
- const matches = await collect({ home, session: candidate.session, includeContext: true });
197
+ const matches = await collect({ home, session: candidate.session, includeContext: true, signal });
167
198
  const selected = matches.filter(item => item.cwd === candidate.cwd);
168
199
  if (signal?.aborted || selected.length !== 1) return boundConversation(null);
169
200
  if (Number.isFinite(candidate.lastActivityAt) && selected[0].lastActivityAt !== candidate.lastActivityAt) {
@@ -23,7 +23,7 @@ export class CodexAppServerError extends Error {
23
23
  }
24
24
 
25
25
  export class CodexAppServerClient {
26
- constructor(child, { maxLineBytes = MAX_LINE, omitInlineAttachments = false } = {}) {
26
+ constructor(child, { maxLineBytes = MAX_LINE, omitInlineAttachments = false, signal } = {}) {
27
27
  this.maxLineBytes = Number.isInteger(maxLineBytes) ? Math.max(MAX_LINE, Math.min(maxLineBytes, 8 * 1024 * 1024)) : MAX_LINE;
28
28
  this.child = child;
29
29
  this.nextId = 0;
@@ -61,7 +61,12 @@ export class CodexAppServerClient {
61
61
  }
62
62
  });
63
63
  child.on('error', error => this.fail(error));
64
- child.on('close', () => this.fail(new CodexAppServerError('closed', 'Codex app-server closed.')));
64
+ child.on('close', () => { this.childClosed = true; this.detachAbort(); this.fail(new CodexAppServerError('closed', 'Codex app-server closed.')); });
65
+ child.stdin?.on?.('error', error => this.fail(error));
66
+ this.abortSignal = signal;
67
+ this.cancelled = () => { this.fail(new CodexAppServerError('cancelled', 'Codex app-server was cancelled.')); void this.close(); };
68
+ signal?.addEventListener('abort', this.cancelled, { once: true });
69
+ if (signal?.aborted) this.cancelled();
65
70
  // Diagnostics can contain private data. Drain, but never retain or expose them.
66
71
  child.stderr?.resume();
67
72
  }
@@ -116,14 +121,28 @@ export class CodexAppServerClient {
116
121
 
117
122
  onNotification(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
118
123
 
119
- async close() {
120
- if (this.child.exitCode !== null) return;
121
- this.child.stdin.end();
122
- this.child.kill('SIGTERM');
123
- await new Promise(resolve => {
124
- const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 2_000);
125
- this.child.once('close', () => { clearTimeout(timer); resolve(); });
124
+ detachAbort() { this.abortSignal?.removeEventListener('abort', this.cancelled); }
125
+
126
+ close() {
127
+ if (this.closing) return this.closing;
128
+ this.detachAbort();
129
+ this.fail(new CodexAppServerError('closed', 'Codex app-server closed.'));
130
+ if (this.childClosed || this.child.exitCode != null || this.child.signalCode != null) return this.closing = Promise.resolve();
131
+ this.closing = new Promise(resolve => {
132
+ const finished = () => {
133
+ clearTimeout(force); clearTimeout(limit);
134
+ this.child.removeListener('close', finished);
135
+ resolve();
136
+ };
137
+ // Subscribe before ending/killing: a fast or fake child can close during
138
+ // kill itself. One close owns both timers and drains rejected RPCs.
139
+ this.child.once('close', finished);
140
+ const force = setTimeout(() => { this.child.kill('SIGKILL'); }, 2_000);
141
+ const limit = setTimeout(finished, 3_000);
142
+ this.child.stdin.end();
143
+ this.child.kill('SIGTERM');
126
144
  });
145
+ return this.closing;
127
146
  }
128
147
  }
129
148
 
@@ -134,9 +153,9 @@ export function supportsCodexWriterLock(version) {
134
153
  return parts[0] > 0 || parts[1] > 158 || parts[1] === 158 && parts[2] >= 0;
135
154
  }
136
155
 
137
- export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal, maxLineBytes, omitInlineAttachments = false } = {}) {
156
+ export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal, maxLineBytes, omitInlineAttachments = false, cancelOnAbort = false } = {}) {
138
157
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
139
- const inspection = await inspectExecutable('codex');
158
+ const inspection = await inspectExecutable('codex', cancelOnAbort ? { signal } : undefined);
140
159
  if (!inspection?.available || !inspection.executable) throw new CodexAppServerError('unavailable', 'Codex executable is unavailable.');
141
160
  if (!supportsCodexWriterLock(inspection.installed_version)) throw new CodexAppServerError('unsupported', 'Codex version does not have a verified app-server writer lock.');
142
161
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
@@ -144,12 +163,13 @@ export async function openCodexAppServer({ inspectExecutable = inspectBackendExe
144
163
  // restore a temporary read-only override. The caller's signal is checked
145
164
  // before spawn and by the turn watcher below.
146
165
  const child = spawnImpl(inspection.executable, ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
147
- const client = new CodexAppServerClient(child, { maxLineBytes, omitInlineAttachments });
166
+ const client = new CodexAppServerClient(child, { maxLineBytes, omitInlineAttachments, signal: cancelOnAbort ? signal : undefined });
148
167
  try {
149
168
  await client.request('initialize', { clientInfo: { name: 'simy_session_supervision', title: 'SIMY Session Supervision', version: '1.0.0' } });
169
+ if (cancelOnAbort && signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
150
170
  client.notify('initialized');
151
171
  return client;
152
- } catch (error) { await client.close(); throw error; }
172
+ } catch (error) { await client.close(); if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.'); throw error; }
153
173
  }
154
174
 
155
175
  export function codexThreadMetadata(thread) {
@@ -266,10 +286,11 @@ export async function readCodexSession(client, session) {
266
286
 
267
287
  export async function readCodexConversation(candidate, { openClient = openCodexAppServer, signal } = {}) {
268
288
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
269
- const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE, omitInlineAttachments: true });
289
+ const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE, omitInlineAttachments: true, cancelOnAbort: true });
270
290
  const cancelled = () => { void client.close(); };
271
291
  signal?.addEventListener('abort', cancelled, { once: true });
272
292
  try {
293
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
273
294
  // Full thread history can contain tens of MiB of tool output. Analysis
274
295
  // reads only metadata and a fixed number of recent display summaries.
275
296
  const metadata = await readCodexSession(client, candidate.session);
@@ -1,7 +1,7 @@
1
1
  import { realpath } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
- import { listCodexSessions, openCodexAppServer, readCodexSession } from './codex-app-server.js';
4
+ import { listCodexSessions, openCodexAppServer, readCodexSession, 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
 
@@ -32,9 +32,15 @@ function initialDiagnosisPlan(objective, contextBootstrap = false) {
32
32
  };
33
33
  }
34
34
 
35
- async function withClient(openClient, work) {
36
- const client = await openClient();
37
- try { return await work(client); } finally { await client.close(); }
35
+ async function withClient(openClient, work, signal) {
36
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.');
37
+ const client = await openClient(signal ? { signal, cancelOnAbort: true } : undefined);
38
+ const cancelled = () => { void client.close(); };
39
+ signal?.addEventListener('abort', cancelled, { once: true });
40
+ try {
41
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.');
42
+ return await work(client);
43
+ } finally { signal?.removeEventListener('abort', cancelled); await client.close(); }
38
44
  }
39
45
 
40
46
  async function liveClaudeHook(record, metadata, inspectProcess) {
@@ -49,14 +55,19 @@ async function liveClaudeHook(record, metadata, inspectProcess) {
49
55
  }
50
56
 
51
57
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
52
- inspectProcess = processIdentity, now = Date.now() } = {}) {
58
+ inspectProcess = processIdentity, now = Date.now(), signal } = {}) {
59
+ const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
60
+ assertActive();
53
61
  const records = await listRecords(root);
54
62
  let discovered = [], codexError = null, claude = [], claudeError = null;
55
63
  try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
56
- prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) })); }
64
+ prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) }), signal); }
57
65
  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.'; }
58
- try { claude = await listClaude(); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
66
+ assertActive();
67
+ try { claude = await listClaude({ signal }); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
68
+ assertActive();
59
69
  const byKey = new Map();
70
+ const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
60
71
  for (const item of [...discovered, ...claude]) byKey.set(`${item.provider}:${item.session}`, item);
61
72
  for (const record of records) {
62
73
  const key = `${record.provider}:${record.session}`;
@@ -67,8 +78,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
67
78
  availability: 'unknown', model: record.admission?.model || null,
68
79
  });
69
80
  }
70
- const projected = (await Promise.all([...byKey.values()].map(async item => {
71
- const record = records.find(r => r.provider === item.provider && r.session === item.session);
81
+ // Historical sessions must not fan out hundreds of process-inspection
82
+ // subprocesses. Preserve input ordering while limiting live metadata probes.
83
+ const items = [...byKey.values()], projected = new Array(items.length);
84
+ let nextItem = 0;
85
+ const project = async item => {
86
+ const record = recordsByKey.get(`${item.provider}:${item.session}`);
72
87
  const run = record?.run;
73
88
  const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
74
89
  const supported = item.provider === 'codex' && item.observation.source === 'codex_app_server' && Boolean(item.model) && !codexError;
@@ -104,7 +119,16 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
104
119
  'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
105
120
  codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
106
121
  };
107
- }))).sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
122
+ };
123
+ await Promise.all(Array.from({ length: Math.min(8, items.length) }, async () => {
124
+ while (nextItem < items.length) {
125
+ assertActive();
126
+ const index = nextItem++;
127
+ projected[index] = await project(items[index]);
128
+ }
129
+ }));
130
+ assertActive();
131
+ projected.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
108
132
  // Keep existing bindings visible during a truncated discovery or provider
109
133
  // outage, even when newer unbound conversations fill the candidate window.
110
134
  const bound = projected.filter(item => item.autoContinue || item.binding.mode !== 'unmanaged');
@@ -14,6 +14,8 @@ const DEFAULT_LEASE_SECONDS = 300;
14
14
  // Claim can retrieve and freeze organization-scoped Slack evidence (45s budget).
15
15
  const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
16
16
  const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
17
+ // Pin SQM independently of the user's interactive model and CLI defaults.
18
+ export const SQM_CODEX_MODEL = "gpt-6.1-sol";
17
19
 
18
20
  export class SqmLocalReviewError extends Error {
19
21
  constructor(message, { status = null, code = "sqm_local_review_error" } = {}) {
@@ -244,6 +246,8 @@ export async function runStructuredProviderReview(
244
246
  ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
245
247
  ] : [
246
248
  "exec",
249
+ "--model",
250
+ SQM_CODEX_MODEL,
247
251
  "--skip-git-repo-check",
248
252
  "--sandbox",
249
253
  "read-only",
@@ -276,7 +280,7 @@ export async function runStructuredProviderReview(
276
280
  }
277
281
  return {
278
282
  output,
279
- model: `${provider}-local/${inspection.installed_version || "unknown"}`,
283
+ model: provider === "codex" ? SQM_CODEX_MODEL : `${provider}-local/${inspection.installed_version || "unknown"}`,
280
284
  };
281
285
  } catch (error) {
282
286
  if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;