@awak-app/simy-cli 0.6.29 → 0.6.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +74 -7
- package/src/delivery-hooks/command.js +5 -2
- package/src/desktop-host.js +2 -2
- package/src/session-supervision/discovery-window.js +13 -1
- package/src/session-supervision/index.js +70 -19
- 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.30";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -9,7 +9,7 @@ import { searchEnabled, failureLimitReached, searchState, recordSearchExperiment
|
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
10
|
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
13
13
|
import { execFile } from 'node:child_process';
|
|
14
14
|
import { promisify } from 'node:util';
|
|
15
15
|
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
@@ -65,9 +65,40 @@ function applyProviderConsent(record, link) {
|
|
|
65
65
|
revision: (record.run.revision || 0) + 1,
|
|
66
66
|
...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
|
|
67
67
|
}
|
|
68
|
+
async function applyArchiveFence(root, record) {
|
|
69
|
+
if (!record) return record;
|
|
70
|
+
const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
|
|
71
|
+
let archived;
|
|
72
|
+
try { archived = JSON.parse(await readFile(file, 'utf8')).archived !== false; }
|
|
73
|
+
catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
|
|
74
|
+
if (!archived) return record;
|
|
75
|
+
return { ...record, providerArchived: true, autoContinue: false, tracked: false, monitor: false,
|
|
76
|
+
...(record.run && !TERMINAL.has(record.run.state) ? { run: { ...record.run, state: 'paused',
|
|
77
|
+
reason: 'The original provider conversation was archived.',
|
|
78
|
+
...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
|
|
79
|
+
}
|
|
80
|
+
export async function recordProviderArchive(root, provider, session, archived, observedAt) {
|
|
81
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
82
|
+
const file = `${recordPath(root, provider, session)}.archive-fence`;
|
|
83
|
+
const lock = `${file}.lock`, token = await acquireStateLock(lock, processIdentity);
|
|
84
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
85
|
+
try {
|
|
86
|
+
let previous; try { previous = JSON.parse(await readFile(file, 'utf8')); } catch {}
|
|
87
|
+
if (previous?.observedAt > observedAt) return;
|
|
88
|
+
await writeFile(temporary, JSON.stringify({ provider, session, archived, observedAt }), { mode: 0o600 });
|
|
89
|
+
await rename(temporary, file);
|
|
90
|
+
} finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
|
|
91
|
+
}
|
|
68
92
|
async function currentProviderConsent(root, records) {
|
|
69
93
|
const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
|
|
70
|
-
|
|
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
|
}
|
|
@@ -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);
|
package/src/desktop-host.js
CHANGED
|
@@ -152,7 +152,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
152
152
|
let providerLinks = await readProviderLinks(root);
|
|
153
153
|
const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
|
|
154
154
|
const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
|
|
155
|
-
const analysisSessions = sessions => linkedSessions(sessions).filter(item => !providerLinks[item.provider].requiresConsent ||
|
|
155
|
+
const analysisSessions = sessions => linkedSessions(sessions).filter(item => item.archived !== true).filter(item => !providerLinks[item.provider].requiresConsent ||
|
|
156
156
|
item.autoContinue === true || item.tracked === true || item.monitor === true || explicitAnalyses.has(`${item.provider}:${item.session}`));
|
|
157
157
|
const authorizeProvider = async (provider, scope, expectedEpoch) => {
|
|
158
158
|
await authorizeWork(scope);
|
|
@@ -386,7 +386,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
386
386
|
if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
|
|
387
387
|
const requestedScope = accessScope(currentAccess);
|
|
388
388
|
const controller = discoveryController = new AbortController();
|
|
389
|
-
sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
|
|
389
|
+
sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, includeArchived: true, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
|
|
390
390
|
.then(async value => {
|
|
391
391
|
if (closed || relay || controller.signal.aborted) return;
|
|
392
392
|
const latestAccess = await accessSnapshot();
|
|
@@ -11,5 +11,17 @@ export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, prior
|
|
|
11
11
|
const recent = [...items].sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.session.localeCompare(b.session));
|
|
12
12
|
const retained = [], history = [];
|
|
13
13
|
for (const item of recent) (priority(item) ? retained : history).push(item);
|
|
14
|
-
|
|
14
|
+
const selected = [...retained, ...history.slice(0, Math.max(0, limit - retained.length))];
|
|
15
|
+
const byKey = new Map(items.map(item => [`${item.provider}:${item.session}`, item]));
|
|
16
|
+
const included = new Set(selected.map(item => `${item.provider}:${item.session}`));
|
|
17
|
+
// Keep observed ancestors so a recent child does not silently become a root.
|
|
18
|
+
for (const item of selected) {
|
|
19
|
+
let parent = item.parentSession;
|
|
20
|
+
while (parent && selected.length < items.length) {
|
|
21
|
+
const key = `${parent.provider}:${parent.session}`;
|
|
22
|
+
if (included.has(key) || !byKey.has(key)) break;
|
|
23
|
+
const ancestor = byKey.get(key); included.add(key); selected.push(ancestor); parent = ancestor.parentSession;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return selected;
|
|
15
27
|
}
|
|
@@ -4,8 +4,8 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
|
|
|
4
4
|
import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
5
5
|
import { realpath } from 'node:fs/promises';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
8
|
-
import { listCodexSessions, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
7
|
+
import { TERMINAL as TERMINAL_RUNS, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
8
|
+
import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
9
9
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
10
10
|
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
11
11
|
import { originalSessionTarget } from './original-session-target.js';
|
|
@@ -114,12 +114,13 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
117
|
-
inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome, connectedProviders = ['codex', 'claude'] } = {}) {
|
|
117
|
+
inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
|
|
118
118
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
119
119
|
assertActive();
|
|
120
120
|
const connected = new Set(connectedProviders);
|
|
121
121
|
const records = (await listRecords(root)).filter(record => connected.has(record.provider));
|
|
122
122
|
const priorities = records.filter(retainedSession);
|
|
123
|
+
let archivedCodex = [];
|
|
123
124
|
let discovered = [], codexError = null, codexReasonCode = null, claude = [], claudeError = null;
|
|
124
125
|
let archiveStates = new Map();
|
|
125
126
|
await Promise.all([
|
|
@@ -127,8 +128,25 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
127
128
|
try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
|
|
128
129
|
const active = await listCodexSessions({ client, inspectState: true, maxSessions: RECENT_SESSION_LIMIT, codexHome,
|
|
129
130
|
prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
|
|
131
|
+
if (includeArchived) {
|
|
132
|
+
const observedArchives = new Map();
|
|
133
|
+
const archiveClient = { request: async (method, params) => {
|
|
134
|
+
const response = await client.request(method, params);
|
|
135
|
+
if (method === 'thread/list' && params.archived === true) {
|
|
136
|
+
for (const thread of (response?.data || []).slice(0, params.limit)) {
|
|
137
|
+
const item = codexThreadMetadata(thread);
|
|
138
|
+
if (item && ['cli', 'vscode', 'appServer'].includes(item.source)) observedArchives.set(item.session, { ...item, archived: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return response;
|
|
142
|
+
} };
|
|
143
|
+
try { await listCodexSessions({ client: archiveClient, archived: true, maxSessions: RECENT_SESSION_LIMIT }); }
|
|
144
|
+
catch { /* Keep positive archive pages even if a later page fails. */ }
|
|
145
|
+
archivedCodex = [...observedArchives.values()];
|
|
146
|
+
}
|
|
130
147
|
const ids = new Set(active.map(item => item.session));
|
|
131
148
|
archiveStates = await readCodexArchiveStates(client, records.filter(record => record.provider === 'codex' && !ids.has(record.session)), { codexHome });
|
|
149
|
+
for (const item of archivedCodex) archiveStates.set(item.session, true);
|
|
132
150
|
return active;
|
|
133
151
|
}, signal); }
|
|
134
152
|
catch (error) {
|
|
@@ -149,6 +167,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
149
167
|
const record = records[index];
|
|
150
168
|
if (record.provider !== 'codex') continue;
|
|
151
169
|
const isArchived = archiveStates.get(record.session) === true;
|
|
170
|
+
if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
|
|
152
171
|
if (isArchived && record.providerArchived === true) continue;
|
|
153
172
|
if (!isArchived && !(record.providerArchived === true && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
|
|
154
173
|
try { records[index] = await updateRecord(root, record.provider, record.session, current => {
|
|
@@ -163,7 +182,9 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
163
182
|
}
|
|
164
183
|
}
|
|
165
184
|
return current;
|
|
166
|
-
});
|
|
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
|
};
|
|
@@ -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
|
+
}
|