@awak-app/simy-cli 0.6.13 → 0.6.17

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 (37) hide show
  1. package/README.md +91 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +97 -23
  4. package/src/auto-update.js +55 -6
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
  8. package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
  9. package/src/codex-plugins/simy/hooks/hooks.json +2 -2
  10. package/src/codex-work-pattern-sync.js +37 -9
  11. package/src/delivery-hooks/command.js +9 -2
  12. package/src/delivery-hooks/desktop.js +10 -12
  13. package/src/delivery-hooks/evidence.js +50 -0
  14. package/src/delivery-hooks/supervisor.js +8 -2
  15. package/src/desktop-access.js +5 -3
  16. package/src/desktop-host.js +511 -74
  17. package/src/desktop-update-stop.js +22 -0
  18. package/src/doctor.js +2 -1
  19. package/src/host-exit-diagnostics.js +28 -0
  20. package/src/index.js +1 -0
  21. package/src/provider-links.js +74 -0
  22. package/src/session-supervision/action-association-host.js +23 -10
  23. package/src/session-supervision/association-refresh.js +12 -0
  24. package/src/session-supervision/association-store.js +26 -10
  25. package/src/session-supervision/automatic-continuation.js +31 -2
  26. package/src/session-supervision/claude-sessions.js +25 -15
  27. package/src/session-supervision/codex-app-server.js +27 -4
  28. package/src/session-supervision/index.js +163 -42
  29. package/src/session-supervision/statistics.js +76 -15
  30. package/src/sqm/command.js +58 -7
  31. package/src/sqm/evidence-client.js +11 -1
  32. package/src/sqm/failure-circuit.js +28 -6
  33. package/src/sqm/history-store.js +49 -18
  34. package/src/sqm/provider-diagnostics.js +1 -0
  35. package/src/sqm/review-worker.js +70 -21
  36. package/src/sqm/workflow-update.js +12 -3
  37. package/src/windows-desktop-updater.js +6 -1
package/README.md CHANGED
@@ -2,16 +2,39 @@
2
2
 
3
3
  Local execution agent for SIMY Local Tasks and Agentic Loop runs.
4
4
 
5
+ Action candidate search runs once for each authenticated live conversation context,
6
+ and again when that context changes or **Refresh** is selected. Background binding
7
+ renewal uses the lightweight binding endpoint instead of repeating candidate search.
8
+ Settled ordinary conversations and terminal managed tasks do not automatically
9
+ search candidates. Bound Actions still renew to reflect changes to deadlines and
10
+ manual links. Candidate text expires after one minute; select **Refresh** to retrieve
11
+ current suggestions after editing Actions in SIMY. Candidate search identities are
12
+ cached in memory, so restarting the host may retrieve live contexts again.
13
+
5
14
  `simy --version` prints the installed CLI version without starting an agent or
6
15
  checking for updates. `simy doctor` performs read-only local connection and
7
16
  provider login checks and prints a JSON diagnostic report: installed/running
8
17
  versions, stale discovery records, session expiry, provider readiness, and recent
9
- structured SQM failures. It does not run a model, renew login, or restart tasks.
18
+ structured SQM failures, plus `host_exit` with the latest native desktop host's
19
+ abnormal exit time and status. `failures.total_failures` counts scoped SQM
20
+ failures only; zero does not mean the desktop has never crashed. `host_exit` is
21
+ local machine history, independent of account/organization and current daemon
22
+ reachability. A recorded exit remains visible after restart; `not_recorded` means
23
+ no retained exit record, and `unavailable` means it could not be verified. It is
24
+ not a lifetime crash count. Only the fixed header is read; stderr is never
25
+ included. Doctor does not run a model, renew login, or restart tasks.
10
26
  Provider login is separate from model access; unverified model access remains
11
27
  `unknown`. Diagnostic journals retain stage/code/HTTP status only, use private
12
28
  files scoped to the Web/account/organization, rotate each writer's four 64 KiB
13
29
  segments, and keep lifetime failure counts after event rotation.
14
30
 
31
+ The desktop host continues sampling connection and access while optional
32
+ conversation metadata is pending. It retains the last completed conversation
33
+ metadata only within the same verified scope, clears it after access or account
34
+ changes, and merges completed metadata with the newer connection observation.
35
+ This prevents a slow metadata read from making a live service appear disconnected;
36
+ it does not conceal actual host exits, failed connections, or revoked access.
37
+
15
38
  The organization menu's **Allow Codex / Claude fallback** setting defaults to
16
39
  off and grants only read-only SQM analysis permission to use the other signed-in
17
40
  local provider. It is saved per Web/account/organization. Switching requires an
@@ -115,6 +138,12 @@ a fresh Codex or Claude login probe succeeds, including after an app restart.
115
138
  An unchanged ready status cannot repeatedly re-run a failed check. Manual retry
116
139
  remains available; continuing the conversation still requires **Auto-continue**.
117
140
 
141
+ You can enable **Auto-continue** while a conversation is running when its original
142
+ provider configuration is verified. SIMY saves the preference and waits for the
143
+ current turn to finish before checking remaining work. Configuration and runtime
144
+ blockers appear in the conversation view with recovery guidance; turning the
145
+ setting off does not interrupt the current turn.
146
+
118
147
  Claude read-only structured reviews retry once when the provider explicitly
119
148
  reports exhausted turns or structured-output repair attempts, within the original
120
149
  timeout. Other failures retain distinct codes and recovery hints. Safe native
@@ -202,9 +231,31 @@ marketplace still points to SIMY’s generated local source. Plugin versions inc
202
231
  receive a fresh cache. Use a new Codex task to pick up updated skills; running
203
232
  tasks are not restarted. Missing or disabled plugins are not enabled automatically.
204
233
  Failed refreshes retry on the next check without blocking daemon startup.
234
+ Auto-continue ON saves consent for the selected original conversation; it does not
235
+ immediately launch a second turn. A verified desktop discovery admission or an
236
+ existing exact admission saves this preference without another provider RPC.
237
+ After the current turn settles, continuation separately rechecks context, the
238
+ live provider channel, approvals and the opt-in epoch. Required human decisions
239
+ remain pending, and OFF prevents future continuation without interrupting the
240
+ current turn. The macOS switch and label explain these boundaries through their
241
+ tooltips and accessibility help.
242
+
243
+ Conversation statistics estimate work time from activity events within the last
244
+ seven days. Usage counters alone leave work time, idle time and utilization
245
+ unavailable; a single observed activity event is a measured zero-duration sample.
246
+ Missing usage records remain distinct from recorded zero tokens.
247
+
205
248
  The desktop Sessions page loads a recent window of up to 200 conversations,
206
- ranked by last activity. Explicitly tracked, monitored, auto-continue and unsettled
207
- supervision records remain visible even outside that window. Claude discovery
249
+ ranked by last activity. Codex candidates come from CLI, VS Code and app-server
250
+ conversation sources; non-interactive exec, unknown sources and delegated agents
251
+ are excluded. A hook observation alone cannot restore a filtered conversation.
252
+ Explicitly tracked, monitored, auto-continue and unsettled supervision records
253
+ remain visible even outside that window. When a retained Codex conversation is
254
+ omitted from the recent provider page, SIMY reads its exact metadata without
255
+ loading turns or taking writer ownership. The same interactive source filter and
256
+ a matching active rollout path are required before restoring provider capabilities
257
+ and probing its latest turn. Exact reads and turn probes share a bounded
258
+ observation deadline. Claude discovery
208
259
  ranks file metadata before opening selected transcripts. A conflicting saved
209
260
  workspace leaves only that conversation's Action association unavailable; it
210
261
  does not stop discovery or change the saved identity. The page lets users
@@ -577,6 +628,26 @@ execution. Keep concrete regression and delivery acceptance evidence separately.
577
628
  If knowledge is unavailable, the command returns exit 2 with the service cause
578
629
  and writes no new proof; any older artifact is not evidence for that attempt.
579
630
 
631
+ Cloud storage is separate from the check outcome. `check` retains the completed
632
+ proof and local check history if its evidence POST fails, returns exit 2 when
633
+ storage is unconfirmed (exit 1 still takes precedence for a failed check), and
634
+ writes a redacted storage receipt to `<proof-path>.upload.json`. Only a server
635
+ acknowledgement matching the proof ID, evidence ID and proof digest counts as
636
+ stored. `--no-upload` intentionally keeps evidence local and does not return
637
+ exit 2 for storage. A successful upload is not evidence of applicable coverage.
638
+
639
+ After resolving a storage/authentication failure, explicitly recover the original
640
+ run with `simy sqm upload-proof --proof /path/to/proof.json` (add
641
+ `--signed-evidence /path/to/signature.json` if using a custom signature path, and
642
+ `--host <original-origin>` for its SIMY environment). This verifies the original
643
+ signature and current organization/account/device before sending the same proof;
644
+ it never reruns checks, resigns evidence or automatically replays an ambiguous
645
+ POST. The backend reconciles repeated proof IDs against their exact digest and
646
+ evidence ID. The receipt includes the saved record ID and evidence ID on success.
647
+ This recovers a historical run, not acceptance of the current working tree.
648
+ Dev/production record visibility, deployed gateway/schema and UI destination
649
+ permissions still require validation in the corresponding environment.
650
+
580
651
  Normal runs do not require a separate sync command: the CLI
581
652
  uses a verified local cache and revalidates it with TTL and ETag semantics.
582
653
  The Agentic Loop runs min-check after each code modification and the full check
@@ -688,7 +759,7 @@ from an empty CLI chat, types repository, branch, executor, attachment, and task
688
759
  input through a pseudoterminal, creates the Web ledger, spawns real child
689
760
  processes, and exercises HIL plus lifecycle controls. It regenerates the
690
761
  screenshots, terminal frames, raw transcript, and SHA-256 manifest under
691
- `docs/pr-evidence/cli-agentic-loop-console/`.
762
+ `.artifacts/e2e/cli-agentic-loop-console/`.
692
763
 
693
764
  ## Delivery Loop design proposals
694
765
 
@@ -806,11 +877,25 @@ explains baseline updates, historical regression coverage and evidence limits.
806
877
  SQM Tasks has separate **Analysis** and **Checks** lists on macOS and Windows.
807
878
  Analysis retains local review summaries; Checks retains both `simy sqm min-check`
808
879
  and `simy sqm check` outcomes, including checks run by the Delivery Loop.
809
- Each list keeps the latest 100 runs under `SIMY_HOME/sqm-history` (default
810
- `~/.simy/sqm-history`), isolated by web origin, account and organization.
880
+ Each desktop list displays the latest 100 runs. Durable summary files under
881
+ `SIMY_HOME/sqm-history` (default `~/.simy/sqm-history`) are retained without
882
+ a count or age cutoff, isolated by web origin, account and organization. Adding
883
+ a run never deletes older history. Old job keys remain available through the
884
+ local task-detail lookup, including failure stage/code, HTTP status, provider
885
+ diagnostics and the matching web task link. The separate rotating diagnostic
886
+ log is a recent-event view; retained job summaries provide longer-term correlation.
887
+ Operators should include this directory in their disk usage and backup policy.
888
+ Deleting a scope directory explicitly removes its history; previously pruned
889
+ records cannot be recovered.
811
890
  History remains visible with local analysis off and after restarting. A saved
812
891
  in-flight analysis is shown as Interrupted until this client claims it again;
813
892
  it does not contribute to the running count. The web workspace owns retry status.
893
+ Model/provider execution failures permit one recovery claim after a five-minute
894
+ cooldown, even when the app/account identity is unchanged. If that attempt fails
895
+ again, the pause survives reconnects and host restarts until a newer SIMY version
896
+ is installed. Sign-in, local installation, SIMY access, schema and persistence
897
+ errors do not open this automatic provider recovery path. The existing six-failure
898
+ circuit still protects other analysis failures.
814
899
 
815
900
  Only summary metadata is retained, without analysis prompts, source inputs,
816
901
  model responses or credentials. Historical summaries are not a replacement for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.13",
3
+ "version": "0.6.17",
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,12 +1,14 @@
1
1
  import { normalizeProviderModelSelection } from "./provider-model-selection.js";
2
2
  import { assertConnectionScope, connectionScope, connectionDiagnostic } from "./connection-diagnostics.js";
3
3
  import { authorizeLaunch } from "./launch-authorization.js";
4
+ import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
4
5
  import { normalizePurposeVerification, assertPurposeActor, preparePurposeVerification, verifyPurposeLocalTask, purposeSigningKeyPath, purposeVerificationCapability, purposeWorkspaceBusy } from "./purpose-local-proof.js";
5
6
  import { startupRequirementChecks, summarizeStartupChecks } from "./startup-preflight.js";
6
7
  import { BROWSER_HANDOFF_TRANSPORT, browserHandoffPage, decryptBrowserHandoff } from "./browser-handoff.js";
7
8
  import { createAuthorizationWindow } from "./browser-authorization.js";
8
9
  import { createSqmHistoryStore, projectSqmHistory, sqmHistoryScope } from "./sqm/history-store.js";
9
10
  import { createSqmFailureCircuit, createSqmFailureCircuitStore } from "./sqm/failure-circuit.js";
11
+ import { readProviderLinks } from './provider-links.js';
10
12
  import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
11
13
  import { readSqmEnabled, writeSqmEnabled, readSqmSettings, writeSqmEngineFallback } from "./desktop-sqm-settings.js";
12
14
  import { createServer } from "node:http";
@@ -35,6 +37,7 @@ import {
35
37
  restoreRun,
36
38
  resumeLocalCodingRun,
37
39
  startLocalCodingRun,
40
+ executeProcessAttempt,
38
41
  syncLocalCodingRun,
39
42
  stopLocalCodingRun,
40
43
  toCompatibleCodingLoopState,
@@ -269,6 +272,13 @@ export async function startAgent({
269
272
  daemon,
270
273
  );
271
274
  let availableCapabilities;
275
+ const inspectedLinks = new WeakMap(), runAuthorizations = new WeakMap();
276
+ let providerLinks = await readProviderLinks(sessionRoot);
277
+ const requireLinkedProvider = async (provider, expected) => {
278
+ const link = (await readProviderLinks(sessionRoot))[provider];
279
+ if (!link?.connected || expected && link.epoch !== expected.epoch) throw Object.assign(new Error("Reconnect this provider to SIMY before using it."), { code: "provider_link_disconnected" });
280
+ return link;
281
+ };
272
282
  let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
273
283
  const providerRecoveryRevisions = { codex: 0, claude: 0 };
274
284
  const refreshAvailableCapabilities = () => {
@@ -390,6 +400,7 @@ export async function startAgent({
390
400
  token: session.token,
391
401
  deviceId: session.device_id,
392
402
  accountId: session.account_id,
403
+ connectedProviders: ["codex", "claude"].filter(provider => providerLinks[provider]?.connected),
393
404
  });
394
405
  } catch (error) {
395
406
  reportCodexWorkPatternSyncError(error, quiet);
@@ -449,7 +460,7 @@ export async function startAgent({
449
460
  providerReadForced = force;
450
461
  providerReadInFlight = (async () => {
451
462
  const previous = providerStatuses;
452
- await Promise.all(["codex", "claude"].map(async (provider) => {
463
+ await Promise.all([readProviderLinks(sessionRoot).then(links => { providerLinks = links; }), ...["codex", "claude"].map(async (provider) => {
453
464
  const next = await (provider === "codex" ? codexStatus : claudeStatus)({ force });
454
465
  // Publish each verified result independently. A slow Claude probe must
455
466
  // not keep a completed Codex result hidden (or vice versa).
@@ -459,7 +470,7 @@ export async function startAgent({
459
470
  providerRecoveryRevisions[provider] += 1;
460
471
  notifyDesktopStatusChange();
461
472
  }
462
- }));
473
+ })]);
463
474
  const binaryStateChanged = ["codex", "claude"].some((provider) =>
464
475
  previous[provider]?.version !== providerStatuses[provider]?.version ||
465
476
  previous[provider]?.state !== providerStatuses[provider]?.state);
@@ -473,12 +484,15 @@ export async function startAgent({
473
484
  };
474
485
 
475
486
  const inspectReadyBackend = async (backend, options) => {
487
+ const link = await requireLinkedProvider(backend);
476
488
  await readProviderStatuses(options);
489
+ await requireLinkedProvider(backend, link);
477
490
  const inspection = normalizeBackendInspection(backend, availableCapabilities);
478
491
  if (inspection.compatible) {
479
492
  const failure = providerAuthenticationFailure(backend, providerStatuses[backend]);
480
493
  if (failure) throw Object.assign(new Error(failure.summary), failure);
481
494
  }
495
+ inspectedLinks.set(inspection, link);
482
496
  return inspection;
483
497
  };
484
498
 
@@ -487,11 +501,53 @@ export async function startAgent({
487
501
  const backend = run.request.backend;
488
502
  const inspection = await inspectReadyBackend(backend, { force: true });
489
503
  await authorizeDesktopWork(scope);
504
+ await requireLinkedProvider(backend, inspectedLinks.get(inspection));
490
505
  if (!inspection.compatible) {
491
506
  throw Object.assign(new Error(backendPreflightSummary(backend, inspection)), {
492
507
  code: `desktop_executor_${inspection.status}`,
493
508
  });
494
509
  }
510
+ await bindRunAuthorization(run, scope, inspection);
511
+ };
512
+
513
+ const authorizeProviderDispatch = async (backend, scope, link) => {
514
+ await authorizeDesktopWork(scope);
515
+ await requireLinkedProvider(backend, link);
516
+ };
517
+ const bindRunAuthorization = async (run, scope, inspection) => {
518
+ const links = await readProviderLinks(sessionRoot);
519
+ links[run.request.backend] = inspectedLinks.get(inspection);
520
+ await authorizeProviderDispatch(run.request.backend, scope, links[run.request.backend]);
521
+ runAuthorizations.set(run, { scope, links });
522
+ };
523
+ const providerRuntimeOptions = (task, options = {}, scope, link) => {
524
+ const guard = () => authorizeProviderDispatch(task.backend, scope, link);
525
+ const wrap = callback => async (...args) => {
526
+ await guard();
527
+ await callback?.(...args);
528
+ await guard();
529
+ };
530
+ return { ...options, beforeProviderInvocation: wrap(options.beforeProviderInvocation), beforeSpawn: wrap(options.beforeSpawn) };
531
+ };
532
+ const codingRuntimeOptions = run => {
533
+ const wrap = (custom, marker) => async context => {
534
+ // Explicit resume may reauthorize the retained run callbacks. Freeze this
535
+ // invocation's scope so reconnection during command resolution cannot do so.
536
+ const authorization = runAuthorizations.get(run);
537
+ const guard = backend => authorizeProviderDispatch(backend, authorization?.scope, authorization?.links[backend]);
538
+ await guard(context.backend);
539
+ if (custom) return custom(context);
540
+ const repositoryPath = await resolveRepositoryPath(run.request);
541
+ return executeProcessAttempt({ ...context, run, repositoryPath, ...(marker ? { marker } : {}),
542
+ resolveCommandImpl: async input => {
543
+ const command = await (dependencies.resolveDesktopExecutorCommand || resolveDesktopExecutorCommand)(input);
544
+ await guard(input.backend);
545
+ return command;
546
+ },
547
+ });
548
+ };
549
+ return { ...runOptions, executeAttempt: wrap(runOptions.executeAttempt),
550
+ executeIndependentAudit: wrap(runOptions.executeIndependentAudit, "SIMY_AUDIT_JSON:") };
495
551
  };
496
552
 
497
553
  let organizationCancellation = Promise.resolve();
@@ -933,7 +989,9 @@ export async function startAgent({
933
989
  });
934
990
  }
935
991
  await authorizeDesktopWork(scope);
936
- return (dependencies.runLocalTask || startLocalTask)(task, options);
992
+ const link = inspectedLinks.get(inspection);
993
+ await requireLinkedProvider(task.backend, link);
994
+ return (dependencies.runLocalTask || startLocalTask)(task, providerRuntimeOptions(task, options, scope, link));
937
995
  },
938
996
  attachmentStore: localTaskAttachmentStore,
939
997
  getDeviceId: () => session?.device_id || null,
@@ -964,7 +1022,9 @@ export async function startAgent({
964
1022
  const actor = JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]);
965
1023
  const settings = await readSqmSettings(sessionRoot, apiOrigin, session);
966
1024
  const engineSettings = JSON.stringify([settings.enable_engine_fallback, settings.allowed_fallback_providers]);
967
- const beforeDispatch = async () => {
1025
+ const reviewLinks = await readProviderLinks(sessionRoot);
1026
+ const beforeDispatch = async (selectedProvider = provider) => {
1027
+ await requireLinkedProvider(selectedProvider, reviewLinks[selectedProvider]);
968
1028
  await authorizeDesktopWork(scope);
969
1029
  const current = await readSqmSettings(sessionRoot, apiOrigin, session);
970
1030
  if (actor !== JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]) ||
@@ -973,19 +1033,22 @@ export async function startAgent({
973
1033
  }
974
1034
  await context.beforeLeaseDispatch?.();
975
1035
  await authorizeDesktopWork(scope);
1036
+ await requireLinkedProvider(selectedProvider, reviewLinks[selectedProvider]);
976
1037
  const finalSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
977
1038
  if (actor !== JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]) ||
978
1039
  engineSettings !== JSON.stringify([finalSettings.enable_engine_fallback, finalSettings.allowed_fallback_providers])) {
979
1040
  throw Object.assign(new Error("SQM actor or fallback settings changed."), { code: "desktop_access_required" });
980
1041
  }
1042
+ await requireLinkedProvider(selectedProvider, reviewLinks[selectedProvider]);
981
1043
  };
982
1044
  return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, {
983
1045
  signal: context.signal, beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
1046
+ allowAutomaticRetry: context.allowAutomaticRetry, beforeAutomaticRetry: context.beforeAutomaticRetry,
984
1047
  allowedFallbackProviders: settings.allowed_fallback_providers, providerReportingSupported: context.providerReportingSupported === true,
985
1048
  });
986
1049
  },
987
1050
  getEnvironmentKey: () => JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id,
988
- session?.expires_at, sqmEnabled, sqmEngineSettings, ["codex", "claude"].map(provider => [providerStatuses[provider]?.state, providerStatuses[provider]?.version])]),
1051
+ session?.expires_at, sqmEnabled, sqmEngineSettings, providerLinks, ["codex", "claude"].map(provider => [providerStatuses[provider]?.state, providerStatuses[provider]?.version])]),
989
1052
  onChange: notifyDesktopStatusChange,
990
1053
  getWorkspaceUrl: () => {
991
1054
  const slug = desktopAccess.snapshot().organizationSlug;
@@ -996,7 +1059,7 @@ export async function startAgent({
996
1059
  recordHistory: (scope, item) => sqmHistory.record(scope, item),
997
1060
  onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
998
1061
  getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
999
- (provider) => availableCapabilities?.backends?.[provider] === true &&
1062
+ (provider) => providerLinks[provider]?.connected === true && availableCapabilities?.backends?.[provider] === true &&
1000
1063
  providerStatuses[provider]?.state === "ready",
1001
1064
  ) || null,
1002
1065
  onError: (error) => reportSqmLocalReviewError(error, quiet),
@@ -1025,7 +1088,7 @@ export async function startAgent({
1025
1088
  continueLocalCodingRunAfterRepositoryApproval(
1026
1089
  run,
1027
1090
  { repository: selected.repository, localPath: selected.local_path },
1028
- runOptions,
1091
+ codingRuntimeOptions(run),
1029
1092
  ),
1030
1093
  );
1031
1094
  };
@@ -1156,6 +1219,7 @@ export async function startAgent({
1156
1219
  return;
1157
1220
  }
1158
1221
  try {
1222
+ await requireLinkedProvider(event.source === "claude_code" ? "claude" : "codex");
1159
1223
  json(
1160
1224
  res,
1161
1225
  200,
@@ -1167,7 +1231,8 @@ export async function startAgent({
1167
1231
  }),
1168
1232
  );
1169
1233
  } catch (error) {
1170
- json(res, error instanceof DevWorkEventError ? error.status : 502, {
1234
+ json(res, error?.code === "provider_link_disconnected" ? 409 : error instanceof DevWorkEventError ? error.status : 502, {
1235
+ ...(error?.code === "provider_link_disconnected" ? { code: error.code } : {}),
1171
1236
  error: error instanceof DevWorkEventError ? error.message : "dev-work event failed",
1172
1237
  });
1173
1238
  }
@@ -1836,19 +1901,18 @@ export async function startAgent({
1836
1901
  localTaskRegistry.create(task);
1837
1902
  requestClaim.complete(task);
1838
1903
  requestClaimCompleted = true;
1839
- const runLocalTask =
1840
- dependencies.runLocalTask ||
1841
- ((localTask) => startLocalTask(localTask, { session, apiOrigin, beforeSpawn: async () => {
1904
+ const runLocalTask = dependencies.runLocalTask || startLocalTask;
1905
+ const runtimeOptions = providerRuntimeOptions(task, { session, apiOrigin, beforeSpawn: async localTask => {
1842
1906
  if (purposeWorkspaceBusy(localTask.workspace_path)) throw Object.assign(new Error("Independent verification owns this workspace."), { code: "purpose_verification_workspace_busy" });
1843
1907
  if (purposeVerification) {
1844
1908
  await authorizeDesktopWork(requestAccessScope);
1845
1909
  assertPurposeActor(purposeVerification, session, apiOrigin, localTask, idempotencyKey);
1846
1910
  if (purposeStartScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin)) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed" });
1847
1911
  }
1848
- } }));
1849
- await authorizeDesktopWork(requestAccessScope);
1912
+ } }, requestAccessScope, inspectedLinks.get(inspection));
1913
+ await authorizeProviderDispatch(body.backend, requestAccessScope, inspectedLinks.get(inspection));
1850
1914
  if (purposeVerification && (purposeStartScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin))) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed", status: 403 });
1851
- task.operation_promise = Promise.resolve(runLocalTask(task))
1915
+ task.operation_promise = Promise.resolve(runLocalTask(task, runtimeOptions))
1852
1916
  .catch(async (error) => {
1853
1917
  task.status = "failed";
1854
1918
  task.control_state = "complete";
@@ -1973,13 +2037,20 @@ export async function startAgent({
1973
2037
  repositoryPath,
1974
2038
  });
1975
2039
  directRegistry.create(task);
2040
+ const link = inspectedLinks.get(inspection);
2041
+ await authorizeProviderDispatch(body.backend, requestAccessScope, link);
1976
2042
  const runDirectTask = dependencies.runDirectTask || startDirectTask;
2043
+ const directOptions = { resolveCommand: async input => {
2044
+ const command = await (dependencies.resolveDesktopExecutorCommand || resolveDesktopExecutorCommand)(input);
2045
+ await authorizeProviderDispatch(body.backend, requestAccessScope, link);
2046
+ return command;
2047
+ } };
1977
2048
  const releaseUpdateWork = acquireUpdateWork(updateManager);
1978
2049
  if (!releaseUpdateWork) {
1979
2050
  json(res, 503, updateInProgressResponse(updateManager));
1980
2051
  return;
1981
2052
  }
1982
- void Promise.resolve(runDirectTask(task))
2053
+ void Promise.resolve(runDirectTask(task, directOptions))
1983
2054
  .catch((error) => {
1984
2055
  task.status = "failed";
1985
2056
  task.error = error instanceof Error ? error.message : String(error);
@@ -2177,7 +2248,8 @@ export async function startAgent({
2177
2248
  if (session !== launchSession || !isSessionValid(session, Date.now(), apiOrigin)) {
2178
2249
  json(res, 409, changedLaunchSession(launchSession, session, apiOrigin)); return;
2179
2250
  }
2180
- void startLocalCodingRun(run, dependencies.runOptions);
2251
+ await bindRunAuthorization(run, requestAccessScope, inspection);
2252
+ void startLocalCodingRun(run, codingRuntimeOptions(run));
2181
2253
  json(res, 202, { ok: true, run_id: run.id });
2182
2254
  return;
2183
2255
  }
@@ -2321,7 +2393,7 @@ export async function startAgent({
2321
2393
  const continuation = continueLocalCodingRun(
2322
2394
  run,
2323
2395
  String(body.message || "Continue after increasing the Provider token budget."),
2324
- runOptions,
2396
+ codingRuntimeOptions(run),
2325
2397
  );
2326
2398
  json(res, 202, {
2327
2399
  ok: true,
@@ -2434,7 +2506,7 @@ export async function startAgent({
2434
2506
  const continuation = continueLocalCodingRunAfterRepositoryApproval(
2435
2507
  run,
2436
2508
  { repository: selected.repository, localPath: selected.local_path },
2437
- runOptions,
2509
+ codingRuntimeOptions(run),
2438
2510
  );
2439
2511
  json(res, 202, {
2440
2512
  ok: true,
@@ -2479,7 +2551,7 @@ export async function startAgent({
2479
2551
 
2480
2552
  json(res, 404, { error: "not found" });
2481
2553
  } catch (err) {
2482
- if (err?.code === "provider_login_required" || err?.code === "provider_status_unknown" ||
2554
+ if (err?.code === "provider_link_disconnected" || err?.code === "provider_login_required" || err?.code === "provider_status_unknown" ||
2483
2555
  (typeof err?.code === "string" && err.code.startsWith("desktop_executor_"))) {
2484
2556
  json(res, 409, { error: err.message, code: err.code, recovery: err.recovery });
2485
2557
  return;
@@ -2772,7 +2844,8 @@ export async function startAgent({
2772
2844
  const executionTarget = normalizeDesktopExecutionTarget(input.executionTarget);
2773
2845
  if (!requirement) throw new Error("Describe the coding task first.");
2774
2846
  if (!repository) throw new Error("Set a repository with /repo owner/name.");
2775
- if (!(await inspectReadyBackend(backend)).compatible) {
2847
+ const inspection = await inspectReadyBackend(backend);
2848
+ if (!inspection.compatible) {
2776
2849
  throw new Error(`${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`);
2777
2850
  }
2778
2851
  const indexedRepository = await ensureRepositoryIndexed(repository);
@@ -2812,18 +2885,19 @@ export async function startAgent({
2812
2885
  await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
2813
2886
  registry.create(run);
2814
2887
  await authorizeDesktopWork(scope);
2815
- void startLocalCodingRun(run, runOptions);
2888
+ await bindRunAuthorization(run, scope, inspection);
2889
+ void startLocalCodingRun(run, codingRuntimeOptions(run));
2816
2890
  return run;
2817
2891
  }),
2818
2892
  continue: (run, guidance) =>
2819
2893
  withUpdateWork(updateManager, async () => {
2820
2894
  await requireRunProvider(run);
2821
- return continueLocalCodingRun(run, guidance, runOptions);
2895
+ return continueLocalCodingRun(run, guidance, codingRuntimeOptions(run));
2822
2896
  }),
2823
2897
  applyDecision: (run, decision) =>
2824
2898
  withUpdateWork(updateManager, async () => {
2825
2899
  await requireRunProvider(run);
2826
- return applyLocalHumanDecision(run, decision, runOptions);
2900
+ return applyLocalHumanDecision(run, decision, codingRuntimeOptions(run));
2827
2901
  }),
2828
2902
  queueGuidance: (run, guidance) => {
2829
2903
  if (enforceDesktopAccess) requireDesktopAccess(desktopAccess.snapshot());
@@ -69,6 +69,10 @@ export class CliAutoUpdater extends EventEmitter {
69
69
  #stopped = false;
70
70
  #registryListener = null;
71
71
  #activeWork = 0;
72
+ #confirmedUpdate = null;
73
+ #confirmedReady = false;
74
+ #desktopIdle = () => true;
75
+ #applyHolds = 0;
72
76
 
73
77
  constructor(options) {
74
78
  super();
@@ -95,13 +99,47 @@ export class CliAutoUpdater extends EventEmitter {
95
99
  }
96
100
 
97
101
  snapshot() {
98
- return structuredClone(this.#snapshot);
102
+ return structuredClone({ ...this.#snapshot, requires_task_stop: !this.#isIdle() });
103
+ }
104
+
105
+ // Close admission before cancellation begins. The same transaction owns the
106
+ // idle check and installation, so a releasing worker cannot race the stop.
107
+ applyConfirmed(stopWork) {
108
+ if (this.#confirmedUpdate) return this.#confirmedUpdate;
109
+ if (this.#applying) return this.#applying;
110
+ if (!this.#snapshot.target_version || !this.#options.lastRelease) {
111
+ return Promise.reject(new Error('Check for an available update first.'));
112
+ }
113
+ this.#setState('stopping_tasks', { message: 'Stopping unfinished tasks before updating…', error: null });
114
+ this.#confirmedUpdate = (async () => {
115
+ try {
116
+ await Promise.resolve().then(stopWork);
117
+ await this.#checking;
118
+ if (!this.#snapshot.target_version) throw new Error('The update is no longer available.');
119
+ if (!this.#isIdle()) throw new Error('Task shutdown did not finish. Restart SIMY and retry the update.');
120
+ if (!this.#snapshot.automatic || classifyVersionChange(this.#options.currentVersion, this.#snapshot.target_version) === 'major') {
121
+ this.#setState('update_available', { message: 'Tasks stopped. Opening the update installer…' });
122
+ await this.performAction();
123
+ return this.snapshot();
124
+ }
125
+ this.#confirmedReady = true;
126
+ this.#setState('waiting_for_idle', { message: 'Tasks stopped. Installing the update…' });
127
+ return await this.maybeApply();
128
+ } catch (error) {
129
+ this.#setState('failed', { message: 'Update could not finish after stopping tasks. Restart SIMY and retry.',
130
+ error: errorMessage(error), action: updateAction(this.#snapshot.target_version, this.#options) });
131
+ return this.snapshot();
132
+ } finally { this.#confirmedUpdate = null; this.#confirmedReady = false; }
133
+ })();
134
+ return this.#confirmedUpdate;
99
135
  }
100
136
 
101
137
  async performAction() {
102
138
  const action = this.#snapshot.action;
103
139
  if (action?.id === "download_macos_installer" && action.url) {
104
- return this.#options.openUrl(action.url);
140
+ const result = await this.#options.openUrl(action.url);
141
+ if (result?.opened === false) throw new Error("Could not open the update installer download.");
142
+ return result;
105
143
  }
106
144
  if (action?.command) {
107
145
  throw new Error(`Run this command after leaving SIMY: ${action.command}`);
@@ -109,8 +147,17 @@ export class CliAutoUpdater extends EventEmitter {
109
147
  throw new Error("No SIMY CLI update action is currently available.");
110
148
  }
111
149
 
150
+ // Hold installation across a multi-worker cancellation, even when the last
151
+ // registry event or work lease is released before every worker has drained.
152
+ holdApply() {
153
+ if (["updating", "restarting"].includes(this.#snapshot.state)) throw new Error("SIMY is already updating.");
154
+ this.#applyHolds++;
155
+ let released = false;
156
+ return () => { if (!released) { released = true; this.#applyHolds--; } };
157
+ }
158
+
112
159
  acceptsNewWork() {
113
- return !["updating", "restarting"].includes(this.#snapshot.state);
160
+ return this.#applyHolds === 0 && !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
114
161
  }
115
162
 
116
163
  beginWork() {
@@ -127,10 +174,11 @@ export class CliAutoUpdater extends EventEmitter {
127
174
  };
128
175
  }
129
176
 
130
- start({ registry, restartDaemon } = {}) {
177
+ start({ registry, restartDaemon, isDesktopIdle = () => true } = {}) {
131
178
  if (this.#snapshot.state === "disabled" || this.#checkTimer || this.#stopped) return this;
132
179
  this.#registry = registry;
133
180
  this.#restartDaemon = restartDaemon;
181
+ this.#desktopIdle = isDesktopIdle;
134
182
  this.#registryListener = () => {
135
183
  if (this.#snapshot.state === "waiting_for_idle") void this.maybeApply();
136
184
  };
@@ -156,7 +204,7 @@ export class CliAutoUpdater extends EventEmitter {
156
204
  async checkNow() {
157
205
  if (this.#stopped || this.#snapshot.state === "disabled") return this.snapshot();
158
206
  if (this.#checking) return this.#checking;
159
- if (["updating", "restarting"].includes(this.#snapshot.state)) return this.snapshot();
207
+ if (["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state) || this.#confirmedUpdate) return this.snapshot();
160
208
 
161
209
  this.#checking = this.#performCheck().finally(() => {
162
210
  this.#checking = null;
@@ -237,6 +285,7 @@ export class CliAutoUpdater extends EventEmitter {
237
285
  }
238
286
 
239
287
  async maybeApply() {
288
+ if (this.#confirmedUpdate && !this.#confirmedReady) return this.snapshot();
240
289
  if (this.#stopped || this.#snapshot.state !== "waiting_for_idle") {
241
290
  return this.snapshot();
242
291
  }
@@ -333,7 +382,7 @@ export class CliAutoUpdater extends EventEmitter {
333
382
  }
334
383
 
335
384
  #isIdle() {
336
- return this.#activeWork === 0 && isRegistryIdle(this.#registry);
385
+ return this.#applyHolds === 0 && this.#activeWork === 0 && isRegistryIdle(this.#registry) && this.#desktopIdle();
337
386
  }
338
387
 
339
388
  #clearIdleTimer() {
@@ -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.6.13";
16
+ export const CLI_VERSION = "0.6.17";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { linkedProvider } from "./delivery-loop.mjs";
3
4
  import { createHash } from "node:crypto";
4
5
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
@@ -13,12 +14,14 @@ const MAX_DISCOVERY_RECORDS = 16;
13
14
 
14
15
  export async function runConnectionReminder({
15
16
  env = process.env,
17
+ provider = env.CLAUDE_PLUGIN_ROOT ? "claude" : "codex",
16
18
  input = {},
17
19
  fetchImpl = globalThis.fetch,
18
20
  now = () => Date.now(),
19
21
  } = {}) {
20
22
  const explicitWebOrigin = resolveExplicitWebOrigin(env);
21
23
  const sessionRoot = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
24
+ if (!await linkedProvider(join(sessionRoot, "delivery-hooks"), provider)) return { continue: true };
22
25
  const pluginData = env.PLUGIN_DATA?.trim() || env.CLAUDE_PLUGIN_DATA?.trim() || join(sessionRoot, "plugin-data", "simy");
23
26
  const sessionId = String(input.session_id || input.sessionId || env.CODEX_THREAD_ID || "unknown");
24
27
  const statePath = reminderStatePath(pluginData, explicitWebOrigin || "any-origin", sessionId);
@@ -172,9 +175,9 @@ async function readHookInput(stream = process.stdin) {
172
175
  }
173
176
  }
174
177
 
175
- export async function main() {
178
+ export async function main(provider) {
176
179
  try {
177
- const result = await runConnectionReminder({ input: await readHookInput() });
180
+ const result = await runConnectionReminder({ provider, input: await readHookInput() });
178
181
  process.stdout.write(`${JSON.stringify(result)}\n`);
179
182
  } catch {
180
183
  process.stdout.write(`${JSON.stringify({ continue: true })}\n`);