@awak-app/simy-cli 0.5.36 → 0.5.38
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/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +248 -32
- package/src/codex-plugins/simy/hooks/workspace-lease.mjs +71 -0
- package/src/delivery-hooks/command.js +15 -2
- package/src/delivery-hooks/desktop.js +12 -2
- package/src/delivery-hooks/supervisor.js +119 -12
- package/src/desktop-host.js +74 -11
- package/src/session-supervision/analysis-manager.js +187 -0
- package/src/session-supervision/claude-sessions.js +47 -5
- package/src/session-supervision/codex-app-server.js +78 -8
- package/src/session-supervision/continuation-analysis.js +121 -0
- package/src/session-supervision/conversation-context.js +51 -0
- package/src/session-supervision/handoff.js +124 -0
- package/src/session-supervision/index.js +6 -3
- package/src/session-supervision/workspace-lease.js +2 -0
- package/src/sqm/claude-plugin.js +1 -0
- package/src/sqm/review-worker.js +55 -8
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { evaluateAssessedPlan, listRecords, managedBudgetReason, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
import { openCodexAppServer, readCodexSession } from './codex-app-server.js';
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
function unsettled(managed) {
|
|
9
|
+
return managed.phase === 'understanding' || managed.executionUncertain || managed.executionAttempt && managed.executionAttempt.phase !== 'checkpointed' || managed.providerWarning || managed.resumeLease || managed.pendingApproval || managed.approvedAction && !managed.approvedAction.dispatchedAt ||
|
|
10
|
+
managed.dispatchLedger?.some(entry => !['settled', 'cancelled'].includes(entry.status)) ||
|
|
11
|
+
managed.dispatches?.some(entry => entry.status && !['settled', 'cancelled', 'completed'].includes(entry.status));
|
|
12
|
+
}
|
|
13
|
+
function exactSource(record, taskId, targetSession, now, allowStoppedPending = false) {
|
|
14
|
+
if (!record?.run?.managed || !record.admission || record.admission.session !== record.session || record.admission.cwd !== record.cwd || record.run.managed.taskId !== taskId) throw new Error('Exact managed task is unavailable.');
|
|
15
|
+
if (now !== undefined && managedBudgetReason(record.run, now)) throw new Error('Original task budget exhausted; handoff cannot restore it.');
|
|
16
|
+
const prior = record.run.managed.handoff;
|
|
17
|
+
const transfer = prior?.status === 'received' ? null : prior;
|
|
18
|
+
if (transfer && transfer.targetSession !== targetSession) throw new Error('A different handoff is already recorded.');
|
|
19
|
+
if (!transfer && (record.run.state !== 'paused' || unsettled(record.run.managed))) throw new Error('Handoff requires paused source with no unsettled lease, dispatch, or approval.');
|
|
20
|
+
if (transfer && (transfer.status === 'pending' && record.run.state !== 'paused' && !(allowStoppedPending && record.run.state === 'stopped') || transfer.status === 'transferred' && record.run.state !== 'stopped')) throw new Error('Source handoff state changed.');
|
|
21
|
+
if (transfer && !['pending', 'transferred'].includes(transfer.status)) throw new Error('This session is a handoff recipient.');
|
|
22
|
+
return record;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Explicit user command only. This transfers SIMY's durable work binding, not
|
|
26
|
+
// provider turns or external effects. Neither session receives a turn here.
|
|
27
|
+
export async function handoffSession({ root, provider, session, taskId, targetSession, openClient = openCodexAppServer, now = Date.now() }) {
|
|
28
|
+
if (provider !== 'codex') throw new Error('Claude handoff is unsupported; return to the original live conversation.');
|
|
29
|
+
if (!UUID.test(session || '') || !UUID.test(targetSession || '') || session === targetSession || !taskId) throw new Error('Distinct exact Codex session IDs and task ID are required.');
|
|
30
|
+
const source = exactSource((await listRecords(root)).find(item => item.provider === provider && item.session === session), taskId, targetSession, now);
|
|
31
|
+
const startedWall = Date.now();
|
|
32
|
+
const budgetNow = () => now + Math.max(0, Date.now() - startedWall);
|
|
33
|
+
const client = await openClient();
|
|
34
|
+
let metadata;
|
|
35
|
+
try {
|
|
36
|
+
const original = await readCodexSession(client, session);
|
|
37
|
+
metadata = await readCodexSession(client, targetSession);
|
|
38
|
+
if (await realpath(original.cwd) !== source.cwd || await realpath(metadata.cwd) !== source.cwd || !metadata.model || metadata.model !== source.run.managed.resumeModel || original.model !== metadata.model) throw new Error('Handoff workspace or model does not match admitted source.');
|
|
39
|
+
const sourceConfiguration = await client.request('thread/resume', { threadId: session, excludeTurns: true });
|
|
40
|
+
const targetConfiguration = await client.request('thread/resume', { threadId: targetSession, excludeTurns: true });
|
|
41
|
+
for (const [configuration, expected] of [[sourceConfiguration, session], [targetConfiguration, targetSession]]) {
|
|
42
|
+
if (configuration?.thread?.id !== expected || !configuration.cwd || await realpath(configuration.cwd) !== source.cwd || configuration.model !== metadata.model || !['readOnly', 'workspaceWrite', 'dangerFullAccess'].includes(configuration.sandbox?.type)) throw new Error('Exact session admission or sandbox is unavailable.');
|
|
43
|
+
}
|
|
44
|
+
if (!isDeepStrictEqual(sourceConfiguration.sandbox, targetConfiguration.sandbox)) throw new Error('Target sandbox must preserve exact source rights.');
|
|
45
|
+
const enrolled = source.run.managed.providerSandbox;
|
|
46
|
+
if (enrolled && Object.entries(enrolled).some(([key, value]) => !isDeepStrictEqual(value, sourceConfiguration.sandbox[key]))) throw new Error('Source sandbox changed from enrolled rights.');
|
|
47
|
+
// Freeze source first. The ordinary control/hook/supervisor paths reject
|
|
48
|
+
// pending/transferred and pending_received markers, including after crash.
|
|
49
|
+
const frozen = await updateRecord(root, provider, session, record => {
|
|
50
|
+
exactSource(record, taskId, targetSession, budgetNow());
|
|
51
|
+
if ((record.run.managed.handoff?.id || null) !== (source.run.managed.handoff?.id || null)) throw new Error('Handoff identity changed or cancelled during admission.');
|
|
52
|
+
if (!record.run.managed.handoff || record.run.managed.handoff.status === 'received') record.run.managed.handoff = { id: randomUUID(), sourceSession: session, targetSession, status: 'pending', startedAt: now };
|
|
53
|
+
return record;
|
|
54
|
+
});
|
|
55
|
+
const handoff = frozen.run.managed.handoff;
|
|
56
|
+
await updateRecord(root, provider, session, async sourceRecord => {
|
|
57
|
+
exactSource(sourceRecord, taskId, targetSession, budgetNow());
|
|
58
|
+
if (sourceRecord.run.managed.handoff?.id !== handoff.id) throw new Error('Handoff identity changed or cancelled.');
|
|
59
|
+
await updateRecord(root, provider, targetSession, target => {
|
|
60
|
+
if (target?.run) {
|
|
61
|
+
if (target.run.id !== frozen.run.id || target.run.managed?.handoff?.id !== handoff.id || target.run.managed.taskId !== taskId) throw new Error('Target already owns another run.');
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
if (target && target.cwd !== source.cwd) throw new Error('Target admission workspace changed.');
|
|
65
|
+
const run = structuredClone(frozen.run);
|
|
66
|
+
run.state = 'paused';
|
|
67
|
+
run.managed.thread = { provider, id: targetSession };
|
|
68
|
+
run.managed.providerSandbox = structuredClone(targetConfiguration.sandbox);
|
|
69
|
+
const option = { id: 'inspect_handoff', action: { kind: 'read', scope: run.managed.approvalScopes[0], summary: 'Inspect transferred goal, frozen acceptance, prior evidence, and current workspace before proposing the next action' },
|
|
70
|
+
assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
|
|
71
|
+
expectedVerification: 'Read-only observations reconcile the durable task with this conversation and workspace.', rollback: 'No changes to undo.' };
|
|
72
|
+
run.managed.plan = { diagnosis: 'Explicit work handoff requires read-only reconciliation before further work.', alternatives: [option], selectedOptionId: option.id, recommendation: { optionId: option.id, reason: 'Preserve task evidence without replaying the previous action.' } };
|
|
73
|
+
run.managed.decision = evaluateAssessedPlan(run.managed.plan, run.managed.autonomyPolicy, run.managed.approvalScopes, now);
|
|
74
|
+
run.managed.decisionHistory = [...(run.managed.decisionHistory || []), run.managed.decision].slice(-20);
|
|
75
|
+
delete run.managed.approvedAction;
|
|
76
|
+
run.checkpoint = null;
|
|
77
|
+
run.nextStep = option.action.summary;
|
|
78
|
+
run.reason = 'Explicit handoff is paused; resume starts with read-only reconciliation.';
|
|
79
|
+
run.managed.handoff = { ...handoff, status: 'pending_received' };
|
|
80
|
+
run.managed.threadHistory = [...(run.managed.threadHistory || [session]), targetSession].slice(-20);
|
|
81
|
+
run.managed.workBindingHistory = [...(run.managed.workBindingHistory || []), { from: session, to: targetSession, handoffId: handoff.id, at: handoff.startedAt }].slice(-20);
|
|
82
|
+
return { ...(target || {}), schemaVersion: 1, provider, session: targetSession, cwd: source.cwd, monitor: true,
|
|
83
|
+
admission: { source: 'codex_app_server', provider, session: targetSession, cwd: source.cwd, model: metadata.model, validatedAt: now }, run };
|
|
84
|
+
});
|
|
85
|
+
return sourceRecord;
|
|
86
|
+
});
|
|
87
|
+
await updateRecord(root, provider, session, record => {
|
|
88
|
+
exactSource(record, taskId, targetSession, budgetNow());
|
|
89
|
+
if (record.run.managed.handoff.id !== handoff.id) throw new Error('Handoff identity changed.');
|
|
90
|
+
record.run.state = 'stopped'; record.run.reason = 'Work transferred to the exact target conversation.';
|
|
91
|
+
record.run.managed.handoff.status = 'transferred';
|
|
92
|
+
record.run.managed.handoff.transferredAt ||= now;
|
|
93
|
+
return record;
|
|
94
|
+
});
|
|
95
|
+
const received = await updateRecord(root, provider, targetSession, record => {
|
|
96
|
+
if (record?.run?.managed?.handoff?.id !== handoff.id) throw new Error('Target handoff identity changed.');
|
|
97
|
+
record.run.managed.handoff.status = 'received';
|
|
98
|
+
return record;
|
|
99
|
+
});
|
|
100
|
+
return { provider, session: targetSession, sourceSession: session, taskId, runId: frozen.run.id, state: received.run.state, handoffId: handoff.id };
|
|
101
|
+
} finally { await client.close(); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
// Cancellation is explicit and permitted only before this transfer has created
|
|
106
|
+
// its target run. Source-then-target locking shares creation's lock order, so
|
|
107
|
+
// an in-flight creator cannot publish an orphan after the marker is cleared.
|
|
108
|
+
export async function cancelHandoffSession({ root, provider, session, taskId, targetSession }) {
|
|
109
|
+
if (provider !== 'codex' || !UUID.test(session || '') || !UUID.test(targetSession || '') || session === targetSession || !taskId) throw new Error('Exact Codex source, target, and task ID are required.');
|
|
110
|
+
const source = await updateRecord(root, provider, session, async record => {
|
|
111
|
+
exactSource(record, taskId, targetSession, undefined, true);
|
|
112
|
+
const marker = record.run.managed.handoff;
|
|
113
|
+
if (marker?.status !== 'pending') throw new Error('Only a pending handoff without a target run can be cancelled.');
|
|
114
|
+
await updateRecord(root, provider, targetSession, target => {
|
|
115
|
+
if (target?.run?.managed?.handoff?.id === marker.id) throw new Error('Target run exists; complete the exact handoff instead of cancelling.');
|
|
116
|
+
return target;
|
|
117
|
+
});
|
|
118
|
+
record.run.managed.workBindingHistory = [...(record.run.managed.workBindingHistory || []),
|
|
119
|
+
{ from: session, to: targetSession, handoffId: marker.id, cancelled: true }].slice(-20);
|
|
120
|
+
delete record.run.managed.handoff;
|
|
121
|
+
return record;
|
|
122
|
+
});
|
|
123
|
+
return { provider, session, targetSession, taskId, state: source.run.state, cancelled: true };
|
|
124
|
+
}
|
|
@@ -60,7 +60,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
60
60
|
binding: { mode, taskId: run?.managed?.taskId || null },
|
|
61
61
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
62
62
|
continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
|
|
63
|
-
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
|
|
63
|
+
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
|
|
64
|
+
canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
|
|
65
|
+
canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run, canAutoContinue: supported || liveHook },
|
|
64
66
|
reason: item.provider === 'claude' ? liveHook ? 'Live SIMY hook can continue this Claude session at its next Stop boundary; cold CLI takeover is unavailable.' :
|
|
65
67
|
process.platform === 'win32' ? 'Claude live hook enrollment is unavailable on Windows because provider process identity cannot be verified; monitoring remains available.' :
|
|
66
68
|
'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
|
|
@@ -96,9 +98,10 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
|
|
99
|
-
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, openClient = openCodexAppServer,
|
|
101
|
+
maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, openClient = openCodexAppServer,
|
|
100
102
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
|
|
101
103
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
104
|
+
if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
|
|
102
105
|
if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
|
|
103
106
|
if (!['autonomous', 'balanced', 'strict'].includes(autonomyPolicy)) throw new Error('Choose an autonomy policy.');
|
|
104
107
|
let metadata;
|
|
@@ -123,7 +126,7 @@ export async function enrollSession({ root, provider, session, objective, accept
|
|
|
123
126
|
const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
124
127
|
maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess,
|
|
125
128
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
126
|
-
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
129
|
+
managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
127
130
|
initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
|
|
128
131
|
autoRecovery: provider === 'codex', resumeChannel: provider === 'codex' ? 'codex_app_server' : null,
|
|
129
132
|
resumeModel: metadata.model } });
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -21,6 +21,7 @@ export async function claudePluginFiles() {
|
|
|
21
21
|
files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
|
|
22
22
|
}
|
|
23
23
|
files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
|
|
24
|
+
files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
|
|
24
25
|
files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
|
|
25
26
|
const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
|
|
26
27
|
delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -178,10 +178,14 @@ export async function runStructuredProviderReview(
|
|
|
178
178
|
inspectExecutor = inspectDesktopExecutor,
|
|
179
179
|
executionTimeoutMs = DEFAULT_EXECUTION_TIMEOUT_MS,
|
|
180
180
|
environment = process.env,
|
|
181
|
+
signal,
|
|
182
|
+
readOnlyAnalysis = false,
|
|
183
|
+
terminationGraceMs = 1_000,
|
|
181
184
|
} = {},
|
|
182
185
|
) {
|
|
183
186
|
assertProvider(provider);
|
|
184
187
|
assertStructuredRequest(request);
|
|
188
|
+
if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
|
|
185
189
|
const label = provider === "claude" ? "Claude Code" : "Codex";
|
|
186
190
|
const inspection = await inspectExecutor(provider);
|
|
187
191
|
if (!inspection?.compatible || !inspection.executable) {
|
|
@@ -207,6 +211,7 @@ export async function runStructuredProviderReview(
|
|
|
207
211
|
"--no-session-persistence", "--tools", "",
|
|
208
212
|
"--setting-sources", "", "--settings", '{"disableAllHooks":true}',
|
|
209
213
|
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
214
|
+
...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
|
|
210
215
|
] : [
|
|
211
216
|
"exec",
|
|
212
217
|
"--skip-git-repo-check",
|
|
@@ -215,6 +220,7 @@ export async function runStructuredProviderReview(
|
|
|
215
220
|
"--ephemeral",
|
|
216
221
|
"--ignore-user-config",
|
|
217
222
|
"--ignore-rules",
|
|
223
|
+
...(readOnlyAnalysis ? codexAnalysisArguments(request.system) : []),
|
|
218
224
|
"--output-schema",
|
|
219
225
|
schemaPath,
|
|
220
226
|
"--output-last-message",
|
|
@@ -222,7 +228,8 @@ export async function runStructuredProviderReview(
|
|
|
222
228
|
"-",
|
|
223
229
|
],
|
|
224
230
|
prompt,
|
|
225
|
-
{ spawnImpl, executionTimeoutMs, label, cwd: workDir, environment, provider
|
|
231
|
+
{ spawnImpl, executionTimeoutMs, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis,
|
|
232
|
+
terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
|
|
226
233
|
);
|
|
227
234
|
const envelope = provider === "claude" ? JSON.parse(stdout) : null;
|
|
228
235
|
if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
|
|
@@ -248,8 +255,22 @@ export async function runStructuredProviderReview(
|
|
|
248
255
|
}
|
|
249
256
|
}
|
|
250
257
|
|
|
251
|
-
function
|
|
258
|
+
function codexAnalysisArguments(system) {
|
|
259
|
+
// --ignore-user-config excludes user-configured MCP. Disable plugins/apps,
|
|
260
|
+
// host skills and tool families as well; read-only sandbox remains a second
|
|
261
|
+
// boundary. These flags are supported by the packaged Codex CLI.
|
|
262
|
+
const disabled = ['shell_tool', 'unified_exec', 'shell_snapshot', 'view_image', 'apps', 'plugins', 'hooks',
|
|
263
|
+
'memories', 'multi_agent', 'multi_agent_v2', 'browser_use', 'computer_use', 'image_generation',
|
|
264
|
+
'code_mode', 'code_mode_host', 'skill_search', 'skill_mcp_dependency_install', 'workspace_dependencies', 'goals'];
|
|
265
|
+
return ['--json', ...disabled.flatMap(feature => ['--disable', feature]),
|
|
266
|
+
'-c', 'features.skip_host_skill_discovery=true', '-c', 'web_search="disabled"',
|
|
267
|
+
'-c', 'mcp_servers={}', '-c', 'approval_policy="never"',
|
|
268
|
+
'-c', `developer_instructions=${JSON.stringify(system)}`];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs }) {
|
|
252
272
|
return new Promise((resolve, reject) => {
|
|
273
|
+
if (signal?.aborted) return reject(new Error('Local analysis was cancelled'));
|
|
253
274
|
const child = spawnImpl(command, args, {
|
|
254
275
|
env: safeReviewEnvironment(environment, provider),
|
|
255
276
|
cwd,
|
|
@@ -259,35 +280,61 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
259
280
|
let stdout = "";
|
|
260
281
|
let stderr = "";
|
|
261
282
|
let settled = false;
|
|
283
|
+
let terminalError = null;
|
|
284
|
+
let killTimer = null;
|
|
285
|
+
const halt = error => {
|
|
286
|
+
if (settled || terminalError) return;
|
|
287
|
+
terminalError = error;
|
|
288
|
+
child.kill?.('SIGTERM');
|
|
289
|
+
if (!settled) killTimer = setTimeout(() => {
|
|
290
|
+
child.kill?.('SIGKILL');
|
|
291
|
+
// Do not acknowledge cancellation while an owned provider is still
|
|
292
|
+
// shutting down. A broken process API is nevertheless bounded.
|
|
293
|
+
if (!settled) killTimer = setTimeout(() => finish(new Error('Local analysis process did not confirm termination')), terminationGraceMs);
|
|
294
|
+
}, terminationGraceMs);
|
|
295
|
+
};
|
|
296
|
+
const cancelled = () => halt(new Error('Local analysis was cancelled'));
|
|
262
297
|
const timer = setTimeout(() => {
|
|
263
|
-
|
|
264
|
-
finish(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
298
|
+
halt(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
265
299
|
}, executionTimeoutMs);
|
|
266
300
|
timer.unref?.();
|
|
267
301
|
const finish = (error = null) => {
|
|
268
302
|
if (settled) return;
|
|
269
303
|
settled = true;
|
|
270
304
|
clearTimeout(timer);
|
|
305
|
+
clearTimeout(killTimer);
|
|
306
|
+
signal?.removeEventListener('abort', cancelled);
|
|
271
307
|
error ? reject(error) : resolve(stdout);
|
|
272
308
|
};
|
|
273
309
|
child.stdout?.setEncoding?.("utf8");
|
|
274
310
|
child.stdout?.on?.("data", (chunk) => {
|
|
275
311
|
if (settled) return;
|
|
276
312
|
stdout += chunk;
|
|
313
|
+
if (readOnlyAnalysis && provider === 'codex') {
|
|
314
|
+
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
315
|
+
let event;
|
|
316
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
317
|
+
if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
|
|
318
|
+
halt(new Error('Read-only analysis attempted to use a tool'));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
277
323
|
if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
|
|
278
|
-
|
|
279
|
-
finish(new Error(`${label} structured output exceeded the size limit`));
|
|
324
|
+
halt(new Error(`${label} structured output exceeded the size limit`));
|
|
280
325
|
}
|
|
281
326
|
});
|
|
282
327
|
child.stdin?.on?.("error", finish);
|
|
283
328
|
child.stderr?.setEncoding?.("utf8");
|
|
284
329
|
child.stderr?.on?.("data", (chunk) => {
|
|
285
|
-
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
330
|
+
if (!readOnlyAnalysis) stderr = `${stderr}${chunk}`.slice(-4000);
|
|
286
331
|
});
|
|
287
332
|
child.on("error", finish);
|
|
288
333
|
child.on("close", (code) => {
|
|
289
|
-
finish(code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`));
|
|
334
|
+
finish(terminalError || (code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`)));
|
|
290
335
|
});
|
|
336
|
+
signal?.addEventListener('abort', cancelled, { once: true });
|
|
337
|
+
if (signal?.aborted) { cancelled(); return; }
|
|
291
338
|
child.stdin?.end(prompt);
|
|
292
339
|
});
|
|
293
340
|
}
|