@awak-app/simy-cli 0.6.29 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.29",
3
+ "version": "0.6.30",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.29";
16
+ export const CLI_VERSION = "0.6.30";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function narrativeImageCapabilityMetadata() {
@@ -9,7 +9,7 @@ import { searchEnabled, failureLimitReached, searchState, recordSearchExperiment
9
9
  import { createHash, randomUUID } from 'node:crypto';
10
10
  import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
11
11
  import { homedir } from 'node:os';
12
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
12
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
13
13
  import { execFile } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
15
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
@@ -65,9 +65,40 @@ function applyProviderConsent(record, link) {
65
65
  revision: (record.run.revision || 0) + 1,
66
66
  ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
67
67
  }
68
+ async function applyArchiveFence(root, record) {
69
+ if (!record) return record;
70
+ const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
71
+ let archived;
72
+ try { archived = JSON.parse(await readFile(file, 'utf8')).archived !== false; }
73
+ catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
74
+ if (!archived) return record;
75
+ return { ...record, providerArchived: true, autoContinue: false, tracked: false, monitor: false,
76
+ ...(record.run && !TERMINAL.has(record.run.state) ? { run: { ...record.run, state: 'paused',
77
+ reason: 'The original provider conversation was archived.',
78
+ ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
79
+ }
80
+ export async function recordProviderArchive(root, provider, session, archived, observedAt) {
81
+ await mkdir(root, { recursive: true, mode: 0o700 });
82
+ const file = `${recordPath(root, provider, session)}.archive-fence`;
83
+ const lock = `${file}.lock`, token = await acquireStateLock(lock, processIdentity);
84
+ const temporary = `${file}.${randomUUID()}.tmp`;
85
+ try {
86
+ let previous; try { previous = JSON.parse(await readFile(file, 'utf8')); } catch {}
87
+ if (previous?.observedAt > observedAt) return;
88
+ await writeFile(temporary, JSON.stringify({ provider, session, archived, observedAt }), { mode: 0o600 });
89
+ await rename(temporary, file);
90
+ } finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
91
+ }
68
92
  async function currentProviderConsent(root, records) {
69
93
  const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
70
- return records.map(record => applyProviderConsent(record, links[record.provider] || {}));
94
+ let archiveNames;
95
+ try { archiveNames = new Set((await readdir(root)).filter(name => name.endsWith('.archive-fence'))); }
96
+ catch { /* A failed index read falls back to fresh per-record fencing. */ }
97
+ return Promise.all(records.map(record => {
98
+ const current = applyProviderConsent(record, links[record.provider] || {});
99
+ if (archiveNames && !archiveNames.has(`${basename(recordPath(root, record.provider, record.session))}.archive-fence`)) return current;
100
+ return applyArchiveFence(root, current);
101
+ }));
71
102
  }
72
103
  export async function linkedProvider(root, provider) {
73
104
  return (await providerLinkState(root, provider)).connected === true;
@@ -177,6 +208,34 @@ async function acquireStateLock(lock, inspectProcess, depth = 0) {
177
208
  await releaseStateLock(reclaim, token);
178
209
  }
179
210
  }
211
+ export async function withSessionParentLock(root, operation, inspectProcess = processIdentity) {
212
+ await mkdir(root, { recursive: true, mode: 0o700 });
213
+ const lock = join(root, 'session-lineage.lock');
214
+ // Distinct SessionStart hooks share this lock. Wait within the hook timeout
215
+ // for short writers, without reclaiming unknown owners or replaying work.
216
+ const deadline = Date.now() + 500;
217
+ let token;
218
+ for (;;) {
219
+ try { token = await acquireStateLock(lock, inspectProcess); break; }
220
+ catch (error) {
221
+ if (Date.now() >= deadline || !/^(?:Delivery state is busy; retry the command\.|Delivery state lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
222
+ await new Promise(resolve => setTimeout(resolve, 10));
223
+ }
224
+ }
225
+ try { return await operation(); } finally { await releaseStateLock(lock, token); }
226
+ }
227
+ export async function validateSessionParent(root, child, parent) {
228
+ const key = item => `${item.provider}:${item.session}`;
229
+ const seen = new Set([key(child)]);
230
+ let cursor = parent;
231
+ for (let depth = 0; cursor; depth++) {
232
+ if (depth >= 256 || seen.has(key(cursor))) throw new Error('Parent relationship would create a cycle or exceed the ancestry limit.');
233
+ seen.add(key(cursor));
234
+ const ancestor = await readRecord(root, cursor.provider, cursor.session);
235
+ if (!ancestor?.observedAt && !ancestor?.admission?.validatedAt) throw new Error('The parent ancestry must contain observed provider conversations.');
236
+ cursor = ancestor.parentSession;
237
+ }
238
+ }
180
239
  export async function updateRecord(root, provider, session, mutate, { inspectProcess = processIdentity } = {}) {
181
240
  await mkdir(root, { recursive: true, mode: 0o700 });
182
241
  const file = recordPath(root, provider, session), lock = `${file}.lock`;
@@ -186,7 +245,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
186
245
  let temporary;
187
246
  try {
188
247
  const stored = await read(file);
189
- const previous = applyProviderConsent(stored, await providerLinkState(root, provider));
248
+ const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
190
249
  const previousBytes = stored === null ? null : JSON.stringify(stored);
191
250
  const next = await mutate(previous);
192
251
  if (next) {
@@ -212,7 +271,7 @@ export async function readRecord(root = rootDirectory(), provider, session) {
212
271
  const path = recordPath(root, provider, session);
213
272
  try {
214
273
  const record = await read(path);
215
- return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyProviderConsent(record, await providerLinkState(root, provider)) : null;
274
+ return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyArchiveFence(root, applyProviderConsent(record, await providerLinkState(root, provider))) : null;
216
275
  } catch { return null; } // Match listRecords' treatment of unreadable records.
217
276
  }
218
277
  const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
@@ -1557,7 +1616,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1557
1616
  const event = input.hook_event_name;
1558
1617
  const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
1559
1618
  let output = {}, boundaryLease = null, claimedWorkspace = null;
1560
- try { await updateRecord(root, provider, input.session_id, async old => {
1619
+ const applyObservation = () => updateRecord(root, provider, input.session_id, async old => {
1561
1620
  const currentLink = await providerLinkState(root, provider);
1562
1621
  if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
1563
1622
  if (old && old.cwd !== cwd) return old;
@@ -1569,7 +1628,11 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1569
1628
  const parentId = env.SIMY_PARENT_SESSION || (parentProvider === 'codex' ? env.CODEX_THREAD_ID : null);
1570
1629
  if (['codex', 'claude'].includes(parentProvider) && /^[0-9a-f-]{36}$/i.test(parentId || '') &&
1571
1630
  !(parentProvider === provider && parentId === input.session_id)) {
1572
- record.parentSession = { provider: parentProvider, session: parentId };
1631
+ const parent = { provider: parentProvider, session: parentId };
1632
+ try {
1633
+ await validateSessionParent(root, { provider, session: input.session_id }, parent);
1634
+ record.parentSession = parent;
1635
+ } catch { /* Unverified or cyclic ancestry never changes navigation. */ }
1573
1636
  }
1574
1637
  }
1575
1638
  const owner = record.run?.managed?.liveHookOwner;
@@ -1711,7 +1774,11 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1711
1774
  record.run.updatedAt = now;
1712
1775
  notifyState(record.run, now);
1713
1776
  return record;
1714
- }); } catch (error) {
1777
+ });
1778
+ try {
1779
+ if (event === 'SessionStart') await withSessionParentLock(root, applyObservation, inspectProcess);
1780
+ else await applyObservation();
1781
+ } catch (error) {
1715
1782
  if (claimedWorkspace?.release) await claimedWorkspace.release();
1716
1783
  throw error;
1717
1784
  }
@@ -1,3 +1,4 @@
1
+ import { linkSessionParent } from '../session-supervision/session-lineage.js';
1
2
  import { adoptAutonomousPolicy } from '../session-supervision/autonomous-upgrade.js';
2
3
  import { deliveryEvidence } from './evidence.js';
3
4
  import { readFile } from 'node:fs/promises';
@@ -79,6 +80,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
79
80
  output('simy delivery runtime install|status|workflows — install an immutable local candidate, inspect its version/digest, or refresh existing plugin sources/cache while preserving enabled state and hook trust. Never enables a task.');
80
81
  output('simy delivery runtime doctor|desktop-install|desktop-activate|watch — audit live host/state/monitor alignment, or prepare a separate local desktop using the verified fixed runtime. Does not start or resume tasks.');
81
82
  output('simy delivery runtime policy-adopt [--task-id ID [--criterion-id ID]] — explicitly delegate decisions within existing permissions. Manual acceptance stays manual unless the user names the exact task and criterion to delegate its verifier.');
83
+ output('simy delivery link_parent --provider codex|claude --session CHILD --parent-provider codex|claude --parent-session PARENT — record an explicit observed navigation relationship without starting work.');
82
84
  output('simy delivery supervise [--once] — service already opted-in tasks with this runtime; refuses an active desktop host.');
83
85
  output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
84
86
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
@@ -88,11 +90,12 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
88
90
  output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--execution-cwd PATH] [--max-failures 3]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Standard start tracks checkpoints and reports done without certifying acceptance. Managed start freezes the original objective, optional user-selected deliveryLevel, and acceptance specification. Desktop Continue infers the original conversation goal first. Continue adopts an eligible ordinary loop with the same goal and failure budget; its first managed action reconciles prior work read-only. --execution-cwd explicitly binds the task directory while provider identity retains its original folder. Resume preserves original task identity, history and approvals.');
89
91
  return 0;
90
92
  }
91
- const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-failures', 'max-no-progress-minutes', 'continuation-policy', 'file', 'managed-file', 'execution-cwd', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
93
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-failures', 'max-no-progress-minutes', 'continuation-policy', 'file', 'managed-file', 'execution-cwd', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session', 'parent-provider', 'parent-session']);
92
94
  const options = parseDeliveryOptions(args, allowed);
93
95
  const base = { root, cwd, provider: options.provider, session: options.session };
94
96
  let result;
95
- if (action === 'evidence') {
97
+ if (action === 'link_parent') result = await linkSessionParent({ ...base, parentProvider: options['parent-provider'], parentSession: options['parent-session'] });
98
+ else if (action === 'evidence') {
96
99
  const record = await readRecord(root, options.provider, options.session);
97
100
  if (!record) throw new Error('Exact provider session record not found.');
98
101
  result = deliveryEvidence(record, root);
@@ -152,7 +152,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
152
152
  let providerLinks = await readProviderLinks(root);
153
153
  const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
154
154
  const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
155
- const analysisSessions = sessions => linkedSessions(sessions).filter(item => !providerLinks[item.provider].requiresConsent ||
155
+ const analysisSessions = sessions => linkedSessions(sessions).filter(item => item.archived !== true).filter(item => !providerLinks[item.provider].requiresConsent ||
156
156
  item.autoContinue === true || item.tracked === true || item.monitor === true || explicitAnalyses.has(`${item.provider}:${item.session}`));
157
157
  const authorizeProvider = async (provider, scope, expectedEpoch) => {
158
158
  await authorizeWork(scope);
@@ -386,7 +386,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
386
386
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
387
387
  const requestedScope = accessScope(currentAccess);
388
388
  const controller = discoveryController = new AbortController();
389
- sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
389
+ sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, includeArchived: true, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
390
390
  .then(async value => {
391
391
  if (closed || relay || controller.signal.aborted) return;
392
392
  const latestAccess = await accessSnapshot();
@@ -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
  }
@@ -4,8 +4,8 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
4
4
  import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
5
5
  import { realpath } from 'node:fs/promises';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
8
- 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';
9
9
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
10
10
  import { loadPurposeRuleSet } from './purpose-instruction.js';
11
11
  import { originalSessionTarget } from './original-session-target.js';
@@ -114,12 +114,13 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
114
114
  }
115
115
 
116
116
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
117
- 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 } = {}) {
118
118
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
119
119
  assertActive();
120
120
  const connected = new Set(connectedProviders);
121
121
  const records = (await listRecords(root)).filter(record => connected.has(record.provider));
122
122
  const priorities = records.filter(retainedSession);
123
+ let archivedCodex = [];
123
124
  let discovered = [], codexError = null, codexReasonCode = null, claude = [], claudeError = null;
124
125
  let archiveStates = new Map();
125
126
  await Promise.all([
@@ -127,8 +128,25 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
127
128
  try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
128
129
  const active = await listCodexSessions({ client, inspectState: true, maxSessions: RECENT_SESSION_LIMIT, codexHome,
129
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
+ }
130
147
  const ids = new Set(active.map(item => item.session));
131
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);
132
150
  return active;
133
151
  }, signal); }
134
152
  catch (error) {
@@ -149,6 +167,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
149
167
  const record = records[index];
150
168
  if (record.provider !== 'codex') continue;
151
169
  const isArchived = archiveStates.get(record.session) === true;
170
+ if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
152
171
  if (isArchived && record.providerArchived === true) continue;
153
172
  if (!isArchived && !(record.providerArchived === true && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
154
173
  try { records[index] = await updateRecord(root, record.provider, record.session, current => {
@@ -163,7 +182,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
163
182
  }
164
183
  }
165
184
  return current;
166
- }); } catch {
185
+ });
186
+ if (!isArchived) await recordProviderArchive(root, record.provider, record.session, false, now);
187
+ } catch {
167
188
  // Reconciliation can contend with a hook writer or an unreclaimable
168
189
  // legacy lock. Preserve stored state and retry on the next refresh;
169
190
  // one record must not discard either provider's entire catalog.
@@ -172,20 +193,30 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
172
193
  assertActive();
173
194
  const byKey = new Map();
174
195
  const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
175
- 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; }
176
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);
177
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
+ }
178
209
  for (const record of records) {
179
210
  // Positive provider evidence still suppresses archived conversations if
180
211
  // their local reconciliation could not be persisted. Never grant work
181
212
  // from an unsuccessful unarchive reconciliation.
182
- if (record.provider === 'codex' && archiveStates.get(record.session) === true) continue;
183
- if (record.providerArchived === true) continue;
213
+ const archived = record.providerArchived === true || record.provider === 'codex' && archiveStates.get(record.session) === true;
214
+ if (archived && !includeArchived) continue;
184
215
  const key = `${record.provider}:${record.session}`;
185
216
  // A hook observation can belong to an AI-created worker. Only explicit
186
217
  // supervision may restore a conversation absent from provider discovery.
187
- if (!byKey.has(key) && retainedSession(record)) byKey.set(key, {
188
- 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',
189
220
  lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
190
221
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
191
222
  availability: 'unknown', model: record.admission?.model || null,
@@ -193,15 +224,32 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
193
224
  }
194
225
  // Select recent history before live probes, keeping explicit supervision
195
226
  // visible even during a truncated discovery or provider outage.
196
- 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), {
197
232
  priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
198
- }), 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);
199
246
  const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
200
247
  assertActive();
201
248
  let nextItem = 0;
202
249
  const project = async item => {
203
250
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
204
251
  const run = record?.run;
252
+ const archived = item.archived === true || record?.providerArchived === true;
205
253
  const mode = run?.managed ? run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' ? 'pending_takeover' : 'managed' : run ? 'standard_loop' : record?.monitor ? 'monitor' : 'unmanaged';
206
254
  const codexChannel = codexContinuationChannel(item.observation.source, item.model, codexError ? { code: codexReasonCode, reason: codexError } : null);
207
255
  const supported = item.provider === 'codex' && codexChannel.ready;
@@ -216,7 +264,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
216
264
  cwd: await realpath(item.cwd), source: item.observation.source, model: item.model, validatedAt: now };
217
265
  } catch { /* Missing workspaces cannot grant an admission. */ }
218
266
  }
219
- 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);
220
270
  const upgradeable = canUpgradeDelivery(run);
221
271
  const continuationIssue = liveHook && run ? !run.managed && !upgradeable
222
272
  ? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
@@ -233,14 +283,15 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
233
283
  } catch { /* Unverified process leaves runtime status unknown. */ }
234
284
  }
235
285
  return { provider: item.provider, session: item.session, cwd: item.cwd,
236
- ...((item.parentSession || record?.parentSession) ? { parentSession: item.parentSession || record.parentSession } : {}),
286
+ ...((item.parentSession || record?.parentSession) ? { parentSession: record?.parentSession || item.parentSession } : {}),
237
287
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
238
288
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
239
289
  ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
240
290
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
241
291
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
242
292
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
243
- autoContinue: record?.autoContinue === true,
293
+ archived,
294
+ autoContinue: !archived && record?.autoContinue === true,
244
295
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
245
296
  autoContinueAvailability: availability,
246
297
  ...autoContinueState(record, isRunning, availability),
@@ -249,13 +300,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
249
300
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
250
301
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
251
302
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
252
- ...(continuationAdmission ? { continuationAdmission } : {}),
303
+ ...(!archived && continuationAdmission ? { continuationAdmission } : {}),
253
304
  continuationReasonCode: availability.reasonCode || claudeChannel?.code || 'ready',
254
305
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
255
- canPurposeContinue: supported && (!run || !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(run.state)),
256
- canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
257
- canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
258
- 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)) },
259
310
  reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
260
311
  codexError || (!supported ? 'Exact model or provider metadata is unavailable.' : null),
261
312
  };
@@ -0,0 +1,27 @@
1
+ import { readRecord, updateRecord, withSessionParentLock, validateSessionParent } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+
3
+ const providers = new Set(['codex', 'claude']);
4
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const key = item => `${item.provider}:${item.session}`;
6
+
7
+ // This is navigation metadata only. Linking never enrolls, resumes, archives,
8
+ // approves or changes the provider's native conversation identity.
9
+ export async function linkSessionParent({ root, provider, session, parentProvider, parentSession }) {
10
+ const child = { provider, session }, parent = { provider: parentProvider, session: parentSession };
11
+ for (const item of [child, parent]) {
12
+ if (!providers.has(item.provider) || !uuid.test(item.session || '')) throw new Error('Exact provider and session IDs are required.');
13
+ }
14
+ if (key(child) === key(parent)) throw new Error('A conversation cannot be its own parent.');
15
+ return withSessionParentLock(root, async () => {
16
+ const record = await readRecord(root, provider, session);
17
+ if (!record?.observedAt && !record?.admission?.validatedAt) throw new Error('The child must be an observed provider conversation.');
18
+ await validateSessionParent(root, child, parent);
19
+ return await updateRecord(root, provider, session, current => {
20
+ if (!current) throw new Error('The child conversation is no longer available.');
21
+ if (current.parentSession && key(current.parentSession) !== key(parent)) throw new Error('An existing parent cannot be silently replaced.');
22
+ current.parentSession = parent;
23
+ current.parentSessionSource = 'explicit';
24
+ return current;
25
+ });
26
+ });
27
+ }