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