@awak-app/simy-cli 0.5.60 → 0.5.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +2 -2
- package/src/agent.js +27 -7
- package/src/backend-executable.js +36 -21
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +105 -16
- package/src/delivery-hooks/desktop.js +4 -2
- package/src/delivery-hooks/supervisor.js +28 -19
- package/src/desktop-host.js +69 -24
- package/src/runner.js +8 -2
- package/src/session-supervision/action-association-api.js +1 -1
- package/src/session-supervision/action-association-host.js +65 -35
- package/src/session-supervision/action-association.js +17 -10
- package/src/session-supervision/action-deadline.js +9 -1
- package/src/session-supervision/association-store.js +78 -21
- package/src/session-supervision/automatic-continuation.js +18 -1
- package/src/session-supervision/claude-sessions.js +48 -17
- package/src/session-supervision/codex-app-server.js +35 -14
- package/src/session-supervision/index.js +34 -10
- package/src/sqm/command.js +55 -45
- package/src/sqm/history-store.js +108 -0
- package/src/sqm/index.js +11 -1
- package/src/sqm/review-worker.js +26 -8
|
@@ -54,7 +54,14 @@ export function createOrdinaryAssociationStore({
|
|
|
54
54
|
const views = new Map(),
|
|
55
55
|
abort = new AbortController();
|
|
56
56
|
let serial = Promise.resolve(),
|
|
57
|
-
closed = false;
|
|
57
|
+
closed = false, nextBatchAt = 0, nextPriorityAt = 0, priorityTimer = null;
|
|
58
|
+
const rememberView = (taskId, view) => {
|
|
59
|
+
views.delete(taskId); views.set(taskId, view);
|
|
60
|
+
while (views.size > 500) views.delete(views.keys().next().value);
|
|
61
|
+
};
|
|
62
|
+
const presentationKey = view => JSON.stringify([view?.auth, view?.status, view?.contextRevision, view?.namespace,
|
|
63
|
+
view?.snapshot?.candidateRevision, view?.snapshot?.binding,
|
|
64
|
+
view?.snapshot?.candidates?.slice(0, 5).map(item => [item.id, item.revision, item.title])]);
|
|
58
65
|
const find = candidate => readRecord(root, candidate.provider, candidate.session);
|
|
59
66
|
async function ensure(candidate, known) {
|
|
60
67
|
if (
|
|
@@ -106,7 +113,7 @@ export function createOrdinaryAssociationStore({
|
|
|
106
113
|
context = sessionAssociationContext(candidate);
|
|
107
114
|
let view = views.get(taskId);
|
|
108
115
|
if (!auth) {
|
|
109
|
-
|
|
116
|
+
rememberView(taskId, { auth, status: 'signed_out' });
|
|
110
117
|
return;
|
|
111
118
|
}
|
|
112
119
|
if (
|
|
@@ -116,13 +123,16 @@ export function createOrdinaryAssociationStore({
|
|
|
116
123
|
now() < view.nextRefresh
|
|
117
124
|
)
|
|
118
125
|
return;
|
|
126
|
+
const previous = view?.auth === auth ? view : null;
|
|
127
|
+
const sameContext = previous?.contextRevision === context.revision;
|
|
119
128
|
view = {
|
|
120
|
-
auth,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
...previous, auth, contextRevision: context.revision,
|
|
130
|
+
// Background renewal retains the last stable presentation. A user refresh
|
|
131
|
+
// can show progress without flashing every cached result back to loading.
|
|
132
|
+
status: force ? 'loading' : previous?.status || 'loading',
|
|
133
|
+
nextRefresh: now() + 60_000, lastAttemptAt: now(), failures: sameContext ? previous?.failures || 0 : 0,
|
|
124
134
|
};
|
|
125
|
-
|
|
135
|
+
rememberView(taskId, view);
|
|
126
136
|
try {
|
|
127
137
|
const snapshot = await api.candidates(
|
|
128
138
|
{ taskId, context, limit: 10 },
|
|
@@ -141,7 +151,8 @@ export function createOrdinaryAssociationStore({
|
|
|
141
151
|
root,
|
|
142
152
|
candidate.provider,
|
|
143
153
|
candidate.session,
|
|
144
|
-
(latest) => {
|
|
154
|
+
async (latest) => {
|
|
155
|
+
if (closed || await api.authRevision() !== auth) throw new ActionAssociationApiError('account_changed');
|
|
145
156
|
if (latest.associationTaskId !== taskId || latest.run?.managed)
|
|
146
157
|
return latest;
|
|
147
158
|
if (
|
|
@@ -173,6 +184,8 @@ export function createOrdinaryAssociationStore({
|
|
|
173
184
|
Object.assign(view, {
|
|
174
185
|
namespace,
|
|
175
186
|
snapshot,
|
|
187
|
+
nextRefresh: now() + (snapshot.binding?.action ? 15_000 : 60_000),
|
|
188
|
+
failures: 0,
|
|
176
189
|
status: snapshot.binding?.action
|
|
177
190
|
? 'bound'
|
|
178
191
|
: snapshot.binding
|
|
@@ -182,6 +195,8 @@ export function createOrdinaryAssociationStore({
|
|
|
182
195
|
} catch (error) {
|
|
183
196
|
view.status = error.code || 'offline';
|
|
184
197
|
view.snapshot = null;
|
|
198
|
+
view.failures = Math.min(4, view.failures + 1);
|
|
199
|
+
view.nextRefresh = now() + Math.min(120_000, 15_000 * 2 ** (view.failures - 1));
|
|
185
200
|
}
|
|
186
201
|
}
|
|
187
202
|
return {
|
|
@@ -192,7 +207,10 @@ export function createOrdinaryAssociationStore({
|
|
|
192
207
|
record,
|
|
193
208
|
]),
|
|
194
209
|
);
|
|
195
|
-
const auth = await api.authRevision();
|
|
210
|
+
const auth = snapshot && Object.hasOwn(snapshot, 'authRevision') ? snapshot.authRevision : await api.authRevision();
|
|
211
|
+
const taskIds = new Set([...records.values()].map(record => record.run?.managed?.taskId || record.associationTaskId).filter(Boolean));
|
|
212
|
+
for (const taskId of views.keys()) if (!taskIds.has(taskId) && !inFlight.has(taskId)) views.delete(taskId);
|
|
213
|
+
const pending = [];
|
|
196
214
|
for (const candidate of sessions) {
|
|
197
215
|
const record = await ensure(
|
|
198
216
|
candidate,
|
|
@@ -207,26 +225,29 @@ export function createOrdinaryAssociationStore({
|
|
|
207
225
|
if (
|
|
208
226
|
auth &&
|
|
209
227
|
!closed &&
|
|
210
|
-
inFlight.size < 3 &&
|
|
211
228
|
!inFlight.has(taskId) &&
|
|
212
229
|
(!cached ||
|
|
213
230
|
cached.auth !== auth ||
|
|
214
231
|
cached.contextRevision !== context.revision ||
|
|
215
232
|
now() >= cached.nextRefresh)
|
|
216
233
|
) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (!closed) onChange();
|
|
222
|
-
});
|
|
223
|
-
inFlight.set(taskId, work);
|
|
234
|
+
const scopedBinding = cached?.auth === auth && cached.snapshot && record.actionAssociation?.authRevision === auth && record.actionAssociation.namespace === cached.namespace;
|
|
235
|
+
pending.push({ candidate, record, taskId, lastAttemptAt: cached?.lastAttemptAt ?? -Infinity,
|
|
236
|
+
priority: Boolean(cached?.auth === auth && cached.snapshot && cached.contextRevision !== context.revision ||
|
|
237
|
+
scopedBinding && (record.actionAssociation.binding?.action || cached.snapshot.binding?.action) && now() >= cached.nextRefresh) });
|
|
224
238
|
}
|
|
225
239
|
const view = views.get(taskId);
|
|
226
|
-
const
|
|
227
|
-
const
|
|
240
|
+
const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
|
|
241
|
+
const snapshot = bindingSnapshot?.contextRevision === context.revision ? bindingSnapshot : null;
|
|
242
|
+
const local = record.actionAssociation;
|
|
243
|
+
const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
|
|
244
|
+
const newer = scoped &&
|
|
245
|
+
(local.binding?.revision || 0) > (bindingSnapshot.binding?.revision || 0);
|
|
246
|
+
// A hook/second desktop can correct or unlink a binding while the
|
|
247
|
+
// network cache remains valid. The current persisted revision wins.
|
|
248
|
+
const binding = newer ? local.binding : bindingSnapshot?.binding;
|
|
228
249
|
candidate.actionAssociation = {
|
|
229
|
-
status: !auth ? 'signed_out' : view?.status || 'loading',
|
|
250
|
+
status: !auth ? 'signed_out' : newer ? local.status : view?.status || 'loading',
|
|
230
251
|
binding: binding
|
|
231
252
|
? {
|
|
232
253
|
...binding,
|
|
@@ -248,11 +269,45 @@ export function createOrdinaryAssociationStore({
|
|
|
248
269
|
title: item.title.slice(0, 120),
|
|
249
270
|
})),
|
|
250
271
|
canCorrect: Boolean(snapshot),
|
|
272
|
+
canReadBinding: Boolean(scoped && binding),
|
|
251
273
|
contextRevision: view?.contextRevision || null,
|
|
252
274
|
candidateRevision: snapshot?.candidateRevision || null,
|
|
253
275
|
truncated: (snapshot?.candidates?.length || 0) > 5,
|
|
254
276
|
};
|
|
255
277
|
}
|
|
278
|
+
const priority = pending.filter(item => item.priority);
|
|
279
|
+
const priorityReady = priority.length && now() >= nextPriorityAt;
|
|
280
|
+
if (pending.length && (now() >= nextBatchAt || priorityReady) && inFlight.size < 3) {
|
|
281
|
+
const onlyPriority = now() < nextBatchAt;
|
|
282
|
+
if (onlyPriority) nextPriorityAt = now() + 250;
|
|
283
|
+
else { nextBatchAt = now() + 3_000; if (priority.length) nextPriorityAt = now() + 250; }
|
|
284
|
+
// Never-checked and least-recently-checked work wins over array order,
|
|
285
|
+
// so a short retry TTL cannot indefinitely starve later conversations.
|
|
286
|
+
// Existing conversations must reflect changed user context promptly. This
|
|
287
|
+
// separately bounded lane never treats old-account or unseen history as
|
|
288
|
+
// urgent, and still shares the three workers with normal backfill.
|
|
289
|
+
const batch = (onlyPriority ? priority : pending).sort((a, b) => Number(b.priority) - Number(a.priority) || a.lastAttemptAt - b.lastAttemptAt).slice(0, 3 - inFlight.size);
|
|
290
|
+
const before = batch.map(({ taskId }) => presentationKey(views.get(taskId)));
|
|
291
|
+
const work = batch.map(({ candidate, record, taskId }) => {
|
|
292
|
+
const result = sync(candidate, false, record).catch(() => {}).finally(() => inFlight.delete(taskId));
|
|
293
|
+
inFlight.set(taskId, result);
|
|
294
|
+
return result;
|
|
295
|
+
});
|
|
296
|
+
void Promise.allSettled(work).then(() => {
|
|
297
|
+
if (!closed && batch.some(({ taskId }, index) => presentationKey(views.get(taskId)) !== before[index])) onChange();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const waitingPriority = priority.some(({ taskId }) => !inFlight.has(taskId));
|
|
301
|
+
if (!waitingPriority && priorityTimer) { clearTimeout(priorityTimer); priorityTimer = null; }
|
|
302
|
+
if (waitingPriority && inFlight.size < 3 && now() < nextPriorityAt && !priorityTimer) {
|
|
303
|
+
// A command may arrive between the bounded priority slots. Wake once
|
|
304
|
+
// when the slot opens rather than waiting for the 3s history heartbeat.
|
|
305
|
+
priorityTimer = setTimeout(() => { priorityTimer = null; if (!closed) onChange(); }, nextPriorityAt - now());
|
|
306
|
+
priorityTimer.unref();
|
|
307
|
+
}
|
|
308
|
+
if (!snapshot && await api.authRevision() !== auth) {
|
|
309
|
+
for (const candidate of sessions) candidate.actionAssociation = { status: 'account_changed', binding: null, candidates: [], canCorrect: false };
|
|
310
|
+
}
|
|
256
311
|
return sessions;
|
|
257
312
|
},
|
|
258
313
|
correct(command, candidate) {
|
|
@@ -336,7 +391,8 @@ export function createOrdinaryAssociationStore({
|
|
|
336
391
|
root,
|
|
337
392
|
candidate.provider,
|
|
338
393
|
candidate.session,
|
|
339
|
-
(latest) => {
|
|
394
|
+
async (latest) => {
|
|
395
|
+
if (closed || await api.authRevision() !== auth) throw new ActionAssociationApiError('account_changed');
|
|
340
396
|
if (
|
|
341
397
|
latest.associationTaskId !== command.taskId ||
|
|
342
398
|
latest.run?.managed
|
|
@@ -364,6 +420,7 @@ export function createOrdinaryAssociationStore({
|
|
|
364
420
|
},
|
|
365
421
|
async close() {
|
|
366
422
|
closed = true;
|
|
423
|
+
clearTimeout(priorityTimer); priorityTimer = null;
|
|
367
424
|
abort.abort();
|
|
368
425
|
await Promise.allSettled([serial, ...inFlight.values()]);
|
|
369
426
|
views.clear();
|
|
@@ -27,6 +27,18 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
27
27
|
let closed = false;
|
|
28
28
|
const tick = candidates => {
|
|
29
29
|
if (closed) return;
|
|
30
|
+
// Drop display/dedup state once a current observation proves it obsolete.
|
|
31
|
+
// Absence alone is insufficient: provider outages must not retry a failed
|
|
32
|
+
// enrollment with the same opt-in epoch and context when it reappears.
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
const key = `${candidate.provider}:${candidate.session}`;
|
|
35
|
+
const attempt = attempted.get(key);
|
|
36
|
+
const bound = ['managed', 'pending_takeover', 'standard_loop'].includes(candidate.binding?.mode);
|
|
37
|
+
if (candidate.autoContinue !== true || bound ||
|
|
38
|
+
attempt && !attempt.startsWith(`${candidate.autoContinueEpoch}:`)) {
|
|
39
|
+
attempted.delete(key); errors.delete(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
30
42
|
for (const candidate of candidates) {
|
|
31
43
|
if (pending.size >= 3) break;
|
|
32
44
|
if (candidate.autoContinue !== true || !candidate.autoContinueEpoch || candidate.isRunning ||
|
|
@@ -76,6 +88,11 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
76
88
|
},
|
|
77
89
|
revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
|
|
78
90
|
async whenIdle() { await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
|
|
79
|
-
async close() {
|
|
91
|
+
async close() {
|
|
92
|
+
closed = true;
|
|
93
|
+
for (const entry of pending.values()) entry.controller.abort();
|
|
94
|
+
await Promise.allSettled([...pending.values()].map(entry => entry.job));
|
|
95
|
+
attempted.clear(); errors.clear();
|
|
96
|
+
},
|
|
80
97
|
};
|
|
81
98
|
}
|
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
this.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
}
|
|
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');
|
package/src/sqm/command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createSqmCheckRecorder } from "./history-store.js";
|
|
1
2
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -46,54 +47,63 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
46
47
|
organizationId,
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const output = option(argv, "--output") || defaultMinResultPath();
|
|
52
|
-
await atomicJson(output, result);
|
|
53
|
-
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
54
|
-
else printMin(result, output);
|
|
55
|
-
return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const minPath = option(argv, "--min-result") || defaultMinResultPath();
|
|
59
|
-
let minCheck;
|
|
50
|
+
const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
51
|
+
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
|
|
60
52
|
try {
|
|
61
|
-
|
|
53
|
+
if (argv[0] === "min-check") {
|
|
54
|
+
const result = await runSqmMinCheck(common);
|
|
55
|
+
const output = option(argv, "--output") || defaultMinResultPath();
|
|
56
|
+
await atomicJson(output, result);
|
|
57
|
+
await save(result);
|
|
58
|
+
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
59
|
+
else printMin(result, output);
|
|
60
|
+
return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const minPath = option(argv, "--min-result") || defaultMinResultPath();
|
|
64
|
+
let minCheck;
|
|
65
|
+
try {
|
|
66
|
+
minCheck = JSON.parse(await readFile(minPath, "utf8"));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code !== "ENOENT") throw error;
|
|
69
|
+
console.warn("No min-check result was found; running the deterministic min-check first.");
|
|
70
|
+
minCheck = await runSqmMinCheck(common);
|
|
71
|
+
await atomicJson(minPath, minCheck);
|
|
72
|
+
}
|
|
73
|
+
if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
|
|
74
|
+
const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
|
|
75
|
+
if (result.status === "unavailable") { await save(result); return printUnavailable(result, argv); }
|
|
76
|
+
const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
|
|
77
|
+
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
78
|
+
const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
|
|
79
|
+
await Promise.all([
|
|
80
|
+
atomicJson(proofPath, result.proof),
|
|
81
|
+
atomicJson(evidencePath, result.signed_evidence),
|
|
82
|
+
atomicJson(diagnosticsPath, {
|
|
83
|
+
policy_version: "sqm-reliability.v1",
|
|
84
|
+
matcher_diagnostics: result.matcher_diagnostics,
|
|
85
|
+
}),
|
|
86
|
+
]);
|
|
87
|
+
let upload = { uploaded: false, reason: "disabled" };
|
|
88
|
+
if (!argv.includes("--no-upload")) {
|
|
89
|
+
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
90
|
+
upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
|
|
91
|
+
}
|
|
92
|
+
result.upload = upload;
|
|
93
|
+
result.artifacts = {
|
|
94
|
+
min_check: minPath,
|
|
95
|
+
proof: proofPath,
|
|
96
|
+
signed_evidence: evidencePath,
|
|
97
|
+
diagnostics: diagnosticsPath,
|
|
98
|
+
};
|
|
99
|
+
await save(result);
|
|
100
|
+
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
101
|
+
else printFull(result);
|
|
102
|
+
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
62
103
|
} catch (error) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
minCheck = await runSqmMinCheck(common);
|
|
66
|
-
await atomicJson(minPath, minCheck);
|
|
67
|
-
}
|
|
68
|
-
if (minCheck.status === "unavailable") return printUnavailable(minCheck, argv);
|
|
69
|
-
const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
|
|
70
|
-
if (result.status === "unavailable") return printUnavailable(result, argv);
|
|
71
|
-
const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
|
|
72
|
-
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
73
|
-
const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
|
|
74
|
-
await Promise.all([
|
|
75
|
-
atomicJson(proofPath, result.proof),
|
|
76
|
-
atomicJson(evidencePath, result.signed_evidence),
|
|
77
|
-
atomicJson(diagnosticsPath, {
|
|
78
|
-
policy_version: "sqm-reliability.v1",
|
|
79
|
-
matcher_diagnostics: result.matcher_diagnostics,
|
|
80
|
-
}),
|
|
81
|
-
]);
|
|
82
|
-
let upload = { uploaded: false, reason: "disabled" };
|
|
83
|
-
if (!argv.includes("--no-upload")) {
|
|
84
|
-
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
85
|
-
upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
|
|
104
|
+
await save({ status: "failed" });
|
|
105
|
+
throw error;
|
|
86
106
|
}
|
|
87
|
-
result.upload = upload;
|
|
88
|
-
result.artifacts = {
|
|
89
|
-
min_check: minPath,
|
|
90
|
-
proof: proofPath,
|
|
91
|
-
signed_evidence: evidencePath,
|
|
92
|
-
diagnostics: diagnosticsPath,
|
|
93
|
-
};
|
|
94
|
-
if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
|
95
|
-
else printFull(result);
|
|
96
|
-
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
function printHelp() {
|