@awak-app/simy-cli 0.6.29 → 0.6.31

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.31",
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.31";
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
  }
@@ -1743,15 +1810,33 @@ function reopenLegacyLimitStop(run, now) {
1743
1810
  }
1744
1811
  run.state = 'paused'; run.reason = 'SIMY no longer limits time or turns. Resume this task to continue.';
1745
1812
  }
1746
- export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, action, inspectProcess = processIdentity, now = Date.now() }) {
1813
+ // Decision versions include explicit human controls, which must invalidate old
1814
+ // UI commands without fabricating provider checkpoint/progress revisions.
1815
+ export function advanceDeliveryControlRevision(run) {
1816
+ run.controlRevision = (run.controlRevision ?? 0) + 1;
1817
+ }
1818
+ export function deliveryDecisionRevision(run) {
1819
+ const controls = run?.controlRevision ?? 0;
1820
+ if (!Number.isSafeInteger(run?.revision) || run.revision < 0 || !Number.isSafeInteger(controls) || controls < 0) return undefined;
1821
+ const revision = run.revision + controls;
1822
+ return Number.isSafeInteger(revision) ? revision : undefined;
1823
+ }
1824
+ export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, action, inspectProcess = processIdentity, now = Date.now() }) {
1747
1825
  for (let retry = 0; ; retry += 1) {
1748
1826
  try { return await updateRecord(root, provider, session, async record => {
1749
1827
  const run = record?.run;
1750
1828
  if (!run) throw new Error('Delivery Loop not found.');
1829
+ if ((expectedRunId !== undefined || expectedRevision !== undefined) &&
1830
+ (typeof expectedRunId !== 'string' || !expectedRunId || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 ||
1831
+ run.id !== expectedRunId || deliveryDecisionRevision(run) !== expectedRevision)) throw new Error('This decision changed. Refresh before choosing.');
1751
1832
  if (record.providerArchived === true && !['stop', 'pause'].includes(action)) throw new Error('Restore the original provider conversation before changing Delivery Loop.');
1752
1833
  if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
1753
1834
  if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
1754
1835
  if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
1836
+ const commitControl = () => {
1837
+ advanceDeliveryControlRevision(run);
1838
+ return record;
1839
+ };
1755
1840
  reopenLegacyLimitStop(run, now);
1756
1841
  if (action === 'revise_purpose') {
1757
1842
  if (!searchEnabled(run.managed) || run.managed.resumeLease || run.state !== 'paused' || !text(purposeUpdate?.sourceRef, 500) || !text(purposeUpdate?.objective, 2000)) throw new Error('Pause the task and supply an explicit user purpose revision with sourceRef, objective and acceptanceCriteria.');
@@ -1776,20 +1861,20 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1776
1861
  run.managed.pendingApproval=null;delete run.managed.approvedAction;delete run.managed.purpose.lastReview;
1777
1862
  run.managed.purpose.search={cycle:0,pivots:0,experiments:[],waits:[],observed:[],lastPivotCycle:0};
1778
1863
  run.revision++;run.updatedAt=now;run.reason='User purpose revision recorded; prior evidence and exact approvals require re-evaluation. Resume explicitly within the existing budget.';
1779
- run.managed.autoRecovery=false;return record;
1864
+ run.managed.autoRecovery=false;return commitControl();
1780
1865
  } else if (action === 'continuation_policy') {
1781
1866
  if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation policy.');
1782
1867
  if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
1783
1868
  run.managed.continuationPolicy = continuationMode(continuationPolicy);
1784
1869
  run.continuationPolicyHistory ||= [];
1785
1870
  run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
1786
- run.updatedAt = now; return record;
1871
+ run.updatedAt = now; return commitControl();
1787
1872
  } else if (action === 'disable_auto_continue') {
1788
1873
  if (!run.managed?.purpose) throw new Error('Purpose continuation is not enabled.');
1789
1874
  run.managed.purpose.enabled = false; run.managed.autoRecovery = false;
1790
1875
  if (!TERMINAL.has(run.state)) { run.state = 'paused'; run.reason = 'Automatic continuation paused by user.'; }
1791
1876
  run.updatedAt = now;
1792
- return record;
1877
+ return commitControl();
1793
1878
  } else if (action === 'dependency_ready') {
1794
1879
  const wait = run.managed?.dependencyWait;
1795
1880
  if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
@@ -1835,8 +1920,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1835
1920
  else { run.state = 'recovery_required'; run.recoveryCause = 'policy_change'; run.reason = 'Policy changed; resume the original conversation for the selected action.'; run.managed.autoRecovery = false; }
1836
1921
  }
1837
1922
  else if (action === 'stop') {
1838
- run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
1839
1923
  if (TERMINAL.has(run.state)) return record;
1924
+ run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
1840
1925
  run.state = 'stopped'; run.reason = 'Stopped by user.';
1841
1926
  }
1842
1927
  else if (action === 'pause') {
@@ -1930,7 +2015,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1930
2015
  rememberRejectedAction(run.managed, pending);
1931
2016
  }
1932
2017
  } else throw new Error('Unsupported control.');
1933
- run.updatedAt = now; notifyState(run, now); return record;
2018
+ run.updatedAt = now; notifyState(run, now); return commitControl();
1934
2019
  }); } catch (error) {
1935
2020
  // Explicit user controls wait briefly for a live short state writer;
1936
2021
  // they never reclaim unknown owners or broaden execution authority.
@@ -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);
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { canReplanDelivery, purposeProgressAt, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canReplanDelivery, deliveryDecisionRevision, purposeProgressAt, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
@@ -30,7 +30,7 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
30
30
  })),
31
31
  runs: records.filter(r => r.run && r.providerArchived !== true).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
32
32
  provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
33
- objective: r.run.objective, state: r.run.state, reason: r.run.reason,
33
+ objective: r.run.objective, state: r.run.state, reason: r.run.reason, runId: r.run.id, revision: deliveryDecisionRevision(r.run), recoveryCause: r.run.recoveryCause, autoRecovery: r.run.managed?.autoRecovery === true,
34
34
  continuations: r.run.continuations,
35
35
  continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
36
36
  startedAt: r.run.startedAt,
@@ -1,3 +1,4 @@
1
+ import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
1
2
  import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
@@ -152,7 +153,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
152
153
  let providerLinks = await readProviderLinks(root);
153
154
  const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
154
155
  const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
155
- const analysisSessions = sessions => linkedSessions(sessions).filter(item => !providerLinks[item.provider].requiresConsent ||
156
+ const analysisSessions = sessions => linkedSessions(sessions).filter(item => item.archived !== true).filter(item => !providerLinks[item.provider].requiresConsent ||
156
157
  item.autoContinue === true || item.tracked === true || item.monitor === true || explicitAnalyses.has(`${item.provider}:${item.session}`));
157
158
  const authorizeProvider = async (provider, scope, expectedEpoch) => {
158
159
  await authorizeWork(scope);
@@ -386,7 +387,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
386
387
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
387
388
  const requestedScope = accessScope(currentAccess);
388
389
  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) }); })
390
+ 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
391
  .then(async value => {
391
392
  if (closed || relay || controller.signal.aborted) return;
392
393
  const latestAccess = await accessSnapshot();
@@ -695,7 +696,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
695
696
  const run = runsBySession.get(`${session.provider}:${session.session}`);
696
697
  if (run) run.actionDeadline = session.actionDeadline;
697
698
  }
698
- status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions };
699
+ status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: sessions.map(candidate => ({ ...candidate,
700
+ decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)) })) };
699
701
  const latestAccess = await accessSnapshot();
700
702
  if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
701
703
  !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
@@ -1172,7 +1174,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1172
1174
  await (dependencies.openHookSettings || openHookSetup)(provider);
1173
1175
  } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_continuation_policy"].includes(action)) {
1174
1176
  await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
1175
- taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
1177
+ taskId: command.taskId, expectedRunId: command.expectedRunId, expectedRevision: command.expectedRevision, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
1176
1178
  } else if (action === "delivery_verify") {
1177
1179
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
1178
1180
  taskId: command.taskId, criterionId: command.criterionId });
@@ -1334,7 +1336,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1334
1336
  await refreshSessions(true);
1335
1337
  } else if (action === "session_answer") {
1336
1338
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
1337
- session: command.session, taskId: command.taskId, questionId: command.questionId, answer: command.answer });
1339
+ session: command.session, taskId: command.taskId, expectedRunId: command.expectedRunId, expectedRevision: command.expectedRevision, questionId: command.questionId, answer: command.answer });
1338
1340
  await refreshSessions(true);
1339
1341
  }
1340
1342
  else throw new Error("Unsupported desktop action.");
@@ -0,0 +1,54 @@
1
+ const clean = value => typeof value === 'string' ? value.trim().slice(0, 2000) : '';
2
+
3
+ // The presentation never invents consent or executes a choice. Commands still
4
+ // validate the exact live proposal, question, run identity and revision.
5
+ export function sessionDecisionPresentation(candidate, run) {
6
+ if (!candidate || candidate.isRunning || candidate.archived) return null;
7
+ const identity = { provider: candidate.provider, session: candidate.session };
8
+ const control = { ...identity, ...(run?.taskId ? { taskId: run.taskId } : {}),
9
+ ...(run?.runId ? { expectedRunId: run.runId, expectedRevision: run.revision } : {}) };
10
+ const choice = (label, effect, action, fields = {}) => ({ label, effect, action, fields: { ...control, ...fields } });
11
+ const frozen = typeof run?.runId === 'string' && run.runId.length > 0 && Number.isSafeInteger(run.revision) && run.revision >= 0;
12
+ const refreshDecision = () => ({ kind: 'unresolved', title: '判断の状態を再確認します',
13
+ question: '現在の作業と判断の対応を確認できません。最新の状態を取得してから選んでください。',
14
+ context: 'この確認だけでは操作を許可したり、作業を再開したりしません。',
15
+ choices: [choice('状況を再確認する', '最新の状態を取得します。許可・停止状態は変更しません。', 'refresh', identity)] });
16
+ const approval = run?.pendingApproval;
17
+ if (approval?.id && clean(approval.summary) && !approval.denied && run.canApprove && !frozen) return refreshDecision();
18
+ if (approval?.id && clean(approval.summary) && !approval.denied && run.canApprove) return {
19
+ kind: 'approval', title: 'この操作を許可しますか?',
20
+ question: `実行する操作:${clean(approval.summary)}`, context: `対象:${({ task: 'この依頼の範囲', repository: 'この作業のリポジトリ' })[approval.scope] || clean(approval.scope) || '依頼された操作の範囲'}`,
21
+ choices: [choice('この操作だけを許可', '表示された対象・操作だけを許可します。AI側で権限確認が必要な場合は別途案内します。', 'delivery_approve', { approvalId: approval.id }),
22
+ choice('許可しない', run.canReplan ? 'この操作は実行しません。別の方法を調べる場合は「AIに状況確認を任せる」を選んでください。' : 'この操作は実行しません。別の方法が必要な場合は、元のAI会話で確認してください。', 'delivery_reject', { approvalId: approval.id })],
23
+ };
24
+ const question = run?.understandingQuestion;
25
+ if (run?.canAnswer && question?.id && clean(question.text) && !frozen) return refreshDecision();
26
+ if (run?.canAnswer && question?.id && clean(question.text)) return {
27
+ kind: 'question', title: '続けるために、この点を教えてください', question: clean(question.text),
28
+ context: '回答を受け取ってから目的を確認し、許可された作業を続けます。',
29
+ answerFields: { ...control, taskId: run.taskId, questionId: question.id },
30
+ choices: [],
31
+ };
32
+ if (run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
33
+ ['supervising', 'watching', 'waiting'].includes(candidate.autoContinueStatus)) return null;
34
+ if (run && ['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state)) {
35
+ const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
36
+ const context = run.state === 'paused' ? 'この作業は停止中です。停止の指示は自動で解除しません。' : ({ session_end: `元の${providerName}会話が終了し、自動継続が止まっています。`,
37
+ process_exit: '元のAIが終了したため、自動継続できていません。', provider_error: 'AIの実行でエラーが発生し、作業が止まっています。',
38
+ shutdown_cancelled: 'SIMYの終了時に作業が中断されました。', hook_owner_mismatch: '元のAI会話の接続が変わりました。' })[run.recoveryCause]
39
+ || '作業を続けるための条件を確認できていません。';
40
+ const choices = [];
41
+ if (run.canReplan && frozen) choices.push(choice('AIに状況確認を任せる', '目的・履歴・制限を維持して原因と安全な次の手順を調べます。操作の許可を追加することはありません。', 'delivery_replan'));
42
+ if (run.canResume && frozen) choices.push(choice('再開を依頼する', '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', 'delivery_resume'));
43
+ choices.push(choice(`元の${candidate.provider === 'claude' ? 'Claude' : 'Codex'}会話を開く`, '接続や権限の確認が必要な場合、元のAI会話で確認できます。会話を開くだけでは作業を再開しません。', 'open_original'));
44
+ if (candidate.autoContinue === true) choices.push(choice('自動継続をオフにする', '今後の自動継続をオフにします。作業の履歴は残ります。', 'session_set_auto_continue', { ...identity, enabled: false }));
45
+ return { kind: 'recovery', title: '作業が止まっています。対応を選んでください', question: frozen && (run.canReplan || run.canResume) ? (run.canReplan ? '選ぶこと:AIに状況確認を任せるか、元の会話を確認するか。' : '選ぶこと:再開を依頼するか、元の会話を確認するか。') : 'この画面からの再開には対応していません。元のAI会話で状態を確認できます。', context: `理由:${context}`, choices };
46
+ }
47
+ if (candidate.autoContinueStatus === 'needs_user' || candidate.analysis?.state === 'needs_user') return {
48
+ kind: 'unresolved', title: '必要な判断内容を確認します',
49
+ question: '判断していただく内容をまだ特定できていません。具体的な内容が分かるまで、操作の許可は求めません。',
50
+ context: 'まず会話と最新の状態を確認します。元のAIが権限の確認を求めている場合は、その会話で確認が必要です。',
51
+ choices: [choice('状況を再確認する', '最新の状態を取得します。許可・停止状態は変更しません。', 'refresh', identity)],
52
+ };
53
+ return null;
54
+ }
@@ -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, advanceDeliveryControlRevision, 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
  };
@@ -353,6 +404,9 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
353
404
  if (next.run.state === 'paused' && next.run.reason === 'Automatic continuation paused by user.') { next.run.state = 'recovery_required'; next.run.recoveryCause = 'session_supervision'; next.run.managed.autoRecovery = true; }
354
405
  } else if (!enabled && next.run.managed.purpose) { if (next.run.state !== 'auto_resuming') next.run.managed.purpose.enabled = false; next.run.managed.autoRecovery = false; if (!TERMINAL_RUNS.has(next.run.state) && next.run.state !== 'auto_resuming') { next.run.state = 'paused'; next.run.reason = 'Automatic continuation paused by user.'; } }
355
406
  }
407
+ // The desktop preference is an explicit human control too: an old decision
408
+ // card must not undo this switch, even when no provider checkpoint arrived.
409
+ if (next.run) advanceDeliveryControlRevision(next.run);
356
410
  return next;
357
411
  });
358
412
  if (enabled && updated.run && !updated.run.managed) {
@@ -473,6 +527,6 @@ export async function continueSession(options) {
473
527
  acceptanceCriteria: [], contextBootstrap: true });
474
528
  }
475
529
 
476
- export async function answerSession({ root, provider, session, taskId, questionId, answer, now = Date.now() }) {
477
- return controlDelivery({ root, provider, session, taskId, questionId, answer, action: 'session_answer', now });
530
+ export async function answerSession({ root, provider, session, taskId, expectedRunId, expectedRevision, questionId, answer, now = Date.now() }) {
531
+ return controlDelivery({ root, provider, session, taskId, expectedRunId, expectedRevision, questionId, answer, action: 'session_answer', now });
478
532
  }
@@ -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
+ }