@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/agent.js +50 -8
  3. package/src/auto-update.js +32 -9
  4. package/src/cli-contract.js +1 -1
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  6. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  7. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  8. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  9. package/src/delivery-hooks/supervisor.js +39 -10
  10. package/src/desktop-host.js +54 -8
  11. package/src/desktop-status.js +1 -1
  12. package/src/direct-executor.js +104 -4
  13. package/src/local-task.js +14 -3
  14. package/src/runner.js +19 -4
  15. package/src/session-supervision/claude-sessions.js +57 -5
  16. package/src/session-supervision/codex-app-server.js +20 -8
  17. package/src/sqm/claude-plugin.js +5 -0
  18. package/src/sqm/review-worker.js +15 -6
  19. package/src/update-detachment/claude-observation.js +33 -0
  20. package/src/update-detachment/codex-context.js +27 -0
  21. package/src/update-detachment/coordinator.js +117 -0
  22. package/src/update-detachment/dispatch-gate.js +1 -0
  23. package/src/update-detachment/ledger-adapter.js +62 -0
  24. package/src/update-detachment/ledger-reader.js +42 -0
  25. package/src/update-detachment/provenance.js +1 -0
  26. package/src/update-detachment/provider-adapters.js +33 -0
  27. package/src/update-detachment/reconcile.js +105 -0
  28. package/src/update-detachment/reconciliation-adapter.js +13 -0
  29. package/src/update-detachment/runtime.js +319 -0
  30. package/src/update-detachment/state-store.js +1 -0
  31. package/src/update-detachment/worker-entry.js +145 -0
  32. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  33. package/src/update-detachment/worker-ledger.js +138 -0
  34. package/src/update-detachment/worker-storage.js +29 -0
  35. package/src/update-detachment/worker.js +167 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
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
- if (choice.desktopAccess && choice.desktopAccess !== "ready") throw new Error("This organization does not have SIMY Developer access.");
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 directOptions = { resolveCommand: async input => {
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, directRegistry, localTaskRegistry][index].get(match[2]);
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, directRegistry, localTaskRegistry]),
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
- return desktopTaskDetail([registry, directRegistry, localTaskRegistry], key);
2868
+ await recoverDirectWorkers();
2869
+ return desktopTaskDetail([registry, directRegistryView, localTaskRegistry], key);
2828
2870
  },
2829
2871
  async refreshDesktopStatus() {
2830
2872
  if (isSessionValid(session, Date.now(), apiOrigin)) {
@@ -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
- start({ registry, restartDaemon, isDesktopIdle = () => true } = {}) {
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.#performUpdate().finally(() => {
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
- return this.#applyHolds === 0 && this.#activeWork === 0 && isRegistryIdle(this.#registry) && this.#desktopIdle();
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;
@@ -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.11";
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 async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity, recallMemory = recallPerspectives, env = process.env }) {
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 (owner && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event) &&
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.pendingApproval)) record.run.state = 'recovery_required';
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.autoRecovery = false; record.run.updatedAt = now; notifyState(record.run, now);
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
- const output = await handleHook({ input: JSON.parse(raw || '{}'), provider });
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
  }