@awak-app/simy-cli 0.7.11 → 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/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/supervisor.js +39 -10
- package/src/desktop-host.js +54 -8
- package/src/desktop-status.js +1 -1
- package/src/direct-executor.js +104 -4
- package/src/local-task.js +14 -3
- package/src/runner.js +19 -4
- package/src/session-supervision/claude-sessions.js +57 -5
- package/src/session-supervision/codex-app-server.js +20 -8
- 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/desktop-host.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createUpdateRuntime } from './update-detachment/runtime.js';
|
|
2
|
+
import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
|
|
3
|
+
import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
|
|
4
|
+
import { createUpdateCoordinator } from "./update-detachment/coordinator.js";
|
|
1
5
|
import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
|
|
2
6
|
import { createPullRequestObserver } from './session-supervision/pull-requests.js';
|
|
3
7
|
import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
|
|
@@ -194,6 +198,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
194
198
|
};
|
|
195
199
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
|
|
196
200
|
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: async () => linkedSessions(await recordReader.list()), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
201
|
+
const updateRuntime = createUpdateRuntime({ root, origin:webOrigin,
|
|
202
|
+
readContext:()=>agent?.getUpdateDetachmentContext?.(), listRecords:()=>recordReader.list(),
|
|
203
|
+
...(dependencies.observeUpdateProvider ? {observe:dependencies.observeUpdateProvider} : {}) });
|
|
204
|
+
const coordinator = createUpdateCoordinator({ ...(dependencies.updateDetachment || {
|
|
205
|
+
store:updateRuntime.store,gate:updateRuntime.gate,
|
|
206
|
+
boundary:dependencies.updateSafeBoundary || (async()=>{const holds=updater?.updateDrainState?.();if(holds && (holds.activeWork || holds.applyHolds))return {safe:false,liveExecutions:holds.activeWork,pendingWrites:holds.applyHolds,unsettledPermissions:0};return updateRuntime.boundary();}),
|
|
207
|
+
reconcile:dependencies.reconcileUpdateConnection || createCoordinatorReconciler(dependencies.updateReconciliation) || updateRuntime.reconcile,
|
|
208
|
+
}), preserveConnectedOnStartup:true, initialState:env.SIMY_UPDATE_TRANSFER_TOKEN?'detached':'connected',
|
|
209
|
+
onChange:()=>{if(agent && !closed && !relay)setImmediate(()=>void publish().catch(()=>{}));} });
|
|
197
210
|
let actionAssociations = makeActionAssociations();
|
|
198
211
|
const statisticsCache = new Map();
|
|
199
212
|
let activeStatisticsRead = null;
|
|
@@ -436,7 +449,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
436
449
|
if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
|
|
437
450
|
await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
|
|
438
451
|
}
|
|
439
|
-
await analysisManager.update(analysisSessions(sessions));
|
|
452
|
+
if (coordinator.acceptsNewWork()) await analysisManager.update(analysisSessions(sessions));
|
|
440
453
|
if (controller.signal.aborted) return;
|
|
441
454
|
sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
|
|
442
455
|
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
@@ -619,6 +632,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
619
632
|
const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
|
|
620
633
|
if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
|
|
621
634
|
status.access = sampledAccess;
|
|
635
|
+
status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
|
|
636
|
+
if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
|
|
622
637
|
const requestedLinks = await decorateProviderSettings(status);
|
|
623
638
|
const sameScope = desktopAccessReady(currentAccess) &&
|
|
624
639
|
accessScope(lastStatus?.access) === accessScope(currentAccess);
|
|
@@ -667,6 +682,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
667
682
|
const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
|
|
668
683
|
if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
|
|
669
684
|
status.access = sampledAccess;
|
|
685
|
+
status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
|
|
686
|
+
if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
|
|
670
687
|
const requestedScope = accessScope(currentAccess);
|
|
671
688
|
const requestedLinks = await decorateProviderSettings(status);
|
|
672
689
|
const allRecords = await recordReader.list();
|
|
@@ -709,7 +726,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
709
726
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
710
727
|
await actionAssociations.decorate(status.deliveryHooks, projection);
|
|
711
728
|
const discovery = refreshSessions();
|
|
712
|
-
automaticContinuation?.tick(discovery.sessions);
|
|
729
|
+
if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions);
|
|
713
730
|
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
714
731
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
715
732
|
const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
|
|
@@ -791,6 +808,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
791
808
|
})();
|
|
792
809
|
|
|
793
810
|
try {
|
|
811
|
+
await updateRuntime.initialize();
|
|
812
|
+
if (env.SIMY_UPDATE_TRANSFER_TOKEN) await updateRuntime.acceptHandoff(env.SIMY_UPDATE_TRANSFER_TOKEN);
|
|
813
|
+
else await coordinator.initialize();
|
|
794
814
|
analysisManager = await makeAnalysisManager();
|
|
795
815
|
automaticContinuation = makeAutomaticContinuation();
|
|
796
816
|
if (!candidate) {
|
|
@@ -803,6 +823,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
803
823
|
dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
|
|
804
824
|
logger: message => diagnostics.write(`update: ${message}`) },
|
|
805
825
|
});
|
|
826
|
+
updater.setCoordinator?.(coordinator);
|
|
806
827
|
agent = await (dependencies.startAgent || startAgent)({
|
|
807
828
|
daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
|
|
808
829
|
deferStartupSynchronization: true, enforceDesktopAccess: true,
|
|
@@ -812,9 +833,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
812
833
|
setImmediate(() => { void publish().catch(() => {}); });
|
|
813
834
|
},
|
|
814
835
|
});
|
|
836
|
+
if (env.SIMY_UPDATE_TRANSFER_TOKEN) await coordinator.reconnect();
|
|
815
837
|
stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
|
|
816
838
|
updater.on?.("change", () => { void publish().catch(() => {}); });
|
|
817
|
-
updater.start({ registry: agent.registry, isDesktopIdle: () => !unfinishedDesktopWork,
|
|
839
|
+
updater.start({ registry: agent.registry, coordinator, isDesktopIdle: () => !unfinishedDesktopWork,
|
|
818
840
|
restartDaemon: async (targetVersion, installation) => {
|
|
819
841
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
820
842
|
discoveryStopped = true;
|
|
@@ -826,12 +848,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
826
848
|
// The candidate also shares association records and their sync outbox.
|
|
827
849
|
// Drain the old writer before starting its replacement.
|
|
828
850
|
await actionAssociations.close();
|
|
851
|
+
const transferToken = coordinator.snapshot().state === 'detached' ? await updateRuntime.prepareHandoff() : null;
|
|
829
852
|
let child;
|
|
830
853
|
try { child = (dependencies.spawn || spawn)(installation.executablePath,
|
|
831
854
|
[join(dirname(installation.entryPath), "desktop-host.js")], {
|
|
832
855
|
stdio: ["pipe", "pipe", "pipe"],
|
|
833
|
-
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
856
|
+
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
|
|
834
857
|
}); } catch (error) {
|
|
858
|
+
if(transferToken)await updateRuntime.reclaimFailedHandoff();
|
|
835
859
|
actionAssociations = makeActionAssociations();
|
|
836
860
|
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
|
|
837
861
|
}
|
|
@@ -869,7 +893,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
869
893
|
let event; try { event = JSON.parse(line); } catch { return; }
|
|
870
894
|
if (accepted) { emit(event); return; }
|
|
871
895
|
if (event.type === "status") {
|
|
872
|
-
if (!validCandidateStatus(event.status, targetVersion, webOrigin)) {
|
|
896
|
+
if (!validCandidateStatus(event.status, targetVersion, webOrigin) || transferToken && (event.status.updateDetachment?.connectionState !== 'connected' || event.status.updateDetachment.operationId !== updateRuntime.snapshot(coordinator.snapshot()).operationId)) {
|
|
873
897
|
finish(reject, new Error("Updated desktop runtime identity mismatch."));
|
|
874
898
|
} else { accepted = true; finish(accept, event); }
|
|
875
899
|
}
|
|
@@ -879,8 +903,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
879
903
|
await stopAgent();
|
|
880
904
|
if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
|
|
881
905
|
emit(first);
|
|
906
|
+
return {connectionTransferred:Boolean(transferToken)};
|
|
882
907
|
} catch (error) {
|
|
883
908
|
lines.close(); await stopUpdateCandidate(child);
|
|
909
|
+
if(transferToken)await updateRuntime.reclaimFailedHandoff();
|
|
884
910
|
if (!closed && !relay) {
|
|
885
911
|
actionAssociations = makeActionAssociations();
|
|
886
912
|
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
|
|
@@ -892,7 +918,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
892
918
|
await publish();
|
|
893
919
|
const maintenance = new Map();
|
|
894
920
|
const tick = worker => {
|
|
895
|
-
if (closed || relay || maintenance.has(worker)) return;
|
|
921
|
+
if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
|
|
896
922
|
const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
|
|
897
923
|
maintenance.set(worker, pending);
|
|
898
924
|
};
|
|
@@ -999,6 +1025,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
999
1025
|
if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
|
|
1000
1026
|
return;
|
|
1001
1027
|
}
|
|
1028
|
+
if(command.action==='cancel_detach_update') {
|
|
1029
|
+
try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
|
|
1030
|
+
catch {emit({type:'result',id:command.id,ok:false,message:'This update operation changed. Refresh its status.'});}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1002
1033
|
// Read-only activity must never hold the ordered control queue. Share an
|
|
1003
1034
|
// identical pending selection and cancel superseded selections immediately.
|
|
1004
1035
|
let statisticsRequest, requestedStatisticsSelection;
|
|
@@ -1014,6 +1045,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1014
1045
|
if (closed) return;
|
|
1015
1046
|
try {
|
|
1016
1047
|
const action = command.action;
|
|
1048
|
+
if(['detach_update','update_reconnect'].includes(action)) {
|
|
1049
|
+
const access=await accessSnapshot(); requireDesktopAccess(access);
|
|
1050
|
+
updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());
|
|
1051
|
+
if(action==='detach_update')await updater.maybeApply({detach:true});
|
|
1052
|
+
else await coordinator.reconnect();
|
|
1053
|
+
emit({type:'result',id:command.id,ok:coordinator.snapshot().state==='connected' && (action!=='detach_update' || updater.snapshot().state==='updated')});
|
|
1054
|
+
await publish();return;
|
|
1055
|
+
}
|
|
1017
1056
|
if (action === "quit") {
|
|
1018
1057
|
if (command.forUpdate === true) {
|
|
1019
1058
|
if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
|
|
@@ -1048,6 +1087,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1048
1087
|
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
|
|
1049
1088
|
hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping tasks to restart.');
|
|
1050
1089
|
}
|
|
1090
|
+
if (!coordinator.acceptsNewWork()) throw new Error('Reconcile the update connection before starting another update.');
|
|
1051
1091
|
await executeUpdate(action === 'prepare_update_restart');
|
|
1052
1092
|
emit({ type: 'result', id: command.id, ok: true });
|
|
1053
1093
|
void publish().catch(() => {}); return;
|
|
@@ -1055,8 +1095,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1055
1095
|
const access = await accessSnapshot();
|
|
1056
1096
|
const commandScope = accessScope(access);
|
|
1057
1097
|
if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
|
|
1058
|
-
|
|
1098
|
+
const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) ||
|
|
1099
|
+
['session_set_auto_continue', 'session_set_email_reports'].includes(action) && command.enabled === false;
|
|
1100
|
+
if (['detach_update', 'update_reconnect'].includes(action)) requireDesktopAccess(access);
|
|
1101
|
+
else if (!recoveryControl && desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
|
|
1059
1102
|
|
|
1103
|
+
if (!coordinator.acceptsNewWork() && !recoveryControl && !["refresh", "reconnect", "task_detail", "check_update", "quit", "delivery_stop", "delivery_pause", "delivery_reject"].includes(action)) {
|
|
1104
|
+
throw new Error("The update connection requires recovery.");
|
|
1105
|
+
}
|
|
1060
1106
|
let commandProviderLink;
|
|
1061
1107
|
if (command.provider && !["provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
|
|
1062
1108
|
!(action === "session_set_auto_continue" && command.enabled === false)) {
|
|
@@ -1137,7 +1183,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1137
1183
|
await agent.refreshDesktopStatus();
|
|
1138
1184
|
await refreshPlugins();
|
|
1139
1185
|
refreshSessions(true);
|
|
1140
|
-
if (desktopAccessReady(currentAccess)) void actionAssociations.tick().then(publish).catch(() => {});
|
|
1186
|
+
if (desktopAccessReady(currentAccess) && coordinator.acceptsNewWork()) void actionAssociations.tick().then(publish).catch(() => {});
|
|
1141
1187
|
}
|
|
1142
1188
|
else if (action === "organizations_refresh") await agent.refreshOrganizations();
|
|
1143
1189
|
else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
|
package/src/desktop-status.js
CHANGED
|
@@ -231,7 +231,7 @@ export function isUnauthorizedError(error) {
|
|
|
231
231
|
|
|
232
232
|
export function canStopDesktopTask(task, registryIndex) {
|
|
233
233
|
const state = task?.status || task?.snapshot?.state || task?.state || task?.control_state || task?.controlState || "unknown";
|
|
234
|
-
return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state);
|
|
234
|
+
return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state) || registryIndex === 1 && state === "recovery_required" && Boolean(task?.worker_handle);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
function taskStatusItem(task, registryIndex) {
|
package/src/direct-executor.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { atomicSave, digest } from './update-detachment/state-store.js';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { captureDispatchProvenance } from './update-detachment/provenance.js';
|
|
1
4
|
import { signalExecutorProcess } from "./runner.js";
|
|
5
|
+
import { createExecutionWorker } from "./update-detachment/worker.js";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
|
|
2
8
|
import { randomUUID } from "node:crypto";
|
|
3
9
|
import { withSceneReasoning } from "./codex-plugins/simy/hooks/scene-reasoning.mjs";
|
|
4
10
|
|
|
@@ -58,10 +64,15 @@ export function createDirectTask({
|
|
|
58
64
|
|
|
59
65
|
export async function startDirectTask(
|
|
60
66
|
task,
|
|
61
|
-
{ resolveCommand = resolveDesktopExecutorCommand, environment = process.env } = {},
|
|
67
|
+
{ resolveCommand = resolveDesktopExecutorCommand, environment = process.env, session, apiOrigin, sessionRoot, worker = null, workerBinding = null, deadlineAt = null, workerPolicy = null } = {},
|
|
62
68
|
) {
|
|
63
69
|
if (!task || task.status !== "queued") throw new Error("Direct task must be queued.");
|
|
64
70
|
if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
|
|
71
|
+
const dispatchOwner = captureDispatchOwner(workerBinding?.root || sessionRoot);
|
|
72
|
+
const dispatchAttemptId = `${task.id}:provider:1`;
|
|
73
|
+
const recordRef = join(dispatchOwner.root, 'direct-tasks', `${digest(task.id)}.json`);
|
|
74
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: task.backend, worktree: task.repository_path, accountSession: session, origin: apiOrigin, taskId: task.id, recordRef });
|
|
75
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
65
76
|
const command = await resolveCommand({
|
|
66
77
|
backend: task.backend,
|
|
67
78
|
instruction: withSceneReasoning("Complete only the requested bounded direct task in this invocation. Respect the assigned permission mode; do not create an automatic retry loop or expand the task scope.", task.instruction),
|
|
@@ -75,17 +86,44 @@ export async function startDirectTask(
|
|
|
75
86
|
task.invocation_count = 1;
|
|
76
87
|
task.updated_at = new Date().toISOString();
|
|
77
88
|
|
|
89
|
+
if (worker || (resolveCommand === resolveDesktopExecutorCommand && process.platform !== "win32")) {
|
|
90
|
+
const owner = worker || createExecutionWorker();
|
|
91
|
+
const identity = owner.identity || workerBinding || {
|
|
92
|
+
provider: task.backend, session: task.id,
|
|
93
|
+
// Standalone direct invocations inherit the existing account environment.
|
|
94
|
+
// This is not a verified existing app-server/live-hook account binding.
|
|
95
|
+
account: "inherited-provider-environment", origin: "simy-direct",
|
|
96
|
+
worktree: path.resolve(task.repository_path),
|
|
97
|
+
};
|
|
98
|
+
task.worker = owner;
|
|
99
|
+
task.worker_handle = { attemptId: task.id, identity };
|
|
100
|
+
const originalDeadline = deadlineAt ?? null;
|
|
101
|
+
task.worker_start = owner.start({ ...task.worker_handle, command, cwd: task.repository_path, backend: task.backend,
|
|
102
|
+
deadlineAt: originalDeadline, taskId: task.id,
|
|
103
|
+
preservation: { taskState: "active", metadata: directTaskSnapshot(task), policy: workerPolicy || { model: "provider-default", permissions: task.permission_mode, approvals: { operation: task.operation, maxProviderInvocations: 1 }, budget: null, deadline: originalDeadline }, artifacts: [] } });
|
|
104
|
+
try { task.worker_handle = { ...task.worker_handle, ...await task.worker_start }; }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.workerHandle) task.worker_handle = error.workerHandle;
|
|
107
|
+
task.status = "recovery_required";
|
|
108
|
+
task.error = "Worker launch outcome could not be confirmed. Reconcile saved evidence before retrying.";
|
|
109
|
+
return task;
|
|
110
|
+
}
|
|
111
|
+
if (task.stop_requested) await owner.stop(task.worker_handle);
|
|
112
|
+
return await reconcileDirectWorker(task);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
atomicSave(recordRef, { schemaVersion: 1, revision: 0, ...directTaskSnapshot(task) });
|
|
78
116
|
return new Promise((resolve) => {
|
|
79
117
|
let settled = false;
|
|
80
118
|
let terminalError = null;
|
|
81
119
|
const rawStdout = [];
|
|
82
120
|
const tokenRecords = [];
|
|
83
|
-
const child = command.spawn(command.bin, command.args, {
|
|
121
|
+
const child = dispatchWithFence(dispatchOwner, { taskId: task.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
|
|
84
122
|
cwd: task.repository_path,
|
|
85
123
|
env: { ...process.env, ...command.env },
|
|
86
124
|
stdio: ["ignore", "pipe", "pipe"],
|
|
87
125
|
detached: process.platform !== "win32",
|
|
88
|
-
});
|
|
126
|
+
}));
|
|
89
127
|
task.child = child;
|
|
90
128
|
const onLine = (line) => {
|
|
91
129
|
const normalized = String(line || "").trim();
|
|
@@ -99,6 +137,7 @@ export async function startDirectTask(
|
|
|
99
137
|
const stdoutDecoder = createProviderStreamDecoder({
|
|
100
138
|
backend: task.backend,
|
|
101
139
|
stream: "stdout",
|
|
140
|
+
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; },
|
|
102
141
|
onLine,
|
|
103
142
|
onTerminalError: (message) => { terminalError ||= message; },
|
|
104
143
|
onUsage: (usage) => tokenRecords.push({ ...usage, phase: "direct", backend: task.backend }),
|
|
@@ -127,6 +166,13 @@ export async function startDirectTask(
|
|
|
127
166
|
task.error = task.stop_requested ? null : error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
|
|
128
167
|
task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
|
|
129
168
|
task.updated_at = new Date().toISOString();
|
|
169
|
+
try {
|
|
170
|
+
atomicSave(recordRef, { schemaVersion: 1, revision: 1, ...directTaskSnapshot(task), ...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}), ...(task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}) });
|
|
171
|
+
queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: recordRef });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
task.dispatch_recovery_required = error.code || 'provenance_persistence_failed';
|
|
174
|
+
try { atomicSave(recordRef, { schemaVersion: 1, revision: 1, ...directTaskSnapshot(task), ...(task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}) }); } catch {}
|
|
175
|
+
}
|
|
130
176
|
resolve(task);
|
|
131
177
|
};
|
|
132
178
|
child.on("error", (error) => finish({ error: error.message }));
|
|
@@ -137,6 +183,7 @@ export async function startDirectTask(
|
|
|
137
183
|
export function directTaskSnapshot(task) {
|
|
138
184
|
return {
|
|
139
185
|
id: task.id,
|
|
186
|
+
...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}),
|
|
140
187
|
status: task.status,
|
|
141
188
|
instruction: task.instruction,
|
|
142
189
|
backend: task.backend,
|
|
@@ -153,15 +200,50 @@ export function directTaskSnapshot(task) {
|
|
|
153
200
|
};
|
|
154
201
|
}
|
|
155
202
|
|
|
203
|
+
// Read-only reconnect: never starts a second Provider invocation.
|
|
204
|
+
export async function reconcileDirectWorker(task, { worker = task.worker || createExecutionWorker() } = {}) {
|
|
205
|
+
while (true) {
|
|
206
|
+
if (task.observation_suspended) return task;
|
|
207
|
+
const state = await worker.result(task.worker_handle);
|
|
208
|
+
if (task.observation_suspended) return task;
|
|
209
|
+
if (state.state === "completed") {
|
|
210
|
+
const receipt = state.receipt;
|
|
211
|
+
Object.assign(task, {
|
|
212
|
+
status: receipt.status, output: receipt.output, result: receipt.result,
|
|
213
|
+
error: receipt.error, token_usage: receipt.token_usage,
|
|
214
|
+
updated_at: new Date(receipt.finishedAt).toISOString(),
|
|
215
|
+
});
|
|
216
|
+
return task;
|
|
217
|
+
}
|
|
218
|
+
if (state.state === "recovery_required") {
|
|
219
|
+
task.status = "recovery_required";
|
|
220
|
+
task.error = state.storageError
|
|
221
|
+
? "Worker receipt storage failed. The confirmed owner remains stoppable; review saved evidence before retrying."
|
|
222
|
+
: "Worker owner could not be confirmed. Review saved evidence before retrying.";
|
|
223
|
+
return task;
|
|
224
|
+
}
|
|
225
|
+
task.output = state.output || task.output;
|
|
226
|
+
task.token_usage = state.token_usage || task.token_usage;
|
|
227
|
+
task.updated_at = new Date().toISOString();
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
156
232
|
// Only the child captured by this task may be signalled, including its owned tools.
|
|
157
233
|
export function stopDirectTask(task) {
|
|
158
234
|
if (!task) return Promise.resolve(task);
|
|
159
235
|
return task.stopPromise ||= stopOwnedDirectTask(task);
|
|
160
236
|
}
|
|
161
237
|
async function stopOwnedDirectTask(task) {
|
|
162
|
-
if (!task || !["queued", "running", "stopping"].includes(task.status)) return task;
|
|
238
|
+
if (!task || (!["queued", "running", "stopping"].includes(task.status) && !(task.status === "recovery_required" && task.worker_handle))) return task;
|
|
163
239
|
task.stop_requested = true;
|
|
164
240
|
task.status = "stopping";
|
|
241
|
+
if (task.worker_handle) {
|
|
242
|
+
// Launch uncertainty does not prevent stopping a separately confirmed owner.
|
|
243
|
+
await task.worker_start?.catch(() => {});
|
|
244
|
+
await task.worker.stop(task.worker_handle);
|
|
245
|
+
return reconcileDirectWorker(task);
|
|
246
|
+
}
|
|
165
247
|
const child = task.child;
|
|
166
248
|
if (!child) { task.status = "stopped"; task.updated_at = new Date().toISOString(); return task; }
|
|
167
249
|
for (const signal of ["SIGINT", "SIGTERM", "SIGKILL"]) {
|
|
@@ -176,3 +258,21 @@ async function stopOwnedDirectTask(task) {
|
|
|
176
258
|
if (task.child === child) throw new Error("The local task did not stop. Try quitting SIMY.");
|
|
177
259
|
return task;
|
|
178
260
|
}
|
|
261
|
+
|
|
262
|
+
// The host reconstructs its registry from the registered preservation record,
|
|
263
|
+
// never from a new Provider dispatch or a user-supplied unbound task ID.
|
|
264
|
+
export async function restoreDirectWorkerTask({ worker, handle, registry }) {
|
|
265
|
+
const state = await worker.adopt(handle);
|
|
266
|
+
const snapshot = worker.readSnapshot(handle);
|
|
267
|
+
if (snapshot?.task?.id !== handle.taskId || snapshot.metadata?.id !== handle.taskId) throw new Error("Direct worker task snapshot unavailable.");
|
|
268
|
+
let task = registry.get(handle.taskId);
|
|
269
|
+
if (!task) task = registry.create({ ...structuredClone(snapshot.metadata), repository_path: handle.identity.worktree,
|
|
270
|
+
worker, worker_handle: handle, child: null, invocation_count: 1,
|
|
271
|
+
stop_requested: snapshot.task.stopRequested || snapshot.task.state === "stopped" || state.state === "stopping" });
|
|
272
|
+
if (state.state === "completed") return reconcileDirectWorker(task, { worker });
|
|
273
|
+
task.stop_requested ||= snapshot.task.stopRequested || state.state === "stopping";
|
|
274
|
+
task.status = task.stop_requested ? "stopping" : state.state === "running" ? "running" : "recovery_required";
|
|
275
|
+
task.output = state.output || task.output;
|
|
276
|
+
task.token_usage = state.token_usage || task.token_usage;
|
|
277
|
+
return task;
|
|
278
|
+
}
|
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/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
|
|
@@ -2,6 +2,7 @@ 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';
|
|
@@ -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 [];
|
|
@@ -203,13 +209,13 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
203
209
|
let nextFile = 0;
|
|
204
210
|
// Catalog prefixes are bounded independently of full transcript size. Use a
|
|
205
211
|
// small I/O pool; exact-context/statistics reads stay serial and identity-safe.
|
|
206
|
-
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 () => {
|
|
207
213
|
while (nextFile < filesToInspect.length && !signal?.aborted) {
|
|
208
214
|
const file = filesToInspect[nextFile++];
|
|
209
|
-
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);
|
|
210
216
|
if (!candidate) continue;
|
|
211
217
|
candidate = decorateLineage(candidate);
|
|
212
|
-
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}` : ''}`;
|
|
213
219
|
if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
|
|
214
220
|
}
|
|
215
221
|
}));
|
|
@@ -305,6 +311,52 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
|
|
|
305
311
|
return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
|
|
306
312
|
}
|
|
307
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
|
+
|
|
308
360
|
export async function readClaudeStatistics(candidate, { home, signal } = {}) {
|
|
309
361
|
signal?.throwIfAborted();
|
|
310
362
|
if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
|