@awak-app/simy-cli 0.5.35 → 0.5.37

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
 
@@ -233,9 +233,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
233
233
  else if (action === "codex_hooks" || action === "claude_hooks") {
234
234
  const provider = action === "codex_hooks" ? "codex" : "claude";
235
235
  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)) {
236
+ } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready"].includes(action)) {
237
237
  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) });
238
+ taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
239
239
  } else if (action === "delivery_verify") {
240
240
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
241
241
  taskId: command.taskId, criterionId: command.criterionId });
@@ -247,12 +247,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
247
247
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
248
248
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
249
249
  autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
250
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
250
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
251
251
  await refreshSessions(true);
252
252
  } else if (action === "session_continue") {
253
253
  await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
254
254
  session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
255
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
255
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
256
256
  await refreshSessions(true);
257
257
  } else if (action === "session_answer") {
258
258
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
@@ -166,7 +166,7 @@ export function isCodexWriterBusy(error) {
166
166
  return error instanceof CodexAppServerError && error.code === -32600 && /already has an active writer/i.test(error.message);
167
167
  }
168
168
 
169
- export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {} } = {}) {
169
+ export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {}, onActivity = () => {}, onUsage = () => {} } = {}) {
170
170
  const run = record.run, decision = run.managed?.decision;
171
171
  if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
172
172
  if (!['read', 'local_edit', 'local_test'].includes(decision.action?.kind)) throw new CodexAppServerError('manual_provider_action', 'This action must be completed in the original provider conversation.');
@@ -177,6 +177,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
177
177
  const client = await openClient({ signal });
178
178
  let originalSandbox = null, overrideStarted = false, activeTurnId = null, turnCompleted = false, turnStart = null;
179
179
  let primaryError = null;
180
+ let usage = null, lastTotal = null;
180
181
  try {
181
182
  const metadata = await readCodexSession(client, record.session);
182
183
  if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== run.managed.resumeModel) throw new CodexAppServerError('identity', 'Codex session workspace or model changed.');
@@ -191,6 +192,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
191
192
  const context = { taskId: run.managed.taskId, objective: run.objective, acceptanceCriteria: run.managed.acceptanceCriteria,
192
193
  decision, diagnosis: run.managed.plan?.diagnosis, remainingWork: run.managed.remainingWork, nextStep: run.nextStep,
193
194
  enrolledScopes: run.managed.approvalScopes,
195
+ lastDependencyResolution: run.managed.lastDependencyResolution,
194
196
  ...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
195
197
  budgets: { deadline: run.deadline, continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures } };
196
198
  const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
@@ -207,6 +209,22 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
207
209
  const done = new Promise((resolve, reject) => {
208
210
  let turnId = null, completion = null, settled = false;
209
211
  const messages = new Map();
212
+ const activeTools = new Set();
213
+ const earlyUsage = [];
214
+ const earlyActivity = [];
215
+ const observeActivity = (method, params) => {
216
+ if (method === 'item/started' && ['commandExecution', 'mcpToolCall', 'webSearch', 'fileChange'].includes(params.item?.type)) activeTools.add(params.item.id);
217
+ if (method === 'item/completed') activeTools.delete(params.item?.id);
218
+ onActivity({ waitingForTool: activeTools.size > 0 });
219
+ };
220
+ const observeUsage = params => {
221
+ const total = params.tokenUsage?.total?.totalTokens, last = params.tokenUsage?.last?.totalTokens;
222
+ if (!Number.isSafeInteger(total) || total < 0 || !Number.isSafeInteger(last) || last < 0 || last > total) return;
223
+ if (lastTotal === null) usage = { source: 'codex_app_server', tokens: last, observedAt: Date.now() };
224
+ else usage.tokens += Math.max(0, total - lastTotal);
225
+ lastTotal = Math.max(lastTotal ?? 0, total);
226
+ onUsage({ ...usage });
227
+ };
210
228
  const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
211
229
  const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
212
230
  const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
@@ -222,6 +240,15 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
222
240
  const unsubscribe = client.onNotification((method, params, requestId) => {
223
241
  if (requestId !== undefined && /requestApproval|requestPermissions|requestUserInput/i.test(method)) return finish(new CodexAppServerError('provider_approval', 'Codex requires provider approval or user input.'));
224
242
  if (params.threadId !== record.session) return;
243
+ // Only this owned turn's activity may renew its response watchdog.
244
+ if (params.turnId === turnId || params.turn?.id === turnId) {
245
+ if (turnId) observeActivity(method, params);
246
+ } else if (!turnId && typeof params.turnId === 'string' && earlyActivity.length < 100) earlyActivity.push({ method, turnId: params.turnId,
247
+ item: { id: params.item?.id, type: params.item?.type } });
248
+ if (method === 'thread/tokenUsage/updated') {
249
+ if (turnId && params.turnId === turnId) observeUsage(params);
250
+ else if (!turnId && earlyUsage.length < 100) earlyUsage.push({ turnId: params.turnId, tokenUsage: params.tokenUsage });
251
+ }
225
252
  if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string' && typeof params.turnId === 'string') messages.set(params.turnId, params.item.text);
226
253
  if (method === 'turn/completed') { completion = params; if (params.turn?.id === turnId) turnCompleted = true; complete(); }
227
254
  });
@@ -230,11 +257,15 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
230
257
  overrideStarted = true;
231
258
  turnStart = client.request('turn/start', { threadId: record.session, input: [{ type: 'text', text: prompt }], cwd: record.cwd,
232
259
  model: run.managed.resumeModel, sandboxPolicy })
233
- .then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else complete(); })
260
+ .then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
261
+ for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
262
+ for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
263
+ complete();
264
+ } })
234
265
  .catch(error => finish(error));
235
266
  });
236
267
  const checkpoint = await done;
237
- return { code: 0, checkpoint };
268
+ return { code: 0, checkpoint, usage };
238
269
  } catch (error) {
239
270
  primaryError = error;
240
271
  throw error;
@@ -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
+ }