@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 +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +98 -13
- package/src/delivery-hooks/command.js +5 -2
- package/src/delivery-hooks/desktop.js +2 -2
- package/src/desktop-host.js +7 -5
- package/src/session-supervision/decision-presentation.js +54 -0
- package/src/session-supervision/discovery-window.js +13 -1
- package/src/session-supervision/index.js +75 -21
- package/src/session-supervision/session-lineage.js +27 -0
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 === '
|
|
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,
|
package/src/desktop-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
183
|
-
if (
|
|
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
|
|
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
|
-
}),
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|