@awak-app/simy-cli 0.7.11 → 0.7.13
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 +50 -8
- 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 => {
|
|
@@ -632,7 +650,9 @@ export async function startAgent({
|
|
|
632
650
|
if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
|
|
633
651
|
if (organizationState !== "ready" || !organizationItems.some(org => org.id === organizationId)) throw new Error("Refresh the organization list before selecting an organization.");
|
|
634
652
|
const choice = organizationItems.find(org => org.id === organizationId);
|
|
635
|
-
|
|
653
|
+
// Selecting an authenticated membership is separate from permission to run.
|
|
654
|
+
// The new session still requires a fresh server entitlement before work.
|
|
655
|
+
if (choice.desktopAccess === "unavailable") throw new Error("Refresh organizations before switching; access could not be verified.");
|
|
636
656
|
organizationSlug = choice.slug;
|
|
637
657
|
if (!organizationSlug) throw new Error('Refresh organizations before switching; its verified slug is unavailable.');
|
|
638
658
|
authNonce = randomBytes(16).toString("base64url");
|
|
@@ -1960,8 +1980,9 @@ export async function startAgent({
|
|
|
1960
1980
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
1961
1981
|
return;
|
|
1962
1982
|
}
|
|
1983
|
+
await recoverDirectWorkers();
|
|
1963
1984
|
const task = directRegistry.get(decodeURIComponent(directExecutionMatch[1]));
|
|
1964
|
-
if (!task) {
|
|
1985
|
+
if (!task || task.worker_handle && (task.worker_handle.identity.account !== session?.account_id || task.worker_handle.identity.device !== session?.device_id)) {
|
|
1965
1986
|
json(res, 404, { error: "direct task not found" });
|
|
1966
1987
|
return;
|
|
1967
1988
|
}
|
|
@@ -2055,7 +2076,15 @@ export async function startAgent({
|
|
|
2055
2076
|
const link = inspectedLinks.get(inspection);
|
|
2056
2077
|
await authorizeProviderDispatch(body.backend, requestAccessScope, link);
|
|
2057
2078
|
const runDirectTask = dependencies.runDirectTask || startDirectTask;
|
|
2058
|
-
const
|
|
2079
|
+
const directWorkerIdentity = { root: sessionRoot, provider: body.backend,
|
|
2080
|
+
session: task.id, account: session.account_id, device: session.device_id,
|
|
2081
|
+
origin: apiOrigin, worktree: repositoryPath };
|
|
2082
|
+
// Production command resolution is wrapped for authorization; explicitly
|
|
2083
|
+
// select the durable owner so this wrapper cannot disable worker execution.
|
|
2084
|
+
const directWorker = (typeof dependencies.executionWorker === "function" ? dependencies.executionWorker({ identity: directWorkerIdentity }) : dependencies.executionWorker) || (!dependencies.runDirectTask &&
|
|
2085
|
+
!dependencies.resolveDesktopExecutorCommand && process.platform !== "win32"
|
|
2086
|
+
? createLedgerExecutionWorker({ identity: directWorkerIdentity }) : null);
|
|
2087
|
+
const directOptions = { session, apiOrigin, sessionRoot, ...(directWorker ? { worker: directWorker, workerBinding: directWorkerIdentity } : {}), resolveCommand: async input => {
|
|
2059
2088
|
const command = await (dependencies.resolveDesktopExecutorCommand || resolveDesktopExecutorCommand)(input);
|
|
2060
2089
|
await authorizeProviderDispatch(body.backend, requestAccessScope, link);
|
|
2061
2090
|
return command;
|
|
@@ -2635,6 +2664,11 @@ export async function startAgent({
|
|
|
2635
2664
|
startHeartbeatTimer();
|
|
2636
2665
|
}
|
|
2637
2666
|
|
|
2667
|
+
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;
|
|
2668
|
+
const directRegistryView = {
|
|
2669
|
+
list: () => directRegistry.list().filter(directTaskVisible),
|
|
2670
|
+
get: id => { const task = directRegistry.get(id); return task && directTaskVisible(task) ? task : null; },
|
|
2671
|
+
};
|
|
2638
2672
|
const stopDesktopTask = async key => {
|
|
2639
2673
|
if (typeof key !== "string" || key.length > 256) throw new Error("Select a running task.");
|
|
2640
2674
|
if (key.startsWith("sqm:")) {
|
|
@@ -2644,7 +2678,7 @@ export async function startAgent({
|
|
|
2644
2678
|
const match = /^(0|1|2):(.+)$/.exec(key);
|
|
2645
2679
|
if (!match) throw new Error("Select a running task.");
|
|
2646
2680
|
const index = Number(match[1]);
|
|
2647
|
-
const task = [registry,
|
|
2681
|
+
const task = [registry, directRegistryView, localTaskRegistry][index].get(match[2]);
|
|
2648
2682
|
if (!task) throw new Error("This task is no longer available.");
|
|
2649
2683
|
if (!canStopDesktopTask(task, index)) return;
|
|
2650
2684
|
await [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task);
|
|
@@ -2654,7 +2688,7 @@ export async function startAgent({
|
|
|
2654
2688
|
desktopWorkSuspended = true;
|
|
2655
2689
|
const stops = [sqmLocalReviewWorker?.close?.(), durableLocalTaskWorker?.close?.(),
|
|
2656
2690
|
...[registry, directRegistry, localTaskRegistry].flatMap((source, index) => source.list()
|
|
2657
|
-
.filter(task => canStopDesktopTask(task, index))
|
|
2691
|
+
.filter(task => canStopDesktopTask(task, index) && task.update_owner_transferred !== true)
|
|
2658
2692
|
.map(task => [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task)))];
|
|
2659
2693
|
const results = await Promise.allSettled(stops);
|
|
2660
2694
|
const failure = results.find(result => result.status === "rejected");
|
|
@@ -2693,6 +2727,7 @@ export async function startAgent({
|
|
|
2693
2727
|
return JSON.stringify(scope) === JSON.stringify(currentHistoryScope()) ? items : [];
|
|
2694
2728
|
};
|
|
2695
2729
|
const getDesktopStatus = async () => {
|
|
2730
|
+
await recoverDirectWorkers();
|
|
2696
2731
|
const authorization = authorizationWindow.snapshot();
|
|
2697
2732
|
const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
|
|
2698
2733
|
if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
|
|
@@ -2716,7 +2751,7 @@ export async function startAgent({
|
|
|
2716
2751
|
pending: Boolean(pendingOrganization || desktopLoginPending) },
|
|
2717
2752
|
...(enforceDesktopAccess ? { access: { ...desktopAccess.snapshot(), revision: desktopScopeRevision() } } : {}),
|
|
2718
2753
|
connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
|
|
2719
|
-
tasks: desktopTaskStatus([registry,
|
|
2754
|
+
tasks: desktopTaskStatus([registry, directRegistryView, localTaskRegistry]),
|
|
2720
2755
|
organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
|
|
2721
2756
|
switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" },
|
|
2722
2757
|
sqm: { enabled: sqmEnabled, engineFallbackEnabled: sqmEngineSettings.enable_engine_fallback,
|
|
@@ -2743,6 +2778,8 @@ export async function startAgent({
|
|
|
2743
2778
|
port,
|
|
2744
2779
|
registry,
|
|
2745
2780
|
directRegistry,
|
|
2781
|
+
recoverDirectWorkers,
|
|
2782
|
+
directWorkerHandles: () => directRegistry.list().map(task => task.worker_handle).filter(handle => handle && handle.identity.account === session?.account_id && handle.identity.device === session?.device_id),
|
|
2746
2783
|
localTaskRegistry,
|
|
2747
2784
|
durableLocalTaskWorker,
|
|
2748
2785
|
sqmLocalReviewWorker,
|
|
@@ -2813,6 +2850,10 @@ export async function startAgent({
|
|
|
2813
2850
|
if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) return null;
|
|
2814
2851
|
return { token: session.token, api_base_url: session.api_base_url };
|
|
2815
2852
|
},
|
|
2853
|
+
getUpdateDetachmentContext() {
|
|
2854
|
+
return { actor: isSessionValid(session, Date.now(), apiOrigin) && !pendingOrganization ? { account: session.account_id, device: session.device_id, origin: apiOrigin } : null,
|
|
2855
|
+
runs: registry.list(), directTasks: directRegistry.list(), localTasks: localTaskRegistry.list() };
|
|
2856
|
+
},
|
|
2816
2857
|
getDesktopStatus,
|
|
2817
2858
|
stopDesktopTask,
|
|
2818
2859
|
stopDesktopTasks,
|
|
@@ -2824,7 +2865,8 @@ export async function startAgent({
|
|
|
2824
2865
|
if (JSON.stringify(scope) !== JSON.stringify(currentHistoryScope())) return null;
|
|
2825
2866
|
return sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? detail;
|
|
2826
2867
|
}
|
|
2827
|
-
|
|
2868
|
+
await recoverDirectWorkers();
|
|
2869
|
+
return desktopTaskDetail([registry, directRegistryView, localTaskRegistry], key);
|
|
2828
2870
|
},
|
|
2829
2871
|
async refreshDesktopStatus() {
|
|
2830
2872
|
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.13";
|
|
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
|
}
|