@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/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLedgerExecutionWorker, discoverLedgerWorkers } from './update-detachment/worker-ledger.js';
|
|
1
2
|
import { normalizeProviderModelSelection } from "./provider-model-selection.js";
|
|
2
3
|
import { assertConnectionScope, connectionScope, connectionDiagnostic } from "./connection-diagnostics.js";
|
|
3
4
|
import { authorizeLaunch } from "./launch-authorization.js";
|
|
@@ -118,6 +119,7 @@ import {
|
|
|
118
119
|
DirectTaskRegistry,
|
|
119
120
|
startDirectTask,
|
|
120
121
|
stopDirectTask,
|
|
122
|
+
restoreDirectWorkerTask,
|
|
121
123
|
} from "./direct-executor.js";
|
|
122
124
|
import {
|
|
123
125
|
classifyExecutionRequirement,
|
|
@@ -214,6 +216,22 @@ export async function startAgent({
|
|
|
214
216
|
// as its device (see /v1/auth/complete), so keep it after it is cleared.
|
|
215
217
|
let reclaimableSession = null;
|
|
216
218
|
if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
|
|
219
|
+
const recoverDirectWorkers = async () => {
|
|
220
|
+
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.account_id || !session?.device_id) return [];
|
|
221
|
+
const actor = { account: session.account_id, device: session.device_id };
|
|
222
|
+
const actorCurrent = () => isSessionValid(session, Date.now(), apiOrigin) && session.account_id === actor.account && session.device_id === actor.device;
|
|
223
|
+
const handles = await discoverLedgerWorkers({ root: sessionRoot, ...actor, origin: apiOrigin });
|
|
224
|
+
for (const handle of handles) {
|
|
225
|
+
const worker = createLedgerExecutionWorker({ identity: handle.identity });
|
|
226
|
+
// Async adoption must not publish an old actor's task after sign-in changes.
|
|
227
|
+
const restored = await restoreDirectWorkerTask({ worker, handle, registry: new DirectTaskRegistry() });
|
|
228
|
+
if (!actorCurrent()) return [];
|
|
229
|
+
if (!directRegistry.get(handle.taskId)) directRegistry.create(restored);
|
|
230
|
+
else await restoreDirectWorkerTask({ worker, handle, registry: directRegistry });
|
|
231
|
+
}
|
|
232
|
+
return handles;
|
|
233
|
+
};
|
|
234
|
+
await recoverDirectWorkers();
|
|
217
235
|
const purposeKeyPath = dependencies.purposeSigningKeyPath || purposeSigningKeyPath(sessionRoot);
|
|
218
236
|
let purposeSigner = null;
|
|
219
237
|
const refreshPurposeSigner = async () => {
|
|
@@ -526,7 +544,7 @@ export async function startAgent({
|
|
|
526
544
|
await callback?.(...args);
|
|
527
545
|
await guard();
|
|
528
546
|
};
|
|
529
|
-
return { ...options, beforeProviderInvocation: wrap(options.beforeProviderInvocation), beforeSpawn: wrap(options.beforeSpawn) };
|
|
547
|
+
return { ...options, sessionRoot, beforeProviderInvocation: wrap(options.beforeProviderInvocation), beforeSpawn: wrap(options.beforeSpawn) };
|
|
530
548
|
};
|
|
531
549
|
const codingRuntimeOptions = run => {
|
|
532
550
|
const wrap = (custom, marker) => async context => {
|
|
@@ -1960,8 +1978,9 @@ export async function startAgent({
|
|
|
1960
1978
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
1961
1979
|
return;
|
|
1962
1980
|
}
|
|
1981
|
+
await recoverDirectWorkers();
|
|
1963
1982
|
const task = directRegistry.get(decodeURIComponent(directExecutionMatch[1]));
|
|
1964
|
-
if (!task) {
|
|
1983
|
+
if (!task || task.worker_handle && (task.worker_handle.identity.account !== session?.account_id || task.worker_handle.identity.device !== session?.device_id)) {
|
|
1965
1984
|
json(res, 404, { error: "direct task not found" });
|
|
1966
1985
|
return;
|
|
1967
1986
|
}
|
|
@@ -2055,7 +2074,15 @@ export async function startAgent({
|
|
|
2055
2074
|
const link = inspectedLinks.get(inspection);
|
|
2056
2075
|
await authorizeProviderDispatch(body.backend, requestAccessScope, link);
|
|
2057
2076
|
const runDirectTask = dependencies.runDirectTask || startDirectTask;
|
|
2058
|
-
const
|
|
2077
|
+
const directWorkerIdentity = { root: sessionRoot, provider: body.backend,
|
|
2078
|
+
session: task.id, account: session.account_id, device: session.device_id,
|
|
2079
|
+
origin: apiOrigin, worktree: repositoryPath };
|
|
2080
|
+
// Production command resolution is wrapped for authorization; explicitly
|
|
2081
|
+
// select the durable owner so this wrapper cannot disable worker execution.
|
|
2082
|
+
const directWorker = (typeof dependencies.executionWorker === "function" ? dependencies.executionWorker({ identity: directWorkerIdentity }) : dependencies.executionWorker) || (!dependencies.runDirectTask &&
|
|
2083
|
+
!dependencies.resolveDesktopExecutorCommand && process.platform !== "win32"
|
|
2084
|
+
? createLedgerExecutionWorker({ identity: directWorkerIdentity }) : null);
|
|
2085
|
+
const directOptions = { session, apiOrigin, sessionRoot, ...(directWorker ? { worker: directWorker, workerBinding: directWorkerIdentity } : {}), resolveCommand: async input => {
|
|
2059
2086
|
const command = await (dependencies.resolveDesktopExecutorCommand || resolveDesktopExecutorCommand)(input);
|
|
2060
2087
|
await authorizeProviderDispatch(body.backend, requestAccessScope, link);
|
|
2061
2088
|
return command;
|
|
@@ -2635,6 +2662,11 @@ export async function startAgent({
|
|
|
2635
2662
|
startHeartbeatTimer();
|
|
2636
2663
|
}
|
|
2637
2664
|
|
|
2665
|
+
const directTaskVisible = task => !task.worker_handle || isSessionValid(session, Date.now(), apiOrigin) && task.worker_handle.identity.account === session.account_id && task.worker_handle.identity.device === session.device_id;
|
|
2666
|
+
const directRegistryView = {
|
|
2667
|
+
list: () => directRegistry.list().filter(directTaskVisible),
|
|
2668
|
+
get: id => { const task = directRegistry.get(id); return task && directTaskVisible(task) ? task : null; },
|
|
2669
|
+
};
|
|
2638
2670
|
const stopDesktopTask = async key => {
|
|
2639
2671
|
if (typeof key !== "string" || key.length > 256) throw new Error("Select a running task.");
|
|
2640
2672
|
if (key.startsWith("sqm:")) {
|
|
@@ -2644,7 +2676,7 @@ export async function startAgent({
|
|
|
2644
2676
|
const match = /^(0|1|2):(.+)$/.exec(key);
|
|
2645
2677
|
if (!match) throw new Error("Select a running task.");
|
|
2646
2678
|
const index = Number(match[1]);
|
|
2647
|
-
const task = [registry,
|
|
2679
|
+
const task = [registry, directRegistryView, localTaskRegistry][index].get(match[2]);
|
|
2648
2680
|
if (!task) throw new Error("This task is no longer available.");
|
|
2649
2681
|
if (!canStopDesktopTask(task, index)) return;
|
|
2650
2682
|
await [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task);
|
|
@@ -2654,7 +2686,7 @@ export async function startAgent({
|
|
|
2654
2686
|
desktopWorkSuspended = true;
|
|
2655
2687
|
const stops = [sqmLocalReviewWorker?.close?.(), durableLocalTaskWorker?.close?.(),
|
|
2656
2688
|
...[registry, directRegistry, localTaskRegistry].flatMap((source, index) => source.list()
|
|
2657
|
-
.filter(task => canStopDesktopTask(task, index))
|
|
2689
|
+
.filter(task => canStopDesktopTask(task, index) && task.update_owner_transferred !== true)
|
|
2658
2690
|
.map(task => [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task)))];
|
|
2659
2691
|
const results = await Promise.allSettled(stops);
|
|
2660
2692
|
const failure = results.find(result => result.status === "rejected");
|
|
@@ -2693,6 +2725,7 @@ export async function startAgent({
|
|
|
2693
2725
|
return JSON.stringify(scope) === JSON.stringify(currentHistoryScope()) ? items : [];
|
|
2694
2726
|
};
|
|
2695
2727
|
const getDesktopStatus = async () => {
|
|
2728
|
+
await recoverDirectWorkers();
|
|
2696
2729
|
const authorization = authorizationWindow.snapshot();
|
|
2697
2730
|
const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
|
|
2698
2731
|
if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
|
|
@@ -2716,7 +2749,7 @@ export async function startAgent({
|
|
|
2716
2749
|
pending: Boolean(pendingOrganization || desktopLoginPending) },
|
|
2717
2750
|
...(enforceDesktopAccess ? { access: { ...desktopAccess.snapshot(), revision: desktopScopeRevision() } } : {}),
|
|
2718
2751
|
connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
|
|
2719
|
-
tasks: desktopTaskStatus([registry,
|
|
2752
|
+
tasks: desktopTaskStatus([registry, directRegistryView, localTaskRegistry]),
|
|
2720
2753
|
organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
|
|
2721
2754
|
switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" },
|
|
2722
2755
|
sqm: { enabled: sqmEnabled, engineFallbackEnabled: sqmEngineSettings.enable_engine_fallback,
|
|
@@ -2743,6 +2776,8 @@ export async function startAgent({
|
|
|
2743
2776
|
port,
|
|
2744
2777
|
registry,
|
|
2745
2778
|
directRegistry,
|
|
2779
|
+
recoverDirectWorkers,
|
|
2780
|
+
directWorkerHandles: () => directRegistry.list().map(task => task.worker_handle).filter(handle => handle && handle.identity.account === session?.account_id && handle.identity.device === session?.device_id),
|
|
2746
2781
|
localTaskRegistry,
|
|
2747
2782
|
durableLocalTaskWorker,
|
|
2748
2783
|
sqmLocalReviewWorker,
|
|
@@ -2813,6 +2848,10 @@ export async function startAgent({
|
|
|
2813
2848
|
if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) return null;
|
|
2814
2849
|
return { token: session.token, api_base_url: session.api_base_url };
|
|
2815
2850
|
},
|
|
2851
|
+
getUpdateDetachmentContext() {
|
|
2852
|
+
return { actor: isSessionValid(session, Date.now(), apiOrigin) && !pendingOrganization ? { account: session.account_id, device: session.device_id, origin: apiOrigin } : null,
|
|
2853
|
+
runs: registry.list(), directTasks: directRegistry.list(), localTasks: localTaskRegistry.list() };
|
|
2854
|
+
},
|
|
2816
2855
|
getDesktopStatus,
|
|
2817
2856
|
stopDesktopTask,
|
|
2818
2857
|
stopDesktopTasks,
|
|
@@ -2824,7 +2863,8 @@ export async function startAgent({
|
|
|
2824
2863
|
if (JSON.stringify(scope) !== JSON.stringify(currentHistoryScope())) return null;
|
|
2825
2864
|
return sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? detail;
|
|
2826
2865
|
}
|
|
2827
|
-
|
|
2866
|
+
await recoverDirectWorkers();
|
|
2867
|
+
return desktopTaskDetail([registry, directRegistryView, localTaskRegistry], key);
|
|
2828
2868
|
},
|
|
2829
2869
|
async refreshDesktopStatus() {
|
|
2830
2870
|
if (isSessionValid(session, Date.now(), apiOrigin)) {
|
package/src/auto-update.js
CHANGED
|
@@ -69,6 +69,8 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
69
69
|
#stopped = false;
|
|
70
70
|
#registryListener = null;
|
|
71
71
|
#activeWork = 0;
|
|
72
|
+
#coordinator = null;
|
|
73
|
+
#connectionTransferred = false;
|
|
72
74
|
#confirmedUpdate = null;
|
|
73
75
|
#confirmedReady = false;
|
|
74
76
|
#desktopIdle = () => true;
|
|
@@ -157,7 +159,7 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
acceptsNewWork() {
|
|
160
|
-
return this.#applyHolds === 0 && !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
|
|
162
|
+
return (!this.#coordinator || this.#coordinator.acceptsNewWork()) && this.#applyHolds === 0 && !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
beginWork() {
|
|
@@ -174,7 +176,12 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
174
176
|
};
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
|
|
179
|
+
updateDrainState() { return { activeWork:this.#activeWork, applyHolds:this.#applyHolds }; }
|
|
180
|
+
|
|
181
|
+
setCoordinator(coordinator) { this.#coordinator = coordinator; }
|
|
182
|
+
|
|
183
|
+
start({ registry, restartDaemon, isDesktopIdle = () => true, coordinator } = {}) {
|
|
184
|
+
this.#coordinator = coordinator || null;
|
|
178
185
|
if (this.#snapshot.state === "disabled" || this.#checkTimer || this.#stopped) return this;
|
|
179
186
|
this.#registry = registry;
|
|
180
187
|
this.#restartDaemon = restartDaemon;
|
|
@@ -284,15 +291,27 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
284
291
|
}
|
|
285
292
|
}
|
|
286
293
|
|
|
287
|
-
async maybeApply() {
|
|
294
|
+
async maybeApply({ detach = false } = {}) {
|
|
288
295
|
if (this.#confirmedUpdate && !this.#confirmedReady) return this.snapshot();
|
|
289
296
|
if (this.#stopped || this.#snapshot.state !== "waiting_for_idle") {
|
|
290
297
|
return this.snapshot();
|
|
291
298
|
}
|
|
292
299
|
if (this.#applying) return this.#applying;
|
|
293
|
-
if (!this.#isIdle()) return this.snapshot();
|
|
294
|
-
|
|
295
|
-
this.#applying = this.#
|
|
300
|
+
if (!this.#isIdle() && !(detach && this.#coordinator)) return this.snapshot();
|
|
301
|
+
|
|
302
|
+
this.#applying = (this.#coordinator
|
|
303
|
+
? this.#coordinator.detachUpdate(async () => {
|
|
304
|
+
const result = await this.#performUpdate();
|
|
305
|
+
return { healthy: result.state === "updated", connectionTransferred: this.#connectionTransferred };
|
|
306
|
+
}).then(connection => {
|
|
307
|
+
if (connection.state !== 'connected') throw new Error('Original conversation reconciliation requires recovery.');
|
|
308
|
+
return this.snapshot();
|
|
309
|
+
})
|
|
310
|
+
: this.#performUpdate()).catch(error => {
|
|
311
|
+
this.#clearIdleTimer();
|
|
312
|
+
this.#setState("failed", { message: "Update connection requires recovery before retrying.", error: errorMessage(error) });
|
|
313
|
+
return this.snapshot();
|
|
314
|
+
}).finally(() => {
|
|
296
315
|
this.#applying = null;
|
|
297
316
|
});
|
|
298
317
|
return this.#applying;
|
|
@@ -344,7 +363,8 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
344
363
|
message: `SIMY CLI ${targetVersion} is installed. Verifying the restarted daemon...`,
|
|
345
364
|
});
|
|
346
365
|
this.#announce();
|
|
347
|
-
await this.#restartDaemon?.(targetVersion, installation);
|
|
366
|
+
const handoff = await this.#restartDaemon?.(targetVersion, installation);
|
|
367
|
+
this.#connectionTransferred = handoff?.connectionTransferred === true;
|
|
348
368
|
this.#clearIdleTimer();
|
|
349
369
|
this.#setState("updated", {
|
|
350
370
|
message: `SIMY CLI ${targetVersion} is running.`,
|
|
@@ -382,7 +402,8 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
382
402
|
}
|
|
383
403
|
|
|
384
404
|
#isIdle() {
|
|
385
|
-
|
|
405
|
+
if (this.#coordinator?.snapshot().state === 'detached') return this.#applyHolds === 0 && this.#activeWork === 0;
|
|
406
|
+
return this.#applyHolds === 0 && this.#activeWork === 0 && isRegistryIdle(this.#registry, { allowSuspended: Boolean(this.#coordinator) }) && this.#desktopIdle();
|
|
386
407
|
}
|
|
387
408
|
|
|
388
409
|
#clearIdleTimer() {
|
|
@@ -905,12 +926,14 @@ export async function reexecuteCliCommand({
|
|
|
905
926
|
});
|
|
906
927
|
}
|
|
907
928
|
|
|
908
|
-
export function isRegistryIdle(registry) {
|
|
929
|
+
export function isRegistryIdle(registry, { allowSuspended = false } = {}) {
|
|
909
930
|
const runs = registry?.list?.() || [];
|
|
910
931
|
return runs.every((run) => {
|
|
911
932
|
if (run?.operation || run?.child || run?.pendingLedgerUpdate || run?.ledgerUpdateRunning) {
|
|
912
933
|
return false;
|
|
913
934
|
}
|
|
935
|
+
if (allowSuspended && ["paused", "blocked", "waiting_human"].includes(run?.controlState || run?.status) && !run?.unsettledPermission) return true;
|
|
936
|
+
if (run?.unsettledPermission) return false;
|
|
914
937
|
if (NON_IDLE_STATES.has(run?.status)) return false;
|
|
915
938
|
if (NON_IDLE_CONTROL_STATES.has(run?.controlState)) return false;
|
|
916
939
|
return true;
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.7.
|
|
16
|
+
export const CLI_VERSION = "0.7.12";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { captureDispatchProvenance } from './update-dispatch-identity.mjs';
|
|
2
3
|
import { MAX_STATE_BYTES } from './state-limits.mjs';
|
|
3
4
|
import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from './recovery-incident.mjs';
|
|
4
5
|
import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
|
|
@@ -13,6 +14,8 @@ import { searchEnabled, failureLimitReached, searchState, recordSearchExperiment
|
|
|
13
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
14
15
|
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
17
|
+
import { atomicSave, readState } from './update-state-store.mjs';
|
|
18
|
+
import { captureHookOwner, fenceHook, rootFromDelivery, readDispatchGate, readTaskAdmission } from './update-dispatch-gate.mjs';
|
|
16
19
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
17
20
|
import { execFile } from 'node:child_process';
|
|
18
21
|
import { promisify } from 'node:util';
|
|
@@ -26,7 +29,7 @@ import { continuationMode } from './continuation-policy.mjs';
|
|
|
26
29
|
import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
|
|
27
30
|
|
|
28
31
|
export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
|
|
29
|
-
const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
|
|
32
|
+
const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification', 'ConfigChange', 'PostModelSwitch']);
|
|
30
33
|
const MAX_BYTES = 256 * 1024;
|
|
31
34
|
const RETRYABLE = new Set(['overloaded', 'server_error', 'rate_limit']);
|
|
32
35
|
const APPROVAL_DECISIONS = new Set(['automatic', 'proceed_and_report', 'wait']);
|
|
@@ -1694,19 +1697,79 @@ async function providerProcess(provider) {
|
|
|
1694
1697
|
}
|
|
1695
1698
|
return null;
|
|
1696
1699
|
}
|
|
1697
|
-
export
|
|
1700
|
+
export const stopMarkerPath = (root, provider, session) => `${recordPath(root, provider, session)}.user-stop`;
|
|
1701
|
+
export const updateInvalidationPath = (root, provider, session) => `${recordPath(root, provider, session)}.update-invalidation`;
|
|
1702
|
+
export function hasPendingUpdateInvalidation(root, record) {
|
|
1703
|
+
const marker = readState(updateInvalidationPath(root, record.provider, record.session));
|
|
1704
|
+
return !marker || marker.schemaVersion !== 1 || typeof marker.id !== 'string' || !marker.id || marker.id !== record.updateObservation?.invalidationId;
|
|
1705
|
+
}
|
|
1706
|
+
export function hasPendingUserStop(root, record) {
|
|
1707
|
+
if (!record?.run) return false;
|
|
1708
|
+
const marker = readState(stopMarkerPath(root, record.provider, record.session));
|
|
1709
|
+
return Boolean(marker && marker.runId === record.run.id && marker.id !== record.run.handledStopMarkerId);
|
|
1710
|
+
}
|
|
1711
|
+
// Synchronous last check after the dispatch journal, immediately before emission.
|
|
1712
|
+
// A UI stop/pause or OFF committed during async evaluation must win over old output.
|
|
1713
|
+
export function canEmitHookContinuation(root, observed) {
|
|
1714
|
+
const latest = readState(recordPath(root, observed?.provider, observed?.session));
|
|
1715
|
+
return Boolean(latest?.run && latest.run.id === observed?.run?.id &&
|
|
1716
|
+
latest.run.revision === observed.run.revision && !TERMINAL.has(latest.run.state) &&
|
|
1717
|
+
!['paused', 'blocked', 'recovery_required', 'verification_pending'].includes(latest.run.state) &&
|
|
1718
|
+
automaticContinuationAllowed(latest) && !hasPendingUserStop(root, latest));
|
|
1719
|
+
}
|
|
1720
|
+
export async function handleHook(options) {
|
|
1721
|
+
const root = options.root || rootDirectory(), event = options.input?.hook_event_name;
|
|
1722
|
+
if (!EVENTS.has(event) || !text(options.input?.session_id) || !text(options.input?.cwd)) return {};
|
|
1723
|
+
const permissionWait = event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(options.input.notification_type);
|
|
1724
|
+
if (options.provider === 'claude' && (permissionWait || ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event))) {
|
|
1725
|
+
const priorInvalidationId = readState(updateInvalidationPath(root, options.provider, options.input.session_id))?.id || null;
|
|
1726
|
+
const marker = { schemaVersion: 1, id: randomUUID(), event };
|
|
1727
|
+
atomicSave(updateInvalidationPath(root, options.provider, options.input.session_id), marker);
|
|
1728
|
+
options = { ...options, invalidationId: marker.id, priorInvalidationId };
|
|
1729
|
+
}
|
|
1730
|
+
if (event === 'Interrupt') {
|
|
1731
|
+
const record = await read(recordPath(root, options.provider, options.input?.session_id));
|
|
1732
|
+
if (record?.run) atomicSave(stopMarkerPath(root, options.provider, options.input.session_id), { schemaVersion: 1, id: randomUUID(), runId: record.run.id });
|
|
1733
|
+
}
|
|
1734
|
+
// Stop/permission lifecycle must never wait on the update dispatch lock.
|
|
1735
|
+
if (event !== 'Stop' && event !== 'SessionStart') { const output = await handleHookState({ ...options, root, suppressDispatch: true }); if (options.emit) options.emit(output); return output; }
|
|
1736
|
+
try {
|
|
1737
|
+
const observedProcess = options.observedProcess === undefined ? await providerProcess(options.provider) : options.observedProcess;
|
|
1738
|
+
options = { ...options, observedProcess };
|
|
1739
|
+
const owner = captureHookOwner(rootFromDelivery(root), options.provider, options.input?.session_id, observedProcess);
|
|
1740
|
+
let current = await read(recordPath(root, options.provider, options.input.session_id)), pendingOutput;
|
|
1741
|
+
return await fenceHook(owner, `${options.provider}:${options.input?.session_id}`, async suppressDispatch => {
|
|
1742
|
+
const output = await handleHookState({ ...options, root, suppressDispatch });
|
|
1743
|
+
pendingOutput = output;
|
|
1744
|
+
current = await read(recordPath(root, options.provider, options.input.session_id));
|
|
1745
|
+
return hasPendingUserStop(root, current) ? {} : output;
|
|
1746
|
+
}, options.emit, () => pendingOutput?.decision !== 'block' || canEmitHookContinuation(root, current), () => {
|
|
1747
|
+
const taskId = current?.run?.managed?.taskId || current?.run?.id;
|
|
1748
|
+
return taskId ? { taskId, attemptId: (current.run.managed?.executionAttempt?.phase === 'dispatching' ? current.run.managed.executionAttempt.id : null) || `${taskId}:hook:${current.run.continuations}`, provenance: captureDispatchProvenance({ root: owner.root, provider: current.provider, providerSession: current.session, worktree: taskDirectory(current), taskId, recordRef: recordPath(root, current.provider, current.session) }) } : null;
|
|
1749
|
+
});
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
if (error.code !== 'state_busy') throw error;
|
|
1752
|
+
// Still reconcile the boundary/checkpoint. No automatic instruction is emitted.
|
|
1753
|
+
await handleHookState({ ...options, root, suppressDispatch: true });
|
|
1754
|
+
if (options.emit) options.emit({});
|
|
1755
|
+
return {};
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
async function handleHookState({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity, suppressDispatch = false, invalidationId = null, priorInvalidationId = null, recallMemory = recallPerspectives, env = process.env }) {
|
|
1698
1759
|
const link = await providerLinkState(root, provider);
|
|
1699
1760
|
if (!link.connected) return {};
|
|
1700
1761
|
if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
|
|
1701
1762
|
let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
|
|
1702
1763
|
const event = input.hook_event_name;
|
|
1703
|
-
const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
|
|
1764
|
+
const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event)) ? await providerProcess(provider) : observedProcess;
|
|
1704
1765
|
let output = {}, boundaryLease = null, claimedWorkspace = null, observed;
|
|
1705
1766
|
const applyObservation = () => updateRecord(root, provider, input.session_id, async old => {
|
|
1706
1767
|
const currentLink = await providerLinkState(root, provider);
|
|
1707
1768
|
if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
|
|
1708
1769
|
if (old && old.cwd !== cwd) return old;
|
|
1709
1770
|
const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
|
|
1771
|
+
const stopMarker = readState(stopMarkerPath(root, provider, input.session_id));
|
|
1772
|
+
if (record.run && typeof stopMarker?.runId === 'string' && stopMarker.runId === record.run.id && stopMarker.id !== record.run.handledStopMarkerId) { record.run.state = 'stopped'; record.run.reason = 'User interrupted the provider.'; }
|
|
1710
1773
|
// Inherited provider identity links an AI-launched conversation to its
|
|
1711
1774
|
// origin. Never infer parentage from a shared workspace or nearby time.
|
|
1712
1775
|
if (event === 'SessionStart' && !record.parentSession) {
|
|
@@ -1721,18 +1784,36 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1721
1784
|
} catch { /* Unverified or cyclic ancestry never changes navigation. */ }
|
|
1722
1785
|
}
|
|
1723
1786
|
}
|
|
1724
|
-
const owner = record.run?.managed?.liveHookOwner;
|
|
1725
|
-
if (
|
|
1787
|
+
const owner = record.run?.managed?.liveHookOwner || (['ConfigChange', 'PostModelSwitch'].includes(event) ? record.hookSessionProcess : null);
|
|
1788
|
+
if (provider === 'claude' && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event) &&
|
|
1789
|
+
(!identity || owner && (identity.pid !== owner.pid || identity.identity !== owner.identity))) {
|
|
1790
|
+
// An unverified lifecycle event invalidates prior proof without adopting
|
|
1791
|
+
// its claimed process. In particular, never retain pre-change policy.
|
|
1792
|
+
record.updateObservation = { schemaVersion: 1, process: null, source: event, observedAt: now, invalidationId,
|
|
1793
|
+
model: null, permissionMode: null, backgroundIdle: false };
|
|
1794
|
+
}
|
|
1795
|
+
if (owner && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event) &&
|
|
1726
1796
|
(!identity || identity.pid !== owner.pid || identity.identity !== owner.identity)) {
|
|
1727
|
-
if (!TERMINAL.has(record.run.state) && !['paused', 'verification_pending'].includes(record.run.state)) {
|
|
1728
|
-
if (!(record.run.state === 'blocked' && record.run.managed
|
|
1797
|
+
if (record.run && !TERMINAL.has(record.run.state) && !['paused', 'verification_pending'].includes(record.run.state)) {
|
|
1798
|
+
if (!(record.run.state === 'blocked' && record.run.managed?.pendingApproval)) record.run.state = 'recovery_required';
|
|
1729
1799
|
record.run.recoveryCause = 'hook_owner_mismatch';
|
|
1730
1800
|
record.run.reason = 'A different or unverified provider process reported this session. Inspect and explicitly resume in the original conversation.';
|
|
1731
|
-
record.run.managed
|
|
1801
|
+
if (record.run.managed) record.run.managed.autoRecovery = false;
|
|
1802
|
+
record.run.updatedAt = now; notifyState(record.run, now);
|
|
1732
1803
|
}
|
|
1733
1804
|
return record;
|
|
1734
1805
|
}
|
|
1735
1806
|
record.observedAt = now; record.lastEvent = event;
|
|
1807
|
+
if (provider === 'claude' && identity && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event)) {
|
|
1808
|
+
// Optional model/mode observations are not complete permission evidence.
|
|
1809
|
+
// Only an explicit empty task AND wakeup registry proves this boundary.
|
|
1810
|
+
// The supported Claude baseline has no PostModelSwitch hook: a later Stop
|
|
1811
|
+
// cannot prove that the SessionStart model is still selected.
|
|
1812
|
+
record.updateObservation = { schemaVersion: 1, process: { ...identity }, source: event, observedAt: now, invalidationId,
|
|
1813
|
+
model: event === 'SessionStart' ? text(input.model, 200) || null : null,
|
|
1814
|
+
permissionMode: text(input.permission_mode, 64) || null,
|
|
1815
|
+
backgroundIdle: event === 'Stop' && Array.isArray(input.background_tasks) && input.background_tasks.length === 0 && Array.isArray(input.session_crons) && input.session_crons.length === 0 };
|
|
1816
|
+
}
|
|
1736
1817
|
record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
|
|
1737
1818
|
if (identity) {
|
|
1738
1819
|
// Legacy records have no frozen hook owner; a new process must not inherit
|
|
@@ -1751,6 +1832,14 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1751
1832
|
}
|
|
1752
1833
|
if (event === 'SessionEnd') record.hookEndedAt = now;
|
|
1753
1834
|
const run = record.run;
|
|
1835
|
+
if(run && event==='Stop') {
|
|
1836
|
+
// Recheck the actual retained task under its record lock. Lifecycle
|
|
1837
|
+
// intake may continue after an update, but cannot spend failure or
|
|
1838
|
+
// continuation budgets while that task's admission remains blocked.
|
|
1839
|
+
const gateRoot=rootFromDelivery(root),gate=readDispatchGate(gateRoot);
|
|
1840
|
+
const fences=Object.values(gate.taskFences?.records || {}).filter(item=>item.taskId===(run.managed?.taskId || run.id) && item.identity.provider===provider && item.identity.session===input.session_id);
|
|
1841
|
+
if(fences.some(item=>!readTaskAdmission({root:gateRoot,identity:item.identity,taskId:item.taskId}).canResume))suppressDispatch=true;
|
|
1842
|
+
}
|
|
1754
1843
|
if (run && event === 'Stop' && input.turn_id && run.lastStopKey === `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}`) return record;
|
|
1755
1844
|
// Observation only: human prompts mark work in progress, and real provider
|
|
1756
1845
|
// stop/end boundaries settle it. Never infer running from SessionStart or
|
|
@@ -1797,7 +1886,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1797
1886
|
run.state = failureLimitReached(run) ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
|
|
1798
1887
|
run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
|
|
1799
1888
|
run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
|
|
1800
|
-
} else if (event === 'Stop') {
|
|
1889
|
+
} else if (event === 'Stop' && !suppressDispatch) {
|
|
1801
1890
|
// OFF suppresses future Stop blocking; it never interrupts the provider
|
|
1802
1891
|
// turn. Lease settlement and runtime observation above still run.
|
|
1803
1892
|
const latestLink = await providerLinkState(root, provider);
|
|
@@ -1926,6 +2015,13 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1926
2015
|
run.id !== expectedRunId || deliveryDecisionRevision(run) !== expectedRevision)) throw new Error('This decision changed. Refresh before choosing.');
|
|
1927
2016
|
if (record.providerArchived === true && !['stop', 'pause'].includes(action)) throw new Error('Restore the original provider conversation before changing Delivery Loop.');
|
|
1928
2017
|
if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
2018
|
+
if (['resume','replan','dependency_ready','session_answer'].includes(action)) {
|
|
2019
|
+
const gateRoot=rootFromDelivery(root),gate=readDispatchGate(gateRoot);
|
|
2020
|
+
const fences=Object.values(gate.taskFences?.records || {}).filter(item=>item.taskId===(run.managed?.taskId || run.id) && item.identity.provider===provider && item.identity.session===session);
|
|
2021
|
+
if(fences.some(item=>!readTaskAdmission({root:gateRoot,identity:item.identity,taskId:item.taskId}).canResume)) {
|
|
2022
|
+
throw Object.assign(new Error('Open the original conversation to review and continue. SIMY cannot resume this task until its current provider model and permissions are verified.'),{code:'task_admission_blocked'});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
1929
2025
|
if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
|
|
1930
2026
|
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
1931
2027
|
const commitControl = () => {
|
|
@@ -2062,6 +2158,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2062
2158
|
}
|
|
2063
2159
|
}
|
|
2064
2160
|
else if (action === 'resume') {
|
|
2161
|
+
const marker = readState(stopMarkerPath(root, provider, session));
|
|
2162
|
+
if (marker?.runId === run.id) run.handledStopMarkerId = marker.id;
|
|
2065
2163
|
const wasStopped = canExplicitlyResumeStopped(run);
|
|
2066
2164
|
if (run.managed?.dependencyWait) throw new Error('Confirm the exact external dependency condition before resuming.');
|
|
2067
2165
|
if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
|
|
@@ -2193,7 +2291,6 @@ export async function main(provider) {
|
|
|
2193
2291
|
let raw = '';
|
|
2194
2292
|
try {
|
|
2195
2293
|
for await (const chunk of process.stdin) { raw += String(chunk); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('Hook input too large.'); }
|
|
2196
|
-
|
|
2197
|
-
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
2294
|
+
await handleHook({ input: JSON.parse(raw || '{}'), provider, emit: output => process.stdout.write(`${JSON.stringify(output)}\n`) });
|
|
2198
2295
|
} catch { process.stdout.write('{}\n'); process.stderr.write('SIMY could not check delivery state; continuation was skipped. Inspect simy delivery status.\n'); }
|
|
2199
2296
|
}
|