@awak-app/simy-cli 0.7.10 → 0.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agent.js +47 -7
  4. package/src/auto-update.js +32 -9
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  8. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  9. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  10. package/src/delivery-hooks/desktop.js +1 -1
  11. package/src/delivery-hooks/supervisor.js +39 -10
  12. package/src/desktop-host.js +90 -15
  13. package/src/desktop-status.js +1 -1
  14. package/src/direct-executor.js +104 -4
  15. package/src/local-task.js +14 -3
  16. package/src/provider-stream.js +3 -0
  17. package/src/runner.js +19 -4
  18. package/src/session-supervision/claude-desktop-lineage.js +19 -5
  19. package/src/session-supervision/claude-sessions.js +73 -15
  20. package/src/session-supervision/codex-app-server.js +28 -9
  21. package/src/session-supervision/index.js +25 -14
  22. package/src/session-supervision/pull-requests.js +52 -0
  23. package/src/session-supervision/work-state.js +84 -0
  24. package/src/sqm/claude-plugin.js +5 -0
  25. package/src/sqm/review-worker.js +15 -6
  26. package/src/update-detachment/claude-observation.js +33 -0
  27. package/src/update-detachment/codex-context.js +27 -0
  28. package/src/update-detachment/coordinator.js +117 -0
  29. package/src/update-detachment/dispatch-gate.js +1 -0
  30. package/src/update-detachment/ledger-adapter.js +62 -0
  31. package/src/update-detachment/ledger-reader.js +42 -0
  32. package/src/update-detachment/provenance.js +1 -0
  33. package/src/update-detachment/provider-adapters.js +33 -0
  34. package/src/update-detachment/reconcile.js +105 -0
  35. package/src/update-detachment/reconciliation-adapter.js +13 -0
  36. package/src/update-detachment/runtime.js +319 -0
  37. package/src/update-detachment/state-store.js +1 -0
  38. package/src/update-detachment/worker-entry.js +145 -0
  39. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  40. package/src/update-detachment/worker-ledger.js +138 -0
  41. package/src/update-detachment/worker-storage.js +29 -0
  42. package/src/update-detachment/worker.js +167 -0
package/README.md CHANGED
@@ -290,7 +290,23 @@ unavailable; a single observed activity event is a measured zero-duration sample
290
290
  Missing usage records remain distinct from recorded zero tokens.
291
291
 
292
292
  The desktop Sessions page loads a recent window of up to 200 conversations,
293
- ranked by last activity. Codex candidates come from CLI, VS Code and app-server
293
+ selected by last activity. The macOS work list then prioritizes verified running
294
+ conversations, including families with running children, followed by work needing
295
+ a human response and recent activity. Its All, Running, Needs attention, Completed
296
+ and Archived views combine with search. Tracking and auto-continue settings never
297
+ prove that an AI is running; observations older than 45 seconds or a failed
298
+ provider refresh display an unknown execution state.
299
+
300
+ List-only hiding supports multiple selection and restoration without changing
301
+ provider archive state or execution consent. Codex archive/restore is a separate
302
+ explicit operation; Claude archive/restore must be performed in Claude, and SIMY
303
+ reads its Desktop archive metadata on refresh. Related GitHub PR summaries are
304
+ read from structured provider metadata and refreshed through an installed,
305
+ authenticated `gh` CLI, with bounded reads, a one-minute cache, timestamps and
306
+ failure indicators. A merge alone never completes a conversation, and known
307
+ unfinished children or open PRs keep a completed task out of the Completed view.
308
+
309
+ Codex candidates come from CLI, VS Code and app-server
294
310
  conversation sources; non-interactive exec, unknown sources and delegated agents
295
311
  are excluded. A hook observation alone cannot restore a filtered conversation.
296
312
  Explicitly tracked, monitored, auto-continue and unsettled supervision records
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
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 => {
@@ -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 directOptions = { resolveCommand: async input => {
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, directRegistry, localTaskRegistry][index].get(match[2]);
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, directRegistry, localTaskRegistry]),
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
- return desktopTaskDetail([registry, directRegistry, localTaskRegistry], key);
2866
+ await recoverDirectWorkers();
2867
+ return desktopTaskDetail([registry, directRegistryView, localTaskRegistry], key);
2828
2868
  },
2829
2869
  async refreshDesktopStatus() {
2830
2870
  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.10";
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 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
  }