@awak-app/simy-cli 0.6.28 → 0.6.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
+ import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
- import { automaticContinuationAllowed, processIdentity, readRecord, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { automaticContinuationAllowed, queuePurposeVerification, processIdentity, readRecord, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
4
  import { purposeDiagnosisPlan } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
4
5
  import { reconcileFindings } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
5
6
  import { requireProviderLink } from '../provider-links.js';
@@ -12,17 +13,19 @@ const contract = record => createHash('sha256').update(JSON.stringify([record.ru
12
13
  record.run.objective, record.run.managed.acceptanceCriteria, record.autoContinueEpoch])).digest('hex');
13
14
  export const canAuditOutcome = record => record?.provider === 'codex' && record.autoContinue === true &&
14
15
  record.providerArchived !== true && record.run?.managed && automaticContinuationAllowed(record) &&
15
- !['stopped', 'completed', 'reported_done', 'budget_exhausted'].includes(record.run.state) &&
16
+ !['stopped', 'budget_exhausted'].includes(record.run.state) &&
17
+ (!['completed','reported_done'].includes(record.run.state) || completionClaimUnbound(record)) &&
18
+ record.run.stopReason?.actor !== 'user' &&
16
19
  !(record.run.state === 'paused' && pauseOrigin(record.run) === 'user') &&
17
20
  !record.run.managed.resumeLease && !record.run.managed.liveWorkspaceLease &&
18
21
  !(Number.isFinite(record.run.deadline) && Date.now() >= record.run.deadline) &&
19
22
  !(Number.isFinite(record.run.maxContinuations) && record.run.continuations >= record.run.maxContinuations) &&
20
- !record.run.managed.usageBlocked && !(record.run.managed.maxTokens && record.run.managed.tokensUsed >= record.run.managed.maxTokens);
23
+ !(record.run.managed.outcomeAudit?.usageReceipts?.filter(row => !row.settled).length >= 128) && !record.run.managed.usageBlocked && !(record.run.managed.maxTokens && record.run.managed.tokensUsed >= record.run.managed.maxTokens);
21
24
 
22
25
  // A distinct provider context reads original instructions and primary evidence.
23
26
  // It has its own read-only lease; original turns are never resumed or edited.
24
- export async function auditConsistencyTask(record, findings, request, { root, signal,
25
- execute = executeCodexAppServer, openClient = openCodexAppServer, authorize = async () => {} } = {}) {
27
+ async function auditConsistencyAttempt(record, findings, request, { root, signal,
28
+ execute = executeCodexAppServer, openClient = openCodexAppServer, authorize = async () => {}, writeRecord = updateRecord } = {}) {
26
29
  if (!canAuditOutcome(record)) return { received: false, reason: 'audit_boundary' };
27
30
  const link = await requireProviderLink({ root, provider: record.provider });
28
31
  const accessScope = await authorize(record);
@@ -30,20 +33,33 @@ export async function auditConsistencyTask(record, findings, request, { root, si
30
33
  try { metadata = await readCodexSession(client, record.session); }
31
34
  finally { await client.close(); }
32
35
  if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== (record.run.managed.resumeModel || record.admission?.model)) return { received: false, reason: 'original_identity_unverified' };
33
- const key = contract(record), id = randomUUID();
36
+ signal?.throwIfAborted();
37
+ const key = contract(record), id = request.attemptId;
34
38
  const owner = { pid: process.pid, identity: await processIdentity(process.pid) };
35
39
  const oldLease = record.run.managed.outcomeAudit?.lease;
36
- if (oldLease && await leaseOwnerDead(oldLease.owner, processIdentity)) await updateRecord(root, record.provider, record.session, live => {
40
+ const oldOwnerDead = oldLease && await leaseOwnerDead(oldLease.owner, processIdentity);
41
+ if (oldLease && (Date.now() >= oldLease.expiresAt || oldOwnerDead)) {
42
+ // The final provider total cannot be recovered from a dead owner's memory.
43
+ await accountAuditUsage(root, record, oldLease.id, null, writeRecord);
44
+ await writeRecord(root, record.provider, record.session, live => {
37
45
  if (live?.run?.managed?.outcomeAudit?.lease?.id === oldLease.id) {
38
46
  delete live.run.managed.outcomeAudit.lease;
47
+ // A confirmed dead process cannot deliver late usage. Settle its dedupe
48
+ // slot, while retaining unknown consumption and every hard-budget block.
49
+ // Expiry alone does not prove death, so live-owner receipts stay pending.
50
+ if (oldOwnerDead) {
51
+ const receipt = live.run.managed.outcomeAudit.usageReceipts?.find(row => row.id === oldLease.id);
52
+ if (receipt) receipt.settled = true;
53
+ }
39
54
  live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: 'audit_owner_exited' };
40
55
  }
41
56
  return live;
42
57
  });
43
- const claimed = await updateRecord(root, record.provider, record.session, live => {
44
- if (!canAuditOutcome(live) || contract(live) !== key || live.run.managed.outcomeAudit?.lease) return live;
58
+ }
59
+ const claimed = await writeRecord(root, record.provider, record.session, live => {
60
+ if (signal?.aborted || !canAuditOutcome(live) || contract(live) !== key || live.run.managed.outcomeAudit?.lease) return live;
45
61
  live.run.managed.outcomeAudit = { ...live.run.managed.outcomeAudit,
46
- lease: { id, owner, requestId: request.id, contract: key, startedAt: Date.now() } };
62
+ lease: { id, owner, requestId: request.id, contract: key, startedAt: Date.now(), expiresAt: request.deadlineAt } };
47
63
  return live;
48
64
  });
49
65
  if (claimed?.run?.managed?.outcomeAudit?.lease?.id !== id) return { received: false, reason: 'audit_lease_busy' };
@@ -66,48 +82,130 @@ export async function auditConsistencyTask(record, findings, request, { root, si
66
82
  signal?.addEventListener('abort', abort, { once: true });
67
83
  if (signal?.aborted) abort();
68
84
  let result;
69
- let usage = null;
85
+ let usage = null, usageWrites = Promise.resolve();
70
86
  try {
71
- result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, beforeDispatch,
72
- onUsage: value => { usage = value; if (m.maxTokens && Number.isFinite(value?.tokens) && (m.tokensUsed || 0) + value.tokens >= m.maxTokens) controller.abort(); } });
87
+ result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, authorizePreparation:beforeDispatch, beforeDispatch,
88
+ onUsage: value => { usage = value; usageWrites = usageWrites.then(() => accountAuditUsage(root, record, id, value, writeRecord)); usageWrites.catch(() => controller.abort()); if (m.maxTokens && Number.isFinite(value?.tokens) && (m.tokensUsed || 0) + value.tokens >= m.maxTokens) controller.abort(); } });
73
89
  await beforeDispatch();
74
90
  const review = result.verification;
75
91
  if (!review || review.source !== 'independent_codex_session' || !review.session || review.session === record.session) throw new Error('Independent audit provenance unavailable.');
76
92
  let persisted = false;
77
- await updateRecord(root, record.provider, record.session, live => {
78
- if (!live?.run?.managed || contract(live) !== key || live.run.managed.outcomeAudit?.lease?.id !== id || !canAuditOutcome(live)) return live;
93
+ await writeRecord(root, record.provider, record.session, live => {
94
+ if (signal?.aborted || Date.now() >= request.deadlineAt || !live?.run?.managed || contract(live) !== key || live.run.managed.outcomeAudit?.lease?.id !== id || !canAuditOutcome(live)) return live;
79
95
  const audit = live.run.managed.outcomeAudit;
80
96
  audit.lastReport = review; audit.lastSuccessAt = Date.now(); audit.requestId = request.id;
81
97
  audit.targetContract = key; audit.lastFailure = null;
82
98
  // Existing finding reconciliation enforces stable IDs, counterevidence
83
99
  // and reopen reasons. A delivered audit never certifies task completion.
84
- if (live.run.managed.purpose?.enabled) reconcileFindings(live.run.managed, review, Date.now());
100
+ if (completionClaimUnbound(live)) {
101
+ audit.completionRecovery = { at: Date.now(), priorState: live.run.state, priorRevision: live.run.revision };
102
+ live.run.revision++;
103
+ queuePurposeVerification(live.run, 'Prior terminal claim has no current controller completion receipt. Independently recheck the original frozen acceptance and remaining defects; an audit report alone cannot certify completion.', Date.now());
104
+ } else if (live.run.managed.purpose?.enabled) reconcileFindings(live.run.managed, review, Date.now());
85
105
  persisted = true;
86
106
  return live;
87
107
  });
88
108
  return { received: true, audited: persisted, ...(persisted ? { passed: review.passed } : { reason: 'target_changed' }) };
89
109
  } catch (error) {
90
110
  const safeCode = ['cancelled', 'closed', 'identity', 'unsupported', 'approval', 'permission_restore', 'manual_provider_action', 'timeout'].includes(error?.code) ? error.code : 'independent_audit_failed';
91
- await updateRecord(root, record.provider, record.session, live => {
111
+ await writeRecord(root, record.provider, record.session, live => {
92
112
  if (live?.run?.managed?.outcomeAudit?.lease?.id === id) live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: safeCode };
93
113
  return live;
94
114
  });
95
115
  return { received: true, audited: false, reason: 'independent_audit_failed' };
96
116
  } finally {
97
117
  signal?.removeEventListener('abort', abort);
98
- await updateRecord(root, record.provider, record.session, live => {
118
+ let usageWriteFailed = false;
119
+ try { await usageWrites; } catch { usageWriteFailed = true; }
120
+ try {
121
+ await accountAuditUsage(root, record, id, usageWriteFailed ? null : result?.usage || usage, writeRecord);
122
+ } finally { await writeRecord(root, record.provider, record.session, live => {
99
123
  if (live?.run?.managed?.outcomeAudit?.lease?.id === id) {
100
- const observed = result?.usage || usage;
101
- const audit = live.run.managed.outcomeAudit;
102
- const measured = observed?.source === 'codex_app_server' && Number.isSafeInteger(observed.tokens) && observed.tokens >= 0;
103
- audit.lastUsage = measured ? observed : null;
104
- if (measured) {
105
- audit.tokensUsed = (audit.tokensUsed || 0) + observed.tokens;
106
- live.run.managed.tokensUsed = (live.run.managed.tokensUsed || 0) + observed.tokens;
107
- } else if (live.run.managed.maxTokens) { live.run.managed.unknownConsumption = true; live.run.managed.usageBlocked = true; }
108
- delete audit.lease;
124
+ delete live.run.managed.outcomeAudit.lease;
109
125
  }
110
126
  return live;
111
- });
127
+ }); }
112
128
  }
113
129
  }
130
+
131
+ async function accountAuditUsage(root, record, id, observed, writeRecord = updateRecord) {
132
+ await writeRecord(root, record.provider, record.session, live => {
133
+ if (!live?.run?.managed || live.run.id !== record.run.id || live.run.managed.taskId !== record.run.managed.taskId) return live;
134
+ const audit = live.run.managed.outcomeAudit ||= {};
135
+ const measured = observed?.source === 'codex_app_server' && Number.isSafeInteger(observed.tokens) && observed.tokens >= 0;
136
+ audit.usageReceipts ||= [];
137
+ let receipt = audit.usageReceipts.find(row => row.id === id);
138
+ if (!receipt) { receipt = { id, tokens: 0, source: measured ? 'measured' : 'unknown' }; audit.usageReceipts.push(receipt); }
139
+ if (measured) {
140
+ const delta = Math.max(0, observed.tokens - receipt.tokens);
141
+ receipt.tokens += delta; receipt.source = 'measured';
142
+ audit.tokensUsed = (audit.tokensUsed || 0) + delta;
143
+ live.run.managed.tokensUsed = (live.run.managed.tokensUsed || 0) + delta;
144
+ audit.lastUsage = observed;
145
+ } else {
146
+ audit.lastUsage = null; audit.unknownConsumption = true;
147
+ if (live.run.managed.maxTokens) { live.run.managed.unknownConsumption = true; live.run.managed.usageBlocked = true; }
148
+ }
149
+ // Keep dedupe identities; never discard an unaccounted late audit receipt.
150
+ delete audit.usageReceiptCapacity;
151
+ return live;
152
+ });
153
+ }
154
+
155
+ // An inspection deadline bounds one read-only audit, not the user's task.
156
+ // Abort fences all late report adoption. Original effect leases are untouched.
157
+ export async function auditConsistencyTask(record, findings, request, options = {}) {
158
+ if (!canAuditOutcome(record)) return { received: false, reason: 'audit_boundary' };
159
+ const controller = new AbortController();
160
+ const abort = () => controller.abort();
161
+ options.signal?.addEventListener('abort', abort, { once: true });
162
+ if (options.signal?.aborted) abort();
163
+ const attemptId = randomUUID();
164
+ const timeoutMs = options.auditTimeoutMs ?? 120_000;
165
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('Invalid audit inspection deadline.');
166
+ let timer, writes = Promise.resolve();
167
+ const writeRecord = (...args) => {
168
+ const pending = writes.catch(() => {}).then(async () => {
169
+ for (let retry = 0; ; retry++) {
170
+ try { return await (options.writeRecord || updateRecord)(...args); }
171
+ catch (error) {
172
+ if (!['Delivery state is busy; retry the command.', 'Delivery state lock owner is unavailable; inspect the lock before retrying.'].includes(error.message) || retry >= 10) throw error;
173
+ await new Promise(resolve => setTimeout(resolve, 20 * (retry + 1)));
174
+ }
175
+ }
176
+ });
177
+ writes = pending; return pending;
178
+ };
179
+ const deadlineAt = Date.now() + timeoutMs;
180
+ let attemptFinished = false, outerFinished = false;
181
+ const settleReceipt = async () => {
182
+ await writeRecord(options.root, record.provider, record.session, live => {
183
+ if (live?.run?.id !== record.run.id || live.run.managed?.taskId !== record.run.managed.taskId) return live;
184
+ const audit = live.run.managed.outcomeAudit;
185
+ const receipt = audit?.usageReceipts?.find(row => row.id === attemptId);
186
+ if (receipt) receipt.settled = true;
187
+ if (audit?.usageReceipts) {
188
+ const terminal = audit.usageReceipts.filter(row => row.settled);
189
+ audit.usageReceipts = audit.usageReceipts.filter(row => !row.settled || terminal.slice(-64).includes(row));
190
+ delete audit.usageReceiptCapacity;
191
+ }
192
+ return live;
193
+ });
194
+ };
195
+ const operation = auditConsistencyAttempt(record, findings, { ...request, attemptId, deadlineAt }, { ...options, signal: controller.signal, writeRecord }).finally(async () => { attemptFinished = true; if (outerFinished) await settleReceipt(); });
196
+ const expiration = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve({ received: true, audited: false, reason: 'audit_timeout' }); }, timeoutMs); });
197
+ try {
198
+ const result = await Promise.race([operation, expiration]);
199
+ if (result.reason === 'audit_timeout') {
200
+ await accountAuditUsage(options.root, record, attemptId, null, writeRecord);
201
+ await writeRecord(options.root, record.provider, record.session, live => {
202
+ if (live?.run?.managed?.outcomeAudit?.lease?.id === attemptId) {
203
+ delete live.run.managed.outcomeAudit.lease;
204
+ live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: 'audit_timeout' };
205
+ }
206
+ return live;
207
+ });
208
+ }
209
+ return result;
210
+ } finally { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); outerFinished = true; if (attemptFinished) await settleReceipt(); }
211
+ }
@@ -11,5 +11,17 @@ export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, prior
11
11
  const recent = [...items].sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.session.localeCompare(b.session));
12
12
  const retained = [], history = [];
13
13
  for (const item of recent) (priority(item) ? retained : history).push(item);
14
- return [...retained, ...history.slice(0, Math.max(0, limit - retained.length))];
14
+ const selected = [...retained, ...history.slice(0, Math.max(0, limit - retained.length))];
15
+ const byKey = new Map(items.map(item => [`${item.provider}:${item.session}`, item]));
16
+ const included = new Set(selected.map(item => `${item.provider}:${item.session}`));
17
+ // Keep observed ancestors so a recent child does not silently become a root.
18
+ for (const item of selected) {
19
+ let parent = item.parentSession;
20
+ while (parent && selected.length < items.length) {
21
+ const key = `${parent.provider}:${parent.session}`;
22
+ if (included.has(key) || !byKey.has(key)) break;
23
+ const ancestor = byKey.get(key); included.add(key); selected.push(ancestor); parent = ancestor.parentSession;
24
+ }
25
+ }
26
+ return selected;
15
27
  }
@@ -1,10 +1,11 @@
1
+ import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
1
2
  import { pauseOrigin } from './autonomous-upgrade.js';
2
3
  import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
3
4
  import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
4
5
  import { realpath } from 'node:fs/promises';
5
6
  import { randomUUID } from 'node:crypto';
6
- import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
7
- import { listCodexSessions, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
7
+ import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
8
+ import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
8
9
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
9
10
  import { loadPurposeRuleSet } from './purpose-instruction.js';
10
11
  import { originalSessionTarget } from './original-session-target.js';
@@ -44,6 +45,7 @@ function autoContinueState(record, isRunning, availability) {
44
45
  if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
45
46
  const state = record.run?.state;
46
47
  if (state === 'budget_exhausted') return { autoContinueStatus: 'needs_user', autoContinueReason: 'Local execution limits have been removed. Resume this same conversation to continue.' };
48
+ if (completionClaimUnbound(record)) return { autoContinueStatus: 'watching', autoContinueReason: 'Verification of the recorded completion remains pending.' };
47
49
  if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Continuation finished for this conversation.' };
48
50
  const managed = record.run?.managed;
49
51
  const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
@@ -112,12 +114,13 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
112
114
  }
113
115
 
114
116
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
115
- inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome, connectedProviders = ['codex', 'claude'] } = {}) {
117
+ inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
116
118
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
117
119
  assertActive();
118
120
  const connected = new Set(connectedProviders);
119
121
  const records = (await listRecords(root)).filter(record => connected.has(record.provider));
120
122
  const priorities = records.filter(retainedSession);
123
+ let archivedCodex = [];
121
124
  let discovered = [], codexError = null, codexReasonCode = null, claude = [], claudeError = null;
122
125
  let archiveStates = new Map();
123
126
  await Promise.all([
@@ -125,8 +128,25 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
125
128
  try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
126
129
  const active = await listCodexSessions({ client, inspectState: true, maxSessions: RECENT_SESSION_LIMIT, codexHome,
127
130
  prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
131
+ if (includeArchived) {
132
+ const observedArchives = new Map();
133
+ const archiveClient = { request: async (method, params) => {
134
+ const response = await client.request(method, params);
135
+ if (method === 'thread/list' && params.archived === true) {
136
+ for (const thread of (response?.data || []).slice(0, params.limit)) {
137
+ const item = codexThreadMetadata(thread);
138
+ if (item && ['cli', 'vscode', 'appServer'].includes(item.source)) observedArchives.set(item.session, { ...item, archived: true });
139
+ }
140
+ }
141
+ return response;
142
+ } };
143
+ try { await listCodexSessions({ client: archiveClient, archived: true, maxSessions: RECENT_SESSION_LIMIT }); }
144
+ catch { /* Keep positive archive pages even if a later page fails. */ }
145
+ archivedCodex = [...observedArchives.values()];
146
+ }
128
147
  const ids = new Set(active.map(item => item.session));
129
148
  archiveStates = await readCodexArchiveStates(client, records.filter(record => record.provider === 'codex' && !ids.has(record.session)), { codexHome });
149
+ for (const item of archivedCodex) archiveStates.set(item.session, true);
130
150
  return active;
131
151
  }, signal); }
132
152
  catch (error) {
@@ -147,6 +167,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
147
167
  const record = records[index];
148
168
  if (record.provider !== 'codex') continue;
149
169
  const isArchived = archiveStates.get(record.session) === true;
170
+ if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
150
171
  if (isArchived && record.providerArchived === true) continue;
151
172
  if (!isArchived && !(record.providerArchived === true && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
152
173
  try { records[index] = await updateRecord(root, record.provider, record.session, current => {
@@ -161,7 +182,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
161
182
  }
162
183
  }
163
184
  return current;
164
- }); } catch {
185
+ });
186
+ if (!isArchived) await recordProviderArchive(root, record.provider, record.session, false, now);
187
+ } catch {
165
188
  // Reconciliation can contend with a hook writer or an unreclaimable
166
189
  // legacy lock. Preserve stored state and retry on the next refresh;
167
190
  // one record must not discard either provider's entire catalog.
@@ -170,20 +193,30 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
170
193
  assertActive();
171
194
  const byKey = new Map();
172
195
  const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
173
- for (const item of [...discovered, ...claude]) {
196
+ for (const item of [...discovered, ...claude, ...archivedCodex]) {
197
+ if (includeArchived && item.archived === true) { byKey.set(`${item.provider}:${item.session}`, item); continue; }
174
198
  if ((item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
175
199
  }
200
+ const requiredAncestors = new Set();
201
+ for (const item of byKey.values()) {
202
+ let parent = recordsByKey.get(`${item.provider}:${item.session}`)?.parentSession || item.parentSession;
203
+ for (let depth = 0; parent && depth < 256; depth++) {
204
+ const key = `${parent.provider}:${parent.session}`;
205
+ if (requiredAncestors.has(key)) break;
206
+ requiredAncestors.add(key); parent = recordsByKey.get(key)?.parentSession;
207
+ }
208
+ }
176
209
  for (const record of records) {
177
210
  // Positive provider evidence still suppresses archived conversations if
178
211
  // their local reconciliation could not be persisted. Never grant work
179
212
  // from an unsuccessful unarchive reconciliation.
180
- if (record.provider === 'codex' && archiveStates.get(record.session) === true) continue;
181
- if (record.providerArchived === true) continue;
213
+ const archived = record.providerArchived === true || record.provider === 'codex' && archiveStates.get(record.session) === true;
214
+ if (archived && !includeArchived) continue;
182
215
  const key = `${record.provider}:${record.session}`;
183
216
  // A hook observation can belong to an AI-created worker. Only explicit
184
217
  // supervision may restore a conversation absent from provider discovery.
185
- if (!byKey.has(key) && retainedSession(record)) byKey.set(key, {
186
- provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
218
+ if (!byKey.has(key) && (retainedSession(record) || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
219
+ provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
187
220
  lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
188
221
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
189
222
  availability: 'unknown', model: record.admission?.model || null,
@@ -191,15 +224,32 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
191
224
  }
192
225
  // Select recent history before live probes, keeping explicit supervision
193
226
  // visible even during a truncated discovery or provider outage.
194
- const items = recentSessionWindow([...byKey.values()], {
227
+ const catalog = [...byKey.values()].map(item => {
228
+ const record = recordsByKey.get(`${item.provider}:${item.session}`);
229
+ return { ...item, ...((record?.parentSession || item.parentSession) ? { parentSession: record?.parentSession || item.parentSession } : {}) };
230
+ });
231
+ const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true), {
195
232
  priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
196
- }), projected = new Array(items.length);
233
+ }), ...recentSessionWindow(catalog.filter(item => item.archived === true))];
234
+ // Include archived ancestors of active rows even when archive visibility is off.
235
+ const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
236
+ const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
237
+ for (const item of items) {
238
+ let parent = item.parentSession;
239
+ while (parent) {
240
+ const key = `${parent.provider}:${parent.session}`;
241
+ if (includedKeys.has(key) || !catalogByKey.has(key)) break;
242
+ const ancestor = catalogByKey.get(key); includedKeys.add(key); items.push(ancestor); parent = ancestor.parentSession;
243
+ }
244
+ }
245
+ const projected = new Array(items.length);
197
246
  const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
198
247
  assertActive();
199
248
  let nextItem = 0;
200
249
  const project = async item => {
201
250
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
202
251
  const run = record?.run;
252
+ const archived = item.archived === true || record?.providerArchived === true;
203
253
  const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
204
254
  const codexChannel = codexContinuationChannel(item.observation.source, item.model, codexError ? { code: codexReasonCode, reason: codexError } : null);
205
255
  const supported = item.provider === 'codex' && codexChannel.ready;
@@ -214,7 +264,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
214
264
  cwd: await realpath(item.cwd), source: item.observation.source, model: item.model, validatedAt: now };
215
265
  } catch { /* Missing workspaces cannot grant an admission. */ }
216
266
  }
217
- const availability = autoContinueAvailability(run, claudeChannel || codexChannel);
267
+ const availability = archived ? { canEnable: false, reasonCode: 'session_archived',
268
+ reason: 'Restore the original provider conversation before enabling automatic continuation.' }
269
+ : autoContinueAvailability(run, claudeChannel || codexChannel);
218
270
  const upgradeable = canUpgradeDelivery(run);
219
271
  const continuationIssue = liveHook && run ? !run.managed && !upgradeable
220
272
  ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
@@ -231,14 +283,15 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
231
283
  } catch { /* Unverified process leaves runtime status unknown. */ }
232
284
  }
233
285
  return { provider: item.provider, session: item.session, cwd: item.cwd,
234
- ...((item.parentSession || record?.parentSession) ? { parentSession: item.parentSession || record.parentSession } : {}),
286
+ ...((item.parentSession || record?.parentSession) ? { parentSession: record?.parentSession || item.parentSession } : {}),
235
287
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
236
288
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
237
289
  ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
238
290
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
239
291
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
240
292
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
241
- autoContinue: record?.autoContinue === true,
293
+ archived,
294
+ autoContinue: !archived && record?.autoContinue === true,
242
295
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
243
296
  autoContinueAvailability: availability,
244
297
  ...autoContinueState(record, isRunning, availability),
@@ -247,13 +300,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
247
300
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
248
301
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
249
302
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
250
- ...(continuationAdmission ? { continuationAdmission } : {}),
303
+ ...(!archived && continuationAdmission ? { continuationAdmission } : {}),
251
304
  continuationReasonCode: availability.reasonCode || claudeChannel?.code || 'ready',
252
305
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
253
- canPurposeContinue: supported && (!run || !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(run.state)),
254
- canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
255
- canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
256
- canAutoContinue: (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
306
+ canPurposeContinue: !archived && supported && (!run || !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(run.state)),
307
+ canAnalyze: !archived && !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
308
+ canEnroll: !archived && (supported || liveHook) && !run, canContinue: !archived && (supported || liveHook) && !run,
309
+ canAutoContinue: !archived && (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
257
310
  reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
258
311
  codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
259
312
  };
@@ -443,7 +496,8 @@ export async function enrollSession({ root, provider, session, objective, accept
443
496
  }
444
497
  const cwd = await realpath(metadata.cwd);
445
498
  const initialPlan = initialDiagnosisPlan(objective.trim(), contextBootstrap);
446
- const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
499
+ const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model,
500
+ ...(metadata.nativeSandbox ? {nativeSandbox:metadata.nativeSandbox,nativeSandboxSource:metadata.nativeSandboxSource} : {}) } : undefined;
447
501
  const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
448
502
  await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
449
503
  const rules = purposeDriven ? await loadPurposeRuleSet() : null;
@@ -13,9 +13,12 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
13
13
  export async function observePurposeTurn(record, { openClient = openCodexAppServer } = {}) {
14
14
  const client = await openClient();
15
15
  try {
16
- const metadata = await readCodexSession(client, record.session);
17
- if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== record.run.managed.resumeModel) return { state: 'unknown' };
18
- const result = await client.request('thread/turns/list', { threadId: record.session, limit: 1, sortDirection: 'desc', itemsView: 'summary' });
16
+ const session=record.run.managed.nativeWait?.session || record.session;
17
+ const cwd=session===record.session?record.cwd:record.run.managed.nativeWait?.cwd;
18
+ if(!cwd)return {state:'unknown'};
19
+ const metadata = await readCodexSession(client, session);
20
+ if (await realpath(metadata.cwd) !== cwd || metadata.model !== record.run.managed.resumeModel) return { state: 'unknown' };
21
+ const result = await client.request('thread/turns/list', { threadId: session, limit: 1, sortDirection: 'desc', itemsView: 'summary' });
19
22
  const turn = result?.data?.[0];
20
23
  if (!turn?.id || !['completed', 'failed', 'interrupted', 'inProgress'].includes(turn.status)) return { state: 'unknown' };
21
24
  return { state: turn.status === 'inProgress' ? 'running' : 'idle', turnId: turn.id, status: turn.status };
@@ -236,7 +239,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
236
239
  } else if (run.state === 'recovery_required' && ['provider_error', 'process_exit', 'shutdown_cancelled', 'interrupted_owner'].includes(run.recoveryCause) &&
237
240
  !run.managed.autoRecovery && now >= run.updatedAt + 15_000) {
238
241
  const code = run.managed.attempts?.at(-1)?.errorCode;
239
- if (code && !['checkpoint', 'checkpoint_rejected', 'closed', 'timeout', 'unavailable', 'turn_failed', 'provider_error', ...(searchEnabled(run.managed) ? ['permission_narrowed', 'manual_provider_action', 'unsupported'] : [])].includes(code)) continue;
242
+ if (code && !['checkpoint', 'checkpoint_rejected', 'closed', 'timeout', 'unavailable', 'turn_failed', 'provider_error', ...(searchEnabled(run.managed) ? ['permission_narrowed', 'permission_restore', 'manual_provider_action', 'unsupported'] : [])].includes(code)) continue;
240
243
  obstacle = { type: 'execution_error', detail: code || run.recoveryCause };
241
244
  }
242
245
  if (!obstacle) continue;