@awak-app/simy-cli 0.7.25 → 0.7.26

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.
Files changed (38) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -2
  3. package/src/cli-contract.js +1 -1
  4. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
  6. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  8. package/src/connection-health.js +253 -0
  9. package/src/connection-presentation.js +85 -0
  10. package/src/connection-recovery.js +102 -0
  11. package/src/console/app.js +60 -6
  12. package/src/console/commands.js +3 -0
  13. package/src/console/index.js +1 -0
  14. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  15. package/src/delivery-hooks/supervisor.js +14 -14
  16. package/src/desktop-host.js +43 -8
  17. package/src/direct-executor.js +8 -2
  18. package/src/doctor.js +1 -0
  19. package/src/index.js +12 -2
  20. package/src/remote-connection-guide.js +222 -0
  21. package/src/session-supervision/analysis-manager.js +24 -4
  22. package/src/session-supervision/automatic-continuation.js +69 -18
  23. package/src/session-supervision/codex-app-server.js +52 -7
  24. package/src/session-supervision/codex-peer-opinion.js +27 -5
  25. package/src/session-supervision/consistency-auditor.js +1 -1
  26. package/src/session-supervision/continuation-analysis.js +7 -3
  27. package/src/session-supervision/decision-presentation.js +1 -1
  28. package/src/session-supervision/index.js +21 -13
  29. package/src/session-supervision/outcome-plan.js +76 -0
  30. package/src/session-supervision/purpose-instruction.js +28 -0
  31. package/src/session-supervision/recovery-frontier.js +14 -4
  32. package/src/session-supervision/remote-command-queue.js +15 -0
  33. package/src/session-supervision/remote-control.js +3 -0
  34. package/src/session-supervision/remote-hosts.js +70 -19
  35. package/src/session-supervision/remote-managed-delivery.js +94 -0
  36. package/src/session-supervision/remote-session-list.js +1 -1
  37. package/src/session-supervision/statistics.js +10 -3
  38. package/src/session-supervision/work-state.js +5 -0
@@ -2,7 +2,7 @@ import { agentRole } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
2
2
  import { captureAcceptanceReads } from './acceptance-reads.js';
3
3
  import { providerFailureDiagnostic } from './provider-failure.js';
4
4
  import { captureDispatchProvenance } from '../update-detachment/provenance.js';
5
- import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
5
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, queueProviderTerminalReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
6
6
  import { normalizePullRequests } from './pull-requests.js';
7
7
  import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
8
8
  import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
@@ -541,7 +541,16 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
541
541
  let cursor = null;
542
542
  do {
543
543
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
544
- const page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
544
+ let page;
545
+ try { page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length); }
546
+ catch (error) {
547
+ // An oversized older turn (often attachments) must not discard an
548
+ // already complete recent exchange. This is omitted history, not a
549
+ // fabricated turn. Latest-page overflow still fails closed, and the
550
+ // classifier must judge whether the bounded context is sufficient.
551
+ if (cursor && turns.some(terminal) && error?.code === 'response_too_large') break;
552
+ throw error;
553
+ }
545
554
  for (const turn of page.data) {
546
555
  if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
547
556
  turnIds.add(turn.id); turns.push(turn);
@@ -654,9 +663,10 @@ async function frozenNativeSandbox(record, root, signal) {
654
663
  return policy;
655
664
  }
656
665
 
657
- export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runCodexPeerOpinion } = {}) {
666
+ export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, dispatchAttemptId: requestedAttemptId, runPeerReview = runCodexPeerOpinion } = {}) {
658
667
  const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
659
668
  let dispatchAttemptId;
669
+ if (requestedAttemptId && (!auditOnly || record.run?.managed?.outcomeAudit?.lease?.id !== requestedAttemptId)) throw new CodexAppServerError('identity', 'Independent audit dispatch must use its original lease.');
660
670
  const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: record.run.managed.taskId, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
661
671
  assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
662
672
  const run = record.run, decision = run.managed?.decision;
@@ -697,13 +707,29 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
697
707
  output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
698
708
  }, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
699
709
  model: run.managed.resumeModel, cwd: taskDirectory(record), openClient, readAuthentication: readCodexAuthentication,
700
- dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance }, attemptId => {
710
+ dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: requestedAttemptId || run.managed.resumeLease?.id,
711
+ provenance: captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: peerSession,
712
+ worktree: taskDirectory(record), taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) }) }, attemptId => {
713
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
701
714
  if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
702
715
  dispatchAttemptId = attemptId;
703
716
  return initiate();
704
717
  }),
705
718
  beforeDispatch: async () => { await authorizePreparation(); await beforeDispatch({ type: 'readOnly', networkAccess: false }); },
706
- onContext: session => { if (peerSession && peerSession !== session) ambiguousContext = true; peerSession = session; },
719
+ onContext: async session => {
720
+ if (!session || session === record.session || peerSession && peerSession !== session) { ambiguousContext = true; throw new CodexAppServerError('identity', 'Independent peer context changed.'); }
721
+ peerSession = session;
722
+ await onEvidence({ source: 'adapter_capture', actionId: decision.id, workerContext: { session, originalSession: record.session, cwd: taskDirectory(record), model: run.managed.resumeModel, ephemeralRequested: true } });
723
+ },
724
+ onStarted: ({ session, turnId }) => {
725
+ if (!dispatchAttemptId || session !== peerSession) throw new CodexAppServerError('identity', 'Independent peer identity changed.');
726
+ queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId });
727
+ onActivity();
728
+ },
729
+ onTerminal: ({ session, turnId, status, response }) => {
730
+ if (!dispatchAttemptId || session !== peerSession || session === record.session) throw new CodexAppServerError('identity', 'Independent peer identity changed.');
731
+ queueProviderTerminalReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, providerSession: session, providerExecutionId: turnId, status, response });
732
+ },
707
733
  onUsage: observed => { usage = observed; onUsage(observed); } });
708
734
  if (!peerSession || peerSession === record.session || ambiguousContext || typeof reply.output?.opinion !== 'string' || !reply.output.opinion.trim()) throw new CodexAppServerError('identity', 'Independent peer context or opinion unavailable.');
709
735
  return { code: 0, dispatchAttemptId, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
@@ -722,6 +748,8 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
722
748
  const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
723
749
  let evidenceQueue = Promise.resolve(), evidenceFailure = null;
724
750
  let activeTurnId = null, turnCompleted = false, turnStart = null, startReceipt = null;
751
+ let releaseTurnNotifications, terminalResolve;
752
+ const terminalObserved = new Promise(resolve => { terminalResolve = resolve; });
725
753
  let usage = null, lastTotal = null;
726
754
  try {
727
755
  // An unavailable login is a connection dependency, not an action approval.
@@ -870,13 +898,18 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
870
898
  lastTotal = Math.max(lastTotal ?? 0, total);
871
899
  onUsage({ ...usage });
872
900
  };
873
- const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
901
+ const cleanup = () => { releaseTurnNotifications = unsubscribe; signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
874
902
  const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
875
903
  const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
876
904
  const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
877
905
  const complete = () => {
878
906
  if (!completion || !turnId || !startRecorded || completion.turn?.id !== turnId) return;
879
907
  turnCompleted = true;
908
+ try { queueProviderTerminalReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, providerSession: targetSession,
909
+ providerExecutionId: turnId, status: completion.turn.status, response: messages.get(turnId) || '' }); }
910
+ catch (error) { terminalResolve(); return finish(error); }
911
+ terminalResolve();
912
+ if (settled) return;
880
913
  if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
881
914
  const message = messages.get(turnId);
882
915
  if (!message || Buffer.byteLength(message) > (run.managed.purpose?.enabled ? 128 : 16) * 1024) return finish(new CodexAppServerError('checkpoint', 'Codex did not return a bounded checkpoint.'));
@@ -887,6 +920,13 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
887
920
  }
888
921
  };
889
922
  const unsubscribe = client.onNotification((method, params, requestId) => {
923
+ if (settled) {
924
+ if (requestId === undefined && params.threadId === targetSession) {
925
+ if (method === 'item/completed' && params.turnId === turnId && params.item?.type === 'agentMessage') messages.set(turnId, params.item.text);
926
+ if (method === 'turn/completed' && params.turn?.id === turnId) { completion = params; complete(); }
927
+ }
928
+ return;
929
+ }
890
930
  if (requestId !== undefined && params.threadId === targetSession && /requestApproval|requestPermissions/i.test(method)) return finish(Object.assign(new CodexAppServerError('provider_approval', 'Codex requires provider approval or user input.'), { providerTurnId: params.turnId || turnId }));
891
931
  if (requestId !== undefined && params.threadId === targetSession && /requestUserInput/i.test(method)) return finish(Object.assign(new CodexAppServerError(searchEnabled(run.managed) ? 'provider_question' : 'provider_approval', 'Provider asked a question; investigate before requesting a human decision.'), { providerTurnId: params.turnId || turnId, question: evidenceExcerpt(params.questions || [], 2000).text }));
892
932
  if (params.threadId !== targetSession) return;
@@ -905,7 +945,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
905
945
  signal?.addEventListener('abort', cancelled, { once: true });
906
946
  client.child.once('close', closed);
907
947
  const targetProvenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: targetSession, worktree: isolated ? executionCwd : record.cwd, taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
908
- turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
948
+ turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: requestedAttemptId || run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
909
949
  model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) }); })
910
950
  .then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (turnId) queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId }); if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
911
951
  startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
@@ -991,6 +1031,11 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
991
1031
  if (turnStart && !activeTurnId) await Promise.race([turnStart, new Promise(resolve => setTimeout(resolve, 2_000))]).catch(() => {});
992
1032
  if (!turnCompleted && activeTurnId) await client.request('turn/interrupt', { threadId: targetSession, turnId: activeTurnId }, 2_000).catch(() => {});
993
1033
  }
1034
+ if (activeTurnId && !turnCompleted && !client.closed) await new Promise(resolve => {
1035
+ const timer = setTimeout(resolve, 2_000);
1036
+ terminalObserved.then(() => { clearTimeout(timer); resolve(); });
1037
+ });
1038
+ releaseTurnNotifications?.();
994
1039
  await client.close();
995
1040
 
996
1041
  }
@@ -11,16 +11,22 @@ const disabledFeatures = [
11
11
  export async function runCodexPeerOpinion(provider, request, {
12
12
  model, cwd, openClient, readAuthentication, signal,
13
13
  beforeDispatch = async () => {}, dispatchTransport = initiate => initiate(),
14
- onContext = () => {}, onUsage = () => {}, timeoutMs = 120_000,
14
+ onContext = () => {}, onUsage = () => {}, onStarted = () => {}, onTerminal = () => {}, timeoutMs = 120_000,
15
15
  } = {}) {
16
16
  if (provider !== 'codex' || !model || !cwd || !openClient || !readAuthentication) {
17
17
  throw new Error('Peer opinion requires the task’s Codex transport, model and workspace.');
18
18
  }
19
19
  signal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
20
20
  signal.throwIfAborted();
21
- const client = await openClient({ signal, cancelOnAbort: true, isolateConnectors: true, maxLineBytes: 1024 * 1024 });
21
+ const client = await openClient({ signal, cancelOnAbort: false, isolateConnectors: true, maxLineBytes: 1024 * 1024 });
22
22
  let threadId, turnId, completed = false, unsubscribe, abort, closed;
23
- let startPromise;
23
+ let startPromise, terminalResolve;
24
+ const terminalObserved = new Promise(resolve => { terminalResolve = resolve; });
25
+ // Preparation is abortable, but an owned turn must get a bounded chance to
26
+ // report interruption before its transport is closed.
27
+ let preparing = true;
28
+ const cancelPreparation = () => { if (preparing) void client.close(); };
29
+ signal.addEventListener('abort', cancelPreparation, { once: true });
24
30
  try {
25
31
  if (await readAuthentication(client) === 'missing') {
26
32
  throw Object.assign(new Error('The task’s Codex account is not signed in.'), { code: 'authentication_required' });
@@ -73,9 +79,14 @@ export async function runCodexPeerOpinion(provider, request, {
73
79
  }
74
80
  };
75
81
  const finish = () => {
76
- if (settled || !turnId || !turns.has(turnId)) return;
82
+ if (!turnId || !turns.has(turnId)) return;
77
83
  const turn = turns.get(turnId);
84
+ if (completed) return;
78
85
  completed = true;
86
+ try { onTerminal({ session: threadId, turnId, status: turn.status, response: messages.get(turnId) || '' }); }
87
+ catch (error) { terminalResolve(); return fail(error); }
88
+ terminalResolve();
89
+ if (settled) return;
79
90
  if (turn.status !== 'completed') return fail(Object.assign(new Error('Peer turn did not complete.'), { code: 'turn_failed' }));
80
91
  let output;
81
92
  try { output = JSON.parse(messages.get(turnId) || ''); } catch {
@@ -89,6 +100,7 @@ export async function runCodexPeerOpinion(provider, request, {
89
100
  };
90
101
  unsubscribe = client.onNotification((method, params, requestId) => {
91
102
  if (params.threadId !== threadId) return;
103
+ if (settled && !['item/completed', 'turn/completed'].includes(method)) return;
92
104
  if (requestId !== undefined) return fail(Object.assign(new Error('Peer requested a tool or approval.'), { code: 'provider_approval' }));
93
105
  if (method === 'item/completed' && params.item?.type === 'agentMessage') messages.set(params.turnId, params.item.text);
94
106
  if (['item/started', 'item/completed'].includes(method) && ['commandExecution', 'mcpToolCall', 'fileChange', 'webSearch', 'dynamicToolCall'].includes(params.item?.type)) {
@@ -108,6 +120,8 @@ export async function runCodexPeerOpinion(provider, request, {
108
120
  client.child?.once('close', closed);
109
121
  await beforeDispatch();
110
122
  signal?.throwIfAborted();
123
+ preparing = false;
124
+ signal.removeEventListener('abort', cancelPreparation);
111
125
  startPromise = Promise.resolve(dispatchTransport(() => client.request('turn/start', {
112
126
  threadId, model, cwd, input: [{ type: 'text', text: request.prompt }],
113
127
  outputSchema: request.output_schema, sandboxPolicy: { type: 'readOnly', networkAccess: false },
@@ -115,14 +129,22 @@ export async function runCodexPeerOpinion(provider, request, {
115
129
  const started = await Promise.race([startPromise, done]);
116
130
  turnId = started.turn?.id;
117
131
  if (!turnId) throw new Error('Peer turn did not start.');
132
+ await onStarted({ session: threadId, turnId });
118
133
  reportUsage();
119
134
  finish();
120
135
  return await done;
121
136
  } finally {
122
137
  signal?.removeEventListener('abort', abort);
123
138
  if (closed) client.child?.removeListener('close', closed);
139
+ signal.removeEventListener('abort', cancelPreparation);
140
+ if (!completed && turnId && !client.closed) {
141
+ await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
142
+ if (!completed && !client.closed) await new Promise(resolve => {
143
+ const timer = setTimeout(resolve, 2000);
144
+ terminalObserved.then(() => { clearTimeout(timer); resolve(); });
145
+ });
146
+ }
124
147
  unsubscribe?.();
125
- if (!completed && turnId) await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
126
148
  await client.close();
127
149
  }
128
150
  }
@@ -121,7 +121,7 @@ async function auditConsistencyAttempt(record, findings, request, { root, signal
121
121
  };
122
122
  let usage = null, usageWrites = Promise.resolve();
123
123
  try {
124
- result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
124
+ result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, dispatchAttemptId: id, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
125
125
  beforeDispatch: async () => { await beforeDispatch(); await retainDiagnostic('dispatching'); },
126
126
  onEvidence: evidence => {
127
127
  const context = evidence?.workerContext;
@@ -1,3 +1,4 @@
1
+ import { OUTCOME_PLAN_SCHEMA, OUTCOME_PLAN_INSTRUCTION, validateOutcomePlan } from './outcome-plan.js';
1
2
  import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
2
3
  import { correctiveFollowupInstruction } from '../codex-plugins/simy/hooks/result-cycle.mjs';
3
4
  import { readCodexConversation } from './codex-app-server.js';
@@ -7,7 +8,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
7
8
  import { inspectProviderStatus } from '../desktop-status.js';
8
9
  import { createHash } from 'node:crypto';
9
10
 
10
- export const CONTINUATION_ANALYSIS_VERSION = 10;
11
+ export const CONTINUATION_ANALYSIS_VERSION = 11;
11
12
  const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
12
13
  const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
13
14
  const string = maximum => ({ type: 'string', maxLength: maximum });
@@ -17,8 +18,9 @@ const EVIDENCE_SCHEMA = {
17
18
  };
18
19
  const OUTPUT_SCHEMA = {
19
20
  type: 'object', additionalProperties: false,
20
- required: ['state', 'reason', 'objective', 'remaining', 'nextAction', 'basis', 'assessment', 'blocker', 'pendingActions', 'nextActionAuthorization'],
21
+ required: ['state', 'reason', 'objective', 'remaining', 'nextAction', 'basis', 'assessment', 'blocker', 'pendingActions', 'nextActionAuthorization', 'outcomePlan'],
21
22
  properties: {
23
+ outcomePlan: { anyOf: [{ type: 'null' }, OUTCOME_PLAN_SCHEMA] },
22
24
  state: { type: 'string', enum: STATES }, reason: string(600), objective: string(600),
23
25
  remaining: { type: 'array', maxItems: 8, items: string(400) },
24
26
  nextAction: { anyOf: [string(600), { type: 'null' }] },
@@ -53,6 +55,7 @@ Determine whether the ORIGINAL user request contains concrete unfinished work wi
53
55
  ${correctiveFollowupInstruction} In this read-only classifier, select and ground the next action; do not perform it or answer the user yourself. A corrective question can establish investigation intent in context; use earlier still-valid work authorization for repairs and tests. Mere uncertainty about a completed result does not prove a defect: investigate the specific disputed outcome without inventing implementation work.
54
56
  When supplied, originalProviderGoal is read-only goal metadata from this exact original conversation. Consider its objective together with the latest finished conversation evidence. An active goal can span several turns: a finished subtask does not necessarily finish the whole goal. Active status alone is NOT evidence of remaining actionable work; require concrete unfinished work in the finished messages. Later user corrections, cancellation and permissions still govern. A complete goal adds no new work or acceptance obligations; a distinct later explicit user request can be assessed on its own. Never resume a paused/blocked/usageLimited/budgetLimited goal or reset its limits. Goal evidence must use role=goal, turnRef=provider-goal and a verbatim excerpt from its objective; never present goal metadata as a human message. A truncated goal is incomplete evidence.
55
57
  Write objective in the original user's language as the intended user experience: who should be able to do what, in which situation. Preserve the actual requested scope; do not replace the outcome with implementation tasks, test commands or constraints. Keep constraints in the reasoning and authorization checks. Do not invent an experience when the user only requested technical work.
58
+ ${OUTCOME_PLAN_INSTRUCTION}
56
59
  State rules:
57
60
  - A final answer or missing plan/checkpoint alone is not completion. If original requested implementation remains explicitly unfinished, generate a concrete authorized next action even when the assistant supplied none; use enabling conditions, alternative routes or independent work. If only comparison and a Word document were requested and delivered, unimplemented features described in that document are outside scope. Do not let a generic completion phrase override concrete contradictory unfinished-work evidence, or invent gaps when the requested outcome is reported complete.
58
61
  - continue: the user requested concrete work, work remains unfinished, and a specific feasible next step can be safely taken within existing authority. A continued local investigation/edit/test can qualify. Require user evidence for the objective AND evidence of concrete remaining work. Do not invent additional acceptance checks after the assistant says the requested work is done.
@@ -181,7 +184,8 @@ export function validateContinuationAnalysis(output, context) {
181
184
  if (blocker && (!assessment.userRequestedWork || !assessment.unfinished || assessment.optionalFollowUp ||
182
185
  !['needs_user', 'waiting', 'uncertain'].includes(state))) throw new Error('Blocker contradicts task status');
183
186
  if (validatedPendingActions.length && (!assessment.userRequestedWork || !assessment.unfinished || assessment.optionalFollowUp || state === 'no_action')) throw new Error('Pending action contradicts task status');
184
- return { state, reason, objective, remaining, nextAction: state === 'continue' ? output.nextAction : null, basis, blocker,
187
+ const outcomePlan = validateOutcomePlan(output.outcomePlan, { objective, state, nextAction: output.nextAction, validateEvidence });
188
+ return { state, reason, objective, remaining, outcomePlan, nextAction: state === 'continue' ? output.nextAction : null, basis, blocker,
185
189
  ...(Object.hasOwn(output, 'pendingActions') ? { pendingActions: validatedPendingActions } : {}),
186
190
  ...(Object.hasOwn(output, 'nextActionAuthorization') ? { nextActionAuthorization: state === 'continue' ? nextActionAuthorization : null } : {}),
187
191
  analysisVersion: CONTINUATION_ANALYSIS_VERSION };
@@ -30,7 +30,7 @@ export function sessionDecisionPresentation(candidate, run) {
30
30
  choices: [],
31
31
  };
32
32
  if (run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
33
- ['supervising', 'watching', 'waiting'].includes(candidate.autoContinueStatus)) return null;
33
+ ['supervising', 'watching', 'waiting', 'starting', 'recovering'].includes(candidate.autoContinueStatus)) return null;
34
34
  if (run && ['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state)) {
35
35
  const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
36
36
  const context = run.state === 'paused' ? 'この作業は停止中です。停止の指示は自動で解除しません。' : ({ session_end: `元の${providerName}会話が終了し、自動継続が止まっています。`,
@@ -127,7 +127,7 @@ function navigationParent(item, record) {
127
127
  }
128
128
 
129
129
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
130
- inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
130
+ inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false } = {}) {
131
131
  const now = requestedNow ?? Date.now();
132
132
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
133
133
  assertActive();
@@ -269,7 +269,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
269
269
  }
270
270
  }
271
271
  const projected = new Array(items.length);
272
- const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
272
+ const codexTitles = connected.has('codex') && (includeSelectionCandidates || items.some(item => item.provider === 'codex' && !sessionTitle(item.title)))
273
+ ? await listCodexTitles({ codexHome }) : new Map();
274
+ // Title-index entries are navigation hints, not verified session metadata.
275
+ // Manual addition still reads the exact provider session before persisting.
276
+ const selectionCandidates = includeSelectionCandidates ? [...codexTitles].map(([session, title]) => ({ provider: 'codex', session, title })) : undefined;
273
277
  assertActive();
274
278
  let nextItem = 0;
275
279
  const project = async item => {
@@ -337,14 +341,15 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
337
341
  ...autoContinueState(record, isRunning, availability),
338
342
  lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
339
343
  taskId: run?.managed?.taskId || record?.associationTaskId || null,
340
- binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
344
+ binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null,
345
+ ...(upgradeable ? { upgradeRunId: run.id } : {}) },
341
346
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
342
347
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
343
348
  ...(!archived && continuationAdmission ? { continuationAdmission } : {}),
344
349
  continuationReasonCode: availability.reasonCode || claudeChannel?.code || 'ready',
345
350
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
346
351
  canPurposeContinue: !archived && supported && (!run || !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(run.state)),
347
- canAnalyze: !archived && !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
352
+ canAnalyze: !archived && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
348
353
  canEnroll: !archived && (supported || liveHook) && !run, canContinue: !archived && (supported || liveHook) && !run,
349
354
  canAutoContinue: !archived && (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
350
355
  reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
@@ -364,7 +369,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
364
369
  const stateNow = requestedNow ?? Date.now();
365
370
  for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
366
371
  projected.sort(compareSessionWork);
367
- return { sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
372
+ return { ...(selectionCandidates ? { selectionCandidates } : {}), sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
368
373
  }
369
374
 
370
375
  // Saving consent needs an exact admitted identity, not another provider RPC.
@@ -447,6 +452,10 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
447
452
  failureBudgetMode: purposeFailureMode(next),
448
453
  ruleDigest: purposeRules.digest, ruleIds: purposeRules.rules.map(rule => rule.id) };
449
454
  if (next.run.state === 'paused' && next.run.reason === 'Automatic continuation paused by user.') { next.run.state = 'recovery_required'; next.run.recoveryCause = 'session_supervision'; next.run.managed.autoRecovery = true; }
455
+ // OFF keeps the current native turn alive. Re-enabling during that
456
+ // same turn restores future continuation without starting another turn.
457
+ if (previous.autoContinue === false && next.run.state === 'auto_resuming' && next.run.stopReason?.actor !== 'user')
458
+ next.run.managed.autoRecovery = true;
450
459
  } else if (!enabled && next.run.managed.purpose) { if (next.run.state !== 'auto_resuming') next.run.managed.purpose.enabled = false; next.run.managed.autoRecovery = false; if (!TERMINAL_RUNS.has(next.run.state) && next.run.state !== 'auto_resuming') { next.run.state = 'paused'; next.run.reason = 'Automatic continuation paused by user.'; } }
451
460
  }
452
461
  // The desktop preference is an explicit human control too: an old decision
@@ -454,20 +463,17 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
454
463
  if (next.run) advanceDeliveryControlRevision(next.run);
455
464
  return next;
456
465
  });
457
- if (enabled && updated.run && !updated.run.managed) {
458
- // Enrollment checks this exact opt-in epoch under its state lock, so an
459
- // overlapping Off action prevents the promotion from dispatching work.
460
- await continueSession({ ...options, root, provider, session,
461
- ...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch, expectedProviderEpoch: link.epoch });
462
- return readRecord(root, provider, session);
463
- }
466
+ // Consent is durable now. Provider reads and ordinary-loop promotion belong
467
+ // to the background coordinator, so their failure cannot undo this response.
464
468
  return updated;
465
469
  }
466
470
 
467
471
  export async function readSessionAutoContinue({ root, provider, session }) {
468
472
  const record = await readRecord(root, provider, session);
469
473
  return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
470
- settings: record?.autoContinueSettings, hasRun: Boolean(record?.run) };
474
+ settings: record?.autoContinueSettings, hasRun: Boolean(record?.run),
475
+ upgradeRunId: canUpgradeDelivery(record?.run, { automatic: true })
476
+ ? record.run.id : null };
471
477
  }
472
478
 
473
479
  // Tracking is local observation metadata. It neither starts nor stops the
@@ -577,6 +583,8 @@ export async function followupSession(options) {
577
583
  export async function continueSession(options) {
578
584
  options = sessionLoopOptions(options);
579
585
  const record = await readRecord(options.root, options.provider, options.session);
586
+ if (options.upgradeRunId && (record?.run?.id !== options.upgradeRunId || record.run.managed))
587
+ throw Object.assign(new Error('The original task changed before automatic continuation started.'), { code: 'session_task_changed' });
580
588
  if (record?.run && !record.run.managed) {
581
589
  // The user asked to continue this task. Adopt its goal and failure budget,
582
590
  // rather than enrolling the generic context-bootstrap goal as a new run.
@@ -0,0 +1,76 @@
1
+ import { sanitizedText } from './conversation-context.js';
2
+
3
+ // A read-only route to the requested outcome. Never an execution permission or
4
+ // acceptance certificate. Old/partial analyses intentionally have no plan.
5
+ const text = { type: 'string', minLength: 1, maxLength: 600 };
6
+ const id = { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,60}$' };
7
+ const evidence = { type: 'array', maxItems: 6, items: {
8
+ type: 'object', additionalProperties: false, required: ['role', 'turnRef', 'snippet'],
9
+ properties: { role: { type: 'string', enum: ['user', 'assistant', 'goal'] },
10
+ turnRef: { type: 'string', maxLength: 160 }, snippet: { type: 'string', maxLength: 280 } },
11
+ } };
12
+ export const OUTCOME_PLAN_SCHEMA = {
13
+ type: 'object', additionalProperties: false, required: ['destination', 'criteria', 'steps'],
14
+ properties: {
15
+ destination: text,
16
+ criteria: { type: 'array', minItems: 1, maxItems: 8, items: {
17
+ type: 'object', additionalProperties: false, required: ['id', 'description', 'basis'],
18
+ properties: { id, description: text, basis: evidence },
19
+ } },
20
+ steps: { type: 'array', minItems: 1, maxItems: 12, items: {
21
+ type: 'object', additionalProperties: false,
22
+ required: ['id', 'title', 'criterionIds', 'dependsOn', 'exitCondition', 'state', 'evidence'],
23
+ properties: { id, title: text, exitCondition: text,
24
+ criterionIds: { type: 'array', minItems: 1, maxItems: 8, items: id },
25
+ dependsOn: { type: 'array', maxItems: 11, items: id },
26
+ state: { type: 'string', enum: ['planned', 'current', 'reported'] }, evidence },
27
+ } },
28
+ },
29
+ };
30
+
31
+ export const OUTCOME_PLAN_INSTRUCTION = `Return outcomePlan as a destination-driven proposed route, separate from chronological work reports. First identify the user's requested destination (exactly objective) and observable completion criteria grounded in direct user or active original-goal evidence. Work BACKWARDS from each criterion: what result must exist immediately before it, and what prerequisites enable that result? Then return those necessary steps in dependency/execution order. Every step must link to criterionIds and have a concrete observable exitCondition; cover every criterion. Do not turn paths, HEAD hashes, commands executed or investigation diary entries into milestones. Infer reasonable prerequisite steps within scope, but never add publication, deployment or other outcomes the user did not request. A plan grants no authority. Preserve blocked/future stages even when one safe action can proceed.
32
+ For each criterion include verbatim basis citations establishing requested scope; assistant suggestions alone are not requirements. For each step use planned unless the conversation explicitly reports its result (reported, with verbatim user/assistant evidence). Reported never means independently verified. At most one step is current; only for state=continue, and its title must exactly equal nextAction. Current depends only on stages reported accomplished; otherwise include the next enabling prerequisite first. Never mark an inferred step accomplished. For answered questions, casual chat, insufficient destination/criteria evidence or a route you cannot ground, return outcomePlan=null rather than inventing a full workflow. Do not treat an absent plan as a reason to continue completed work.`;
33
+
34
+ export function validateOutcomePlan(value, { objective, state, nextAction, validateEvidence }) {
35
+ if (value == null) return null;
36
+ try {
37
+ const string = value => {
38
+ if (typeof value !== 'string' || !value.trim() || value.length > 600 || sanitizedText(value) !== value || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(value)) throw Error('Invalid text');
39
+ return value;
40
+ };
41
+ const identifier = value => { if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,60}$/.test(value)) throw Error('Invalid id'); return value; };
42
+ const list = (value, max, min = 0) => { if (!Array.isArray(value) || value.length < min || value.length > max) throw Error('Invalid list'); return value; };
43
+ if (value.destination !== objective || !objective) throw Error('Wrong destination');
44
+ const ids = new Set();
45
+ const criteria = list(value.criteria, 8, 1).map(c => {
46
+ const id = identifier(c.id);
47
+ if (ids.has(id)) throw Error('Duplicate criterion');
48
+ ids.add(id);
49
+ const basis = list(c.basis, 6, 1).map(validateEvidence);
50
+ if (!basis.every(e => ['user', 'goal'].includes(e.role))) throw Error('Unrequested criterion');
51
+ return { id, description: string(c.description), basis };
52
+ });
53
+ const prior = new Map(), covered = new Set();
54
+ let current = 0;
55
+ const steps = list(value.steps, 12, 1).map(s => {
56
+ const id = identifier(s.id), title = string(s.title), exitCondition = string(s.exitCondition);
57
+ if (prior.has(id) || !['planned', 'current', 'reported'].includes(s.state)) throw Error('Invalid step');
58
+ const criterionIds = list(s.criterionIds, 8, 1).map(identifier);
59
+ if (criterionIds.some(id => !ids.has(id))) throw Error('Unknown criterion');
60
+ criterionIds.forEach(id => covered.add(id));
61
+ const dependsOn = list(s.dependsOn, 11).map(identifier);
62
+ // Prior-only references reject cycles and forward/missing dependencies.
63
+ if (dependsOn.some(id => !prior.has(id))) throw Error('Invalid dependency');
64
+ const evidence = list(s.evidence, 6).map(validateEvidence);
65
+ if (s.state === 'reported' && (!evidence.length || evidence.some(e => e.role === 'goal'))) throw Error('Missing result report');
66
+ if (s.state === 'current' && (++current > 1 || state !== 'continue' || title !== nextAction || dependsOn.some(id => prior.get(id) !== 'reported'))) throw Error('Wrong current step');
67
+ prior.set(id, s.state);
68
+ return { id, title, criterionIds, dependsOn, exitCondition, state: s.state, evidence };
69
+ });
70
+ if (covered.size !== ids.size || state === 'continue' && current !== 1) throw Error('Incomplete route');
71
+ return { destination: objective, criteria, steps };
72
+ } catch {
73
+ // An optional presentation plan must never change triage/dispatch authority.
74
+ return null;
75
+ }
76
+ }
@@ -9,6 +9,33 @@ import { readFile, readdir } from 'node:fs/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { purposeContract, llmDecisionInstruction } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
11
11
  const root = new URL('../orchestrator/delivery/', import.meta.url);
12
+
13
+ // Transport context is a view. Scheduling, effects, stops and acceptance keep
14
+ // using the complete durable ledger; old alternatives must not exhaust RPC input.
15
+ export function purposeInstructionContext(managed) {
16
+ const view = structuredClone(managed), ledger = view.purpose?.evidence;
17
+ if (!Array.isArray(ledger?.units) || ledger.units.length <= 64) return view;
18
+ const ids = new Set([view.decision?.id, view.recoveryDiagnosisDecisionId,
19
+ view.executionUncertain?.decisionId].filter(Boolean));
20
+ const byId = new Map(ledger.units.map(unit => [unit.id, unit]));
21
+ const pending = [...ids];
22
+ for (let index = 0; index < pending.length; index++) {
23
+ for (const id of byId.get(pending[index])?.dependsOn || []) if (!ids.has(id)) {
24
+ ids.add(id); pending.push(id);
25
+ }
26
+ }
27
+ const important = ledger.units.filter(unit => ids.has(unit.id) ||
28
+ ['unknown','running','dispatched','in_flight'].includes(unit.status));
29
+ const chosen = [...important, ...ledger.units.slice(-48)];
30
+ const unique = new Map(chosen.map(unit => [unit.id, unit]));
31
+ const statuses = {};
32
+ for (const unit of ledger.units) statuses[unit.status] = (statuses[unit.status] || 0) + 1;
33
+ ledger.contextProjection = { totalUnits: ledger.units.length, includedUnits: unique.size,
34
+ omittedUnits: ledger.units.length - unique.size, statuses,
35
+ note: 'Transmission view only. Omitted operations remain in durable state, not completed or authorized. Read additional units through the existing delivery status CLI when needed; select fields rather than dumping the full ledger.' };
36
+ ledger.units = [...unique.values()];
37
+ return view;
38
+ }
12
39
  // Optional reference material must not become an execution prerequisite.
13
40
  export async function loadPurposeRuleSet(options = {}) {
14
41
  try { return await readPurposeRuleSet(options); }
@@ -45,6 +72,7 @@ async function readPurposeRuleSet({ readFileImpl = readFile, readdirImpl = readd
45
72
  }
46
73
  export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
47
74
  if (!managed?.purpose?.enabled) return withSceneReasoning();
75
+ managed = purposeInstructionContext(managed);
48
76
  const ruleSet = await loadPurposeRuleSet();
49
77
  if (reviewer) return `${llmDecisionInstruction}\nInspect the requested behavior and report concrete defects or missing outcomes. Consult relevant references only when useful. No rule inventory coverage, proof attachments, score or audit receipt is required.`;
50
78
  const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
@@ -3,6 +3,18 @@ import { reconcileWorkFrontier, revalidateWorkProposal } from '../codex-plugins/
3
3
  import { searchEnabled } from '../codex-plugins/simy/hooks/purpose-search.mjs';
4
4
  import { taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
5
 
6
+ function frontierOptions(record) {
7
+ const m = record.run.managed, ledger = m.purpose?.evidence;
8
+ const prior = ledger?.units?.find(unit => unit.id === m.executionUncertain?.decisionId);
9
+ // This controller-created read exists to reconcile one retained unknown
10
+ // effect. Keep that effect unknown and fence all writers and other holders.
11
+ const reconciling = m.decision?.action?.kind === 'read' &&
12
+ m.recoveryDiagnosisDecisionId === m.decision.id && prior?.status === 'unknown' &&
13
+ prior.owner === m.taskId && prior.purposeRevision === ledger.purposeRevision;
14
+ return { stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
15
+ ...(reconciling ? { reconcileUnknownUnitId: prior.id } : {}) };
16
+ }
17
+
6
18
  // Enroll the controller's canonical workspace, never a worker-provided lock ID.
7
19
  // Read-only diagnosis/verification is the path that resolves observed effects;
8
20
  // it must remain possible while the frontier prevents another writer.
@@ -30,7 +42,7 @@ export function recoveryWorkProposal(record) {
30
42
  if (unit.action.kind !== 'read' && !unit.writeResources.includes(workspace)) unit.writeResources.push(workspace);
31
43
  }
32
44
  // Missing PR readback stays blocked. This boundary never invents CI evidence.
33
- const proposal = reconcileWorkFrontier(managed, { stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state) });
45
+ const proposal = reconcileWorkFrontier(managed, frontierOptions(record));
34
46
  delete managed.frontierIssue;
35
47
  return proposal;
36
48
  } catch {
@@ -45,9 +57,7 @@ export function admitRecoveryWork(record, proposal, leaseId, now) {
45
57
  const unit = managed.purpose.evidence.units.find(row => row.id === managed.decision.id);
46
58
  if (!unit && managed.decision.action.kind === 'read' && ['understanding', 'needs_clarification'].includes(managed.phase)) return;
47
59
  if (!unit) throw new Error('Selected work unit is missing.');
48
- const current = revalidateWorkProposal(managed, proposal, unit.id, {
49
- stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
50
- });
60
+ const current = revalidateWorkProposal(managed, proposal, unit.id, frontierOptions(record));
51
61
  if (!current.eligible) {
52
62
  throw new Error(`Work proposal needs reconciliation: ${current.reason || 'not eligible'}`);
53
63
  }
@@ -0,0 +1,15 @@
1
+ // A remote usage scan must not hold up explicit preference or stop requests.
2
+ // Mutations retain arrival order; authorization remains in the native handlers.
3
+ export function createRemoteCommandQueue({ handle }) {
4
+ let controls = Promise.resolve(), observations = Promise.resolve();
5
+ return {
6
+ enqueue(command) {
7
+ const observation = ['session_statistics', 'refresh'].includes(command.action);
8
+ const pending = (observation ? observations : controls).then(() => handle(command));
9
+ // Keep a failed request from poisoning subsequent requests in that lane.
10
+ if (observation) observations = pending.catch(() => {});
11
+ else controls = pending.catch(() => {});
12
+ return pending;
13
+ },
14
+ };
15
+ }
@@ -1,3 +1,6 @@
1
+ export { createRemoteManagedDelivery } from './remote-managed-delivery.js';
2
+ export { createRemoteCommandQueue } from './remote-command-queue.js';
3
+
1
4
  // Only host-discovered admission may reach the native mutation API.
2
5
  export function createControl({ authorize, select, set, followup, save, revoke, root }) {
3
6
  return async command => {