@awak-app/simy-cli 0.5.34 → 0.5.36

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.
@@ -1,14 +1,26 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
3
 
4
- export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), output = console.log } = {}) {
4
+ async function readCheckpointInput(input) {
5
+ const chunks = [];
6
+ let bytes = 0;
7
+ for await (const chunk of input) {
8
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
9
+ bytes += buffer.length;
10
+ if (bytes > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
11
+ chunks.push(buffer);
12
+ }
13
+ return Buffer.concat(chunks).toString('utf8');
14
+ }
15
+
16
+ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
5
17
  const [action, ...args] = argv;
6
18
  if (!action || action === '--help') {
7
- output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json\nsimy delivery status\nsimy delivery stop|resume --provider codex|claude --session ID\nEnable and review SIMY hooks in your provider first. Starting a loop opts in only this session. Resume requires the user to resolve the blocker; original budgets are retained.');
19
+ output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original budgets.');
8
20
  return 0;
9
21
  }
10
22
  const options = {};
11
- const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'file']);
23
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy']);
12
24
  for (let i = 0; i < args.length; i += 2) {
13
25
  const key = args[i]?.replace(/^--/, '');
14
26
  if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
@@ -17,14 +29,23 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
17
29
  const base = { root, cwd, provider: options.provider, session: options.session };
18
30
  let result;
19
31
  if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
20
- else if (action === 'start') result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'],
32
+ else if (action === 'start') {
33
+ let managed;
34
+ if (options['managed-file']) {
35
+ const data = await readFile(options['managed-file'], 'utf8');
36
+ if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Managed task specification exceeds 64 KiB.');
37
+ managed = JSON.parse(data);
38
+ }
39
+ result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed,
21
40
  ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
41
+ }
22
42
  else if (action === 'checkpoint') {
23
43
  if (!options.file) throw new Error('--file is required.');
24
- const data = await readFile(options.file, 'utf8');
44
+ const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
25
45
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
26
46
  result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
27
- } else if (['stop', 'resume'].includes(action)) result = await controlDelivery({ ...base, action });
47
+ } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], action });
48
+ else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
28
49
  else throw new Error('Unknown delivery command.');
29
50
  output(JSON.stringify(result, null, 2));
30
51
  return 0;
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canReplanDelivery, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { originalSessionTarget } from '../session-supervision/original-session-target.js';
4
5
 
5
6
  export const HOOK_SETUP = Object.freeze({
6
7
  codex: { settingsUrl: 'codex://settings', docsUrl: 'https://learn.chatgpt.com/docs/hooks', command: '/hooks' },
@@ -11,13 +12,46 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
11
12
  const records = await reconcileRecords({ root, now });
12
13
  return {
13
14
  providers: Object.fromEntries(['codex', 'claude'].map(provider => {
14
- const latest = records.filter(r => r.provider === provider).sort((a, b) => b.observedAt - a.observedAt)[0];
15
+ const latest = records.filter(r => r.provider === provider && Number.isFinite(r.observedAt)).sort((a, b) => b.observedAt - a.observedAt)[0];
15
16
  return [provider, { ...HOOK_SETUP[provider], state: latest ? 'observed' : 'not_observed', lastObservedAt: latest?.observedAt || null }];
16
17
  })),
17
18
  runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
18
- provider: r.provider, session: r.session, objective: r.run.objective, state: r.run.state, reason: r.run.reason,
19
+ provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
20
+ objective: r.run.objective, state: r.run.state, reason: r.run.reason,
19
21
  continuations: r.run.continuations, maxContinuations: r.run.maxContinuations, deadline: r.run.deadline,
20
- needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted'].includes(r.run.state), updatedAt: r.run.updatedAt,
22
+ needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
23
+ managed: Boolean(r.run.managed), taskId: r.run.managed?.taskId || null, actionId: r.run.managed?.actionId || null,
24
+ actionDueAt: r.run.managed?.actionDueAt ?? null,
25
+ thread: r.run.managed?.thread || null, acceptanceCriteria: (r.run.managed?.acceptanceCriteria || []).map(item =>
26
+ item.check?.type === 'file_equals' ? { ...item, check: { type: 'file_equals', path: item.check.exactPath || item.check.path, text: item.check.text } } : item),
27
+ phase: r.run.managed?.phase || 'active', sessionUnderstanding: r.run.managed?.sessionUnderstanding || null,
28
+ understandingQuestion: r.run.managed?.understandingQuestion || null,
29
+ remainingWork: r.run.managed?.remainingWork || null, nextStep: r.run.nextStep,
30
+ pendingApproval: r.run.managed?.pendingApproval || null,
31
+ autonomyPolicy: r.run.managed?.autonomyPolicy || null, plan: r.run.managed?.plan || null,
32
+ decision: r.run.managed?.decision || null, decisionHistory: r.run.managed?.decisionHistory || [],
33
+ escalation: r.run.managed?.escalation || null,
34
+ approvedAction: r.run.managed?.approvedAction || null,
35
+ providerWarning: r.run.managed?.providerWarning || null,
36
+ capability: r.run.managed ? (r.run.state === 'recovery_required' && !r.run.managed.autoRecovery ? 'manual_recovery' :
37
+ r.run.managed.resumeChannel === 'codex_app_server' ? 'codex_app_server' :
38
+ r.run.state === 'auto_resuming' ? 'codex_exec_resume' : r.run.managed.capability) : 'live_hook',
39
+ notification: r.run.managed?.notification || null,
40
+ verification: r.run.managed ? { verified: Object.keys(r.run.managed.verified).length, total: r.run.managed.acceptanceCriteria.length,
41
+ pending: r.run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(r.run.managed.verified, item.id)).map(item => item.id),
42
+ results: r.run.managed.verified } : null,
43
+ canStop: !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
44
+ canPause: Boolean(r.run.managed) && (['active', 'auto_resuming'].includes(r.run.state) ||
45
+ r.run.state === 'recovery_required' && ['session_supervision', 'approved_action'].includes(r.run.recoveryCause) &&
46
+ r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
47
+ canResume: ['blocked', 'recovery_required', 'paused'].includes(r.run.state) && r.run.managed?.phase !== 'needs_clarification' &&
48
+ !r.run.managed?.pendingApproval && now < r.run.deadline && r.run.continuations < r.run.maxContinuations,
49
+ canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
50
+ canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
51
+ canReplan: canReplanDelivery(r.run, r.provider, now),
52
+ canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
53
+ now < r.run.deadline && r.run.continuations < r.run.maxContinuations && r.run.failures < r.run.maxFailures,
54
+ canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
21
55
  })),
22
56
  };
23
57
  }
@@ -0,0 +1,281 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname } from 'node:path';
4
+ import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, notifyState, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
+ import { resolveBackendExecutable } from '../backend-executable.js';
6
+ import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
7
+
8
+ const MAX_OUTPUT = 64 * 1024;
9
+ const SAFE_PROVIDER_CODES = new Set(['identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
10
+ 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval']);
11
+ function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
12
+ function providerFailureReason(code) {
13
+ if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
14
+ if (code === 'permission_narrowed') return 'The original Codex sandbox no longer permits the selected action.';
15
+ if (code === 'identity') return 'Codex session identity, workspace, or model changed; inspect before resuming.';
16
+ if (code === 'checkpoint' || code === 'checkpoint_rejected') return 'Codex returned an invalid managed checkpoint; inspect its plan before resuming.';
17
+ if (code === 'turn_failed') return 'The Codex turn did not complete; inspect the original conversation.';
18
+ return 'Codex recovery process failed; inspect its checkpoint and original session before manual resume.';
19
+ }
20
+ async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
21
+ if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
22
+ const current = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
23
+ if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
24
+ throw new Error('Managed recovery lease changed before provider dispatch.');
25
+ }
26
+ if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
27
+ const managed = current.run.managed;
28
+ const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
29
+ if (managed.autonomyPolicy && ((managed.decision?.outcome === 'wait' && !approvedWait) || managed.resumeLease.decisionId !== managed.decision?.id)) throw new Error('Assessed action changed before provider dispatch.');
30
+ }
31
+ export function parseAgentCheckpoint(output, expectedSession) {
32
+ let message = null;
33
+ let startedSession = null;
34
+ for (const line of String(output).split('\n')) {
35
+ try {
36
+ const event = JSON.parse(line);
37
+ if (event.type === 'thread.started') {
38
+ if (startedSession && startedSession !== event.thread_id) throw new Error('Codex reported inconsistent thread identities.');
39
+ startedSession = event.thread_id;
40
+ }
41
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') message = event.item.text;
42
+ } catch (error) { if (error?.message === 'Codex reported inconsistent thread identities.') throw error; }
43
+ }
44
+ if (startedSession !== expectedSession) throw new Error('Codex resumed a different or unverified thread.');
45
+ if (!message || Buffer.byteLength(message) > 16 * 1024) throw new Error('Codex did not return a bounded final checkpoint.');
46
+ let checkpoint;
47
+ try { checkpoint = JSON.parse(message); } catch { throw new Error('Codex final message was not a JSON checkpoint.'); }
48
+ if (!checkpoint || typeof checkpoint !== 'object' || Array.isArray(checkpoint)) throw new Error('Invalid Codex checkpoint.');
49
+ return checkpoint;
50
+ }
51
+
52
+ // The provider CLI is a new process. It is used only for an exact Codex UUID
53
+ // enrolled for this recovery channel after a corroborated process exit.
54
+ export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now } = {}) {
55
+ if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
56
+ const binary = await executable('codex');
57
+ if (!binary) throw new Error('Codex executable unavailable.');
58
+ await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
59
+ const run = record.run;
60
+ const context = { taskId: run.managed.taskId, session: record.session, objective: run.objective, nextStep: run.nextStep,
61
+ remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
62
+ currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
63
+ budgets: { continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures, deadline: run.deadline } };
64
+ const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
65
+ const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
66
+ const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
67
+ if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
68
+ if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn(binary, args, { cwd: record.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
71
+ env: { ...process.env, SIMY_HOME: dirname(root) } });
72
+ let error = '', spawnError = null, pending = '', lastMessage = null;
73
+ const startedSessions = new Set();
74
+ const consumeLine = line => {
75
+ if (Buffer.byteLength(line) > MAX_OUTPUT) return;
76
+ try {
77
+ const event = JSON.parse(line);
78
+ if (event.type === 'thread.started' && typeof event.thread_id === 'string') startedSessions.add(event.thread_id);
79
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') lastMessage = event.item.text;
80
+ } catch { /* Other provider events are ignored. */ }
81
+ };
82
+ let forceKill;
83
+ const terminate = () => {
84
+ if (child.exitCode !== null || forceKill) return;
85
+ child.kill('SIGTERM');
86
+ forceKill = setTimeout(() => child.kill('SIGKILL'), 2_000);
87
+ forceKill.unref();
88
+ };
89
+ signal?.addEventListener('abort', terminate, { once: true });
90
+ const watch = setInterval(() => {
91
+ void listRecords(root).then(records => {
92
+ const current = records.find(item => item.provider === record.provider && item.session === record.session);
93
+ if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
94
+ }).catch(terminate);
95
+ }, 500);
96
+ watch.unref();
97
+ child.stdout.on('data', chunk => {
98
+ const lines = (pending + chunk).split('\n');
99
+ pending = lines.pop().slice(-MAX_OUTPUT);
100
+ for (const line of lines) consumeLine(line);
101
+ });
102
+ child.stderr.on('data', chunk => { error = (error + chunk).slice(-MAX_OUTPUT); });
103
+ const cleanup = () => { clearInterval(watch); clearTimeout(forceKill); signal?.removeEventListener('abort', terminate); };
104
+ child.once('error', failure => { spawnError = failure; terminate(); });
105
+ child.once('close', code => {
106
+ cleanup(); if (pending) consumeLine(pending);
107
+ const output = [...startedSessions].map(thread_id => JSON.stringify({ type: 'thread.started', thread_id })).join('\n') + '\n' +
108
+ (lastMessage === null ? '' : JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: lastMessage } }));
109
+ if (spawnError) reject(spawnError); else resolve({ code, output, error });
110
+ });
111
+ });
112
+ }
113
+
114
+ export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess, signal } = {}) {
115
+ const tickWallStart = Date.now();
116
+ const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
117
+ if (signal?.aborted) return;
118
+ await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}) });
119
+ if (signal?.aborted) return;
120
+ const records = await listRecords(root);
121
+ for (const record of records) {
122
+ if (signal?.aborted) break;
123
+ const claimNow = currentTime();
124
+ const run = record.run;
125
+ if (!run?.managed) continue;
126
+ if (run.state === 'auto_resuming' && claimNow - run.managed.resumeLease?.claimedAt > 5 * 60_000) {
127
+ await updateRecord(root, record.provider, record.session, current => {
128
+ if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
129
+ current.run.state = 'recovery_required'; current.run.managed.autoRecovery = false;
130
+ current.run.reason = 'Managed recovery was interrupted; inspect the original session before manual resume.';
131
+ current.run.updatedAt = claimNow; notifyState(current.run, claimNow); return current;
132
+ });
133
+ continue;
134
+ }
135
+ const appServer = run.managed.resumeChannel === 'codex_app_server';
136
+ const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
137
+ const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
138
+ run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
139
+ if (run.state !== 'recovery_required' || !causeAllowed || !run.managed.autoRecovery || run.managed.pendingApproval || run.managed.autonomyPolicy && run.managed.decision?.outcome === 'wait' && !approvedWait || appServer && run.managed.nextAttemptAt > claimNow) continue;
140
+ const leaseId = randomUUID();
141
+ let claimed;
142
+ try { claimed = await updateRecord(root, record.provider, record.session, current => {
143
+ const live = current?.run;
144
+ const appChannel = live?.managed?.resumeChannel === 'codex_app_server';
145
+ const allowedCause = live?.recoveryCause === 'process_exit' || appChannel && ['session_supervision', 'approved_action'].includes(live?.recoveryCause);
146
+ const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
147
+ live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
148
+ if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
149
+ if (currentTime() >= live.deadline || live.continuations >= live.maxContinuations || live.failures >= live.maxFailures) {
150
+ live.state = 'budget_exhausted'; live.reason = 'Original managed continuation budget exhausted.'; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
151
+ }
152
+ live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null };
153
+ live.managed.autoAttempts = (live.managed.autoAttempts || 0) + 1;
154
+ live.updatedAt = currentTime(); return current;
155
+ }); } catch (error) {
156
+ if (error?.message === 'Delivery state is busy; retry the command.') continue;
157
+ throw error;
158
+ }
159
+ if (claimed?.run?.managed?.resumeLease?.id !== leaseId) continue;
160
+ let result;
161
+ const controller = new AbortController();
162
+ const abort = () => controller.abort();
163
+ if (signal?.aborted) controller.abort();
164
+ else signal?.addEventListener('abort', abort, { once: true });
165
+ const timeout = setTimeout(abort, Math.max(1, Math.min(5 * 60_000, claimed.run.deadline - currentTime())));
166
+ const watch = setInterval(() => {
167
+ void listRecords(root).then(records => {
168
+ const current = records.find(item => item.provider === record.provider && item.session === record.session);
169
+ if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) controller.abort();
170
+ }).catch(() => controller.abort());
171
+ }, 500);
172
+ watch.unref();
173
+ try {
174
+ if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
175
+ if (controller.signal.aborted) result = { code: -2, cancelled: true };
176
+ else {
177
+ const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
178
+ result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
179
+ beforeDispatch: async providerSandbox => {
180
+ await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
181
+ await updateRecord(root, record.provider, record.session, current => {
182
+ const live = current?.run;
183
+ if (live?.state !== 'auto_resuming' || live.managed?.resumeLease?.id !== leaseId) throw new Error('Managed recovery lease changed before provider dispatch.');
184
+ if (appServer && !live.managed.providerSandbox) {
185
+ if (!providerSandbox || !['readOnly', 'workspaceWrite', 'dangerFullAccess', 'externalSandbox'].includes(providerSandbox.type)) throw new Error('Original Codex sandbox policy is unavailable.');
186
+ live.managed.providerSandbox = { type: providerSandbox.type,
187
+ ...(providerSandbox.type === 'workspaceWrite' ? { writableRoots: Array.isArray(providerSandbox.writableRoots) ? providerSandbox.writableRoots.filter(value => typeof value === 'string').slice(0, 20) : [] } : {}) };
188
+ }
189
+ const approved = live.managed.approvedAction;
190
+ if (live.managed.decision?.outcome === 'wait') {
191
+ if (approved?.id !== live.managed.decision.id || approved.dispatchedAt) throw new Error('Selected action needs fresh approval.');
192
+ approved.dispatchedAt = currentTime();
193
+ }
194
+ return current;
195
+ });
196
+ } });
197
+ }
198
+ } catch (error) { result = { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
199
+ errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
200
+ finally { clearTimeout(timeout); clearInterval(watch); signal?.removeEventListener('abort', abort); }
201
+ if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
202
+ if (result.code === 0) {
203
+ try {
204
+ await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
205
+ checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, now: Date.now() });
206
+ } catch { result = { code: -1, errorCode: 'checkpoint_rejected' }; }
207
+ }
208
+ await updateRecord(root, record.provider, record.session, async current => {
209
+ const live = current?.run;
210
+ if (live?.managed?.resumeLease?.id !== leaseId) return current;
211
+ delete live.managed.resumeLease;
212
+ live.managed.attempts ||= [];
213
+ live.managed.attempts.push({ id: leaseId, startedAt: claimed.run.managed.resumeLease.claimedAt, endedAt: Date.now(),
214
+ checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
215
+ errorCode: result.code === 0 || result.busy || result.cancelled ? null : safeProviderCode(result.errorCode),
216
+ restorationUnverified: result.restorationUnverified === true,
217
+ reason: result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
218
+ nextStep: live.nextStep });
219
+ live.managed.attempts = live.managed.attempts.slice(-20);
220
+ if (result.restorationUnverified) {
221
+ live.managed.providerWarning = 'Original Codex permissions could not be verified after the interrupted turn; inspect that conversation.';
222
+ if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
223
+ }
224
+ if (live.state !== 'auto_resuming') {
225
+ live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
226
+ }
227
+ if (result.busy) {
228
+ live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
229
+ live.reason = 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
230
+ live.managed.nextAttemptAt = Date.now() + 15_000;
231
+ } else if (result.errorCode === 'provider_approval') {
232
+ live.state = 'blocked'; live.blocker = 'authorization'; live.managed.autoRecovery = false;
233
+ live.reason = 'Codex requires provider approval or user input in the original conversation.';
234
+ } else if (result.cancelled) {
235
+ live.managed.autoRecovery = false; live.state = 'recovery_required'; live.recoveryCause = 'shutdown_cancelled';
236
+ live.reason = 'Managed recovery was cancelled; inspect the original session before manual resume.';
237
+ } else if (result.code !== 0) {
238
+ live.failures += 1; live.managed.autoRecovery = false;
239
+ live.state = live.failures >= live.maxFailures ? 'failed' : 'recovery_required';
240
+ live.recoveryCause = 'provider_error';
241
+ live.reason = appServer ? providerFailureReason(safeProviderCode(result.errorCode)) +
242
+ (result.restorationUnverified ? ' Original provider permissions also require inspection.' : '') :
243
+ 'Codex recovery process failed; inspect its checkpoint and original session before manual resume.';
244
+ } else if (live.revision <= run.revision) {
245
+ live.failures += 1;
246
+ live.reason = result.code === 0 ? 'Codex recovery returned without a checkpoint.' : 'Codex recovery failed; inspect the original session.';
247
+ live.state = live.failures >= live.maxFailures ? 'failed' : 'recovery_required';
248
+ live.recoveryCause = 'process_exit';
249
+ } else {
250
+ if (live.checkpoint?.status === 'completed') {
251
+ await verifyAutomaticCriteria(current, Date.now());
252
+ const pending = live.managed.acceptanceCriteria.filter(item => !Object.hasOwn(live.managed.verified, item.id));
253
+ if (pending.length) {
254
+ live.state = 'verification_pending'; live.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`;
255
+ live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
256
+ }
257
+ }
258
+ const decision = decideStop(live, Date.now());
259
+ current.run = decision.run;
260
+ if (current.run.state === 'active') {
261
+ current.run.state = 'recovery_required'; current.run.recoveryCause = appServer ? 'session_supervision' : 'process_exit';
262
+ current.run.reason = 'Verified checkpoint recorded; next bounded exact-session attempt is pending.';
263
+ }
264
+ }
265
+ current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
266
+ });
267
+ }
268
+ }
269
+
270
+ export function createManagedSupervisor(options) {
271
+ let running = null, closed = false;
272
+ const controller = new AbortController();
273
+ return {
274
+ tick() {
275
+ if (closed) return Promise.resolve();
276
+ if (!running) running = tickManagedTasks({ ...options, signal: controller.signal }).finally(() => { running = null; });
277
+ return running;
278
+ },
279
+ async close() { closed = true; controller.abort(); await running; },
280
+ };
281
+ }
@@ -1,5 +1,7 @@
1
1
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
- import { controlDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
2
+ import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
3
+ import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
4
+ import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession } from "./session-supervision/index.js";
3
5
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
4
6
  import { spawn, execFile } from "node:child_process";
5
7
  import { promisify } from "node:util";
@@ -39,8 +41,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
39
41
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
40
42
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
41
43
  let agent, updater, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null;
44
+ const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks") });
42
45
  let shuttingDown = null;
43
46
  let commands;
47
+ let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
48
+ let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
49
+ const refreshSessions = (force = false) => {
50
+ if (force && sessionRefresh) sessionRefreshAgain = true;
51
+ if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
52
+ sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
53
+ .then(value => { sessionStatus = value; sessionRefreshedAt = Date.now(); })
54
+ .catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
55
+ .finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
56
+ if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
57
+ }
58
+ return sessionStatus;
59
+ };
44
60
  let pluginSetup = { state: "unknown" };
45
61
  const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
46
62
  const refreshPlugins = async () => {
@@ -56,12 +72,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
56
72
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
57
73
  status.pluginSetup = { ...pluginSetup };
58
74
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
75
+ status.sessionSupervision = refreshSessions();
59
76
  if (!closed && !relay) emit({ type: "status", status });
60
77
  }).finally(() => { publishing = null; });
61
78
  return publishing;
62
79
  };
63
80
  const stopAgent = async () => {
64
81
  clearInterval(timer);
82
+ await managedSupervisor.close();
65
83
  await stopWorkflows();
66
84
  if (agent) await createAgentShutdown(agent)();
67
85
  };
@@ -147,14 +165,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
147
165
  } });
148
166
  await refreshPlugins();
149
167
  await publish();
150
- timer = setInterval(() => { void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); }, 3_000);
168
+ timer = setInterval(() => {
169
+ void managedSupervisor.tick().catch(() => {});
170
+ void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
171
+ }, 3_000);
151
172
  } catch (error) { await close(); throw error; }
152
173
 
153
174
  let queue = Promise.resolve();
154
175
  commands = createInterface({ input, crlfDelay: Infinity });
155
176
  commands.on("line", (line) => {
156
177
  if (closed) return;
157
- if (line.length > 4096) return;
178
+ if (line.length > 16384) return;
158
179
  let command; try { command = JSON.parse(line); } catch { return; }
159
180
  if (typeof command.id !== "string" || command.id.length > 128) return;
160
181
  if (relay) {
@@ -175,6 +196,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
175
196
  else await agent.refreshCodexStatus?.();
176
197
  await agent.refreshDesktopStatus();
177
198
  await refreshPlugins();
199
+ refreshSessions(true);
178
200
  }
179
201
  else if (action === "login") {
180
202
  const url = await agent.requestDesktopLogin();
@@ -211,9 +233,31 @@ export async function startDesktopHost({ input = process.stdin, output = process
211
233
  else if (action === "codex_hooks" || action === "claude_hooks") {
212
234
  const provider = action === "codex_hooks" ? "codex" : "claude";
213
235
  await (dependencies.openHookSettings || openHookSetup)(provider);
214
- } else if (action === "delivery_stop" || action === "delivery_resume") {
236
+ } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy"].includes(action)) {
215
237
  await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
216
- action: action === "delivery_stop" ? "stop" : "resume" });
238
+ taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, action: action.slice("delivery_".length) });
239
+ } else if (action === "delivery_verify") {
240
+ await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
241
+ taskId: command.taskId, criterionId: command.criterionId });
242
+ } else if (action === "session_monitor" || action === "session_unmonitor") {
243
+ await (dependencies.monitorSession || monitorSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
244
+ session: command.session, monitor: action === "session_monitor" });
245
+ await refreshSessions(true);
246
+ } else if (action === "session_enroll") {
247
+ await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
248
+ session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
249
+ autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
250
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
251
+ await refreshSessions(true);
252
+ } else if (action === "session_continue") {
253
+ await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
254
+ session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
255
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
256
+ await refreshSessions(true);
257
+ } else if (action === "session_answer") {
258
+ await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
259
+ session: command.session, taskId: command.taskId, questionId: command.questionId, answer: command.answer });
260
+ await refreshSessions(true);
217
261
  }
218
262
  else if (action === "quit") {
219
263
  const status = await agent.getDesktopStatus();
@@ -223,8 +267,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
223
267
  } else throw new Error("Unsupported desktop action.");
224
268
  emit({ type: "result", id: command.id, ok: true });
225
269
  await publish();
226
- } catch {
227
- emit({ type: "result", id: command.id, ok: false, message: "Action could not finish. Refresh the status and try again; active tasks must finish before quitting." });
270
+ } catch (error) {
271
+ emit({ type: "result", id: command.id, ok: false, message: String(command.action || "").startsWith("session_")
272
+ ? sessionActionError(error) : "Action could not finish. Refresh the status and try again; active tasks must finish before quitting." });
228
273
  }
229
274
  }).catch(() => {});
230
275
  });
@@ -232,6 +277,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
232
277
  return { close, publish, agent };
233
278
  }
234
279
 
280
+ function sessionActionError(error) {
281
+ const message = String(error?.message || "");
282
+ if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
283
+ if (/model.*unavailable|did not expose.*model/i.test(message)) return "This provider did not expose the conversation model; automatic continuation is unavailable.";
284
+ if (/workspace changed|metadata.*unavailable|session.*changed/i.test(message)) return "The conversation identity or workspace changed. Refresh and select it again.";
285
+ if (/objective|acceptance|autonomy policy|budget/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
286
+ if (/question changed|answer is empty|session understanding|context/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
287
+ return "This conversation could not be enrolled. Refresh its provider status and try again.";
288
+ }
289
+
235
290
  function validCandidateStatus(status, version, webOrigin) {
236
291
  return status?.schemaVersion === 1 && status.version === version && status.webOrigin === webOrigin
237
292
  && ["signed_out", "signed_in", "expired"].includes(status.auth?.state)
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ import { prepareDaemonWorkflows } from "./sqm/workflow-onboarding.js";
25
25
  import { startWorkflowUpdates } from "./sqm/workflow-update.js";
26
26
  import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
27
27
  import { assertNoDesktopHostForOrigin } from "./desktop-process-lock.js";
28
+ import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
28
29
 
29
30
  const argv = process.argv.slice(2);
30
31
  const args = new Set(argv);
@@ -187,8 +188,14 @@ async function runLocalAgent() {
187
188
  updateManager,
188
189
  });
189
190
  let stopWorkflowUpdates = async () => {};
191
+ const managedSupervisor = daemon ? createManagedSupervisor({ root: join(process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks') }) : null;
192
+ const managedTimer = managedSupervisor ? setInterval(() => { void managedSupervisor.tick().catch(() => {}); }, 3_000) : null;
193
+ managedTimer?.unref();
194
+ if (managedSupervisor) void managedSupervisor.tick().catch(() => {});
190
195
  const closeAgent = createAgentShutdown(agent);
191
196
  const shutdownAgent = async () => {
197
+ if (managedTimer) clearInterval(managedTimer);
198
+ await managedSupervisor?.close();
192
199
  await stopWorkflowUpdates();
193
200
  await closeAgent();
194
201
  };