@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.
@@ -1,5 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { continueSession } from '../session-supervision/index.js';
4
+ import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
3
5
 
4
6
  async function readCheckpointInput(input) {
5
7
  const chunks = [];
@@ -16,11 +18,12 @@ async function readCheckpointInput(input) {
16
18
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
17
19
  const [action, ...args] = argv;
18
20
  if (!action || action === '--help') {
21
+ output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-tokens N (Codex only)]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
19
22
  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.');
20
23
  return 0;
21
24
  }
22
25
  const options = {};
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']);
26
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
24
27
  for (let i = 0; i < args.length; i += 2) {
25
28
  const key = args[i]?.replace(/^--/, '');
26
29
  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.');
@@ -29,6 +32,11 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
29
32
  const base = { root, cwd, provider: options.provider, session: options.session };
30
33
  let result;
31
34
  if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
35
+ else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'],
36
+ ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
37
+ ['max-no-progress-minutes', 'maxNoProgressMinutes'], ['max-tokens', 'maxTokens']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
38
+ else if (action === 'handoff') result = await handoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
39
+ else if (action === 'handoff_cancel') result = await cancelHandoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
32
40
  else if (action === 'start') {
33
41
  let managed;
34
42
  if (options['managed-file']) {
@@ -36,6 +44,11 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
36
44
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Managed task specification exceeds 64 KiB.');
37
45
  managed = JSON.parse(data);
38
46
  }
47
+ if (options['max-tokens'] !== undefined || options['max-no-progress-minutes'] !== undefined) {
48
+ if (!managed || typeof managed !== 'object') throw new Error('Managed budget flags require --managed-file; use delivery continue for context-first supervision.');
49
+ managed = { ...managed, ...Object.fromEntries([['max-tokens', 'maxTokens'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
50
+ .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) };
51
+ }
39
52
  result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed,
40
53
  ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
41
54
  }
@@ -44,7 +57,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
44
57
  const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
45
58
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
46
59
  result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
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 });
60
+ } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], waitId: options['wait-id'], answer: options.answer, action });
48
61
  else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
49
62
  else throw new Error('Unknown delivery command.');
50
63
  output(JSON.stringify(result, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { canReplanDelivery, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canReplanDelivery, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
@@ -27,6 +27,13 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
27
27
  phase: r.run.managed?.phase || 'active', sessionUnderstanding: r.run.managed?.sessionUnderstanding || null,
28
28
  understandingQuestion: r.run.managed?.understandingQuestion || null,
29
29
  remainingWork: r.run.managed?.remainingWork || null, nextStep: r.run.nextStep,
30
+ dependencyWait: r.run.managed?.dependencyWait || null,
31
+ executionAttempt: r.run.managed?.executionAttempt || null, executionUncertain: r.run.managed?.executionUncertain || null,
32
+ handoff: r.run.managed?.handoff || null, threadHistory: r.run.managed?.threadHistory || [],
33
+ progressBudget: r.run.managed ? { minutes: r.run.managed.maxNoProgressMinutes, lastVerifiedProgressAt: r.run.managed.lastVerifiedProgressAt,
34
+ attempts: r.run.managed.noProgress, maxAttempts: r.run.managed.maxNoProgress } : null,
35
+ tokenBudget: r.run.managed?.maxTokens ? { limit: r.run.managed.maxTokens, used: r.run.managed.tokensUsed,
36
+ usageAvailable: r.run.managed.usageAvailable, source: 'provider_observed', dollarCost: null } : null,
30
37
  pendingApproval: r.run.managed?.pendingApproval || null,
31
38
  autonomyPolicy: r.run.managed?.autonomyPolicy || null, plan: r.run.managed?.plan || null,
32
39
  decision: r.run.managed?.decision || null, decisionHistory: r.run.managed?.decisionHistory || [],
@@ -45,12 +52,15 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
45
52
  r.run.state === 'recovery_required' && ['session_supervision', 'approved_action'].includes(r.run.recoveryCause) &&
46
53
  r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
47
54
  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,
55
+ !r.run.managed?.pendingApproval && !r.run.managed?.dependencyWait && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status) &&
56
+ !managedBudgetReason(r.run, now) && now < r.run.deadline && r.run.continuations < r.run.maxContinuations,
49
57
  canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
50
58
  canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
51
59
  canReplan: canReplanDelivery(r.run, r.provider, now),
52
60
  canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
53
61
  now < r.run.deadline && r.run.continuations < r.run.maxContinuations && r.run.failures < r.run.maxFailures,
62
+ canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
63
+ !managedBudgetReason(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
54
64
  canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
55
65
  })),
56
66
  };
@@ -1,13 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, notifyState, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
5
  import { resolveBackendExecutable } from '../backend-executable.js';
6
6
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
7
+ import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
7
8
 
8
9
  const MAX_OUTPUT = 64 * 1024;
9
10
  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
+ 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval', 'usage_unavailable']);
11
12
  function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
12
13
  function providerFailureReason(code) {
13
14
  if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
@@ -24,6 +25,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
24
25
  throw new Error('Managed recovery lease changed before provider dispatch.');
25
26
  }
26
27
  if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
28
+ if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
27
29
  const managed = current.run.managed;
28
30
  const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
29
31
  if (managed.autonomyPolicy && ((managed.decision?.outcome === 'wait' && !approvedWait) || managed.resumeLease.decisionId !== managed.decision?.id)) throw new Error('Assessed action changed before provider dispatch.');
@@ -51,13 +53,14 @@ export function parseAgentCheckpoint(output, expectedSession) {
51
53
 
52
54
  // The provider CLI is a new process. It is used only for an exact Codex UUID
53
55
  // enrolled for this recovery channel after a corroborated process exit.
54
- export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now } = {}) {
56
+ export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {} } = {}) {
55
57
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
56
58
  const binary = await executable('codex');
57
59
  if (!binary) throw new Error('Codex executable unavailable.');
58
60
  await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
59
61
  const run = record.run;
60
- const context = { taskId: run.managed.taskId, session: record.session, objective: run.objective, nextStep: run.nextStep,
62
+ const context = { taskId: run.managed.taskId, session: record.session, objective: run.objective, nextStep: run.nextStep,
63
+ lastDependencyResolution: run.managed.lastDependencyResolution,
61
64
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
62
65
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
63
66
  budgets: { continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures, deadline: run.deadline } };
@@ -95,6 +98,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
95
98
  }, 500);
96
99
  watch.unref();
97
100
  child.stdout.on('data', chunk => {
101
+ onActivity();
98
102
  const lines = (pending + chunk).split('\n');
99
103
  pending = lines.pop().slice(-MAX_OUTPUT);
100
104
  for (const line of lines) consumeLine(line);
@@ -111,7 +115,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
111
115
  });
112
116
  }
113
117
 
114
- export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess, signal } = {}) {
118
+ export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal } = {}) {
115
119
  const tickWallStart = Date.now();
116
120
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
117
121
  if (signal?.aborted) return;
@@ -123,11 +127,18 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
123
127
  const claimNow = currentTime();
124
128
  const run = record.run;
125
129
  if (!run?.managed) continue;
126
- if (run.state === 'auto_resuming' && claimNow - run.managed.resumeLease?.claimedAt > 5 * 60_000) {
130
+ if (run.state === 'auto_resuming') {
131
+ const owner = run.managed.resumeLease?.owner;
132
+ const ownerDead = await leaseOwnerDead(owner, inspectProcess);
133
+ // A live/unknown owner is never taken over merely because time passed.
134
+ if (!ownerDead && (owner || claimNow - run.managed.resumeLease?.claimedAt <= 5 * 60_000)) continue;
127
135
  await updateRecord(root, record.provider, record.session, current => {
128
136
  if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
129
137
  current.run.state = 'recovery_required'; current.run.managed.autoRecovery = false;
130
138
  current.run.reason = 'Managed recovery was interrupted; inspect the original session before manual resume.';
139
+ const attempt = current.run.managed.executionAttempt;
140
+ if (attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') current.run.managed.executionUncertain = {
141
+ attemptId: attempt.id, decisionId: attempt.decisionId, action: attempt.action, at: claimNow };
131
142
  current.run.updatedAt = claimNow; notifyState(current.run, claimNow); return current;
132
143
  });
133
144
  continue;
@@ -136,8 +147,11 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
136
147
  const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
137
148
  const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
138
149
  run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
150
+ if (run.managed.executionUncertain && run.managed.recoveryDiagnosisDecisionId !== run.managed.decision?.id) continue;
151
+ if (['pending', 'transferred', 'pending_received'].includes(run.managed.handoff?.status)) continue;
139
152
  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
153
  const leaseId = randomUUID();
154
+ const ownerIdentity = await inspectProcess(process.pid) || `pid:${process.pid}`;
141
155
  let claimed;
142
156
  try { claimed = await updateRecord(root, record.provider, record.session, current => {
143
157
  const live = current?.run;
@@ -146,27 +160,39 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
146
160
  const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
147
161
  live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
148
162
  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) {
163
+ if (managedBudgetReason(live, currentTime()) || currentTime() >= live.deadline || live.continuations >= live.maxContinuations || live.failures >= live.maxFailures) {
150
164
  live.state = 'budget_exhausted'; live.reason = 'Original managed continuation budget exhausted.'; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
151
165
  }
152
- live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null };
166
+ live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
167
+ owner: { pid: process.pid, identity: ownerIdentity } };
168
+ live.managed.executionAttempt = { id: leaseId, decisionId: live.managed.decision?.id || null,
169
+ action: live.managed.decision?.action || live.managed.proposedAction, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
153
170
  live.managed.autoAttempts = (live.managed.autoAttempts || 0) + 1;
154
171
  live.updatedAt = currentTime(); return current;
155
172
  }); } catch (error) {
156
- if (error?.message === 'Delivery state is busy; retry the command.') continue;
173
+ // mkdir can become visible before its owner receipt is written. Treat an
174
+ // ambiguous owner as contention, never as permission to reclaim it.
175
+ if (error?.message === 'Delivery state is busy; retry the command.' ||
176
+ error?.message === 'Delivery state lock owner is unavailable; inspect the lock before retrying.') continue;
157
177
  throw error;
158
178
  }
159
179
  if (claimed?.run?.managed?.resumeLease?.id !== leaseId) continue;
160
180
  let result;
181
+ let resource;
182
+ let lastActivityAt = currentTime();
183
+ let usage = null;
184
+ let tokenBudgetExceeded = false;
185
+ let dispatched = false, waitingForTool = false;
161
186
  const controller = new AbortController();
162
187
  const abort = () => controller.abort();
163
188
  if (signal?.aborted) controller.abort();
164
189
  else signal?.addEventListener('abort', abort, { once: true });
165
- const timeout = setTimeout(abort, Math.max(1, Math.min(5 * 60_000, claimed.run.deadline - currentTime())));
190
+ const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
166
191
  const watch = setInterval(() => {
167
192
  void listRecords(root).then(records => {
168
193
  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();
194
+ if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
195
+ managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
170
196
  }).catch(() => controller.abort());
171
197
  }, 500);
172
198
  watch.unref();
@@ -174,8 +200,42 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
174
200
  if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
175
201
  if (controller.signal.aborted) result = { code: -2, cancelled: true };
176
202
  else {
203
+ resource = await acquireWorkspaceLease({ root, record: claimed, leaseId, inspectProcess, onRecover: async prior => {
204
+ await updateRecord(root, prior.provider, prior.session, previous => {
205
+ if (previous?.run?.managed?.taskId === prior.taskId && previous.run.managed.executionAttempt?.phase !== 'checkpointed') {
206
+ previous.run.managed.executionUncertain ||= { attemptId: prior.leaseId, decisionId: prior.decisionId, at: currentTime() };
207
+ previous.run.managed.autoRecovery = false;
208
+ }
209
+ return previous;
210
+ });
211
+ } });
212
+ if (resource.recoveredOwner) {
213
+ // The earlier writer may have changed files before its host crashed.
214
+ // Keep this workspace blocked until its original task is inspected.
215
+ await updateRecord(root, resource.recoveredOwner.provider, resource.recoveredOwner.session, previous => {
216
+ if (previous?.run?.managed?.taskId === resource.recoveredOwner.taskId && previous.run.managed.executionAttempt?.phase !== 'checkpointed') {
217
+ previous.run.managed.executionUncertain ||= { attemptId: resource.recoveredOwner.leaseId,
218
+ decisionId: resource.recoveredOwner.decisionId, at: currentTime() };
219
+ previous.run.managed.autoRecovery = false;
220
+ if (!['stopped', 'paused', 'completed', 'reported_done'].includes(previous.run.state)) previous.run.state = 'recovery_required';
221
+ previous.run.reason = 'An interrupted workspace writer needs read-only result reconciliation before more changes.';
222
+ }
223
+ return previous;
224
+ });
225
+ result = { code: -3, busy: true };
226
+ } else if (resource.busy) result = { code: -3, busy: true };
227
+ else if (['local_edit', 'local_test'].includes(claimed.run.managed.decision?.action.kind) &&
228
+ (await listRecords(root)).some(other => other.cwd === claimed.cwd && other.run?.managed?.executionUncertain)) result = { code: -3, busy: true };
229
+ if (!result) {
177
230
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
178
231
  result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
232
+ onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
233
+ onUsage: observed => {
234
+ usage = observed;
235
+ if (claimed.run.managed.maxTokens && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= claimed.run.managed.maxTokens) {
236
+ tokenBudgetExceeded = true; controller.abort();
237
+ }
238
+ },
179
239
  beforeDispatch: async providerSandbox => {
180
240
  await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
181
241
  await updateRecord(root, record.provider, record.session, current => {
@@ -187,6 +247,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
187
247
  ...(providerSandbox.type === 'workspaceWrite' ? { writableRoots: Array.isArray(providerSandbox.writableRoots) ? providerSandbox.writableRoots.filter(value => typeof value === 'string').slice(0, 20) : [] } : {}) };
188
248
  }
189
249
  const approved = live.managed.approvedAction;
250
+ live.managed.executionAttempt.phase = 'dispatching';
251
+ live.managed.executionAttempt.dispatchedAt = currentTime();
252
+ live.managed.executionAttempt.lastActivityAt = currentTime();
253
+ dispatched = true;
190
254
  if (live.managed.decision?.outcome === 'wait') {
191
255
  if (approved?.id !== live.managed.decision.id || approved.dispatchedAt) throw new Error('Selected action needs fresh approval.');
192
256
  approved.dispatchedAt = currentTime();
@@ -194,21 +258,51 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
194
258
  return current;
195
259
  });
196
260
  } });
261
+ }
197
262
  }
198
263
  } catch (error) { result = { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
199
264
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
200
265
  finally { clearTimeout(timeout); clearInterval(watch); signal?.removeEventListener('abort', abort); }
201
266
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
267
+ if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
268
+ result = { ...result, usageUnavailable: true };
269
+ }
202
270
  if (result.code === 0) {
203
271
  try {
204
272
  await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
205
273
  checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, now: Date.now() });
274
+ await updateRecord(root, record.provider, record.session, current => {
275
+ if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
276
+ current.run.managed.executionAttempt.phase = 'checkpointed';
277
+ current.run.managed.executionAttempt.checkpointRevision = current.run.revision;
278
+ if (claimed.run.managed.recoveryDiagnosisDecisionId === claimed.run.managed.decision?.id &&
279
+ current.run.managed.workHistory?.at(-1)?.result?.status === 'observed') {
280
+ // This accepts a read-only observation, never grants a retry of the
281
+ // old action. The newly assessed plan still passes the normal gate.
282
+ current.run.managed.reconciledExecutions ||= [];
283
+ current.run.managed.reconciledExecutions.push({ ...current.run.managed.executionUncertain, reconciledAt: Date.now(), revision: current.run.revision });
284
+ current.run.managed.reconciledExecutions = current.run.managed.reconciledExecutions.slice(-10);
285
+ delete current.run.managed.executionUncertain;
286
+ delete current.run.managed.recoveryDiagnosisDecisionId;
287
+ }
288
+ return current;
289
+ });
206
290
  } catch { result = { code: -1, errorCode: 'checkpoint_rejected' }; }
207
291
  }
208
292
  await updateRecord(root, record.provider, record.session, async current => {
209
293
  const live = current?.run;
210
294
  if (live?.managed?.resumeLease?.id !== leaseId) return current;
211
295
  delete live.managed.resumeLease;
296
+ if (usage) {
297
+ live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
298
+ live.managed.usageAvailable = true;
299
+ live.managed.lastUsage = { ...usage, attemptId: leaseId };
300
+ } else if (!result.busy && live.managed.maxTokens && dispatched) {
301
+ live.managed.usageAvailable = false; live.managed.usageBlocked = true;
302
+ }
303
+ const attempt = live.managed.executionAttempt;
304
+ if (!result.busy && attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') live.managed.executionUncertain = {
305
+ attemptId: leaseId, decisionId: attempt.decisionId, action: attempt.action, at: Date.now() };
212
306
  live.managed.attempts ||= [];
213
307
  live.managed.attempts.push({ id: leaseId, startedAt: claimed.run.managed.resumeLease.claimedAt, endedAt: Date.now(),
214
308
  checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
@@ -222,9 +316,19 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
222
316
  if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
223
317
  }
224
318
  if (live.state !== 'auto_resuming') {
319
+ if (live.managed.usageBlocked && !['stopped', 'paused', 'completed', 'reported_done'].includes(live.state)) {
320
+ live.managed.autoRecovery = false;
321
+ live.reason = `${live.reason} Provider token usage is unavailable; further budgeted turns are blocked.`;
322
+ }
225
323
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
226
324
  }
227
- if (result.busy) {
325
+ if (tokenBudgetExceeded || live.managed.maxTokens && live.managed.tokensUsed >= live.managed.maxTokens) {
326
+ live.state = 'budget_exhausted'; live.managed.autoRecovery = false;
327
+ live.reason = 'Observed provider token budget exhausted; inspect any interrupted action in the original conversation.';
328
+ } else if (live.managed.usageBlocked) {
329
+ live.state = 'recovery_required'; live.managed.autoRecovery = false;
330
+ live.reason = 'Provider usage was unavailable; token-budgeted continuation cannot safely start another turn.';
331
+ } else if (result.busy) {
228
332
  live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
229
333
  live.reason = 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
230
334
  live.managed.nextAttemptAt = Date.now() + 15_000;
@@ -264,6 +368,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
264
368
  }
265
369
  current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
266
370
  });
371
+ // Settling uncertainty must precede unlocking: otherwise another session
372
+ // could start a write in the effect/checkpoint persistence gap.
373
+ await resource?.release({ unconfirmed: result.restorationUnverified === true });
267
374
  }
268
375
  }
269
376
 
@@ -2,6 +2,8 @@ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
2
  import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
3
3
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
4
4
  import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession } from "./session-supervision/index.js";
5
+ import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
6
+ import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
5
7
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
6
8
  import { spawn, execFile } from "node:child_process";
7
9
  import { promisify } from "node:util";
@@ -28,6 +30,9 @@ import {
28
30
  } from "./desktop-process-lock.js";
29
31
 
30
32
  const silentLogger = { log() {}, error() {} };
33
+ // Match the native DesktopStatusFrames limit: 200 conversation summaries can
34
+ // exceed 128 KiB, including after a signed runtime handoff.
35
+ const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
31
36
 
32
37
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
33
38
  dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
@@ -40,7 +45,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
40
45
  }
41
46
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
42
47
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
43
- let agent, updater, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null;
48
+ let agent, updater, analysisManager, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null;
44
49
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks") });
45
50
  let shuttingDown = null;
46
51
  let commands;
@@ -50,13 +55,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
50
55
  if (force && sessionRefresh) sessionRefreshAgain = true;
51
56
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
52
57
  sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
53
- .then(value => { sessionStatus = value; sessionRefreshedAt = Date.now(); })
58
+ .then(async value => { await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
54
59
  .catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
55
60
  .finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
56
61
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
57
62
  }
58
63
  return sessionStatus;
59
64
  };
65
+ const selectedSession = async command => {
66
+ refreshSessions(true);
67
+ await sessionRefresh;
68
+ // A forced refresh requested during discovery schedules one follow-up.
69
+ if (sessionRefresh) await sessionRefresh;
70
+ const candidate = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
71
+ if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
72
+ return candidate;
73
+ };
60
74
  let pluginSetup = { state: "unknown" };
61
75
  const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
62
76
  const refreshPlugins = async () => {
@@ -66,19 +80,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
66
80
  } catch { pluginSetup = { state: "error" }; }
67
81
  };
68
82
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
83
+ const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
84
+ root: join(root, 'session-analysis'), analyze: dependencies.analyzeContinuation,
85
+ onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
86
+ });
69
87
  const publish = () => {
70
88
  if (closed || relay || !agent) return Promise.resolve();
71
89
  if (publishing) return publishing;
72
90
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
73
91
  status.pluginSetup = { ...pluginSetup };
74
92
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
75
- status.sessionSupervision = refreshSessions();
93
+ const discovery = refreshSessions();
94
+ status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => analysisManager.project(candidate)) };
76
95
  if (!closed && !relay) emit({ type: "status", status });
77
96
  }).finally(() => { publishing = null; });
78
97
  return publishing;
79
98
  };
80
99
  const stopAgent = async () => {
81
100
  clearInterval(timer);
101
+ await analysisManager?.close();
82
102
  await managedSupervisor.close();
83
103
  await stopWorkflows();
84
104
  if (agent) await createAgentShutdown(agent)();
@@ -99,6 +119,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
99
119
  })();
100
120
 
101
121
  try {
122
+ analysisManager = await makeAnalysisManager();
102
123
  if (!candidate) {
103
124
  await (dependencies.assertNoLegacyDaemon || assertNoLegacyCliDaemon)(webOrigin);
104
125
  }
@@ -119,11 +140,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
119
140
  updater.on?.("change", () => { void publish().catch(() => {}); });
120
141
  updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
121
142
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
122
- const child = (dependencies.spawn || spawn)(installation.executablePath,
143
+ // The candidate shares this cache. Stop inference before it starts so a
144
+ // signed handoff cannot create two analysis writers or duplicate calls.
145
+ await analysisManager.close();
146
+ let child;
147
+ try { child = (dependencies.spawn || spawn)(installation.executablePath,
123
148
  [join(dirname(installation.entryPath), "desktop-host.js")], {
124
149
  stdio: ["pipe", "pipe", "ignore"],
125
150
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
126
- });
151
+ }); } catch (error) { analysisManager = await makeAnalysisManager(); refreshSessions(true); throw error; }
127
152
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
128
153
  let accepted = false;
129
154
  child.stdin.on("error", () => {
@@ -145,7 +170,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
145
170
  child.once("error", () => finish(reject, new Error("Updated desktop runtime could not start.")));
146
171
  child.once("exit", () => { if (!accepted) finish(reject, new Error("Updated desktop runtime exited before verification.")); });
147
172
  lines.on("line", (line) => {
148
- if (line.length > 131072) return;
173
+ if (Buffer.byteLength(line, "utf8") > MAX_DESKTOP_FRAME_BYTES) {
174
+ if (accepted) emit({ type: "error", message: "SIMY update status was too large. Reconnect to start the service again." });
175
+ else finish(reject, new Error("Updated desktop runtime status was too large."));
176
+ return;
177
+ }
149
178
  let event; try { event = JSON.parse(line); } catch { return; }
150
179
  if (accepted) { emit(event); return; }
151
180
  if (event.type === "status") {
@@ -160,7 +189,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
160
189
  if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
161
190
  emit(first);
162
191
  } catch (error) {
163
- lines.close(); child.kill("SIGTERM"); throw error;
192
+ lines.close(); await stopUpdateCandidate(child);
193
+ if (!closed && !relay) { analysisManager = await makeAnalysisManager(); refreshSessions(true); }
194
+ throw error;
164
195
  }
165
196
  } });
166
197
  await refreshPlugins();
@@ -233,9 +264,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
233
264
  else if (action === "codex_hooks" || action === "claude_hooks") {
234
265
  const provider = action === "codex_hooks" ? "codex" : "claude";
235
266
  await (dependencies.openHookSettings || openHookSetup)(provider);
236
- } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy"].includes(action)) {
267
+ } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready"].includes(action)) {
237
268
  await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
238
- taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, action: action.slice("delivery_".length) });
269
+ taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
239
270
  } else if (action === "delivery_verify") {
240
271
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
241
272
  taskId: command.taskId, criterionId: command.criterionId });
@@ -247,12 +278,28 @@ export async function startDesktopHost({ input = process.stdin, output = process
247
278
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
248
279
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
249
280
  autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
250
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
281
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
251
282
  await refreshSessions(true);
283
+ } else if (action === "session_analyze") {
284
+ await analysisManager.request(await selectedSession(command));
252
285
  } else if (action === "session_continue") {
286
+ const candidate = await selectedSession(command);
287
+ const manager = analysisManager;
288
+ const recommendation = manager.requireRecommendation(candidate);
289
+ if (candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
290
+ const context = await (dependencies.readContinuationContext || readContinuationContext)(candidate);
291
+ // Provider activity timestamps can have one-second precision. Re-read
292
+ // the bounded context before enrollment to reject same-second edits.
293
+ if (context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
294
+ await manager.invalidate(candidate);
295
+ throw new Error('This conversation changed; wait for its updated analysis.');
296
+ }
297
+ // Reading provider context can overlap an update handoff. The old
298
+ // manager must still be active before task enrollment is dispatched.
299
+ manager.requireRecommendation(candidate);
253
300
  await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
254
301
  session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
255
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
302
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
256
303
  await refreshSessions(true);
257
304
  } else if (action === "session_answer") {
258
305
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
@@ -295,6 +342,22 @@ function validCandidateStatus(status, version, webOrigin) {
295
342
  && Array.isArray(status.tasks.items) && typeof status.codex?.state === "string";
296
343
  }
297
344
 
345
+ async function stopUpdateCandidate(child) {
346
+ if (child.exitCode !== null) return;
347
+ await new Promise((resolve, reject) => {
348
+ const forced = setTimeout(() => child.kill('SIGKILL'), 2000);
349
+ const limit = setTimeout(() => finish(new Error('Updated desktop runtime did not stop.')), 10_000);
350
+ const finish = error => {
351
+ clearTimeout(forced); clearTimeout(limit);
352
+ child.removeListener('exit', exited);
353
+ error ? reject(error) : resolve();
354
+ };
355
+ const exited = () => finish();
356
+ child.once('exit', exited);
357
+ child.kill('SIGTERM');
358
+ });
359
+ }
360
+
298
361
  export async function openProviderLogin(provider, root, {
299
362
  inspectExecutable = inspectBackendExecutable, spawnImpl = spawn,
300
363
  execFileImpl = promisify(execFile), platform = process.platform, env = process.env,