@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.
@@ -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, canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run, canAutoContinue: supported || liveHook },
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 } });
@@ -0,0 +1,2 @@
1
+ // The identical lease protocol also ships with the provider hooks.
2
+ export { acquireWorkspaceLease, releaseWorkspaceLease, leaseOwnerDead } from '../codex-plugins/simy/hooks/workspace-lease.mjs';
@@ -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.
@@ -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 spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider }) {
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
- child.kill?.("SIGTERM");
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
- child.kill?.("SIGTERM");
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
  }