@awak-app/simy-cli 0.7.9 → 0.7.11

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 (36) hide show
  1. package/README.md +17 -1
  2. package/package.json +4 -3
  3. package/src/cli-contract.js +1 -1
  4. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +12 -2
  5. package/src/codex-plugins/simy/hooks/hypothesis-sheet.mjs +1 -1
  6. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +6 -5
  7. package/src/codex-plugins/simy/hooks/recovery-incident.mjs +191 -0
  8. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +22 -0
  9. package/src/delivery-hooks/command.js +34 -1
  10. package/src/delivery-hooks/desktop.js +1 -1
  11. package/src/delivery-hooks/diagnosis-sheet.js +2 -2
  12. package/src/delivery-hooks/diagnosis.js +5 -3
  13. package/src/delivery-hooks/local-supervisor.js +7 -1
  14. package/src/delivery-hooks/recovery.js +33 -0
  15. package/src/delivery-hooks/supervisor-owner.js +74 -0
  16. package/src/delivery-hooks/supervisor-service.js +65 -0
  17. package/src/delivery-hooks/supervisor-watchdog.js +151 -0
  18. package/src/delivery-hooks/supervisor.js +26 -0
  19. package/src/desktop-host.js +36 -7
  20. package/src/direct-executor.js +2 -1
  21. package/src/orchestrator/instruction.js +4 -4
  22. package/src/orchestrator/loop.js +1 -1
  23. package/src/orchestrator/problem-solving.js +2 -2
  24. package/src/provider-stream.js +3 -0
  25. package/src/session-supervision/claude-desktop-lineage.js +19 -5
  26. package/src/session-supervision/claude-sessions.js +16 -10
  27. package/src/session-supervision/codex-app-server.js +17 -6
  28. package/src/session-supervision/continuation-analysis.js +3 -2
  29. package/src/session-supervision/index.js +25 -14
  30. package/src/session-supervision/pull-requests.js +52 -0
  31. package/src/session-supervision/purpose-instruction.js +10 -2
  32. package/src/session-supervision/purpose-recovery.js +7 -4
  33. package/src/session-supervision/work-state.js +84 -0
  34. package/src/sqm/claude-plugin.js +2 -0
  35. package/src/sqm/local-tests.js +1 -0
  36. package/src/sqm/review-worker.js +3 -0
@@ -174,20 +174,20 @@ export function buildCodingInstruction(
174
174
  section(
175
175
  "problem_solving_protocol",
176
176
  [
177
- "Treat implementation as hypothesis testing: state assumptions, list plausible causes or approaches, inspect discriminating evidence, then choose the smallest strategy supported by that evidence.",
177
+ "Use the shared scene reasoning cycle for implementation and retries. Let the current issue determine useful perspectives and the smallest next check; do not require a separate reasoning workflow.",
178
178
  attemptNumber > 1
179
179
  ? "This is a retry. Repair clear failures directly; compare plausible causes when needed. Reasoning notes are advisory, not a prerequisite to editing."
180
180
  : "For the first attempt, investigate material uncertainty before a risky operation; use a reasonable assumption for low-impact reversible choices and verify as you proceed.",
181
181
  attemptNumber > 1
182
- ? "Explain exactly what changed from the previous attempt and why the new evidence supports this strategy."
183
- : "Record the uncertainty, first verification, concrete evidence checked, observed result, selected hypothesis, and strategy in the structured result.",
182
+ ? "When useful, briefly note what changed the interpretation or next check; an extra reasoning explanation is not required to perform the repair."
183
+ : "Use existing result fields for actual checks and outcomes; reasoning notes and the hypothesis sheet are optional.",
184
184
  ].join("\n"),
185
185
  ),
186
186
  section(
187
187
  "execution_contract",
188
188
  [
189
189
  "Read the repository instructions and current git state before editing.",
190
- "Before editing, resolve the highest uncertainty with the first discriminating verification. If it cannot be resolved, stop and report a fallback instead of claiming completion.",
190
+ "Use the shared scene reasoning cycle to investigate uncertainty that affects the selected operation. If that operation cannot safely proceed, retain its blocker and choose independent authorized work or a smaller useful check. Do not claim unresolved outcomes are complete.",
191
191
  "Preserve user changes and keep the diff scoped to the requirement.",
192
192
  "Run relevant tests, inspect the final diff, commit, push, and create or update a PR.",
193
193
  `The PR must target ${charter.base_branch}.`,
@@ -631,7 +631,7 @@ async function handleFailedAudit({
631
631
  state.ai_strategy_diagnoses.push(key);
632
632
  state.ai_strategy_diagnoses = state.ai_strategy_diagnoses.slice(-32);
633
633
  findings.push({ passed: false, code: 'AI_STRATEGY_CHANGE_REQUIRED', severity: 'blocker', target: 'retry_loop',
634
- explanation: 'Investigate the repeated failure with LLM context synthesis, alternatives and tool evidence. Choose a different reversible method before another attempt; do not repeat uncertain effects or ask a person to certify technical correctness.', repairability: 'auto', blocker_kind: 'machine' });
634
+ explanation: 'Investigate the repeated failure with the shared scene reasoning cycle: understand this scene, derive and select useful perspectives, then connect the interpretation to the next check. Choose a different reversible method before another attempt; do not repeat uncertain effects or ask a person to certify technical correctness.', repairability: 'auto', blocker_kind: 'machine' });
635
635
  snapshot.final_audit = { ...(snapshot.final_audit || {}), passed: false, findings, requires_human: false, auto_repairable: true };
636
636
  appendEvent(snapshot, 're_instructing', 'AI diagnosis is required before treating technical repetition as an execution blocker.', { code: 'ai_strategy_change', attempt_number: attemptNumber });
637
637
  await publish(snapshot, 're_instructing', onUpdate);
@@ -94,9 +94,9 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
94
94
  export function problemSolvingResultContract() {
95
95
  return [
96
96
  hypothesisSheetInstruction,
97
- "problem_solving must be an object with hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
97
+ "problem_solving must be an object; {} is valid when no optional reasoning notes are useful. When recording notes, use hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
98
98
  "Each hypothesis must use {id, statement}; evidence_checked must list concrete observations or commands.",
99
- "On attempt 1, record the assumptions, hypotheses considered, selected hypothesis, and implementation strategy; changed_from_previous may be null.",
99
+ "Use the shared scene reasoning cycle on every attempt. Record useful assumptions or interpretations briefly when helpful; changed_from_previous may be null.",
100
100
  "On retry, use reasoning proportional to the uncertainty. If the repair is clear, make it and verify it; do not invent a fixed number of hypotheses or change a working strategy merely to satisfy a form. For persistent unexplained failures, compare plausible causes and inspect discriminating evidence. These reasoning notes are advisory; unchanged failures are still subject to the retry circuit.",
101
101
  ].join("\n");
102
102
  }
@@ -206,6 +206,9 @@ function formatClaudeToolResults(prefix, content) {
206
206
  }
207
207
 
208
208
  function contentLines(prefix, label, value) {
209
+ // Native providers wrap machine results in assistant events too. Keep the
210
+ // result available to the parser without flooding the user transcript.
211
+ if (label === "assistant" && STRUCTURED_MARKERS.some(marker => String(value || "").trimStart().startsWith(marker))) return [];
209
212
  return String(value || "")
210
213
  .split(/\r?\n/)
211
214
  .map((line) => cleanLine(line))
@@ -1,3 +1,4 @@
1
+ import { normalizePullRequests } from './pull-requests.js';
1
2
  import { constants } from 'node:fs';
2
3
  import { lstat, open, readdir } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
@@ -70,12 +71,14 @@ async function metadata(path, desktopId, sessions) {
70
71
  let value = null;
71
72
  if (item.sessionId === desktopId && UUID.test(item.cliSessionId || '') && typeof item.cwd === 'string' && isAbsolute(item.cwd)) {
72
73
  value = { desktopId, session: item.cliSessionId, cwd: resolve(item.cwd),
74
+ ...(complete ? { providerArchived: typeof item.isArchived === 'boolean' ? item.isArchived : typeof item.archived === 'boolean' ? item.archived : undefined,
75
+ pullRequests: normalizePullRequests(item.pullRequests || item.gitHubPullRequests || item.githubPullRequests || item.githubPRs || item.gitHubPRs || item.gitHubPR || item.pullRequest || (item.prUrl ? { url: item.prUrl, state: item.prStatus } : null)) } : {}),
73
76
  parentDesktopId: complete && LOCAL.test(item.spawnedFrom?.sessionId || '') ? item.spawnedFrom.sessionId : null };
74
77
  }
75
78
  const after = await lstat(path, { bigint: true });
76
79
  if (fingerprint(after) !== key || fingerprint(await handle.stat({ bigint: true })) !== key || after.isSymbolicLink()) return null;
77
80
  if (!complete && !value) return null;
78
- // Cache only identity/lineage. Desktop files also contain private settings;
81
+ // Cache only identity, lineage, archive and PR summaries. Desktop files also contain private settings;
79
82
  // none of those fields or conversation text leave this bounded reader.
80
83
  cache.delete(path); cache.set(path, { key, value, complete });
81
84
  while (cache.size > MAX_FILES) cache.delete(cache.keys().next().value);
@@ -86,7 +89,7 @@ async function metadata(path, desktopId, sessions) {
86
89
 
87
90
  // Desktop spawn parents use local_* IDs, whereas transcripts and SIMY use CLI
88
91
  // UUIDs. Resolve them within the same account AND organization partition.
89
- export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null } = {}) {
92
+ export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null, includeState = false } = {}) {
90
93
  if (signal?.aborted) return new Map();
91
94
  const files = [];
92
95
  for (const account of await directories(root)) {
@@ -122,12 +125,23 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
122
125
  parentSession: { provider: 'claude', session: parent.session },
123
126
  source: 'claude_desktop_spawned_from' });
124
127
  }
128
+ if (includeState) for (const partition of partitions.values()) for (const item of partition.values()) {
129
+ if (claims.get(item.session) !== 1 || sessions && !sessions.has(item.session)) continue;
130
+ lineage.set(item.session, { cwd: item.cwd, ...lineage.get(item.session),
131
+ providerArchived: item.providerArchived, pullRequests: item.pullRequests || [], observedAt: Date.now() });
132
+ }
125
133
  // Reject cyclic Desktop metadata rather than inventing a root or parent.
126
134
  for (const session of [...lineage.keys()]) {
127
135
  const seen = new Set(); let cursor = session;
128
136
  while (lineage.has(cursor)) {
129
- if (seen.has(cursor)) { for (const id of seen) lineage.delete(id); break; }
130
- seen.add(cursor); cursor = lineage.get(cursor).parentSession.session;
137
+ if (seen.has(cursor)) {
138
+ for (const id of seen) {
139
+ if (!includeState) lineage.delete(id);
140
+ else { const { parentSession, parentCwd, source, ...state } = lineage.get(id); lineage.set(id, state); }
141
+ }
142
+ break;
143
+ }
144
+ seen.add(cursor); cursor = lineage.get(cursor).parentSession?.session;
131
145
  }
132
146
  }
133
147
  return lineage;
@@ -138,7 +152,7 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
138
152
  // discovery and unrelated provider controls must not wait for the entire scan.
139
153
  export async function observeClaudeDesktopLineage(options = {}, { read = readClaudeDesktopLineage, budgetMs = 1500 } = {}) {
140
154
  if (options.signal?.aborted) return new Map();
141
- const root = options.root || claudeDesktopSessionRoot(options.home);
155
+ const root = (options.root || claudeDesktopSessionRoot(options.home)) + (options.includeState ? ':state' : ':lineage');
142
156
  let entry = observations.get(root);
143
157
  if (!entry) {
144
158
  entry = { value: new Map(), pending: null };
@@ -6,7 +6,7 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
7
7
  import { sessionTitle } from './session-titles.js';
8
8
  import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
9
- import { observeClaudeDesktopLineage } from './claude-desktop-lineage.js';
9
+ import { observeClaudeDesktopLineage, readClaudeDesktopLineage } from './claude-desktop-lineage.js';
10
10
 
11
11
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
12
  const MAX_PROJECTS = 256;
@@ -186,17 +186,19 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
186
186
  }));
187
187
  }
188
188
  if (signal?.aborted) return [];
189
- const desktopLineage = !session && cacheMetadata
190
- ? await observeClaudeDesktopLineage({ home, signal, sessions: new Set(descriptors.map(item => item.session)) }) : new Map();
189
+ const desktopOptions = { home, signal, includeState: true, sessions: new Set(descriptors.map(item => item.session)) };
190
+ const desktopLineage = await (session ? readClaudeDesktopLineage(desktopOptions) : observeClaudeDesktopLineage(desktopOptions));
191
191
  const decorateLineage = candidate => {
192
192
  const origin = desktopLineage.get(candidate.session);
193
- if (origin?.cwd === candidate.cwd && !candidate.parentSession)
194
- return { ...candidate, parentSession: origin.parentSession, parentSessionSource: origin.source };
193
+ if (origin?.cwd === candidate.cwd)
194
+ return { ...candidate, ...(typeof origin.providerArchived === 'boolean' ? { archived: origin.providerArchived } : {}), pullRequests: origin.pullRequests || [],
195
+ ...(!candidate.parentSession && origin.parentSession ? { parentSession: origin.parentSession, parentSessionSource: origin.source } : {}) };
195
196
  return candidate;
196
197
  };
197
- const selected = session ? descriptors : recentSessionWindow(descriptors, {
198
- limit: maximum, priority: item => priorities.has(item.session),
199
- });
198
+ const selected = session ? descriptors : [
199
+ ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
200
+ ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
201
+ ];
200
202
  const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
201
203
  let nextFile = 0;
202
204
  // Catalog prefixes are bounded independently of full transcript size. Use a
@@ -218,7 +220,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
218
220
  const attempted = new Set(result.keys());
219
221
  for (const child of result.values()) {
220
222
  const origin = desktopLineage.get(child.session);
221
- if (!origin || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
223
+ if (!origin?.parentSession || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
222
224
  for (const file of descriptors.filter(item => item.session === origin.parentSession.session)) {
223
225
  const key = `${file.session}\0${origin.parentCwd}`;
224
226
  if (attempted.has(key) || attempted.size >= MAX_TRANSCRIPTS || signal?.aborted) break;
@@ -243,7 +245,10 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
243
245
  }
244
246
  }
245
247
  const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
246
- return session ? sorted : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
248
+ return session ? sorted : [
249
+ ...recentSessionWindow(sorted.filter(item => item.archived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
250
+ ...recentSessionWindow(sorted.filter(item => item.archived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
251
+ ];
247
252
  }
248
253
 
249
254
  export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
@@ -256,6 +261,7 @@ export async function readClaudeSession(session, { home } = {}) {
256
261
  const matches = await collect({ home, limit: RECENT_SESSION_LIMIT, session: id });
257
262
  const selected = typeof session === 'object' && typeof session.cwd === 'string'
258
263
  ? matches.filter(item => item.cwd === session.cwd) : matches;
264
+ if (selected.length === 1 && selected[0].archived === true) throw Object.assign(new Error('Restore the original provider conversation before continuing.'), { code: 'session_archived' });
259
265
  return selected.length === 1 ? selected[0] : null;
260
266
  }
261
267
 
@@ -1,3 +1,4 @@
1
+ import { normalizePullRequests } from './pull-requests.js';
1
2
  import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
2
3
  import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
3
4
  import { collectPurposeObservations, refreshPurposeObservations, repositoryIdentity } from './purpose-observer.js';
@@ -202,13 +203,19 @@ export function codexThreadMetadata(thread) {
202
203
  if (thread.parentThreadId || thread.source?.subAgent || thread.source?.subagent) return null;
203
204
  return {
204
205
  provider: 'codex', session: thread.id, cwd: thread.cwd,
206
+ pullRequests: normalizePullRequests(thread.pullRequests || thread.artifacts?.filter?.(item => item.type === 'pull_request')),
205
207
  ...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
206
208
  ? { parentSession: { provider: 'codex', session: thread.forkedFromId } } : {}),
207
209
  title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
208
210
  source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
209
211
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
210
212
  model: typeof thread.model === 'string' ? thread.model : null,
211
- observation: { source: 'codex_app_server', observedAt: Date.now() },
213
+ // Preserve native wait flags for presentation without changing isRunning,
214
+ // which also protects the provider writer lock. Unknown flags fail closed.
215
+ observation: { source: 'codex_app_server', observedAt: Date.now(),
216
+ ...(thread.status?.type === 'active' ? { activeFlags: Array.isArray(thread.status.activeFlags)
217
+ && thread.status.activeFlags.every(flag => ['waitingOnApproval', 'waitingOnUserInput'].includes(flag))
218
+ ? [...new Set(thread.status.activeFlags)] : null } : {}) },
212
219
  ...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
213
220
  availability: 'unknown',
214
221
  };
@@ -617,7 +624,7 @@ async function frozenNativeSandbox(record, root, signal) {
617
624
  return policy;
618
625
  }
619
626
 
620
- export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
627
+ 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 = runStructuredProviderReview } = {}) {
621
628
  const run = record.run, decision = run.managed?.decision;
622
629
  if(run.managed?.enrollment?.nativeAuthorityDigest && run.managed.enrollment.nativeAuthorityDigest!==createHash('sha256').update(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource])).digest('hex'))throw new CodexAppServerError('permission_narrowed','Frozen native enrollment authority changed; do not dispatch with the altered ceiling.');
623
630
  if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
@@ -672,7 +679,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
672
679
  };
673
680
  const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
674
681
  let evidenceQueue = Promise.resolve(), evidenceFailure = null;
675
- let activeTurnId = null, turnCompleted = false, turnStart = null;
682
+ let activeTurnId = null, turnCompleted = false, turnStart = null, startReceipt = null;
676
683
  let usage = null, lastTotal = null;
677
684
  try {
678
685
  // An unavailable login is a connection dependency, not an action approval.
@@ -789,7 +796,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
789
796
  }
790
797
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Worker dispatch was cancelled during preparation.');
791
798
  const done = new Promise((resolve, reject) => {
792
- let turnId = null, completion = null, settled = false;
799
+ let turnId = null, completion = null, settled = false, startRecorded = false;
793
800
  const messages = new Map();
794
801
  const activeTools = new Set();
795
802
  const earlyActivity = [];
@@ -819,7 +826,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
819
826
  const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
820
827
  const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
821
828
  const complete = () => {
822
- if (!completion || !turnId || completion.turn?.id !== turnId) return;
829
+ if (!completion || !turnId || !startRecorded || completion.turn?.id !== turnId) return;
823
830
  turnCompleted = true;
824
831
  if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
825
832
  const message = messages.get(turnId);
@@ -846,7 +853,10 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
846
853
  client.child.once('close', closed);
847
854
  turnStart = client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
848
855
  model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) })
849
- .then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
856
+ .then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
857
+ startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
858
+ await startReceipt;
859
+ startRecorded = true;
850
860
  for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
851
861
  for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
852
862
  complete();
@@ -912,6 +922,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
912
922
  await evidenceQueue;
913
923
  throw error;
914
924
  } finally {
925
+ if (startReceipt) await startReceipt.catch(() => {});
915
926
  if (turnStart && !client.closed) {
916
927
  // Only the controller-owned target may be interrupted. Isolated reads
917
928
  // never load or change the original conversation's policy defaults.
@@ -1,3 +1,4 @@
1
+ import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
1
2
  import { readCodexConversation } from './codex-app-server.js';
2
3
  import { readClaudeConversation } from './claude-sessions.js';
3
4
  import { boundConversation, boundProviderGoal, providerGoalEvidence, sanitizedText } from './conversation-context.js';
@@ -5,7 +6,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
5
6
  import { inspectProviderStatus } from '../desktop-status.js';
6
7
  import { createHash } from 'node:crypto';
7
8
 
8
- export const CONTINUATION_ANALYSIS_VERSION = 5;
9
+ export const CONTINUATION_ANALYSIS_VERSION = 6;
9
10
  const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
10
11
  const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
11
12
  const string = maximum => ({ type: 'string', maxLength: maximum });
@@ -228,7 +229,7 @@ export async function analyzeContinuation(candidate, {
228
229
  await beforeProviderDispatch();
229
230
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
230
231
  result = await runProvider(candidate.provider, {
231
- system: SYSTEM, prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
232
+ system: withSceneReasoning(SYSTEM), prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
232
233
  }, { ...providerOptions, signal, readOnlyAnalysis: true, beforeDispatch: beforeProviderDispatch });
233
234
  } catch (error) {
234
235
  if (error?.code === 'desktop_access_required') return uncertain('SIMY organization access is required before checking this conversation.', 'desktop_access_required');
@@ -1,3 +1,5 @@
1
+ import { sessionWorkState, compareSessionWork } from './work-state.js';
2
+ import { normalizePullRequests } from './pull-requests.js';
1
3
  import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
2
4
  import { pauseOrigin } from './autonomous-upgrade.js';
3
5
  import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
@@ -123,7 +125,8 @@ function navigationParent(item, record) {
123
125
  }
124
126
 
125
127
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
126
- inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
128
+ inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
129
+ const now = requestedNow ?? Date.now();
127
130
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
128
131
  assertActive();
129
132
  const connected = new Set(connectedProviders);
@@ -174,11 +177,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
174
177
  for (let index = 0; index < records.length; index++) {
175
178
  assertActive();
176
179
  const record = records[index];
177
- if (record.provider !== 'codex') continue;
178
- const isArchived = archiveStates.get(record.session) === true;
180
+ const claudeItem = record.provider === 'claude' ? claude.find(item => item.session === record.session && item.cwd === record.cwd) : null;
181
+ const archiveState = record.provider === 'codex' ? archiveStates.get(record.session) ?? (activeCodex.has(record.session) ? false : undefined) : claudeItem?.archived;
182
+ if (typeof archiveState !== 'boolean') continue;
183
+ const isArchived = archiveState;
179
184
  if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
180
185
  if (isArchived && record.providerArchived === true) continue;
181
- if (!isArchived && !(record.providerArchived === true && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
186
+ if (!isArchived && !(record.providerArchived === true && archiveState === false)) continue;
182
187
  try { records[index] = await updateRecord(root, record.provider, record.session, current => {
183
188
  if (!current) return current;
184
189
  current.providerArchived = isArchived;
@@ -204,7 +209,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
204
209
  const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
205
210
  for (const item of [...discovered, ...claude, ...archivedCodex]) {
206
211
  if (includeArchived && item.archived === true) { byKey.set(`${item.provider}:${item.session}`, item); continue; }
207
- if ((item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
212
+ if (item.archived !== true && (item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
208
213
  }
209
214
  const requiredAncestors = new Set();
210
215
  for (const item of byKey.values()) {
@@ -224,7 +229,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
224
229
  const key = `${record.provider}:${record.session}`;
225
230
  // A hook observation can belong to an AI-created worker. Only explicit
226
231
  // supervision may restore a conversation absent from provider discovery.
227
- if (!byKey.has(key) && (retainedSession(record) || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
232
+ if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
228
233
  provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
229
234
  lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
230
235
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
@@ -236,11 +241,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
236
241
  const catalog = [...byKey.values()].map(item => {
237
242
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
238
243
  const parentSession = navigationParent(item, record);
239
- return { ...item, ...(parentSession ? { parentSession } : {}) };
244
+ return { ...item, localHidden: record?.listHidden === true, ...(parentSession ? { parentSession } : {}) };
240
245
  });
241
- const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true), {
246
+ const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
242
247
  priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
243
- }), ...recentSessionWindow(catalog.filter(item => item.archived === true))];
248
+ }), ...recentSessionWindow(catalog.filter(item => item.archived === true || item.localHidden), { priority: item => item.localHidden })];
244
249
  // Include archived ancestors of active rows even when archive visibility is off.
245
250
  const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
246
251
  const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
@@ -295,14 +300,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
295
300
  const continuationIssue = liveHook && run ? !run.managed && !upgradeable
296
301
  ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
297
302
  : TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
298
- let isRunning = item.isRunning;
303
+ let isRunning = item.isRunning, observation = item.observation;
299
304
  const hookIsNewer = Number.isFinite(record?.runtime?.observedAt) && Number.isFinite(item.lastActivityAt) && record.runtime.observedAt > item.lastActivityAt;
300
305
  if ((isRunning === undefined || isRunning === false && hookIsNewer) && record?.runtime?.process?.identity && record.cwd === item.cwd) {
301
306
  try {
302
307
  // A hook newer than the persisted provider activity may describe a
303
308
  // new turn. Require its same live process; older hooks cannot override
304
309
  // a more recent completed provider turn.
305
- if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) isRunning = true;
310
+ if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
306
311
  else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
307
312
  } catch { /* Unverified process leaves runtime status unknown. */ }
308
313
  }
@@ -314,12 +319,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
314
319
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
315
320
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
316
321
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
322
+ providerArchived: archived, localHidden: record?.listHidden === true,
317
323
  archived,
324
+ pullRequests: normalizePullRequests(item.pullRequests),
318
325
  autoContinue: !archived && record?.autoContinue === true,
319
326
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
320
327
  autoContinueAvailability: availability,
321
328
  ...autoContinueState(record, isRunning, availability),
322
- lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
329
+ lastActivityAt: item.lastActivityAt, observation, availability: item.availability,
323
330
  taskId: run?.managed?.taskId || record?.associationTaskId || null,
324
331
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
325
332
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
@@ -343,8 +350,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
343
350
  }
344
351
  }));
345
352
  assertActive();
346
- projected.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
347
- return { sessions: projected, 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 };
353
+ // Provider reads stamp observations after discovery starts. Compare them at
354
+ // projection time, while preserving an explicitly supplied verification clock.
355
+ const stateNow = requestedNow ?? Date.now();
356
+ for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
357
+ projected.sort(compareSessionWork);
358
+ 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 };
348
359
  }
349
360
 
350
361
  // Saving consent needs an exact admitted identity, not another provider RPC.
@@ -0,0 +1,52 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execute = promisify(execFile);
4
+ const PR_URL = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)\/?$/;
5
+ export function normalizePullRequests(value) {
6
+ const rows = Array.isArray(value) ? value : value ? [value] : [];
7
+ const unique = new Map();
8
+ for (const row of rows.slice(0, 32)) {
9
+ const url = typeof row === 'string' ? row : row?.url;
10
+ if (!PR_URL.test(url || '')) continue;
11
+ const state = String(row.state || row.status || '').toUpperCase();
12
+ unique.set(url, { url, state: row.merged === true || row.mergedAt ? 'MERGED' : ['OPEN', 'CLOSED', 'MERGED'].includes(state) ? state : 'UNKNOWN',
13
+ reviewDecision: ['CHANGES_REQUESTED', 'APPROVED', 'REVIEW_REQUIRED'].includes(row.reviewDecision) ? row.reviewDecision : null,
14
+ checkedAt: Number.isFinite(row.checkedAt) ? row.checkedAt : null });
15
+ }
16
+ return [...unique.values()];
17
+ }
18
+
19
+ // One cache per host, bounded concurrency, no network work on the 3s status tick.
20
+ export function createPullRequestObserver({ read = async url => {
21
+ const match = PR_URL.exec(url);
22
+ const { stdout } = await execute('gh', ['pr', 'view', match[3], '--repo', `${match[1]}/${match[2]}`, '--json', 'url,state,reviewDecision,mergedAt'], { timeout: 2000, maxBuffer: 64 * 1024, windowsHide: true });
23
+ return JSON.parse(stdout);
24
+ }, now = Date.now, ttlMs = 60_000, budgetMs = 3500 } = {}) {
25
+ const cache = new Map();
26
+ let cursor = 0;
27
+ return async (sessions, { signal } = {}) => {
28
+ const catalog = [...new Set(sessions.flatMap(item => (item.pullRequests || []).map(pr => pr.url)))];
29
+ // Continue after the last attempted URL, including when slow reads consume
30
+ // the budget. Restarting at row zero can starve the tail indefinitely.
31
+ const start = catalog.length ? cursor % catalog.length : 0;
32
+ const urls = [...catalog.slice(start), ...catalog.slice(0, start)].slice(0, 128);
33
+ let next = 0; const started = now();
34
+ await Promise.all(Array.from({ length: Math.min(4, urls.length) }, async () => {
35
+ while (next < urls.length && !signal?.aborted && now() - started < budgetMs) {
36
+ const url = urls[next++], previous = cache.get(url);
37
+ if (previous && now() - previous.at < ttlMs) continue;
38
+ try { const rows = normalizePullRequests(await read(url));
39
+ if (rows[0]?.url !== url) throw new Error('PR identity mismatch');
40
+ cache.set(url, { at: now(), value: { ...rows[0], checkedAt: now(), fetchFailed: false } });
41
+ } catch { cache.set(url, { at: now(), value: { ...(previous?.value || { url, state: 'UNKNOWN' }), fetchFailed: true } }); }
42
+ }
43
+ }));
44
+ cursor = catalog.length ? (start + next) % catalog.length : 0;
45
+ while (cache.size > 512) cache.delete(cache.keys().next().value);
46
+ return sessions.map(item => ({ ...item, pullRequests: (item.pullRequests || []).map(pr => {
47
+ const observed = cache.get(pr.url)?.value;
48
+ const value = observed ? { ...pr, ...observed, ...(observed.state === 'UNKNOWN' ? { state: pr.state } : {}) } : pr;
49
+ return { ...value, stale: !Number.isFinite(value.checkedAt) || now() - value.checkedAt > ttlMs };
50
+ }) }));
51
+ };
52
+ }
@@ -1,3 +1,5 @@
1
+ import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
2
+ import { currentRecoveryIncident, unresolvedRecoveryContext } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
1
3
  import { recallPerspectives, perspectiveInstruction } from '../codex-plugins/simy/hooks/perspective-memory.mjs';
2
4
  import { loadHypothesisKnowledge, hypothesisKnowledgeInstruction } from '../sqm/hypothesis-knowledge.js';
3
5
  import { selectiveRuleReview, applicableRuleIds } from '../codex-plugins/simy/hooks/purpose-rule-policy.mjs';
@@ -40,12 +42,18 @@ export async function loadPurposeRuleSet() {
40
42
  return { digest: createHash('sha256').update(JSON.stringify(sources)).digest('hex'), rules };
41
43
  }
42
44
  export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
43
- if (!managed?.purpose?.enabled) return '';
45
+ if (!managed?.purpose?.enabled) return withSceneReasoning();
44
46
  const ruleSet = await loadPurposeRuleSet();
45
47
  if (ruleSet.digest !== managed.purpose.ruleDigest) throw Object.assign(new Error('Purpose workflow changed; refresh its rule inventory before dispatch.'), { code: 'purpose_rules_changed' });
46
48
  if (reviewer && selectiveRuleReview(managed)) return `${llmDecisionInstruction}\nIndependently verify the original acceptance criteria and applicable canonical obligations from actual evidence. Consult the inventory as needed after forming an LLM judgment; do not enumerate every ID or impose core controls as universal checklist gates. Report only rules actually evaluated; omitted inventory entries are not passed or not_applicable. Known applicable obligations ${JSON.stringify(applicableRuleIds(managed))} must be independently assessed and cannot be discarded by worker claims. Do not copy worker dispositions as proof. Failed, unavailable or missing evidence remains unverified with a concrete acquisition/repair finding. No unrelated rule coverage obligation is implied.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}`;
47
49
  if (reviewer) return `${llmDecisionInstruction}\nCanonical rule inventory. Review applicability and evidence for EVERY ID, including HC-01 through HC-102; these controls are requirements, not proof that their mechanisms exist. Read the referenced sections and acceptance details as needed. User instructions retain priority. Do not impose unrelated development/release controls on other task types. Core controls ${CORE_CONTROL_IDS.join(', ')} apply to every autonomous task; inspect their actual bounded behavior, including conditional duties whose trigger did not occur, rather than marking the entire control not_applicable. Return rules:[{id,evidenceRefs:[{ref,target,observation}],status:"satisfied|failed|unverified|not_applicable",basis:"inspected evidence or specific applicability reason"}]. Keep basis concise with source locators; stored receipts are limited to 160 UTF-8 bytes and basisTruncated means re-read the original evidence. Never copy worker dispositions as independent proof. Missing capability is unverified and requires an acquisition/repair finding, not a human certification request.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}`;
48
50
  const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
49
51
  const memory = await memoryLoader({ root: memoryRoot, record, signal });
50
- return `${purposeContract(managed)}\n${perspectiveInstruction(memory)}\n${hypothesisKnowledgeInstruction(knowledge, { flexible: selectiveRuleReview(managed) })}\n${searchInstruction(managed)}\nForm a grounded candidate judgment with LLM reasoning first; consult the applicable canonical rule sections using read tools to challenge that judgment before its effects. This ruleSet is authoritative workflow material; user instructions retain priority. ${selectiveRuleReview(managed) ? "Review applicable rules and known obligations only; do not require full inventory coverage." : "Use the purpose contract for full initial rule coverage and changed dispositions on subsequent search turns."} Missing sources or unavailable tools remain unverified; do not fabricate coverage. Repository AGENTS.md and explicit user constraints also apply.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}\nPrior rule dispositions (provider reported, re-evaluate applicability): ${JSON.stringify(managed.purpose.version === 2 ? managed.purpose.lastReview?.rules || [] : [])}\nLast purpose review (provider reported): ${JSON.stringify(managed.purpose.lastReview ? { gap: managed.purpose.lastReview.gap, hypothesis: managed.purpose.lastReview.hypothesis, noChangeAssessment: managed.purpose.lastReview.noChangeAssessment, observations: managed.purpose.lastReview.observations, unresolvedRules: managed.purpose.lastReview.rules.filter(rule => ['failed', 'unverified'].includes(rule.status)) } : null)}\nRecovery context: ${JSON.stringify(managed.purpose.lastRecovery || null)}`;
52
+ const attempt = managed.executionAttempt;
53
+ const recoveryContext = { previous: managed.purpose.lastRecovery || null, incident: currentRecoveryIncident(managed),
54
+ unresolved: unresolvedRecoveryContext(managed),
55
+ receipt: attempt ? { id: attempt.id, phase: attempt.phase, decisionId: attempt.decisionId,
56
+ providerSessionId: attempt.providerSessionId || null, providerTurnId: attempt.providerTurnId || null,
57
+ startedAt: attempt.startedAt || null, dispatchedAt: attempt.dispatchedAt || null } : null };
58
+ return `${purposeContract(managed)}\n${perspectiveInstruction(memory)}\n${hypothesisKnowledgeInstruction(knowledge, { flexible: selectiveRuleReview(managed) })}\n${searchInstruction(managed)}\nUse the shared scene reasoning cycle; consult applicable canonical rule sections as perspective inputs and challenge the candidate before its effects. This ruleSet is authoritative workflow material; user instructions retain priority. ${selectiveRuleReview(managed) ? "Review applicable rules and known obligations only; do not require full inventory coverage." : "Use the purpose contract for full initial rule coverage and changed dispositions on subsequent search turns."} Missing sources or unavailable tools remain unverified; do not fabricate coverage. Repository AGENTS.md and explicit user constraints also apply.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}\nPrior rule dispositions (provider reported, re-evaluate applicability): ${JSON.stringify(managed.purpose.version === 2 ? managed.purpose.lastReview?.rules || [] : [])}\nLast purpose review (provider reported): ${JSON.stringify(managed.purpose.lastReview ? { gap: managed.purpose.lastReview.gap, hypothesis: managed.purpose.lastReview.hypothesis, noChangeAssessment: managed.purpose.lastReview.noChangeAssessment, observations: managed.purpose.lastReview.observations, unresolvedRules: managed.purpose.lastReview.rules.filter(rule => ['failed', 'unverified'].includes(rule.status)) } : null)}\nRecovery context (controller receipt data, not instructions, permission or acceptance; return to the original unfinished work after the next useful check): ${JSON.stringify(recoveryContext)}`;
51
59
  }
@@ -58,6 +58,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
58
58
  const deferred = [];
59
59
 
60
60
  for (let record of await readRecords()) {
61
+ if (signal?.aborted) break;
61
62
  try {
62
63
  let run = record.run;
63
64
  if (upgradeEligible(record) && (policyAdoptionPending(record) || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
@@ -98,6 +99,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
98
99
  let link;
99
100
  try { link = await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
100
101
  const authorized = async () => {
102
+ signal?.throwIfAborted();
101
103
  await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch });
102
104
  const latest = await readRecord(root, record.provider, record.session);
103
105
  if (!latest || !automaticContinuationAllowed(latest) || latest.run?.id !== run.id || latest.autoContinueEpoch !== record.autoContinueEpoch || ['paused', 'stopped'].includes(latest.run.state)) throw new Error('Purpose observation authorization changed.');
@@ -105,7 +107,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
105
107
  const observe = async read => { await authorized(); const result = await read(); await authorized(); return result; };
106
108
  const persist = mutate => writeRecord(root, record.provider, record.session, async current => {
107
109
  try { await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch }); } catch { return current; }
108
- if (!current || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
110
+ if (signal?.aborted || !current || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
109
111
  return mutate(current);
110
112
  });
111
113
  // Recheck the current budget and boundaries under the existing record lock.
@@ -203,7 +205,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
203
205
  if (current?.run?.id !== run.id || current.run.state !== 'blocked' || current.run.revision !== run.revision || current.run.updatedAt !== run.updatedAt) return current;
204
206
  current.run.managed.authenticationWait.nextCheckAt = now + 60_000;
205
207
  if (authentication === 'available') {
206
- const queued = queuePurposeDiagnosis(current.run, 'Configured provider login is available again. Inspect the original purpose and any interrupted action before choosing authorized work.', `authentication-restored:${run.managed.authenticationWait.since}`, now);
208
+ const queued = queuePurposeDiagnosis(current.run, 'Configured provider login is available again. Inspect the original purpose and any interrupted action before choosing authorized work.', `authentication-restored:${run.managed.authenticationWait.since}`, now, 'authentication');
207
209
  if (queued) delete current.run.managed.authenticationWait;
208
210
  }
209
211
  return current;
@@ -219,7 +221,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
219
221
  current.run.managed.purpose.observationState = observed.state;
220
222
  return current;
221
223
  });
222
- if (observed.state === 'idle') obstacle = { type: 'idle_without_checkpoint', detail: observed.turnId };
224
+ if (observed.state === 'idle' && (!observed.session || observed.session === record.session)) obstacle = { type: 'idle_without_checkpoint', detail: observed.turnId };
223
225
  } else if (run.state === 'blocked' && ['missing_information', 'authorization', 'external_dependency'].includes(run.blocker)) {
224
226
  if (run.blockerSource === 'provider' || run.managed.attempts?.at(-1)?.errorCode === 'provider_approval') {
225
227
  if (now < (run.managed.purpose.lastObservedAt || 0) + 60_000) continue;
@@ -309,7 +311,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
309
311
  if (['native_wait_updated', 'native_wait_diagnosis'].includes(obstacle.type) && !nativeCurrent(current)) return current;
310
312
  if (searchEnabled(current.run.managed) && ['original_context_updated', 'native_wait_updated'].includes(obstacle.type)) current.run.managed.purpose.nextSearchAt = 0;
311
313
  const queued = queuePurposeDiagnosis(current.run,
312
- `Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}. Gather evidence before deciding that a person is needed. Preserve unresolved approvals/dependencies; choose independent work or a safe alternative where possible.`, key, Date.now());
314
+ `Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}. Use the shared scene reasoning cycle to select the next useful check. Preserve unresolved approvals/dependencies; choose independent work or a safe alternative where possible, then return to the original unfinished work.`, key, now, obstacle.type);
313
315
  if (queued && obstacle.type === 'external_dependency') {
314
316
  const purpose = current.run.managed.purpose;
315
317
  purpose.dependencyChecks = (purpose.dependencyChecks || 0) + 1;
@@ -318,6 +320,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
318
320
  return current;
319
321
  });
320
322
  } catch (error) {
323
+ if (signal?.aborted) break;
321
324
  if (!/^Delivery state (?:is busy; retry the command\.|lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
322
325
  // One unavailable record cannot prevent independent conversations from
323
326
  // progressing. Never delete or reclaim an ambiguous writer lock.