@awak-app/simy-cli 0.6.16 → 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.
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
@@ -124,6 +138,12 @@ a fresh Codex or Claude login probe succeeds, including after an app restart.
124
138
  An unchanged ready status cannot repeatedly re-run a failed check. Manual retry
125
139
  remains available; continuing the conversation still requires **Auto-continue**.
126
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
+
127
147
  Claude read-only structured reviews retry once when the provider explicitly
128
148
  reports exhausted turns or structured-output repair attempts, within the original
129
149
  timeout. Other failures retain distinct codes and recovery hints. Safe native
@@ -211,9 +231,31 @@ marketplace still points to SIMY’s generated local source. Plugin versions inc
211
231
  receive a fresh cache. Use a new Codex task to pick up updated skills; running
212
232
  tasks are not restarted. Missing or disabled plugins are not enabled automatically.
213
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
+
214
248
  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
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
217
259
  ranks file metadata before opening selected transcripts. A conflicting saved
218
260
  workspace leaves only that conversation's Action association unavailable; it
219
261
  does not stop discovery or change the saved identity. The page lets users
@@ -586,6 +628,26 @@ execution. Keep concrete regression and delivery acceptance evidence separately.
586
628
  If knowledge is unavailable, the command returns exit 2 with the service cause
587
629
  and writes no new proof; any older artifact is not evidence for that attempt.
588
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
+
589
651
  Normal runs do not require a separate sync command: the CLI
590
652
  uses a verified local cache and revalidates it with TTL and ETag semantics.
591
653
  The Agentic Loop runs min-check after each code modification and the full check
@@ -697,7 +759,7 @@ from an empty CLI chat, types repository, branch, executor, attachment, and task
697
759
  input through a pseudoterminal, creates the Web ledger, spawns real child
698
760
  processes, and exercises HIL plus lifecycle controls. It regenerates the
699
761
  screenshots, terminal frames, raw transcript, and SHA-256 manifest under
700
- `docs/pr-evidence/cli-agentic-loop-console/`.
762
+ `.artifacts/e2e/cli-agentic-loop-console/`.
701
763
 
702
764
  ## Delivery Loop design proposals
703
765
 
@@ -815,11 +877,25 @@ explains baseline updates, historical regression coverage and evidence limits.
815
877
  SQM Tasks has separate **Analysis** and **Checks** lists on macOS and Windows.
816
878
  Analysis retains local review summaries; Checks retains both `simy sqm min-check`
817
879
  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.
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.
820
890
  History remains visible with local analysis off and after restarting. A saved
821
891
  in-flight analysis is shown as Interrupted until this client claims it again;
822
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.
823
899
 
824
900
  Only summary metadata is retained, without analysis prompts, source inputs,
825
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.16",
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());
@@ -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.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`);
@@ -3,7 +3,7 @@
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
@@ -27,6 +27,23 @@ export const taskDirectory = record => record.run?.executionCwd || record.cwd;
27
27
  export const taskWorkspaceDirectories = record => [...new Set([record.cwd, taskDirectory(record)])];
28
28
  export const sharesTaskWorkspace = (left, right) => taskWorkspaceDirectories(left).some(cwd => taskWorkspaceDirectories(right).includes(cwd));
29
29
  const checkpointDirectoryMatches = (record, cwd) => record.cwd === cwd || taskDirectory(record) === cwd;
30
+ // Plugins ship this standalone file without the CLI's provider-links module.
31
+ // Honor the same durable revocation file before accepting hook work; missing
32
+ // files retain legacy behavior, while malformed saved policy fails closed.
33
+ export async function providerLinkState(root, provider) {
34
+ try {
35
+ const settings = JSON.parse(await readFile(join(dirname(resolve(root)), 'provider-links.json'), 'utf8'));
36
+ const valid = settings?.schemaVersion === 1 && ['codex', 'claude'].every(key => {
37
+ const value = settings.providers?.[key];
38
+ return value && typeof value.connected === 'boolean' && typeof value.requiresConsent === 'boolean' &&
39
+ (value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128);
40
+ });
41
+ return valid ? settings.providers[provider] ?? { connected: false, epoch: null } : { connected: false, epoch: null };
42
+ } catch (error) { return { connected: error.code === 'ENOENT', epoch: null }; }
43
+ }
44
+ export async function linkedProvider(root, provider) {
45
+ return (await providerLinkState(root, provider)).connected === true;
46
+ }
30
47
  const digest = value => createHash('sha256').update(value).digest('hex');
31
48
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
32
49
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
@@ -472,7 +489,10 @@ export function deliveryAcceptanceContext(run) {
472
489
  ` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}. Continue only within the original request and permissions; manual checks require the user. SQM is optional and 0 applicable checks do not establish quality acceptance.`;
473
490
  }
474
491
  export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxFailures, continuationPolicy, managed, admission,
475
- liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
492
+ liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
493
+ const link = await providerLinkState(root, provider);
494
+ if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
495
+ if (!link.connected) throw Object.assign(new Error("Reconnect this provider to SIMY before using it."), { code: "provider_link_disconnected" });
476
496
  cwd = await realpath(cwd);
477
497
  if (executionCwd !== undefined) {
478
498
  executionCwd = await realpath(executionCwd);
@@ -534,6 +554,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
534
554
  }
535
555
  if (managedState && !managedState.contextBootstrap) freezeEnrollment(run, now);
536
556
  if (waiting) escalate(run, run.reason);
557
+ const latest = await providerLinkState(root, provider);
558
+ if (!latest.connected || latest.epoch !== link.epoch) throw Object.assign(new Error("SIMY connection changed before enrollment."), { code: "provider_link_disconnected" });
537
559
  return { ...previous, run };
538
560
  });
539
561
  }
@@ -1052,12 +1074,16 @@ async function providerProcess(provider) {
1052
1074
  return null;
1053
1075
  }
1054
1076
  export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity }) {
1077
+ const link = await providerLinkState(root, provider);
1078
+ if (!link.connected) return {};
1055
1079
  if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
1056
1080
  let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
1057
1081
  const event = input.hook_event_name;
1058
1082
  const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
1059
1083
  let output = {}, boundaryLease = null, claimedWorkspace = null;
1060
1084
  try { await updateRecord(root, provider, input.session_id, async old => {
1085
+ const currentLink = await providerLinkState(root, provider);
1086
+ if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
1061
1087
  if (old && old.cwd !== cwd) return old;
1062
1088
  const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
1063
1089
  const owner = record.run?.managed?.liveHookOwner;
@@ -1139,7 +1165,8 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1139
1165
  } else if (event === 'Stop') {
1140
1166
  // OFF suppresses future Stop blocking; it never interrupts the provider
1141
1167
  // turn. Lease settlement and runtime observation above still run.
1142
- if (!automaticContinuationAllowed(record)) return record;
1168
+ const latestLink = await providerLinkState(root, provider);
1169
+ if (!automaticContinuationAllowed(record) || !latestLink.connected || latestLink.epoch !== link.epoch) return record;
1143
1170
  // Codex turn IDs make re-delivered Stop events idempotent. Claude has no
1144
1171
  // stable event ID; never dedupe on text (identical text can be a new turn).
1145
1172
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;