@awak-app/simy-cli 0.7.10 → 0.7.12
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/README.md +17 -1
- package/package.json +1 -1
- package/src/agent.js +47 -7
- package/src/auto-update.js +32 -9
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
- package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
- package/src/delivery-hooks/desktop.js +1 -1
- package/src/delivery-hooks/supervisor.js +39 -10
- package/src/desktop-host.js +90 -15
- package/src/desktop-status.js +1 -1
- package/src/direct-executor.js +104 -4
- package/src/local-task.js +14 -3
- package/src/provider-stream.js +3 -0
- package/src/runner.js +19 -4
- package/src/session-supervision/claude-desktop-lineage.js +19 -5
- package/src/session-supervision/claude-sessions.js +73 -15
- package/src/session-supervision/codex-app-server.js +28 -9
- package/src/session-supervision/index.js +25 -14
- package/src/session-supervision/pull-requests.js +52 -0
- package/src/session-supervision/work-state.js +84 -0
- package/src/sqm/claude-plugin.js +5 -0
- package/src/sqm/review-worker.js +15 -6
- package/src/update-detachment/claude-observation.js +33 -0
- package/src/update-detachment/codex-context.js +27 -0
- package/src/update-detachment/coordinator.js +117 -0
- package/src/update-detachment/dispatch-gate.js +1 -0
- package/src/update-detachment/ledger-adapter.js +62 -0
- package/src/update-detachment/ledger-reader.js +42 -0
- package/src/update-detachment/provenance.js +1 -0
- package/src/update-detachment/provider-adapters.js +33 -0
- package/src/update-detachment/reconcile.js +105 -0
- package/src/update-detachment/reconciliation-adapter.js +13 -0
- package/src/update-detachment/runtime.js +319 -0
- package/src/update-detachment/state-store.js +1 -0
- package/src/update-detachment/worker-entry.js +145 -0
- package/src/update-detachment/worker-ledger-runtime.js +34 -0
- package/src/update-detachment/worker-ledger.js +138 -0
- package/src/update-detachment/worker-storage.js +29 -0
- package/src/update-detachment/worker.js +167 -0
package/src/local-task.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureDispatchProvenance } from './update-detachment/provenance.js';
|
|
2
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
|
|
1
3
|
import { normalizePurposeVerification } from "./purpose-local-proof.js";
|
|
2
4
|
import { buildWorkflowReportingInstruction } from "./workflow-reporting.js";
|
|
3
5
|
import { execFile } from "node:child_process";
|
|
@@ -449,12 +451,17 @@ export async function startLocalTask(
|
|
|
449
451
|
beforeSpawn = null,
|
|
450
452
|
session = null,
|
|
451
453
|
apiOrigin = null,
|
|
454
|
+
sessionRoot,
|
|
452
455
|
observationFetch = globalThis.fetch,
|
|
453
456
|
deliverableUploadSleep = undefined,
|
|
454
457
|
} = {},
|
|
455
458
|
) {
|
|
456
459
|
if (!task || task.status !== "queued") throw new Error("Local Task must be queued.");
|
|
457
460
|
if (task.invocation_count >= 1) throw new Error("Local Tasks allow only one Provider invocation.");
|
|
461
|
+
const dispatchOwner = captureDispatchOwner(sessionRoot);
|
|
462
|
+
const dispatchAttemptId = `${task.id}:provider:1`;
|
|
463
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: task.backend, worktree: task.workspace_path, accountSession: session || undefined, origin: apiOrigin || undefined, taskId: task.id, recordRef: path.join(task.task_root, 'task.json') });
|
|
464
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
458
465
|
updateTask(task, {
|
|
459
466
|
control_state: "starting",
|
|
460
467
|
invocation_count: 1,
|
|
@@ -573,7 +580,7 @@ export async function startLocalTask(
|
|
|
573
580
|
const tokenRecords = [];
|
|
574
581
|
let child;
|
|
575
582
|
try {
|
|
576
|
-
child = command.spawn(command.bin, command.args, {
|
|
583
|
+
child = dispatchWithFence(dispatchOwner, { taskId: task.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
|
|
577
584
|
cwd: task.workspace_path,
|
|
578
585
|
env: {
|
|
579
586
|
...process.env,
|
|
@@ -586,7 +593,7 @@ export async function startLocalTask(
|
|
|
586
593
|
},
|
|
587
594
|
stdio: ["ignore", "pipe", "pipe"],
|
|
588
595
|
detached: process.platform !== "win32",
|
|
589
|
-
});
|
|
596
|
+
}));
|
|
590
597
|
} catch (error) {
|
|
591
598
|
void rm(runtimeTempRoot, { recursive: true, force: true }).then(
|
|
592
599
|
() => reject(error),
|
|
@@ -631,7 +638,7 @@ export async function startLocalTask(
|
|
|
631
638
|
stream: "stdout",
|
|
632
639
|
onLine,
|
|
633
640
|
onUsage,
|
|
634
|
-
onEvent: (event) => observationSender?.observe(event),
|
|
641
|
+
onEvent: (event) => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: task.backend, event, onError: error => { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) task.dispatch_identity = identity; observationSender?.observe(event); },
|
|
635
642
|
onTerminalError: (message) => {
|
|
636
643
|
task.error ||= message;
|
|
637
644
|
task.reason_code ||= "provider_execution_failed";
|
|
@@ -786,6 +793,8 @@ export async function startLocalTask(
|
|
|
786
793
|
: task.error || "Local Task failed.",
|
|
787
794
|
});
|
|
788
795
|
await persist(task);
|
|
796
|
+
try { queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: provenance.recordRef }); }
|
|
797
|
+
catch (error) { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; try { await persist(task); } catch {} }
|
|
789
798
|
resolve(task);
|
|
790
799
|
};
|
|
791
800
|
child.on("error", (spawnError) => void finish({ error: spawnError.message }));
|
|
@@ -831,6 +840,8 @@ export async function stopLocalTask(task, { persist = persistLocalTask } = {}) {
|
|
|
831
840
|
export function localTaskSnapshot(task, { includeEvents = true, includeLocalPaths = false } = {}) {
|
|
832
841
|
return {
|
|
833
842
|
id: task.id,
|
|
843
|
+
...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}),
|
|
844
|
+
...(includeLocalPaths && task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}),
|
|
834
845
|
kind: "local_task",
|
|
835
846
|
status: task.status,
|
|
836
847
|
control_state: task.control_state,
|
package/src/provider-stream.js
CHANGED
|
@@ -206,6 +206,9 @@ function formatClaudeToolResults(prefix, content) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
function contentLines(prefix, label, value) {
|
|
209
|
+
// Native providers wrap machine results in assistant events too. Keep the
|
|
210
|
+
// result available to the parser without flooding the user transcript.
|
|
211
|
+
if (label === "assistant" && STRUCTURED_MARKERS.some(marker => String(value || "").trimStart().startsWith(marker))) return [];
|
|
209
212
|
return String(value || "")
|
|
210
213
|
.split(/\r?\n/)
|
|
211
214
|
.map((line) => cleanLine(line))
|
package/src/runner.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureDispatchProvenance } from './update-detachment/provenance.js';
|
|
2
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
|
|
1
3
|
import { execFile, execFileSync } from "node:child_process";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { EventEmitter, once } from "node:events";
|
|
@@ -1060,7 +1062,11 @@ export async function executeProcessAttempt({
|
|
|
1060
1062
|
onExecutionComplete = null,
|
|
1061
1063
|
readOnly = false,
|
|
1062
1064
|
}) {
|
|
1065
|
+
const dispatchOwner = captureDispatchOwner(run.sqmHistoryRoot || (run.localStateRoot ? path.dirname(run.localStateRoot) : undefined));
|
|
1063
1066
|
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
1067
|
+
const dispatchAttemptId = `${run.snapshot.id}:${phase}:${attemptNumber ?? run.snapshot.attempts?.length ?? 0}`;
|
|
1068
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: backend, worktree: repositoryPath, accountSession: run.session, origin: run.apiOrigin || undefined, taskId: run.snapshot.id, recordRef: run.localStateRoot ? path.join(run.localStateRoot, `${run.snapshot.id}.json`) : `coding-run:${run.snapshot.id}` });
|
|
1069
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
1064
1070
|
const command = await resolveCommandImpl({
|
|
1065
1071
|
backend,
|
|
1066
1072
|
instruction,
|
|
@@ -1094,7 +1100,7 @@ export async function executeProcessAttempt({
|
|
|
1094
1100
|
let terminalError = null;
|
|
1095
1101
|
let terminalErrorCode = null;
|
|
1096
1102
|
const checkpointWrites = [];
|
|
1097
|
-
const child = command.spawn(command.bin, command.args, {
|
|
1103
|
+
const child = dispatchWithFence(dispatchOwner, { taskId: run.snapshot.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
|
|
1098
1104
|
cwd: repositoryPath,
|
|
1099
1105
|
env: {
|
|
1100
1106
|
...process.env,
|
|
@@ -1104,14 +1110,14 @@ export async function executeProcessAttempt({
|
|
|
1104
1110
|
},
|
|
1105
1111
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1106
1112
|
detached: process.platform !== "win32",
|
|
1107
|
-
});
|
|
1113
|
+
}));
|
|
1108
1114
|
run.child = child;
|
|
1109
1115
|
|
|
1110
1116
|
const stdoutDecoder = createProviderStreamDecoder({
|
|
1111
1117
|
backend,
|
|
1112
1118
|
stream: "stdout",
|
|
1113
1119
|
onLine: (line) => emitOutput(run, line, { backend }),
|
|
1114
|
-
onEvent: (event) => observationSender?.observe(event),
|
|
1120
|
+
onEvent: (event) => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: backend, event, onError: error => { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) { run.snapshot.metadata.dispatch_identities ||= {}; run.snapshot.metadata.dispatch_identities[dispatchAttemptId] = identity; } observationSender?.observe(event); },
|
|
1115
1121
|
onTerminalError: (message) => { terminalError ||= message; },
|
|
1116
1122
|
onUsage: (usage) => {
|
|
1117
1123
|
tokenUsageRecords.push({
|
|
@@ -1183,7 +1189,16 @@ export async function executeProcessAttempt({
|
|
|
1183
1189
|
startedAt,
|
|
1184
1190
|
finishedAt: new Date().toISOString(),
|
|
1185
1191
|
};
|
|
1186
|
-
try {
|
|
1192
|
+
try {
|
|
1193
|
+
if (run.localStateRoot) {
|
|
1194
|
+
run.snapshot.metadata.dispatch_results ||= {};
|
|
1195
|
+
run.snapshot.metadata.dispatch_results[dispatchAttemptId] = execution;
|
|
1196
|
+
await saveCodingRunSnapshot(run.localStateRoot, run.snapshot);
|
|
1197
|
+
try { queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: provenance.recordRef }); }
|
|
1198
|
+
catch (error) { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; await saveCodingRunSnapshot(run.localStateRoot, run.snapshot); }
|
|
1199
|
+
}
|
|
1200
|
+
await onExecutionComplete?.(execution);
|
|
1201
|
+
}
|
|
1187
1202
|
catch (error) { reject(error); return; }
|
|
1188
1203
|
// The run must always resolve: a WorkFlow observation failure (or a
|
|
1189
1204
|
// sender bug) never changes or blocks the Provider result. flush() is
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
2
|
import { constants } from 'node:fs';
|
|
2
3
|
import { lstat, open, readdir } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
@@ -70,12 +71,14 @@ async function metadata(path, desktopId, sessions) {
|
|
|
70
71
|
let value = null;
|
|
71
72
|
if (item.sessionId === desktopId && UUID.test(item.cliSessionId || '') && typeof item.cwd === 'string' && isAbsolute(item.cwd)) {
|
|
72
73
|
value = { desktopId, session: item.cliSessionId, cwd: resolve(item.cwd),
|
|
74
|
+
...(complete ? { providerArchived: typeof item.isArchived === 'boolean' ? item.isArchived : typeof item.archived === 'boolean' ? item.archived : undefined,
|
|
75
|
+
pullRequests: normalizePullRequests(item.pullRequests || item.gitHubPullRequests || item.githubPullRequests || item.githubPRs || item.gitHubPRs || item.gitHubPR || item.pullRequest || (item.prUrl ? { url: item.prUrl, state: item.prStatus } : null)) } : {}),
|
|
73
76
|
parentDesktopId: complete && LOCAL.test(item.spawnedFrom?.sessionId || '') ? item.spawnedFrom.sessionId : null };
|
|
74
77
|
}
|
|
75
78
|
const after = await lstat(path, { bigint: true });
|
|
76
79
|
if (fingerprint(after) !== key || fingerprint(await handle.stat({ bigint: true })) !== key || after.isSymbolicLink()) return null;
|
|
77
80
|
if (!complete && !value) return null;
|
|
78
|
-
// Cache only identity
|
|
81
|
+
// Cache only identity, lineage, archive and PR summaries. Desktop files also contain private settings;
|
|
79
82
|
// none of those fields or conversation text leave this bounded reader.
|
|
80
83
|
cache.delete(path); cache.set(path, { key, value, complete });
|
|
81
84
|
while (cache.size > MAX_FILES) cache.delete(cache.keys().next().value);
|
|
@@ -86,7 +89,7 @@ async function metadata(path, desktopId, sessions) {
|
|
|
86
89
|
|
|
87
90
|
// Desktop spawn parents use local_* IDs, whereas transcripts and SIMY use CLI
|
|
88
91
|
// UUIDs. Resolve them within the same account AND organization partition.
|
|
89
|
-
export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null } = {}) {
|
|
92
|
+
export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null, includeState = false } = {}) {
|
|
90
93
|
if (signal?.aborted) return new Map();
|
|
91
94
|
const files = [];
|
|
92
95
|
for (const account of await directories(root)) {
|
|
@@ -122,12 +125,23 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
|
|
|
122
125
|
parentSession: { provider: 'claude', session: parent.session },
|
|
123
126
|
source: 'claude_desktop_spawned_from' });
|
|
124
127
|
}
|
|
128
|
+
if (includeState) for (const partition of partitions.values()) for (const item of partition.values()) {
|
|
129
|
+
if (claims.get(item.session) !== 1 || sessions && !sessions.has(item.session)) continue;
|
|
130
|
+
lineage.set(item.session, { cwd: item.cwd, ...lineage.get(item.session),
|
|
131
|
+
providerArchived: item.providerArchived, pullRequests: item.pullRequests || [], observedAt: Date.now() });
|
|
132
|
+
}
|
|
125
133
|
// Reject cyclic Desktop metadata rather than inventing a root or parent.
|
|
126
134
|
for (const session of [...lineage.keys()]) {
|
|
127
135
|
const seen = new Set(); let cursor = session;
|
|
128
136
|
while (lineage.has(cursor)) {
|
|
129
|
-
if (seen.has(cursor)) {
|
|
130
|
-
|
|
137
|
+
if (seen.has(cursor)) {
|
|
138
|
+
for (const id of seen) {
|
|
139
|
+
if (!includeState) lineage.delete(id);
|
|
140
|
+
else { const { parentSession, parentCwd, source, ...state } = lineage.get(id); lineage.set(id, state); }
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
seen.add(cursor); cursor = lineage.get(cursor).parentSession?.session;
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
return lineage;
|
|
@@ -138,7 +152,7 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
|
|
|
138
152
|
// discovery and unrelated provider controls must not wait for the entire scan.
|
|
139
153
|
export async function observeClaudeDesktopLineage(options = {}, { read = readClaudeDesktopLineage, budgetMs = 1500 } = {}) {
|
|
140
154
|
if (options.signal?.aborted) return new Map();
|
|
141
|
-
const root = options.root || claudeDesktopSessionRoot(options.home);
|
|
155
|
+
const root = (options.root || claudeDesktopSessionRoot(options.home)) + (options.includeState ? ':state' : ':lineage');
|
|
142
156
|
let entry = observations.get(root);
|
|
143
157
|
if (!entry) {
|
|
144
158
|
entry = { value: new Map(), pending: null };
|
|
@@ -2,11 +2,12 @@ import { statisticsFromHandle, unavailableStatistics } from './statistics.js';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
7
|
import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
|
|
7
8
|
import { sessionTitle } from './session-titles.js';
|
|
8
9
|
import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
|
|
9
|
-
import { observeClaudeDesktopLineage } from './claude-desktop-lineage.js';
|
|
10
|
+
import { observeClaudeDesktopLineage, readClaudeDesktopLineage } from './claude-desktop-lineage.js';
|
|
10
11
|
|
|
11
12
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
13
|
const MAX_PROJECTS = 256;
|
|
@@ -101,7 +102,7 @@ async function latestCustomTitle(handle, size, id) {
|
|
|
101
102
|
return title;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal) {
|
|
105
|
+
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal, includeBoundary = false) {
|
|
105
106
|
signal?.throwIfAborted();
|
|
106
107
|
const id = fileName.slice(0, -'.jsonl'.length);
|
|
107
108
|
if (!UUID.test(id)) return null;
|
|
@@ -147,12 +148,17 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
147
148
|
signal?.throwIfAborted();
|
|
148
149
|
if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
|
|
149
150
|
if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
|
|
151
|
+
if (includeBoundary) {
|
|
152
|
+
candidate.boundary = await boundaryFromHandle(handle, opened, id, metadata.cwd, signal);
|
|
153
|
+
const after = await lstat(path);
|
|
154
|
+
if (!after.isFile() || after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return null;
|
|
155
|
+
}
|
|
150
156
|
return candidate;
|
|
151
157
|
} catch (error) { metadataCache.delete(path); if (signal?.aborted) throw error; return null; }
|
|
152
158
|
finally { await handle?.close(); }
|
|
153
159
|
}
|
|
154
160
|
|
|
155
|
-
async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
|
|
161
|
+
async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, signal } = {}) {
|
|
156
162
|
if (signal?.aborted) return [];
|
|
157
163
|
const root = await projectsRoot(home);
|
|
158
164
|
if (!root) return [];
|
|
@@ -186,28 +192,30 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
186
192
|
}));
|
|
187
193
|
}
|
|
188
194
|
if (signal?.aborted) return [];
|
|
189
|
-
const
|
|
190
|
-
|
|
195
|
+
const desktopOptions = { home, signal, includeState: true, sessions: new Set(descriptors.map(item => item.session)) };
|
|
196
|
+
const desktopLineage = await (session ? readClaudeDesktopLineage(desktopOptions) : observeClaudeDesktopLineage(desktopOptions));
|
|
191
197
|
const decorateLineage = candidate => {
|
|
192
198
|
const origin = desktopLineage.get(candidate.session);
|
|
193
|
-
if (origin?.cwd === candidate.cwd
|
|
194
|
-
return { ...candidate,
|
|
199
|
+
if (origin?.cwd === candidate.cwd)
|
|
200
|
+
return { ...candidate, ...(typeof origin.providerArchived === 'boolean' ? { archived: origin.providerArchived } : {}), pullRequests: origin.pullRequests || [],
|
|
201
|
+
...(!candidate.parentSession && origin.parentSession ? { parentSession: origin.parentSession, parentSessionSource: origin.source } : {}) };
|
|
195
202
|
return candidate;
|
|
196
203
|
};
|
|
197
|
-
const selected = session ? descriptors :
|
|
198
|
-
limit: maximum, priority: item => priorities.has(item.session),
|
|
199
|
-
|
|
204
|
+
const selected = session ? descriptors : [
|
|
205
|
+
...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
206
|
+
...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
207
|
+
];
|
|
200
208
|
const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
|
|
201
209
|
let nextFile = 0;
|
|
202
210
|
// Catalog prefixes are bounded independently of full transcript size. Use a
|
|
203
211
|
// small I/O pool; exact-context/statistics reads stay serial and identity-safe.
|
|
204
|
-
await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
|
|
212
|
+
await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics || includeBoundary ? 1 : 8, filesToInspect.length) }, async () => {
|
|
205
213
|
while (nextFile < filesToInspect.length && !signal?.aborted) {
|
|
206
214
|
const file = filesToInspect[nextFile++];
|
|
207
|
-
let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
|
|
215
|
+
let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal, includeBoundary);
|
|
208
216
|
if (!candidate) continue;
|
|
209
217
|
candidate = decorateLineage(candidate);
|
|
210
|
-
const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
|
|
218
|
+
const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics || includeBoundary ? `\0${file.projectName}\0${file.fileName}` : ''}`;
|
|
211
219
|
if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
|
|
212
220
|
}
|
|
213
221
|
}));
|
|
@@ -218,7 +226,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
218
226
|
const attempted = new Set(result.keys());
|
|
219
227
|
for (const child of result.values()) {
|
|
220
228
|
const origin = desktopLineage.get(child.session);
|
|
221
|
-
if (!origin || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
|
|
229
|
+
if (!origin?.parentSession || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
|
|
222
230
|
for (const file of descriptors.filter(item => item.session === origin.parentSession.session)) {
|
|
223
231
|
const key = `${file.session}\0${origin.parentCwd}`;
|
|
224
232
|
if (attempted.has(key) || attempted.size >= MAX_TRANSCRIPTS || signal?.aborted) break;
|
|
@@ -243,7 +251,10 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
243
251
|
}
|
|
244
252
|
}
|
|
245
253
|
const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
|
|
246
|
-
return session ? sorted :
|
|
254
|
+
return session ? sorted : [
|
|
255
|
+
...recentSessionWindow(sorted.filter(item => item.archived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
256
|
+
...recentSessionWindow(sorted.filter(item => item.archived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
257
|
+
];
|
|
247
258
|
}
|
|
248
259
|
|
|
249
260
|
export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
|
|
@@ -256,6 +267,7 @@ export async function readClaudeSession(session, { home } = {}) {
|
|
|
256
267
|
const matches = await collect({ home, limit: RECENT_SESSION_LIMIT, session: id });
|
|
257
268
|
const selected = typeof session === 'object' && typeof session.cwd === 'string'
|
|
258
269
|
? matches.filter(item => item.cwd === session.cwd) : matches;
|
|
270
|
+
if (selected.length === 1 && selected[0].archived === true) throw Object.assign(new Error('Restore the original provider conversation before continuing.'), { code: 'session_archived' });
|
|
259
271
|
return selected.length === 1 ? selected[0] : null;
|
|
260
272
|
}
|
|
261
273
|
|
|
@@ -299,6 +311,52 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
|
|
|
299
311
|
return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
|
|
300
312
|
}
|
|
301
313
|
|
|
314
|
+
// Update recovery needs coverage of the complete saved transcript, not the
|
|
315
|
+
// deliberately shortened text used for presentation. Stream and validate every
|
|
316
|
+
// envelope while retaining only a digest and counters, never message prose.
|
|
317
|
+
async function boundaryFromHandle(handle, opened, session, cwd, signal) {
|
|
318
|
+
const maxBytes = 64 * 1024 * 1024, maxLineBytes = 4 * 1024 * 1024;
|
|
319
|
+
if (!opened.size || opened.size > maxBytes) return null;
|
|
320
|
+
const hash = createHash('sha256'), buffer = Buffer.alloc(64 * 1024);
|
|
321
|
+
let offset = 0, pending = Buffer.alloc(0), rows = 0, messageCount = 0, lastMessageRef = null;
|
|
322
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
323
|
+
while (offset < opened.size) {
|
|
324
|
+
signal?.throwIfAborted();
|
|
325
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, opened.size - offset), offset);
|
|
326
|
+
if (!bytesRead) return null;
|
|
327
|
+
const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); offset += bytesRead;
|
|
328
|
+
pending = Buffer.concat([pending, chunk]);
|
|
329
|
+
let newline;
|
|
330
|
+
while ((newline = pending.indexOf(10)) !== -1) {
|
|
331
|
+
if (newline > maxLineBytes || ++rows > 250000) return null;
|
|
332
|
+
const line = pending.subarray(0, newline); pending = pending.subarray(newline + 1);
|
|
333
|
+
if (!line.length) continue;
|
|
334
|
+
let item;
|
|
335
|
+
try { item = JSON.parse(decoder.decode(line)); } catch { return null; }
|
|
336
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
337
|
+
if (unscopedFileHistory(item)) continue;
|
|
338
|
+
if (item.sessionId !== session || item.isSidechain === true) return null;
|
|
339
|
+
if (!['user', 'assistant'].includes(item.type) || item.isMeta === true) continue;
|
|
340
|
+
if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd) || !within(cwd, resolve(item.cwd)) || typeof item.uuid !== 'string' || !item.uuid) return null;
|
|
341
|
+
if (item.type === 'user' && !textContent(item.message?.content).trim() && !item.message?.content?.every?.(part => part?.type === 'tool_result')) return null;
|
|
342
|
+
messageCount++; lastMessageRef = item.uuid;
|
|
343
|
+
}
|
|
344
|
+
if (pending.length > maxLineBytes) return null;
|
|
345
|
+
}
|
|
346
|
+
const after = await handle.stat();
|
|
347
|
+
if (pending.length || !messageCount || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return null;
|
|
348
|
+
return { schemaVersion: 1, complete: true, digest: hash.digest('hex'), byteLength: offset, messageCount, lastMessageRef };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function readClaudeBoundary(candidate, { home, signal = AbortSignal.timeout(8000) } = {}) {
|
|
352
|
+
signal.throwIfAborted();
|
|
353
|
+
if (!UUID.test(candidate?.session || '') || typeof candidate?.cwd !== 'string') return null;
|
|
354
|
+
const matches = (await collect({ home, session: candidate.session, includeBoundary: true, signal })).filter(item => item.cwd === candidate.cwd);
|
|
355
|
+
signal.throwIfAborted();
|
|
356
|
+
if (matches.length !== 1 || Number.isFinite(candidate.lastActivityAt) && matches[0].lastActivityAt !== candidate.lastActivityAt) return null;
|
|
357
|
+
return matches[0].boundary || null;
|
|
358
|
+
}
|
|
359
|
+
|
|
302
360
|
export async function readClaudeStatistics(candidate, { home, signal } = {}) {
|
|
303
361
|
signal?.throwIfAborted();
|
|
304
362
|
if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { captureDispatchProvenance } from '../update-detachment/provenance.js';
|
|
2
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
|
|
3
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
4
|
import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
|
|
2
5
|
import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
3
6
|
import { collectPurposeObservations, refreshPurposeObservations, repositoryIdentity } from './purpose-observer.js';
|
|
@@ -17,7 +20,7 @@ import { createInterface } from 'node:readline';
|
|
|
17
20
|
import { homedir } from 'node:os';
|
|
18
21
|
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
19
22
|
import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecutable } from '../backend-executable.js';
|
|
20
|
-
import { canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
23
|
+
import { hasPendingUserStop, recordPath, rootDirectory, canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
21
24
|
import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
|
|
22
25
|
import { JsonDataUrlFilter } from './json-data-url-filter.js';
|
|
23
26
|
import { RECENT_SESSION_LIMIT } from './discovery-window.js';
|
|
@@ -202,13 +205,19 @@ export function codexThreadMetadata(thread) {
|
|
|
202
205
|
if (thread.parentThreadId || thread.source?.subAgent || thread.source?.subagent) return null;
|
|
203
206
|
return {
|
|
204
207
|
provider: 'codex', session: thread.id, cwd: thread.cwd,
|
|
208
|
+
pullRequests: normalizePullRequests(thread.pullRequests || thread.artifacts?.filter?.(item => item.type === 'pull_request')),
|
|
205
209
|
...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
|
|
206
210
|
? { parentSession: { provider: 'codex', session: thread.forkedFromId } } : {}),
|
|
207
211
|
title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
|
|
208
212
|
source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
|
|
209
213
|
lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
|
|
210
214
|
model: typeof thread.model === 'string' ? thread.model : null,
|
|
211
|
-
|
|
215
|
+
// Preserve native wait flags for presentation without changing isRunning,
|
|
216
|
+
// which also protects the provider writer lock. Unknown flags fail closed.
|
|
217
|
+
observation: { source: 'codex_app_server', observedAt: Date.now(),
|
|
218
|
+
...(thread.status?.type === 'active' ? { activeFlags: Array.isArray(thread.status.activeFlags)
|
|
219
|
+
&& thread.status.activeFlags.every(flag => ['waitingOnApproval', 'waitingOnUserInput'].includes(flag))
|
|
220
|
+
? [...new Set(thread.status.activeFlags)] : null } : {}) },
|
|
212
221
|
...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
|
|
213
222
|
availability: 'unknown',
|
|
214
223
|
};
|
|
@@ -618,6 +627,10 @@ async function frozenNativeSandbox(record, root, signal) {
|
|
|
618
627
|
}
|
|
619
628
|
|
|
620
629
|
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
|
|
630
|
+
const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
|
|
631
|
+
let dispatchAttemptId;
|
|
632
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: record.run.managed.taskId, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
|
|
633
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
621
634
|
const run = record.run, decision = run.managed?.decision;
|
|
622
635
|
if(run.managed?.enrollment?.nativeAuthorityDigest && run.managed.enrollment.nativeAuthorityDigest!==createHash('sha256').update(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource])).digest('hex'))throw new CodexAppServerError('permission_narrowed','Frozen native enrollment authority changed; do not dispatch with the altered ceiling.');
|
|
623
636
|
if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
|
|
@@ -653,11 +666,16 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
653
666
|
prompt: peerOpinionPrompt(record, sourceContext, peerAction),
|
|
654
667
|
output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
|
|
655
668
|
}, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
|
|
669
|
+
dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance }, attemptId => {
|
|
670
|
+
if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
|
|
671
|
+
dispatchAttemptId = attemptId;
|
|
672
|
+
return initiate();
|
|
673
|
+
}),
|
|
656
674
|
beforeDispatch: async () => { await authorizePreparation(); await beforeDispatch({ type: 'readOnly', networkAccess: false }); },
|
|
657
675
|
onContext: session => { if (peerSession && peerSession !== session) ambiguousContext = true; peerSession = session; },
|
|
658
676
|
onUsage: observed => { usage = observed; onUsage(observed); } });
|
|
659
677
|
if (!peerSession || peerSession === record.session || ambiguousContext || typeof reply.output?.opinion !== 'string' || !reply.output.opinion.trim()) throw new CodexAppServerError('identity', 'Independent peer context or opinion unavailable.');
|
|
660
|
-
return { code: 0, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
|
|
678
|
+
return { code: 0, dispatchAttemptId, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
|
|
661
679
|
}
|
|
662
680
|
// Connector schemas can exceed ordinary checkpoint limits; keep review pages
|
|
663
681
|
// bounded to one server and a separate finite transport ceiling.
|
|
@@ -844,9 +862,10 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
844
862
|
});
|
|
845
863
|
signal?.addEventListener('abort', cancelled, { once: true });
|
|
846
864
|
client.child.once('close', closed);
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
.
|
|
865
|
+
const targetProvenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: targetSession, worktree: isolated ? executionCwd : record.cwd, taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
|
|
866
|
+
turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
|
|
867
|
+
model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) }); })
|
|
868
|
+
.then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (turnId) queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId }); if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
850
869
|
startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
|
|
851
870
|
await startReceipt;
|
|
852
871
|
startRecorded = true;
|
|
@@ -901,14 +920,14 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
901
920
|
review.findings.push(criterion ? { id: 'workspace-audit-binding', requirementId: `criterion:${criterion.id}`, source: 'controller_workspace_inventory', owner: 'controller', gap, verification: 'Establish the correct authorized workspace scope and independently bind its actual artifacts before acceptance.', reopenReason: 'The current workspace identity is unavailable or its scope changed.' } : gap);
|
|
902
921
|
}
|
|
903
922
|
review = assessIndependentInspection(review, { toolEvidence, observations: reviewObservations, criteria: effectiveAcceptanceCriteria(run.managed), flexible: flexibleReasoning(run.managed) });
|
|
904
|
-
return { code: 0, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
|
|
923
|
+
return { code: 0, dispatchAttemptId, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
|
|
905
924
|
}
|
|
906
925
|
const currentEvidence = beforeEvidence ? await captureWorkspace() : null;
|
|
907
926
|
const controllerEvidence = beforeEvidence ? { source: 'adapter_capture', actionId: decision.id,
|
|
908
927
|
baseline: run.managed.purpose.evidence?.baseline ? undefined : { ...beforeEvidence, kind: 'first_managed_observation', continuation: run.continuations },
|
|
909
928
|
current: currentEvidence, before: beforeEvidence.digest, after: currentEvidence?.digest, receipts: toolEvidence } : undefined;
|
|
910
|
-
if (!run.managed.purpose?.enabled) return { code: 0, checkpoint };
|
|
911
|
-
return { code: 0, checkpoint, usage, providerTurnId: activeTurnId, providerSessionId:targetSession, toolEvidence, sourceContext, controllerEvidence };
|
|
929
|
+
if (!run.managed.purpose?.enabled) return { code: 0, dispatchAttemptId, checkpoint };
|
|
930
|
+
return { code: 0, dispatchAttemptId, checkpoint, usage, providerTurnId: activeTurnId, providerSessionId:targetSession, toolEvidence, sourceContext, controllerEvidence };
|
|
912
931
|
} catch (error) {
|
|
913
932
|
if (activeTurnId) error.providerTurnId ||= activeTurnId;
|
|
914
933
|
if(targetSession!==record.session)error.providerSessionId ||=targetSession;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sessionWorkState, compareSessionWork } from './work-state.js';
|
|
2
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
3
|
import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
|
|
2
4
|
import { pauseOrigin } from './autonomous-upgrade.js';
|
|
3
5
|
import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
|
|
@@ -123,7 +125,8 @@ function navigationParent(item, record) {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
126
|
-
inspectProcess = processIdentity, now
|
|
128
|
+
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
|
|
129
|
+
const now = requestedNow ?? Date.now();
|
|
127
130
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
128
131
|
assertActive();
|
|
129
132
|
const connected = new Set(connectedProviders);
|
|
@@ -174,11 +177,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
174
177
|
for (let index = 0; index < records.length; index++) {
|
|
175
178
|
assertActive();
|
|
176
179
|
const record = records[index];
|
|
177
|
-
|
|
178
|
-
const
|
|
180
|
+
const claudeItem = record.provider === 'claude' ? claude.find(item => item.session === record.session && item.cwd === record.cwd) : null;
|
|
181
|
+
const archiveState = record.provider === 'codex' ? archiveStates.get(record.session) ?? (activeCodex.has(record.session) ? false : undefined) : claudeItem?.archived;
|
|
182
|
+
if (typeof archiveState !== 'boolean') continue;
|
|
183
|
+
const isArchived = archiveState;
|
|
179
184
|
if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
|
|
180
185
|
if (isArchived && record.providerArchived === true) continue;
|
|
181
|
-
if (!isArchived && !(record.providerArchived === true &&
|
|
186
|
+
if (!isArchived && !(record.providerArchived === true && archiveState === false)) continue;
|
|
182
187
|
try { records[index] = await updateRecord(root, record.provider, record.session, current => {
|
|
183
188
|
if (!current) return current;
|
|
184
189
|
current.providerArchived = isArchived;
|
|
@@ -204,7 +209,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
204
209
|
const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
|
|
205
210
|
for (const item of [...discovered, ...claude, ...archivedCodex]) {
|
|
206
211
|
if (includeArchived && item.archived === true) { byKey.set(`${item.provider}:${item.session}`, item); continue; }
|
|
207
|
-
if ((item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
212
|
+
if (item.archived !== true && (item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
208
213
|
}
|
|
209
214
|
const requiredAncestors = new Set();
|
|
210
215
|
for (const item of byKey.values()) {
|
|
@@ -224,7 +229,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
224
229
|
const key = `${record.provider}:${record.session}`;
|
|
225
230
|
// A hook observation can belong to an AI-created worker. Only explicit
|
|
226
231
|
// supervision may restore a conversation absent from provider discovery.
|
|
227
|
-
if (!byKey.has(key) && (retainedSession(record) || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
|
|
232
|
+
if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
|
|
228
233
|
provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
|
|
229
234
|
lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
|
|
230
235
|
observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
|
|
@@ -236,11 +241,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
236
241
|
const catalog = [...byKey.values()].map(item => {
|
|
237
242
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
238
243
|
const parentSession = navigationParent(item, record);
|
|
239
|
-
return { ...item, ...(parentSession ? { parentSession } : {}) };
|
|
244
|
+
return { ...item, localHidden: record?.listHidden === true, ...(parentSession ? { parentSession } : {}) };
|
|
240
245
|
});
|
|
241
|
-
const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true), {
|
|
246
|
+
const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
|
|
242
247
|
priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
|
|
243
|
-
}), ...recentSessionWindow(catalog.filter(item => item.archived === true))];
|
|
248
|
+
}), ...recentSessionWindow(catalog.filter(item => item.archived === true || item.localHidden), { priority: item => item.localHidden })];
|
|
244
249
|
// Include archived ancestors of active rows even when archive visibility is off.
|
|
245
250
|
const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
|
|
246
251
|
const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
|
|
@@ -295,14 +300,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
295
300
|
const continuationIssue = liveHook && run ? !run.managed && !upgradeable
|
|
296
301
|
? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
|
|
297
302
|
: TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
|
|
298
|
-
let isRunning = item.isRunning;
|
|
303
|
+
let isRunning = item.isRunning, observation = item.observation;
|
|
299
304
|
const hookIsNewer = Number.isFinite(record?.runtime?.observedAt) && Number.isFinite(item.lastActivityAt) && record.runtime.observedAt > item.lastActivityAt;
|
|
300
305
|
if ((isRunning === undefined || isRunning === false && hookIsNewer) && record?.runtime?.process?.identity && record.cwd === item.cwd) {
|
|
301
306
|
try {
|
|
302
307
|
// A hook newer than the persisted provider activity may describe a
|
|
303
308
|
// new turn. Require its same live process; older hooks cannot override
|
|
304
309
|
// a more recent completed provider turn.
|
|
305
|
-
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) isRunning = true;
|
|
310
|
+
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
306
311
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
307
312
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
308
313
|
}
|
|
@@ -314,12 +319,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
314
319
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
315
320
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
316
321
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
322
|
+
providerArchived: archived, localHidden: record?.listHidden === true,
|
|
317
323
|
archived,
|
|
324
|
+
pullRequests: normalizePullRequests(item.pullRequests),
|
|
318
325
|
autoContinue: !archived && record?.autoContinue === true,
|
|
319
326
|
autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
|
|
320
327
|
autoContinueAvailability: availability,
|
|
321
328
|
...autoContinueState(record, isRunning, availability),
|
|
322
|
-
lastActivityAt: item.lastActivityAt, observation
|
|
329
|
+
lastActivityAt: item.lastActivityAt, observation, availability: item.availability,
|
|
323
330
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
324
331
|
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
|
|
325
332
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
@@ -343,8 +350,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
343
350
|
}
|
|
344
351
|
}));
|
|
345
352
|
assertActive();
|
|
346
|
-
|
|
347
|
-
|
|
353
|
+
// Provider reads stamp observations after discovery starts. Compare them at
|
|
354
|
+
// projection time, while preserving an explicitly supplied verification clock.
|
|
355
|
+
const stateNow = requestedNow ?? Date.now();
|
|
356
|
+
for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
|
|
357
|
+
projected.sort(compareSessionWork);
|
|
358
|
+
return { sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
348
359
|
}
|
|
349
360
|
|
|
350
361
|
// Saving consent needs an exact admitted identity, not another provider RPC.
|