@awak-app/simy-cli 0.7.10 → 0.7.12

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 (42) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agent.js +47 -7
  4. package/src/auto-update.js +32 -9
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  8. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  9. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  10. package/src/delivery-hooks/desktop.js +1 -1
  11. package/src/delivery-hooks/supervisor.js +39 -10
  12. package/src/desktop-host.js +90 -15
  13. package/src/desktop-status.js +1 -1
  14. package/src/direct-executor.js +104 -4
  15. package/src/local-task.js +14 -3
  16. package/src/provider-stream.js +3 -0
  17. package/src/runner.js +19 -4
  18. package/src/session-supervision/claude-desktop-lineage.js +19 -5
  19. package/src/session-supervision/claude-sessions.js +73 -15
  20. package/src/session-supervision/codex-app-server.js +28 -9
  21. package/src/session-supervision/index.js +25 -14
  22. package/src/session-supervision/pull-requests.js +52 -0
  23. package/src/session-supervision/work-state.js +84 -0
  24. package/src/sqm/claude-plugin.js +5 -0
  25. package/src/sqm/review-worker.js +15 -6
  26. package/src/update-detachment/claude-observation.js +33 -0
  27. package/src/update-detachment/codex-context.js +27 -0
  28. package/src/update-detachment/coordinator.js +117 -0
  29. package/src/update-detachment/dispatch-gate.js +1 -0
  30. package/src/update-detachment/ledger-adapter.js +62 -0
  31. package/src/update-detachment/ledger-reader.js +42 -0
  32. package/src/update-detachment/provenance.js +1 -0
  33. package/src/update-detachment/provider-adapters.js +33 -0
  34. package/src/update-detachment/reconcile.js +105 -0
  35. package/src/update-detachment/reconciliation-adapter.js +13 -0
  36. package/src/update-detachment/runtime.js +319 -0
  37. package/src/update-detachment/state-store.js +1 -0
  38. package/src/update-detachment/worker-entry.js +145 -0
  39. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  40. package/src/update-detachment/worker-ledger.js +138 -0
  41. package/src/update-detachment/worker-storage.js +29 -0
  42. package/src/update-detachment/worker.js +167 -0
package/src/local-task.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { captureDispatchProvenance } from './update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
1
3
  import { normalizePurposeVerification } from "./purpose-local-proof.js";
2
4
  import { buildWorkflowReportingInstruction } from "./workflow-reporting.js";
3
5
  import { execFile } from "node:child_process";
@@ -449,12 +451,17 @@ export async function startLocalTask(
449
451
  beforeSpawn = null,
450
452
  session = null,
451
453
  apiOrigin = null,
454
+ sessionRoot,
452
455
  observationFetch = globalThis.fetch,
453
456
  deliverableUploadSleep = undefined,
454
457
  } = {},
455
458
  ) {
456
459
  if (!task || task.status !== "queued") throw new Error("Local Task must be queued.");
457
460
  if (task.invocation_count >= 1) throw new Error("Local Tasks allow only one Provider invocation.");
461
+ const dispatchOwner = captureDispatchOwner(sessionRoot);
462
+ const dispatchAttemptId = `${task.id}:provider:1`;
463
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: task.backend, worktree: task.workspace_path, accountSession: session || undefined, origin: apiOrigin || undefined, taskId: task.id, recordRef: path.join(task.task_root, 'task.json') });
464
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
458
465
  updateTask(task, {
459
466
  control_state: "starting",
460
467
  invocation_count: 1,
@@ -573,7 +580,7 @@ export async function startLocalTask(
573
580
  const tokenRecords = [];
574
581
  let child;
575
582
  try {
576
- child = command.spawn(command.bin, command.args, {
583
+ child = dispatchWithFence(dispatchOwner, { taskId: task.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
577
584
  cwd: task.workspace_path,
578
585
  env: {
579
586
  ...process.env,
@@ -586,7 +593,7 @@ export async function startLocalTask(
586
593
  },
587
594
  stdio: ["ignore", "pipe", "pipe"],
588
595
  detached: process.platform !== "win32",
589
- });
596
+ }));
590
597
  } catch (error) {
591
598
  void rm(runtimeTempRoot, { recursive: true, force: true }).then(
592
599
  () => reject(error),
@@ -631,7 +638,7 @@ export async function startLocalTask(
631
638
  stream: "stdout",
632
639
  onLine,
633
640
  onUsage,
634
- onEvent: (event) => observationSender?.observe(event),
641
+ onEvent: (event) => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: task.backend, event, onError: error => { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) task.dispatch_identity = identity; observationSender?.observe(event); },
635
642
  onTerminalError: (message) => {
636
643
  task.error ||= message;
637
644
  task.reason_code ||= "provider_execution_failed";
@@ -786,6 +793,8 @@ export async function startLocalTask(
786
793
  : task.error || "Local Task failed.",
787
794
  });
788
795
  await persist(task);
796
+ try { queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: provenance.recordRef }); }
797
+ catch (error) { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; try { await persist(task); } catch {} }
789
798
  resolve(task);
790
799
  };
791
800
  child.on("error", (spawnError) => void finish({ error: spawnError.message }));
@@ -831,6 +840,8 @@ export async function stopLocalTask(task, { persist = persistLocalTask } = {}) {
831
840
  export function localTaskSnapshot(task, { includeEvents = true, includeLocalPaths = false } = {}) {
832
841
  return {
833
842
  id: task.id,
843
+ ...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}),
844
+ ...(includeLocalPaths && task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}),
834
845
  kind: "local_task",
835
846
  status: task.status,
836
847
  control_state: task.control_state,
@@ -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))
package/src/runner.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { captureDispatchProvenance } from './update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
1
3
  import { execFile, execFileSync } from "node:child_process";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { EventEmitter, once } from "node:events";
@@ -1060,7 +1062,11 @@ export async function executeProcessAttempt({
1060
1062
  onExecutionComplete = null,
1061
1063
  readOnly = false,
1062
1064
  }) {
1065
+ const dispatchOwner = captureDispatchOwner(run.sqmHistoryRoot || (run.localStateRoot ? path.dirname(run.localStateRoot) : undefined));
1063
1066
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
1067
+ const dispatchAttemptId = `${run.snapshot.id}:${phase}:${attemptNumber ?? run.snapshot.attempts?.length ?? 0}`;
1068
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: backend, worktree: repositoryPath, accountSession: run.session, origin: run.apiOrigin || undefined, taskId: run.snapshot.id, recordRef: run.localStateRoot ? path.join(run.localStateRoot, `${run.snapshot.id}.json`) : `coding-run:${run.snapshot.id}` });
1069
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
1064
1070
  const command = await resolveCommandImpl({
1065
1071
  backend,
1066
1072
  instruction,
@@ -1094,7 +1100,7 @@ export async function executeProcessAttempt({
1094
1100
  let terminalError = null;
1095
1101
  let terminalErrorCode = null;
1096
1102
  const checkpointWrites = [];
1097
- const child = command.spawn(command.bin, command.args, {
1103
+ const child = dispatchWithFence(dispatchOwner, { taskId: run.snapshot.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
1098
1104
  cwd: repositoryPath,
1099
1105
  env: {
1100
1106
  ...process.env,
@@ -1104,14 +1110,14 @@ export async function executeProcessAttempt({
1104
1110
  },
1105
1111
  stdio: ["ignore", "pipe", "pipe"],
1106
1112
  detached: process.platform !== "win32",
1107
- });
1113
+ }));
1108
1114
  run.child = child;
1109
1115
 
1110
1116
  const stdoutDecoder = createProviderStreamDecoder({
1111
1117
  backend,
1112
1118
  stream: "stdout",
1113
1119
  onLine: (line) => emitOutput(run, line, { backend }),
1114
- onEvent: (event) => observationSender?.observe(event),
1120
+ onEvent: (event) => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: backend, event, onError: error => { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) { run.snapshot.metadata.dispatch_identities ||= {}; run.snapshot.metadata.dispatch_identities[dispatchAttemptId] = identity; } observationSender?.observe(event); },
1115
1121
  onTerminalError: (message) => { terminalError ||= message; },
1116
1122
  onUsage: (usage) => {
1117
1123
  tokenUsageRecords.push({
@@ -1183,7 +1189,16 @@ export async function executeProcessAttempt({
1183
1189
  startedAt,
1184
1190
  finishedAt: new Date().toISOString(),
1185
1191
  };
1186
- try { await onExecutionComplete?.(execution); }
1192
+ try {
1193
+ if (run.localStateRoot) {
1194
+ run.snapshot.metadata.dispatch_results ||= {};
1195
+ run.snapshot.metadata.dispatch_results[dispatchAttemptId] = execution;
1196
+ await saveCodingRunSnapshot(run.localStateRoot, run.snapshot);
1197
+ try { queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: provenance.recordRef }); }
1198
+ catch (error) { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; await saveCodingRunSnapshot(run.localStateRoot, run.snapshot); }
1199
+ }
1200
+ await onExecutionComplete?.(execution);
1201
+ }
1187
1202
  catch (error) { reject(error); return; }
1188
1203
  // The run must always resolve: a WorkFlow observation failure (or a
1189
1204
  // sender bug) never changes or blocks the Provider result. flush() is
@@ -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 };
@@ -2,11 +2,12 @@ import { statisticsFromHandle, unavailableStatistics } from './statistics.js';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, open, readdir, realpath } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
+ import { createHash } from 'node:crypto';
5
6
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
7
  import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
7
8
  import { sessionTitle } from './session-titles.js';
8
9
  import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
9
- import { observeClaudeDesktopLineage } from './claude-desktop-lineage.js';
10
+ import { observeClaudeDesktopLineage, readClaudeDesktopLineage } from './claude-desktop-lineage.js';
10
11
 
11
12
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
13
  const MAX_PROJECTS = 256;
@@ -101,7 +102,7 @@ async function latestCustomTitle(handle, size, id) {
101
102
  return title;
102
103
  }
103
104
 
104
- async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal) {
105
+ async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal, includeBoundary = false) {
105
106
  signal?.throwIfAborted();
106
107
  const id = fileName.slice(0, -'.jsonl'.length);
107
108
  if (!UUID.test(id)) return null;
@@ -147,12 +148,17 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
147
148
  signal?.throwIfAborted();
148
149
  if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
149
150
  if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
151
+ if (includeBoundary) {
152
+ candidate.boundary = await boundaryFromHandle(handle, opened, id, metadata.cwd, signal);
153
+ const after = await lstat(path);
154
+ if (!after.isFile() || after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return null;
155
+ }
150
156
  return candidate;
151
157
  } catch (error) { metadataCache.delete(path); if (signal?.aborted) throw error; return null; }
152
158
  finally { await handle?.close(); }
153
159
  }
154
160
 
155
- async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
161
+ async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, signal } = {}) {
156
162
  if (signal?.aborted) return [];
157
163
  const root = await projectsRoot(home);
158
164
  if (!root) return [];
@@ -186,28 +192,30 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
186
192
  }));
187
193
  }
188
194
  if (signal?.aborted) return [];
189
- const desktopLineage = !session && cacheMetadata
190
- ? await observeClaudeDesktopLineage({ home, signal, sessions: new Set(descriptors.map(item => item.session)) }) : new Map();
195
+ const desktopOptions = { home, signal, includeState: true, sessions: new Set(descriptors.map(item => item.session)) };
196
+ const desktopLineage = await (session ? readClaudeDesktopLineage(desktopOptions) : observeClaudeDesktopLineage(desktopOptions));
191
197
  const decorateLineage = candidate => {
192
198
  const origin = desktopLineage.get(candidate.session);
193
- if (origin?.cwd === candidate.cwd && !candidate.parentSession)
194
- return { ...candidate, parentSession: origin.parentSession, parentSessionSource: origin.source };
199
+ if (origin?.cwd === candidate.cwd)
200
+ return { ...candidate, ...(typeof origin.providerArchived === 'boolean' ? { archived: origin.providerArchived } : {}), pullRequests: origin.pullRequests || [],
201
+ ...(!candidate.parentSession && origin.parentSession ? { parentSession: origin.parentSession, parentSessionSource: origin.source } : {}) };
195
202
  return candidate;
196
203
  };
197
- const selected = session ? descriptors : recentSessionWindow(descriptors, {
198
- limit: maximum, priority: item => priorities.has(item.session),
199
- });
204
+ const selected = session ? descriptors : [
205
+ ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
206
+ ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
207
+ ];
200
208
  const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
201
209
  let nextFile = 0;
202
210
  // Catalog prefixes are bounded independently of full transcript size. Use a
203
211
  // small I/O pool; exact-context/statistics reads stay serial and identity-safe.
204
- await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
212
+ await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics || includeBoundary ? 1 : 8, filesToInspect.length) }, async () => {
205
213
  while (nextFile < filesToInspect.length && !signal?.aborted) {
206
214
  const file = filesToInspect[nextFile++];
207
- let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
215
+ let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal, includeBoundary);
208
216
  if (!candidate) continue;
209
217
  candidate = decorateLineage(candidate);
210
- const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
218
+ const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics || includeBoundary ? `\0${file.projectName}\0${file.fileName}` : ''}`;
211
219
  if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
212
220
  }
213
221
  }));
@@ -218,7 +226,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
218
226
  const attempted = new Set(result.keys());
219
227
  for (const child of result.values()) {
220
228
  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;
229
+ if (!origin?.parentSession || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
222
230
  for (const file of descriptors.filter(item => item.session === origin.parentSession.session)) {
223
231
  const key = `${file.session}\0${origin.parentCwd}`;
224
232
  if (attempted.has(key) || attempted.size >= MAX_TRANSCRIPTS || signal?.aborted) break;
@@ -243,7 +251,10 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
243
251
  }
244
252
  }
245
253
  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) });
254
+ return session ? sorted : [
255
+ ...recentSessionWindow(sorted.filter(item => item.archived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
256
+ ...recentSessionWindow(sorted.filter(item => item.archived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
257
+ ];
247
258
  }
248
259
 
249
260
  export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
@@ -256,6 +267,7 @@ export async function readClaudeSession(session, { home } = {}) {
256
267
  const matches = await collect({ home, limit: RECENT_SESSION_LIMIT, session: id });
257
268
  const selected = typeof session === 'object' && typeof session.cwd === 'string'
258
269
  ? matches.filter(item => item.cwd === session.cwd) : matches;
270
+ if (selected.length === 1 && selected[0].archived === true) throw Object.assign(new Error('Restore the original provider conversation before continuing.'), { code: 'session_archived' });
259
271
  return selected.length === 1 ? selected[0] : null;
260
272
  }
261
273
 
@@ -299,6 +311,52 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
299
311
  return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
300
312
  }
301
313
 
314
+ // Update recovery needs coverage of the complete saved transcript, not the
315
+ // deliberately shortened text used for presentation. Stream and validate every
316
+ // envelope while retaining only a digest and counters, never message prose.
317
+ async function boundaryFromHandle(handle, opened, session, cwd, signal) {
318
+ const maxBytes = 64 * 1024 * 1024, maxLineBytes = 4 * 1024 * 1024;
319
+ if (!opened.size || opened.size > maxBytes) return null;
320
+ const hash = createHash('sha256'), buffer = Buffer.alloc(64 * 1024);
321
+ let offset = 0, pending = Buffer.alloc(0), rows = 0, messageCount = 0, lastMessageRef = null;
322
+ const decoder = new TextDecoder('utf-8', { fatal: true });
323
+ while (offset < opened.size) {
324
+ signal?.throwIfAborted();
325
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, opened.size - offset), offset);
326
+ if (!bytesRead) return null;
327
+ const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); offset += bytesRead;
328
+ pending = Buffer.concat([pending, chunk]);
329
+ let newline;
330
+ while ((newline = pending.indexOf(10)) !== -1) {
331
+ if (newline > maxLineBytes || ++rows > 250000) return null;
332
+ const line = pending.subarray(0, newline); pending = pending.subarray(newline + 1);
333
+ if (!line.length) continue;
334
+ let item;
335
+ try { item = JSON.parse(decoder.decode(line)); } catch { return null; }
336
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
337
+ if (unscopedFileHistory(item)) continue;
338
+ if (item.sessionId !== session || item.isSidechain === true) return null;
339
+ if (!['user', 'assistant'].includes(item.type) || item.isMeta === true) continue;
340
+ if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd) || !within(cwd, resolve(item.cwd)) || typeof item.uuid !== 'string' || !item.uuid) return null;
341
+ if (item.type === 'user' && !textContent(item.message?.content).trim() && !item.message?.content?.every?.(part => part?.type === 'tool_result')) return null;
342
+ messageCount++; lastMessageRef = item.uuid;
343
+ }
344
+ if (pending.length > maxLineBytes) return null;
345
+ }
346
+ const after = await handle.stat();
347
+ if (pending.length || !messageCount || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return null;
348
+ return { schemaVersion: 1, complete: true, digest: hash.digest('hex'), byteLength: offset, messageCount, lastMessageRef };
349
+ }
350
+
351
+ export async function readClaudeBoundary(candidate, { home, signal = AbortSignal.timeout(8000) } = {}) {
352
+ signal.throwIfAborted();
353
+ if (!UUID.test(candidate?.session || '') || typeof candidate?.cwd !== 'string') return null;
354
+ const matches = (await collect({ home, session: candidate.session, includeBoundary: true, signal })).filter(item => item.cwd === candidate.cwd);
355
+ signal.throwIfAborted();
356
+ if (matches.length !== 1 || Number.isFinite(candidate.lastActivityAt) && matches[0].lastActivityAt !== candidate.lastActivityAt) return null;
357
+ return matches[0].boundary || null;
358
+ }
359
+
302
360
  export async function readClaudeStatistics(candidate, { home, signal } = {}) {
303
361
  signal?.throwIfAborted();
304
362
  if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
@@ -1,3 +1,6 @@
1
+ import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
3
+ import { normalizePullRequests } from './pull-requests.js';
1
4
  import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
2
5
  import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
3
6
  import { collectPurposeObservations, refreshPurposeObservations, repositoryIdentity } from './purpose-observer.js';
@@ -17,7 +20,7 @@ import { createInterface } from 'node:readline';
17
20
  import { homedir } from 'node:os';
18
21
  import { basename, isAbsolute, join, relative, sep } from 'node:path';
19
22
  import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecutable } from '../backend-executable.js';
20
- import { canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
23
+ import { hasPendingUserStop, recordPath, rootDirectory, canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
21
24
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
22
25
  import { JsonDataUrlFilter } from './json-data-url-filter.js';
23
26
  import { RECENT_SESSION_LIMIT } from './discovery-window.js';
@@ -202,13 +205,19 @@ export function codexThreadMetadata(thread) {
202
205
  if (thread.parentThreadId || thread.source?.subAgent || thread.source?.subagent) return null;
203
206
  return {
204
207
  provider: 'codex', session: thread.id, cwd: thread.cwd,
208
+ pullRequests: normalizePullRequests(thread.pullRequests || thread.artifacts?.filter?.(item => item.type === 'pull_request')),
205
209
  ...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
206
210
  ? { parentSession: { provider: 'codex', session: thread.forkedFromId } } : {}),
207
211
  title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
208
212
  source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
209
213
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
210
214
  model: typeof thread.model === 'string' ? thread.model : null,
211
- observation: { source: 'codex_app_server', observedAt: Date.now() },
215
+ // Preserve native wait flags for presentation without changing isRunning,
216
+ // which also protects the provider writer lock. Unknown flags fail closed.
217
+ observation: { source: 'codex_app_server', observedAt: Date.now(),
218
+ ...(thread.status?.type === 'active' ? { activeFlags: Array.isArray(thread.status.activeFlags)
219
+ && thread.status.activeFlags.every(flag => ['waitingOnApproval', 'waitingOnUserInput'].includes(flag))
220
+ ? [...new Set(thread.status.activeFlags)] : null } : {}) },
212
221
  ...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
213
222
  availability: 'unknown',
214
223
  };
@@ -618,6 +627,10 @@ async function frozenNativeSandbox(record, root, signal) {
618
627
  }
619
628
 
620
629
  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 } = {}) {
630
+ const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
631
+ let dispatchAttemptId;
632
+ 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) });
633
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
621
634
  const run = record.run, decision = run.managed?.decision;
622
635
  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
636
  if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
@@ -653,11 +666,16 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
653
666
  prompt: peerOpinionPrompt(record, sourceContext, peerAction),
654
667
  output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
655
668
  }, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
669
+ dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance }, attemptId => {
670
+ if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
671
+ dispatchAttemptId = attemptId;
672
+ return initiate();
673
+ }),
656
674
  beforeDispatch: async () => { await authorizePreparation(); await beforeDispatch({ type: 'readOnly', networkAccess: false }); },
657
675
  onContext: session => { if (peerSession && peerSession !== session) ambiguousContext = true; peerSession = session; },
658
676
  onUsage: observed => { usage = observed; onUsage(observed); } });
659
677
  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.');
660
- return { code: 0, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
678
+ return { code: 0, dispatchAttemptId, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
661
679
  }
662
680
  // Connector schemas can exceed ordinary checkpoint limits; keep review pages
663
681
  // bounded to one server and a separate finite transport ceiling.
@@ -844,9 +862,10 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
844
862
  });
845
863
  signal?.addEventListener('abort', cancelled, { once: true });
846
864
  client.child.once('close', closed);
847
- turnStart = client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
848
- model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) })
849
- .then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
865
+ 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) });
866
+ 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,
867
+ model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) }); })
868
+ .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 {
850
869
  startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
851
870
  await startReceipt;
852
871
  startRecorded = true;
@@ -901,14 +920,14 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
901
920
  review.findings.push(criterion ? { id: 'workspace-audit-binding', requirementId: `criterion:${criterion.id}`, source: 'controller_workspace_inventory', owner: 'controller', gap, verification: 'Establish the correct authorized workspace scope and independently bind its actual artifacts before acceptance.', reopenReason: 'The current workspace identity is unavailable or its scope changed.' } : gap);
902
921
  }
903
922
  review = assessIndependentInspection(review, { toolEvidence, observations: reviewObservations, criteria: effectiveAcceptanceCriteria(run.managed), flexible: flexibleReasoning(run.managed) });
904
- return { code: 0, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
923
+ return { code: 0, dispatchAttemptId, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
905
924
  }
906
925
  const currentEvidence = beforeEvidence ? await captureWorkspace() : null;
907
926
  const controllerEvidence = beforeEvidence ? { source: 'adapter_capture', actionId: decision.id,
908
927
  baseline: run.managed.purpose.evidence?.baseline ? undefined : { ...beforeEvidence, kind: 'first_managed_observation', continuation: run.continuations },
909
928
  current: currentEvidence, before: beforeEvidence.digest, after: currentEvidence?.digest, receipts: toolEvidence } : undefined;
910
- if (!run.managed.purpose?.enabled) return { code: 0, checkpoint };
911
- return { code: 0, checkpoint, usage, providerTurnId: activeTurnId, providerSessionId:targetSession, toolEvidence, sourceContext, controllerEvidence };
929
+ if (!run.managed.purpose?.enabled) return { code: 0, dispatchAttemptId, checkpoint };
930
+ return { code: 0, dispatchAttemptId, checkpoint, usage, providerTurnId: activeTurnId, providerSessionId:targetSession, toolEvidence, sourceContext, controllerEvidence };
912
931
  } catch (error) {
913
932
  if (activeTurnId) error.providerTurnId ||= activeTurnId;
914
933
  if(targetSession!==record.session)error.providerSessionId ||=targetSession;
@@ -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.