@awak-app/simy-cli 0.6.16 → 0.6.18

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 +99 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +123 -28
  4. package/src/auto-update.js +15 -3
  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 +4 -3
  16. package/src/desktop-host.js +669 -73
  17. package/src/doctor.js +2 -1
  18. package/src/host-exit-diagnostics.js +28 -0
  19. package/src/index.js +1 -0
  20. package/src/native-development-report-completion.js +143 -0
  21. package/src/native-development-reports.js +137 -0
  22. package/src/orchestrator/checklist-continuation.js +5 -0
  23. package/src/orchestrator/delivery/WORKFLOW.md +4 -0
  24. package/src/orchestrator/delivery/references/state-model.md +187 -0
  25. package/src/orchestrator/loop.js +119 -34
  26. package/src/provider-links.js +74 -0
  27. package/src/runner.js +31 -2
  28. package/src/session-supervision/automatic-continuation.js +31 -2
  29. package/src/session-supervision/claude-sessions.js +25 -15
  30. package/src/session-supervision/codex-app-server.js +27 -4
  31. package/src/session-supervision/index.js +163 -42
  32. package/src/session-supervision/statistics.js +76 -15
  33. package/src/sqm/command.js +58 -7
  34. package/src/sqm/evidence-client.js +11 -1
  35. package/src/sqm/failure-circuit.js +28 -6
  36. package/src/sqm/history-store.js +49 -18
  37. package/src/sqm/provider-diagnostics.js +1 -0
  38. package/src/sqm/review-worker.js +70 -21
  39. package/src/sqm/workflow-update.js +12 -3
  40. package/src/windows-bootstrap.js +2 -1
  41. package/src/windows-desktop-updater.js +6 -1
  42. package/src/windows-inbound.js +9 -0
package/README.md CHANGED
@@ -15,12 +15,26 @@ cached in memory, so restarting the host may retrieve live contexts again.
15
15
  checking for updates. `simy doctor` performs read-only local connection and
16
16
  provider login checks and prints a JSON diagnostic report: installed/running
17
17
  versions, stale discovery records, session expiry, provider readiness, and recent
18
- 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.
19
26
  Provider login is separate from model access; unverified model access remains
20
27
  `unknown`. Diagnostic journals retain stage/code/HTTP status only, use private
21
28
  files scoped to the Web/account/organization, rotate each writer's four 64 KiB
22
29
  segments, and keep lifetime failure counts after event rotation.
23
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
+
24
38
  The organization menu's **Allow Codex / Claude fallback** setting defaults to
25
39
  off and grants only read-only SQM analysis permission to use the other signed-in
26
40
  local provider. It is saved per Web/account/organization. Switching requires an
@@ -72,6 +86,18 @@ and an already consumed remote result prevent automatic recovery. An unfinished
72
86
  worker or a missing, invalid or redacted structured result still requires
73
87
  reconciliation; no unknown external operation is automatically replayed.
74
88
 
89
+ After CI finishes successfully, the authenticated daemon refreshes the independent
90
+ review of the same saved candidate without dispatching its implementation worker.
91
+ The reviewed HEAD, fetched base, objective, preparation evidence and minimum
92
+ completion matrix must still match; changed candidates require explicit work.
93
+ Pending/failing checks, a dirty checkout and missing required checks do not trigger
94
+ this review. Human peer approval remains required when the charter requires it.
95
+ The refreshed review is checked against newly collected evidence before readiness
96
+ can pass. Stop cancels review continuation. A failed or interrupted refresh is not
97
+ repeated on every monitor tick; explicitly continue the same run to recover.
98
+ Older reviews without a saved CI basis can refresh only when their prior evidence
99
+ still establishes the complete reviewed identity; otherwise explicitly continue.
100
+
75
101
  For interrupted Agentic work, `GET /v1/agentic-loop/:id/recovery` provides a
76
102
  read-only objective and checkout fingerprint. Reconcile an uncertain side effect
77
103
  with `POST` to that endpoint (`action: reconcile`, authoritative
@@ -124,6 +150,12 @@ a fresh Codex or Claude login probe succeeds, including after an app restart.
124
150
  An unchanged ready status cannot repeatedly re-run a failed check. Manual retry
125
151
  remains available; continuing the conversation still requires **Auto-continue**.
126
152
 
153
+ You can enable **Auto-continue** while a conversation is running when its original
154
+ provider configuration is verified. SIMY saves the preference and waits for the
155
+ current turn to finish before checking remaining work. Configuration and runtime
156
+ blockers appear in the conversation view with recovery guidance; turning the
157
+ setting off does not interrupt the current turn.
158
+
127
159
  Claude read-only structured reviews retry once when the provider explicitly
128
160
  reports exhausted turns or structured-output repair attempts, within the original
129
161
  timeout. Other failures retain distinct codes and recovery hints. Safe native
@@ -211,9 +243,31 @@ marketplace still points to SIMY’s generated local source. Plugin versions inc
211
243
  receive a fresh cache. Use a new Codex task to pick up updated skills; running
212
244
  tasks are not restarted. Missing or disabled plugins are not enabled automatically.
213
245
  Failed refreshes retry on the next check without blocking daemon startup.
246
+ Auto-continue ON saves consent for the selected original conversation; it does not
247
+ immediately launch a second turn. A verified desktop discovery admission or an
248
+ existing exact admission saves this preference without another provider RPC.
249
+ After the current turn settles, continuation separately rechecks context, the
250
+ live provider channel, approvals and the opt-in epoch. Required human decisions
251
+ remain pending, and OFF prevents future continuation without interrupting the
252
+ current turn. The macOS switch and label explain these boundaries through their
253
+ tooltips and accessibility help.
254
+
255
+ Conversation statistics estimate work time from activity events within the last
256
+ seven days. Usage counters alone leave work time, idle time and utilization
257
+ unavailable; a single observed activity event is a measured zero-duration sample.
258
+ Missing usage records remain distinct from recorded zero tokens.
259
+
214
260
  The desktop Sessions page loads a recent window of up to 200 conversations,
215
- ranked by last activity. Explicitly tracked, monitored, auto-continue and unsettled
216
- supervision records remain visible even outside that window. Claude discovery
261
+ ranked by last activity. Codex candidates come from CLI, VS Code and app-server
262
+ conversation sources; non-interactive exec, unknown sources and delegated agents
263
+ are excluded. A hook observation alone cannot restore a filtered conversation.
264
+ Explicitly tracked, monitored, auto-continue and unsettled supervision records
265
+ remain visible even outside that window. When a retained Codex conversation is
266
+ omitted from the recent provider page, SIMY reads its exact metadata without
267
+ loading turns or taking writer ownership. The same interactive source filter and
268
+ a matching active rollout path are required before restoring provider capabilities
269
+ and probing its latest turn. Exact reads and turn probes share a bounded
270
+ observation deadline. Claude discovery
217
271
  ranks file metadata before opening selected transcripts. A conflicting saved
218
272
  workspace leaves only that conversation's Action association unavailable; it
219
273
  does not stop discovery or change the saved identity. The page lets users
@@ -586,6 +640,26 @@ execution. Keep concrete regression and delivery acceptance evidence separately.
586
640
  If knowledge is unavailable, the command returns exit 2 with the service cause
587
641
  and writes no new proof; any older artifact is not evidence for that attempt.
588
642
 
643
+ Cloud storage is separate from the check outcome. `check` retains the completed
644
+ proof and local check history if its evidence POST fails, returns exit 2 when
645
+ storage is unconfirmed (exit 1 still takes precedence for a failed check), and
646
+ writes a redacted storage receipt to `<proof-path>.upload.json`. Only a server
647
+ acknowledgement matching the proof ID, evidence ID and proof digest counts as
648
+ stored. `--no-upload` intentionally keeps evidence local and does not return
649
+ exit 2 for storage. A successful upload is not evidence of applicable coverage.
650
+
651
+ After resolving a storage/authentication failure, explicitly recover the original
652
+ run with `simy sqm upload-proof --proof /path/to/proof.json` (add
653
+ `--signed-evidence /path/to/signature.json` if using a custom signature path, and
654
+ `--host <original-origin>` for its SIMY environment). This verifies the original
655
+ signature and current organization/account/device before sending the same proof;
656
+ it never reruns checks, resigns evidence or automatically replays an ambiguous
657
+ POST. The backend reconciles repeated proof IDs against their exact digest and
658
+ evidence ID. The receipt includes the saved record ID and evidence ID on success.
659
+ This recovers a historical run, not acceptance of the current working tree.
660
+ Dev/production record visibility, deployed gateway/schema and UI destination
661
+ permissions still require validation in the corresponding environment.
662
+
589
663
  Normal runs do not require a separate sync command: the CLI
590
664
  uses a verified local cache and revalidates it with TTL and ETag semantics.
591
665
  The Agentic Loop runs min-check after each code modification and the full check
@@ -697,7 +771,7 @@ from an empty CLI chat, types repository, branch, executor, attachment, and task
697
771
  input through a pseudoterminal, creates the Web ledger, spawns real child
698
772
  processes, and exercises HIL plus lifecycle controls. It regenerates the
699
773
  screenshots, terminal frames, raw transcript, and SHA-256 manifest under
700
- `docs/pr-evidence/cli-agentic-loop-console/`.
774
+ `.artifacts/e2e/cli-agentic-loop-console/`.
701
775
 
702
776
  ## Delivery Loop design proposals
703
777
 
@@ -759,6 +833,11 @@ not a GitHub hook that mechanically intercepts every external close command.
759
833
 
760
834
  ### Submit repaired local delivery evidence to SQM
761
835
 
836
+ The [integrated delivery state model](src/orchestrator/delivery/references/state-model.md)
837
+ connects delivery milestones, repair/wait/resume branches and actual controller
838
+ states. Both executor and auditor receive its path. It documents existing control
839
+ and evidence duties; it adds no scheduler or transition-enforcement mechanism.
840
+
762
841
  The bundled Delivery Loop maintains an external acceptance checklist with separate
763
842
  user-journey and technical-invariant results. It refreshes the table after each
764
843
  evaluated action and marks evidence pending when the code or runtime context
@@ -815,11 +894,25 @@ explains baseline updates, historical regression coverage and evidence limits.
815
894
  SQM Tasks has separate **Analysis** and **Checks** lists on macOS and Windows.
816
895
  Analysis retains local review summaries; Checks retains both `simy sqm min-check`
817
896
  and `simy sqm check` outcomes, including checks run by the Delivery Loop.
818
- Each list keeps the latest 100 runs under `SIMY_HOME/sqm-history` (default
819
- `~/.simy/sqm-history`), isolated by web origin, account and organization.
897
+ Each desktop list displays the latest 100 runs. Durable summary files under
898
+ `SIMY_HOME/sqm-history` (default `~/.simy/sqm-history`) are retained without
899
+ a count or age cutoff, isolated by web origin, account and organization. Adding
900
+ a run never deletes older history. Old job keys remain available through the
901
+ local task-detail lookup, including failure stage/code, HTTP status, provider
902
+ diagnostics and the matching web task link. The separate rotating diagnostic
903
+ log is a recent-event view; retained job summaries provide longer-term correlation.
904
+ Operators should include this directory in their disk usage and backup policy.
905
+ Deleting a scope directory explicitly removes its history; previously pruned
906
+ records cannot be recovered.
820
907
  History remains visible with local analysis off and after restarting. A saved
821
908
  in-flight analysis is shown as Interrupted until this client claims it again;
822
909
  it does not contribute to the running count. The web workspace owns retry status.
910
+ Model/provider execution failures permit one recovery claim after a five-minute
911
+ cooldown, even when the app/account identity is unchanged. If that attempt fails
912
+ again, the pause survives reconnects and host restarts until a newer SIMY version
913
+ is installed. Sign-in, local installation, SIMY access, schema and persistence
914
+ errors do not open this automatic provider recovery path. The existing six-failure
915
+ circuit still protects other analysis failures.
823
916
 
824
917
  Only summary metadata is retained, without analysis prompts, source inputs,
825
918
  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.16",
3
+ "version": "0.6.18",
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,25 +484,85 @@ 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
 
485
- const requireRunProvider = async (run) => {
499
+ const requireRunProvider = async (run, { backend = run.request.backend } = {}) => {
486
500
  const scope = await authorizeDesktopWork();
487
- const backend = run.request.backend;
488
501
  const inspection = await inspectReadyBackend(backend, { force: true });
489
502
  await authorizeDesktopWork(scope);
503
+ await requireLinkedProvider(backend, inspectedLinks.get(inspection));
490
504
  if (!inspection.compatible) {
491
505
  throw Object.assign(new Error(backendPreflightSummary(backend, inspection)), {
492
506
  code: `desktop_executor_${inspection.status}`,
493
507
  });
494
508
  }
509
+ await bindRunAuthorization(run, scope, inspection, backend);
510
+ };
511
+
512
+ const authorizeProviderDispatch = async (backend, scope, link) => {
513
+ await authorizeDesktopWork(scope);
514
+ await requireLinkedProvider(backend, link);
515
+ };
516
+ const bindRunAuthorization = async (run, scope, inspection, backend = run.request.backend) => {
517
+ const links = await readProviderLinks(sessionRoot);
518
+ links[backend] = inspectedLinks.get(inspection);
519
+ await authorizeProviderDispatch(backend, scope, links[backend]);
520
+ runAuthorizations.set(run, { scope, links });
521
+ };
522
+ const providerRuntimeOptions = (task, options = {}, scope, link) => {
523
+ const guard = () => authorizeProviderDispatch(task.backend, scope, link);
524
+ const wrap = callback => async (...args) => {
525
+ await guard();
526
+ await callback?.(...args);
527
+ await guard();
528
+ };
529
+ return { ...options, beforeProviderInvocation: wrap(options.beforeProviderInvocation), beforeSpawn: wrap(options.beforeSpawn) };
530
+ };
531
+ const codingRuntimeOptions = run => {
532
+ const wrap = (custom, marker) => async context => {
533
+ // Explicit resume may reauthorize the retained run callbacks. Freeze this
534
+ // invocation's scope so reconnection during command resolution cannot do so.
535
+ const authorization = runAuthorizations.get(run);
536
+ const guard = backend => authorizeProviderDispatch(backend, authorization?.scope, authorization?.links[backend]);
537
+ await guard(context.backend);
538
+ if (custom) return custom(context);
539
+ const repositoryPath = await resolveRepositoryPath(run.request);
540
+ return executeProcessAttempt({ ...context, run, repositoryPath, ...(marker ? { marker } : {}),
541
+ resolveCommandImpl: async input => {
542
+ const command = await (dependencies.resolveDesktopExecutorCommand || resolveDesktopExecutorCommand)(input);
543
+ await guard(input.backend);
544
+ return command;
545
+ },
546
+ });
547
+ };
548
+ return { ...runOptions, executeAttempt: wrap(runOptions.executeAttempt),
549
+ executeIndependentAudit: wrap(runOptions.executeIndependentAudit, "SIMY_AUDIT_JSON:") };
550
+ };
551
+
552
+ const recheckAuthorizedCodingRun = (run, options = {}) => {
553
+ const scope = desktopScopeRevision();
554
+ return recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence, ...options,
555
+ executeIndependentAudit: context => withUpdateWork(updateManager,
556
+ () => codingRuntimeOptions(run).executeIndependentAudit(context)),
557
+ authorizeReview: async () => {
558
+ await authorizeDesktopWork(scope);
559
+ if (!isSessionValid(session, Date.now(), apiOrigin) || !sameRunScope(run.session, session)) {
560
+ throw new Error("The original authorized coding run session changed.");
561
+ }
562
+ await requireRunProvider(run, { backend: run.snapshot.charter.audit_backend });
563
+ await authorizeDesktopWork(scope);
564
+ },
565
+ });
495
566
  };
496
567
 
497
568
  let organizationCancellation = Promise.resolve();
@@ -740,7 +811,7 @@ export async function startAgent({
740
811
  for (const run of registry.list()) {
741
812
  if (!["pr_ready_for_review", "merge_ready"].includes(run.status) || run.operation || run.child) continue;
742
813
  try {
743
- await (dependencies.recheckCodingRun || recheckLocalCodingRun)(run);
814
+ await (dependencies.recheckCodingRun || recheckAuthorizedCodingRun)(run);
744
815
  } catch (error) {
745
816
  await markCodingRunMonitorFailure(run, error);
746
817
  }
@@ -933,7 +1004,9 @@ export async function startAgent({
933
1004
  });
934
1005
  }
935
1006
  await authorizeDesktopWork(scope);
936
- return (dependencies.runLocalTask || startLocalTask)(task, options);
1007
+ const link = inspectedLinks.get(inspection);
1008
+ await requireLinkedProvider(task.backend, link);
1009
+ return (dependencies.runLocalTask || startLocalTask)(task, providerRuntimeOptions(task, options, scope, link));
937
1010
  },
938
1011
  attachmentStore: localTaskAttachmentStore,
939
1012
  getDeviceId: () => session?.device_id || null,
@@ -964,7 +1037,9 @@ export async function startAgent({
964
1037
  const actor = JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]);
965
1038
  const settings = await readSqmSettings(sessionRoot, apiOrigin, session);
966
1039
  const engineSettings = JSON.stringify([settings.enable_engine_fallback, settings.allowed_fallback_providers]);
967
- const beforeDispatch = async () => {
1040
+ const reviewLinks = await readProviderLinks(sessionRoot);
1041
+ const beforeDispatch = async (selectedProvider = provider) => {
1042
+ await requireLinkedProvider(selectedProvider, reviewLinks[selectedProvider]);
968
1043
  await authorizeDesktopWork(scope);
969
1044
  const current = await readSqmSettings(sessionRoot, apiOrigin, session);
970
1045
  if (actor !== JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]) ||
@@ -973,19 +1048,22 @@ export async function startAgent({
973
1048
  }
974
1049
  await context.beforeLeaseDispatch?.();
975
1050
  await authorizeDesktopWork(scope);
1051
+ await requireLinkedProvider(selectedProvider, reviewLinks[selectedProvider]);
976
1052
  const finalSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
977
1053
  if (actor !== JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]) ||
978
1054
  engineSettings !== JSON.stringify([finalSettings.enable_engine_fallback, finalSettings.allowed_fallback_providers])) {
979
1055
  throw Object.assign(new Error("SQM actor or fallback settings changed."), { code: "desktop_access_required" });
980
1056
  }
1057
+ await requireLinkedProvider(selectedProvider, reviewLinks[selectedProvider]);
981
1058
  };
982
1059
  return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, {
983
1060
  signal: context.signal, beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
1061
+ allowAutomaticRetry: context.allowAutomaticRetry, beforeAutomaticRetry: context.beforeAutomaticRetry,
984
1062
  allowedFallbackProviders: settings.allowed_fallback_providers, providerReportingSupported: context.providerReportingSupported === true,
985
1063
  });
986
1064
  },
987
1065
  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])]),
1066
+ session?.expires_at, sqmEnabled, sqmEngineSettings, providerLinks, ["codex", "claude"].map(provider => [providerStatuses[provider]?.state, providerStatuses[provider]?.version])]),
989
1067
  onChange: notifyDesktopStatusChange,
990
1068
  getWorkspaceUrl: () => {
991
1069
  const slug = desktopAccess.snapshot().organizationSlug;
@@ -996,7 +1074,7 @@ export async function startAgent({
996
1074
  recordHistory: (scope, item) => sqmHistory.record(scope, item),
997
1075
  onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
998
1076
  getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
999
- (provider) => availableCapabilities?.backends?.[provider] === true &&
1077
+ (provider) => providerLinks[provider]?.connected === true && availableCapabilities?.backends?.[provider] === true &&
1000
1078
  providerStatuses[provider]?.state === "ready",
1001
1079
  ) || null,
1002
1080
  onError: (error) => reportSqmLocalReviewError(error, quiet),
@@ -1025,7 +1103,7 @@ export async function startAgent({
1025
1103
  continueLocalCodingRunAfterRepositoryApproval(
1026
1104
  run,
1027
1105
  { repository: selected.repository, localPath: selected.local_path },
1028
- runOptions,
1106
+ codingRuntimeOptions(run),
1029
1107
  ),
1030
1108
  );
1031
1109
  };
@@ -1156,6 +1234,7 @@ export async function startAgent({
1156
1234
  return;
1157
1235
  }
1158
1236
  try {
1237
+ await requireLinkedProvider(event.source === "claude_code" ? "claude" : "codex");
1159
1238
  json(
1160
1239
  res,
1161
1240
  200,
@@ -1167,7 +1246,8 @@ export async function startAgent({
1167
1246
  }),
1168
1247
  );
1169
1248
  } catch (error) {
1170
- json(res, error instanceof DevWorkEventError ? error.status : 502, {
1249
+ json(res, error?.code === "provider_link_disconnected" ? 409 : error instanceof DevWorkEventError ? error.status : 502, {
1250
+ ...(error?.code === "provider_link_disconnected" ? { code: error.code } : {}),
1171
1251
  error: error instanceof DevWorkEventError ? error.message : "dev-work event failed",
1172
1252
  });
1173
1253
  }
@@ -1836,19 +1916,18 @@ export async function startAgent({
1836
1916
  localTaskRegistry.create(task);
1837
1917
  requestClaim.complete(task);
1838
1918
  requestClaimCompleted = true;
1839
- const runLocalTask =
1840
- dependencies.runLocalTask ||
1841
- ((localTask) => startLocalTask(localTask, { session, apiOrigin, beforeSpawn: async () => {
1919
+ const runLocalTask = dependencies.runLocalTask || startLocalTask;
1920
+ const runtimeOptions = providerRuntimeOptions(task, { session, apiOrigin, beforeSpawn: async localTask => {
1842
1921
  if (purposeWorkspaceBusy(localTask.workspace_path)) throw Object.assign(new Error("Independent verification owns this workspace."), { code: "purpose_verification_workspace_busy" });
1843
1922
  if (purposeVerification) {
1844
1923
  await authorizeDesktopWork(requestAccessScope);
1845
1924
  assertPurposeActor(purposeVerification, session, apiOrigin, localTask, idempotencyKey);
1846
1925
  if (purposeStartScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin)) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed" });
1847
1926
  }
1848
- } }));
1849
- await authorizeDesktopWork(requestAccessScope);
1927
+ } }, requestAccessScope, inspectedLinks.get(inspection));
1928
+ await authorizeProviderDispatch(body.backend, requestAccessScope, inspectedLinks.get(inspection));
1850
1929
  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))
1930
+ task.operation_promise = Promise.resolve(runLocalTask(task, runtimeOptions))
1852
1931
  .catch(async (error) => {
1853
1932
  task.status = "failed";
1854
1933
  task.control_state = "complete";
@@ -1973,13 +2052,20 @@ export async function startAgent({
1973
2052
  repositoryPath,
1974
2053
  });
1975
2054
  directRegistry.create(task);
2055
+ const link = inspectedLinks.get(inspection);
2056
+ await authorizeProviderDispatch(body.backend, requestAccessScope, link);
1976
2057
  const runDirectTask = dependencies.runDirectTask || startDirectTask;
2058
+ const directOptions = { resolveCommand: async input => {
2059
+ const command = await (dependencies.resolveDesktopExecutorCommand || resolveDesktopExecutorCommand)(input);
2060
+ await authorizeProviderDispatch(body.backend, requestAccessScope, link);
2061
+ return command;
2062
+ } };
1977
2063
  const releaseUpdateWork = acquireUpdateWork(updateManager);
1978
2064
  if (!releaseUpdateWork) {
1979
2065
  json(res, 503, updateInProgressResponse(updateManager));
1980
2066
  return;
1981
2067
  }
1982
- void Promise.resolve(runDirectTask(task))
2068
+ void Promise.resolve(runDirectTask(task, directOptions))
1983
2069
  .catch((error) => {
1984
2070
  task.status = "failed";
1985
2071
  task.error = error instanceof Error ? error.message : String(error);
@@ -2177,7 +2263,8 @@ export async function startAgent({
2177
2263
  if (session !== launchSession || !isSessionValid(session, Date.now(), apiOrigin)) {
2178
2264
  json(res, 409, changedLaunchSession(launchSession, session, apiOrigin)); return;
2179
2265
  }
2180
- void startLocalCodingRun(run, dependencies.runOptions);
2266
+ await bindRunAuthorization(run, requestAccessScope, inspection);
2267
+ void startLocalCodingRun(run, codingRuntimeOptions(run));
2181
2268
  json(res, 202, { ok: true, run_id: run.id });
2182
2269
  return;
2183
2270
  }
@@ -2321,7 +2408,7 @@ export async function startAgent({
2321
2408
  const continuation = continueLocalCodingRun(
2322
2409
  run,
2323
2410
  String(body.message || "Continue after increasing the Provider token budget."),
2324
- runOptions,
2411
+ codingRuntimeOptions(run),
2325
2412
  );
2326
2413
  json(res, 202, {
2327
2414
  ok: true,
@@ -2434,7 +2521,7 @@ export async function startAgent({
2434
2521
  const continuation = continueLocalCodingRunAfterRepositoryApproval(
2435
2522
  run,
2436
2523
  { repository: selected.repository, localPath: selected.local_path },
2437
- runOptions,
2524
+ codingRuntimeOptions(run),
2438
2525
  );
2439
2526
  json(res, 202, {
2440
2527
  ok: true,
@@ -2466,7 +2553,7 @@ export async function startAgent({
2466
2553
  json(res, 409, { error: "run is not ready for PR evidence recheck" });
2467
2554
  return;
2468
2555
  }
2469
- const snapshot = await recheckLocalCodingRun(run);
2556
+ const snapshot = await recheckAuthorizedCodingRun(run);
2470
2557
  json(res, 200, { ok: true, run_id: run.id, state: snapshot.state });
2471
2558
  return;
2472
2559
  }
@@ -2479,7 +2566,7 @@ export async function startAgent({
2479
2566
 
2480
2567
  json(res, 404, { error: "not found" });
2481
2568
  } catch (err) {
2482
- if (err?.code === "provider_login_required" || err?.code === "provider_status_unknown" ||
2569
+ if (err?.code === "provider_link_disconnected" || err?.code === "provider_login_required" || err?.code === "provider_status_unknown" ||
2483
2570
  (typeof err?.code === "string" && err.code.startsWith("desktop_executor_"))) {
2484
2571
  json(res, 409, { error: err.message, code: err.code, recovery: err.recovery });
2485
2572
  return;
@@ -2720,6 +2807,12 @@ export async function startAgent({
2720
2807
  !Number.isFinite(ticket.expiresAt) || ticket.expiresAt <= Date.now() || ticket.expiresAt > Date.now() + 60_000) throw new Error("Desktop sign-in changed");
2721
2808
  return { code: ticket.code, expiresAt: ticket.expiresAt, userId: ticket.userId, orgSlug: ticket.orgSlug };
2722
2809
  },
2810
+ // This is an in-process capability for the native host. It is deliberately
2811
+ // absent from status frames and never written to disk by the host.
2812
+ getLocalCliApiSession() {
2813
+ if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) return null;
2814
+ return { token: session.token, api_base_url: session.api_base_url };
2815
+ },
2723
2816
  getDesktopStatus,
2724
2817
  stopDesktopTask,
2725
2818
  stopDesktopTasks,
@@ -2772,7 +2865,8 @@ export async function startAgent({
2772
2865
  const executionTarget = normalizeDesktopExecutionTarget(input.executionTarget);
2773
2866
  if (!requirement) throw new Error("Describe the coding task first.");
2774
2867
  if (!repository) throw new Error("Set a repository with /repo owner/name.");
2775
- if (!(await inspectReadyBackend(backend)).compatible) {
2868
+ const inspection = await inspectReadyBackend(backend);
2869
+ if (!inspection.compatible) {
2776
2870
  throw new Error(`${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`);
2777
2871
  }
2778
2872
  const indexedRepository = await ensureRepositoryIndexed(repository);
@@ -2812,18 +2906,19 @@ export async function startAgent({
2812
2906
  await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
2813
2907
  registry.create(run);
2814
2908
  await authorizeDesktopWork(scope);
2815
- void startLocalCodingRun(run, runOptions);
2909
+ await bindRunAuthorization(run, scope, inspection);
2910
+ void startLocalCodingRun(run, codingRuntimeOptions(run));
2816
2911
  return run;
2817
2912
  }),
2818
2913
  continue: (run, guidance) =>
2819
2914
  withUpdateWork(updateManager, async () => {
2820
2915
  await requireRunProvider(run);
2821
- return continueLocalCodingRun(run, guidance, runOptions);
2916
+ return continueLocalCodingRun(run, guidance, codingRuntimeOptions(run));
2822
2917
  }),
2823
2918
  applyDecision: (run, decision) =>
2824
2919
  withUpdateWork(updateManager, async () => {
2825
2920
  await requireRunProvider(run);
2826
- return applyLocalHumanDecision(run, decision, runOptions);
2921
+ return applyLocalHumanDecision(run, decision, codingRuntimeOptions(run));
2827
2922
  }),
2828
2923
  queueGuidance: (run, guidance) => {
2829
2924
  if (enforceDesktopAccess) requireDesktopAccess(desktopAccess.snapshot());
@@ -2838,7 +2933,7 @@ export async function startAgent({
2838
2933
  recheck: (run) =>
2839
2934
  withUpdateWork(updateManager, async () => {
2840
2935
  await authorizeDesktopWork();
2841
- return recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence });
2936
+ return recheckAuthorizedCodingRun(run, { collectEvidence: runOptions.collectEvidence });
2842
2937
  }),
2843
2938
  },
2844
2939
  };
@@ -72,6 +72,7 @@ export class CliAutoUpdater extends EventEmitter {
72
72
  #confirmedUpdate = null;
73
73
  #confirmedReady = false;
74
74
  #desktopIdle = () => true;
75
+ #applyHolds = 0;
75
76
 
76
77
  constructor(options) {
77
78
  super();
@@ -136,7 +137,9 @@ export class CliAutoUpdater extends EventEmitter {
136
137
  async performAction() {
137
138
  const action = this.#snapshot.action;
138
139
  if (action?.id === "download_macos_installer" && action.url) {
139
- 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;
140
143
  }
141
144
  if (action?.command) {
142
145
  throw new Error(`Run this command after leaving SIMY: ${action.command}`);
@@ -144,8 +147,17 @@ export class CliAutoUpdater extends EventEmitter {
144
147
  throw new Error("No SIMY CLI update action is currently available.");
145
148
  }
146
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
+
147
159
  acceptsNewWork() {
148
- return !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
160
+ return this.#applyHolds === 0 && !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
149
161
  }
150
162
 
151
163
  beginWork() {
@@ -370,7 +382,7 @@ export class CliAutoUpdater extends EventEmitter {
370
382
  }
371
383
 
372
384
  #isIdle() {
373
- return this.#activeWork === 0 && isRegistryIdle(this.#registry) && this.#desktopIdle();
385
+ return this.#applyHolds === 0 && this.#activeWork === 0 && isRegistryIdle(this.#registry) && this.#desktopIdle();
374
386
  }
375
387
 
376
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.16";
16
+ export const CLI_VERSION = "0.6.18";
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`);