@awak-app/simy-cli 0.5.47 → 0.5.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.47",
3
+ "version": "0.5.49",
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,6 +1,7 @@
1
+ import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
1
2
  import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
2
3
  import { createServer } from "node:http";
3
- import { randomBytes } from "node:crypto";
4
+ import { createHash, randomBytes } from "node:crypto";
4
5
  import { createReadStream } from "node:fs";
5
6
  import { dirname, join, resolve } from "node:path";
6
7
 
@@ -142,6 +143,7 @@ import { inspectSqmAgentSkill, installSqmAgentSkill } from "./sqm/agent-skill.js
142
143
  import {
143
144
  createSqmLocalReviewApi,
144
145
  createSqmLocalReviewWorker,
146
+ runStructuredProviderReview,
145
147
  } from "./sqm/review-worker.js";
146
148
  import { createCodexWorkPatternSync, workPatternHistoryHomes } from "./codex-work-pattern-sync.js";
147
149
  import {
@@ -159,6 +161,7 @@ export async function startAgent({
159
161
  sessionRoot,
160
162
  updateManager = null,
161
163
  deferStartupSynchronization = false,
164
+ enforceDesktopAccess = false,
162
165
  onDesktopStatusChange = null,
163
166
  dependencies = {},
164
167
  quiet = false,
@@ -228,14 +231,13 @@ export async function startAgent({
228
231
  availableCapabilities = withCliContract({
229
232
  ...contractSourceCapabilities,
230
233
  ...(features ? { features: { ...features, ...(Object.hasOwn(features, "sqm_local_review")
231
- ? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization } : {}) } } : {}),
234
+ ? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization && desktopWorkAllowed() } : {}) } } : {}),
232
235
  ...(providerStatuses ? { provider_statuses: providerStatuses } : {}),
233
236
  }, installMode, {
234
237
  authenticated: isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
235
238
  });
236
239
  return availableCapabilities;
237
240
  };
238
- refreshAvailableCapabilities();
239
241
  const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
240
242
  const reclaimDevice = dependencies.reclaimDevice || reclaimLocalDevice;
241
243
  const runOptions = dependencies.runOptions || {};
@@ -257,6 +259,33 @@ export async function startAgent({
257
259
  if (shuttingDown) return;
258
260
  try { onDesktopStatusChange?.(); } catch { /* Desktop status polling remains available. */ }
259
261
  };
262
+ const desktopAccess = createDesktopAccessPolicy({
263
+ readSession: () => session,
264
+ isValid: value => isSessionValid(value, Date.now(), apiOrigin),
265
+ isSwitching: () => Boolean(pendingOrganization),
266
+ onChange: notifyDesktopStatusChange,
267
+ fetchAccess: dependencies.fetchDesktopAccess || (async selected => {
268
+ const response = await (dependencies.fetch || globalThis.fetch)(webApiUrl('desktop-access', {
269
+ webOrigin: apiOrigin, apiBaseUrl: selected.api_base_url,
270
+ }), { headers: webApiHeaders({ token: selected.token }), signal: AbortSignal.timeout(10_000), cache: 'no-store' });
271
+ if (!response.ok) throw Object.assign(new Error('Desktop access could not be verified.'), { status: response.status });
272
+ return response.json();
273
+ }),
274
+ });
275
+ const desktopScopeRevision = () => createHash('sha256').update(JSON.stringify([
276
+ session?.account_id ?? null, session?.organization_id ?? null, session?.device_id ?? null, session?.token ?? null,
277
+ pendingOrganization?.id ?? null, isSessionValid(session, Date.now(), apiOrigin),
278
+ ])).digest('hex');
279
+ const desktopWorkAllowed = () => !enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot());
280
+ const authorizeDesktopWork = async expectedScope => {
281
+ if (enforceDesktopAccess) {
282
+ requireDesktopAccess(await desktopAccess.refresh());
283
+ const scope = desktopScopeRevision();
284
+ if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
285
+ return scope;
286
+ }
287
+ };
288
+ refreshAvailableCapabilities();
260
289
  let durableLocalTaskWorker = null;
261
290
  let sqmLocalReviewWorker = null;
262
291
  let codexWorkPatternSync = null;
@@ -264,6 +293,7 @@ export async function startAgent({
264
293
 
265
294
  let codexWorkPatternSkipReason = null;
266
295
  const pollCodexWorkPatterns = () => {
296
+ if (!desktopWorkAllowed()) return Promise.resolve();
267
297
  if (shuttingDown) return;
268
298
  const skipReason = codexWorkPatternSkipReasonFor({
269
299
  daemon,
@@ -385,8 +415,10 @@ export async function startAgent({
385
415
  };
386
416
 
387
417
  const requireRunProvider = async (run) => {
418
+ const scope = await authorizeDesktopWork();
388
419
  const backend = run.request.backend;
389
420
  const inspection = await inspectReadyBackend(backend, { force: true });
421
+ await authorizeDesktopWork(scope);
390
422
  if (!inspection.compatible) {
391
423
  throw Object.assign(new Error(backendPreflightSummary(backend, inspection)), {
392
424
  code: `desktop_executor_${inspection.status}`,
@@ -412,7 +444,8 @@ export async function startAgent({
412
444
  if (!Array.isArray(payload.organizations)) throw new Error("Invalid organization list.");
413
445
  if (session !== requestedSession) return;
414
446
  organizationItems = payload.organizations.filter(org => typeof org.id === "string" && typeof org.name === "string")
415
- .slice(0, 100).map(org => ({ id: org.id, name: org.name.slice(0, 120) }));
447
+ .slice(0, 100).map(org => ({ id: org.id, name: org.name.slice(0, 120),
448
+ ...(typeof org.slug === 'string' && org.slug && org.slug.length <= 160 && !/[\/\\?#\x00-\x1f]/.test(org.slug) ? { slug: org.slug } : {}) }));
416
449
  organizationState = "ready";
417
450
  } catch {
418
451
  if (session === requestedSession) organizationState = "unavailable";
@@ -421,16 +454,23 @@ export async function startAgent({
421
454
  return organizationRefresh;
422
455
  };
423
456
  const requestDesktopLogin = (organizationId = null) => {
457
+ let organizationSlug = null;
424
458
  if (organizationId) {
425
459
  if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
426
460
  if (organizationState !== "ready" || !organizationItems.some(org => org.id === organizationId)) throw new Error("Refresh the organization list before selecting an organization.");
461
+ organizationSlug = organizationItems.find(org => org.id === organizationId)?.slug;
462
+ if (!organizationSlug) throw new Error('Refresh organizations before switching; its verified slug is unavailable.');
427
463
  if (organizationBusy()) throw Object.assign(new Error("Wait for running tasks to finish before switching organizations."), { code: "desktop_organization_busy" });
428
464
  authNonce = randomBytes(16).toString("base64url");
429
465
  pendingOrganization = { id: organizationId, accountId: session.account_id };
430
466
  loginUrl = null;
431
467
  refreshAvailableCapabilities();
432
468
  notifyDesktopStatusChange();
433
- } else if (isSessionValid(session, Date.now(), apiOrigin)) return null;
469
+ } else if (isSessionValid(session, Date.now(), apiOrigin)) {
470
+ if (!enforceDesktopAccess || desktopAccess.snapshot().state !== 'signed_out') return null;
471
+ authNonce = randomBytes(16).toString("base64url");
472
+ loginUrl = null;
473
+ }
434
474
  if (!loginUrl) {
435
475
  loginUrl = new URL("/local-cli/connect", apiOrigin);
436
476
  loginUrl.searchParams.set("port", String(port));
@@ -438,7 +478,10 @@ export async function startAgent({
438
478
  loginUrl.searchParams.set("cli_version", CLI_VERSION);
439
479
  loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
440
480
  if (installMode) loginUrl.searchParams.set("install_mode", installMode);
441
- if (organizationId) loginUrl.searchParams.set("org_id", organizationId);
481
+ if (organizationId) {
482
+ loginUrl.searchParams.set('org_id', organizationId);
483
+ loginUrl.searchParams.set('org_slug', organizationSlug);
484
+ }
442
485
  }
443
486
  return loginUrl.toString();
444
487
  };
@@ -452,6 +495,7 @@ export async function startAgent({
452
495
  };
453
496
 
454
497
  const restoreRemoteRuns = async () => {
498
+ if (!desktopWorkAllowed()) return;
455
499
  if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
456
500
  const snapshots = dependencies.listRemoteRuns
457
501
  ? await dependencies.listRemoteRuns({
@@ -518,6 +562,7 @@ export async function startAgent({
518
562
  };
519
563
 
520
564
  const restoreLocalCodingRuns = async () => {
565
+ if (!desktopWorkAllowed()) return;
521
566
  const { snapshots, errors } = await loadCodingRunSnapshots(codingRunStateRoot);
522
567
  for (const error of errors) {
523
568
  reportStartupConnectionError(new Error(`Coding run ${error.id}: ${error.message}`), quiet);
@@ -548,6 +593,7 @@ export async function startAgent({
548
593
  };
549
594
 
550
595
  const monitorCodingRuns = () => {
596
+ if (!desktopWorkAllowed()) return Promise.resolve();
551
597
  if (!daemon || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
552
598
  if (codingRunMonitorInFlight) return codingRunMonitorInFlight;
553
599
  codingRunMonitorInFlight = (async () => {
@@ -564,6 +610,7 @@ export async function startAgent({
564
610
  };
565
611
 
566
612
  const startCodingRunMonitor = () => {
613
+ if (!desktopWorkAllowed()) return;
567
614
  if (!daemon || codingRunMonitorTimer) return;
568
615
  codingRunMonitorTimer = setInterval(() => {
569
616
  void monitorCodingRuns().catch((error) => reportStartupConnectionError(error, quiet));
@@ -579,6 +626,7 @@ export async function startAgent({
579
626
  let deviceHeartbeatSucceeded = false;
580
627
  const advertisedProviders = JSON.stringify(providerStatuses);
581
628
  heartbeatInFlight = Promise.resolve()
629
+ .then(async () => { if (enforceDesktopAccess) await desktopAccess.refresh(true); })
582
630
  .then(() =>
583
631
  heartbeatDevice({
584
632
  apiOrigin,
@@ -604,12 +652,12 @@ export async function startAgent({
604
652
  }
605
653
  pollCodexWorkPatterns();
606
654
  };
607
- if (deferStartupSynchronization) {
655
+ if (desktopWorkAllowed() && deferStartupSynchronization) {
608
656
  void publishVerifiedProviders().catch((error) => reportHeartbeatError(error, quiet));
609
- } else {
657
+ } else if (desktopWorkAllowed()) {
610
658
  await publishVerifiedProviders();
611
659
  }
612
- if (shuttingDown || pendingOrganization) return false;
660
+ if (shuttingDown || pendingOrganization || !desktopWorkAllowed()) return false;
613
661
  const sqmResult = sqmEnabled && sqmLocalReviewWorker
614
662
  ? await sqmLocalReviewWorker.poll()
615
663
  : null;
@@ -661,9 +709,11 @@ export async function startAgent({
661
709
  };
662
710
 
663
711
  const scanRepositories = async (root) => {
712
+ const scope = await authorizeDesktopWork();
664
713
  const scan = dependencies.scanRepositories
665
714
  ? await dependencies.scanRepositories(root)
666
715
  : await scanGitRepositories(root);
716
+ await authorizeDesktopWork(scope);
667
717
  repositoryInventory = mergeRepositoryInventory(repositoryInventory, scan.repositories);
668
718
  authorizedRepositoryRoots = [
669
719
  ...new Set([...authorizedRepositoryRoots, scan.root].filter(Boolean)),
@@ -731,12 +781,14 @@ export async function startAgent({
731
781
  workspaceRoot: dependencies.localTaskWorkspaceRoot,
732
782
  resolveRepository: ensureRepositoryIndexed,
733
783
  runLocalTask: async (task, options) => {
784
+ const scope = await authorizeDesktopWork();
734
785
  const inspection = await inspectReadyBackend(task.backend);
735
786
  if (!inspection.compatible) {
736
787
  throw Object.assign(new Error(backendPreflightSummary(task.backend, inspection)), {
737
788
  code: `desktop_executor_${inspection.status}`,
738
789
  });
739
790
  }
791
+ await authorizeDesktopWork(scope);
740
792
  return (dependencies.runLocalTask || startLocalTask)(task, options);
741
793
  },
742
794
  attachmentStore: localTaskAttachmentStore,
@@ -762,9 +814,13 @@ export async function startAgent({
762
814
  sqmLocalReviewWorker = createWorker({
763
815
  api: sqmApi,
764
816
  beginWork: () => acquireUpdateWork(updateManager),
765
- runReview: dependencies.runSqmLocalReview,
817
+ runReview: async (request, provider) => {
818
+ const scope = await authorizeDesktopWork();
819
+ const beforeDispatch = () => authorizeDesktopWork(scope);
820
+ return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch });
821
+ },
766
822
  onChange: notifyDesktopStatusChange,
767
- getProvider: () => availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
823
+ getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
768
824
  (provider) => availableCapabilities?.backends?.[provider] === true &&
769
825
  providerStatuses[provider]?.state === "ready",
770
826
  ) || null,
@@ -773,6 +829,7 @@ export async function startAgent({
773
829
  }
774
830
 
775
831
  const selectRepository = async (run, repository) => {
832
+ await authorizeDesktopWork();
776
833
  if (run) ensureAcceptingNewWork(updateManager);
777
834
  const selected = repository?.local_path
778
835
  ? repositoryInventory.find(
@@ -812,6 +869,14 @@ export async function startAgent({
812
869
 
813
870
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
814
871
  const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
872
+ const desktopRecoveryRoute = ['/v1/health', '/v1/capabilities', '/v1/auth/complete'].includes(url.pathname) ||
873
+ req.method === 'POST' && (/^\/v1\/local-tasks\/[^/]+\/control$/.test(url.pathname) || /^\/v1\/agentic-loop\/[^/]+\/control$/.test(agenticLoopPath));
874
+ let requestAccessScope;
875
+ if (enforceDesktopAccess && !desktopRecoveryRoute) {
876
+ try { requestAccessScope = await authorizeDesktopWork(); }
877
+ catch { json(res, 403, { error: 'Desktop access is required.', code: 'desktop_access_required', access: desktopAccess.snapshot() }); return; }
878
+ }
879
+
815
880
  const startsLocalExecution =
816
881
  url.pathname === "/v1/local-tasks/start" ||
817
882
  url.pathname === "/v1/direct-execution/start";
@@ -1168,7 +1233,7 @@ export async function startAgent({
1168
1233
  }
1169
1234
  const localTaskControlMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/control$/);
1170
1235
  if (req.method === "POST" && localTaskControlMatch) {
1171
- if (!isSessionValid(session, Date.now(), apiOrigin)) {
1236
+ if (!enforceDesktopAccess && !isSessionValid(session, Date.now(), apiOrigin)) {
1172
1237
  json(res, 401, { error: "simy session expired; run simy again" });
1173
1238
  return;
1174
1239
  }
@@ -1485,6 +1550,7 @@ export async function startAgent({
1485
1550
  const runLocalTask =
1486
1551
  dependencies.runLocalTask ||
1487
1552
  ((localTask) => startLocalTask(localTask, { session, apiOrigin }));
1553
+ await authorizeDesktopWork(requestAccessScope);
1488
1554
  task.operation_promise = Promise.resolve(runLocalTask(task))
1489
1555
  .catch(async (error) => {
1490
1556
  task.status = "failed";
@@ -1600,6 +1666,7 @@ export async function startAgent({
1600
1666
  });
1601
1667
  return;
1602
1668
  }
1669
+ await authorizeDesktopWork(requestAccessScope);
1603
1670
  const task = createDirectTask({
1604
1671
  instruction,
1605
1672
  backend: body.backend,
@@ -1795,6 +1862,7 @@ export async function startAgent({
1795
1862
  await cleanupRunAttachments(stagedAttachments);
1796
1863
  throw error;
1797
1864
  }
1865
+ await authorizeDesktopWork(requestAccessScope);
1798
1866
  void startLocalCodingRun(run, dependencies.runOptions);
1799
1867
  json(res, 202, { ok: true, run_id: run.id });
1800
1868
  return;
@@ -1802,7 +1870,7 @@ export async function startAgent({
1802
1870
 
1803
1871
  const controlMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/control$/);
1804
1872
  if (req.method === "POST" && controlMatch) {
1805
- if (!isSessionValid(session, Date.now(), apiOrigin)) {
1873
+ if (!enforceDesktopAccess && !isSessionValid(session, Date.now(), apiOrigin)) {
1806
1874
  json(res, 401, { error: "simy session expired; run simy again" });
1807
1875
  return;
1808
1876
  }
@@ -1821,6 +1889,8 @@ export async function startAgent({
1821
1889
  } else if (body.action === "pause") {
1822
1890
  pauseLocalCodingRun(run);
1823
1891
  } else if (body.action === "resume") {
1892
+ try { await authorizeDesktopWork(); }
1893
+ catch { json(res, 403, { error: "Desktop access is required.", code: "desktop_access_required" }); return; }
1824
1894
  if (!acceptsNewWork(updateManager)) {
1825
1895
  json(res, 503, updateInProgressResponse(updateManager));
1826
1896
  return;
@@ -2074,6 +2144,7 @@ export async function startAgent({
2074
2144
 
2075
2145
  const synchronizeStartup = async () => {
2076
2146
  try {
2147
+ if (enforceDesktopAccess) { await desktopAccess.refresh(); if (!desktopWorkAllowed()) { await heartbeat(); return; } }
2077
2148
  await refreshAuthorizedRepositoryInventory();
2078
2149
  if (shuttingDown) return;
2079
2150
  await heartbeat();
@@ -2088,7 +2159,7 @@ export async function startAgent({
2088
2159
  if (deferStartupSynchronization) {
2089
2160
  // The desktop can show restored tasks and account state while network and
2090
2161
  // provider checks run. Unknown remains unknown until each probe succeeds.
2091
- void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
2162
+ if (desktopWorkAllowed()) void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
2092
2163
  if (isSessionValid(session, Date.now(), apiOrigin)) {
2093
2164
  startHeartbeatTimer();
2094
2165
  void synchronizeStartup();
@@ -2129,7 +2200,8 @@ export async function startAgent({
2129
2200
  const getDesktopStatus = async () => {
2130
2201
  const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
2131
2202
  if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
2132
- if (deferStartupSynchronization && !shuttingDown) {
2203
+ if (enforceDesktopAccess) void desktopAccess.refresh().catch(() => {});
2204
+ if (deferStartupSynchronization && !shuttingDown && desktopWorkAllowed()) {
2133
2205
  // Cached checks return immediately; expired checks refresh in the
2134
2206
  // background without making the desktop frame wait for subprocesses.
2135
2207
  void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
@@ -2143,11 +2215,12 @@ export async function startAgent({
2143
2215
  version: CLI_VERSION,
2144
2216
  webOrigin: apiOrigin,
2145
2217
  auth,
2218
+ ...(enforceDesktopAccess ? { access: { ...desktopAccess.snapshot(), revision: desktopScopeRevision() } } : {}),
2146
2219
  connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
2147
2220
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
2148
2221
  organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
2149
2222
  switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
2150
- sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization,
2223
+ sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
2151
2224
  activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2152
2225
  issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2153
2226
  providerReady: [codex, claude].some(provider => provider?.state === "ready") },
@@ -2173,9 +2246,20 @@ export async function startAgent({
2173
2246
  return requestDesktopLogin();
2174
2247
  },
2175
2248
  requestDesktopLogin,
2249
+ async refreshDesktopAccess() {
2250
+ const previous = desktopAccess.snapshot().state;
2251
+ const result = await desktopAccess.refresh(true);
2252
+ if (enforceDesktopAccess && result.state === 'ready' && previous !== 'ready' && !shuttingDown) {
2253
+ void synchronizeAuthorizedSession().catch(error => reportStartupConnectionError(error, quiet));
2254
+ }
2255
+ return { ...result, revision: desktopScopeRevision() };
2256
+ },
2257
+ getDesktopAccess: () => ({ ...desktopAccess.snapshot(), revision: desktopScopeRevision() }),
2258
+ requireDesktopAccess: authorizeDesktopWork,
2176
2259
  refreshOrganizations,
2177
2260
  cancelOrganizationSelection() { authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
2178
2261
  async setSqmEnabled(enabled) {
2262
+ if (enabled) await authorizeDesktopWork();
2179
2263
  if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
2180
2264
  const settingsSession = session;
2181
2265
  await writeSqmEnabled(sessionRoot, apiOrigin, settingsSession, enabled);
@@ -2206,6 +2290,7 @@ export async function startAgent({
2206
2290
  scanRepositories,
2207
2291
  selectRepository,
2208
2292
  create: (input) => withUpdateWork(updateManager, async () => {
2293
+ const scope = await authorizeDesktopWork();
2209
2294
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
2210
2295
  throw new Error("Sign in to SIMY before starting a coding task.");
2211
2296
  }
@@ -2252,6 +2337,7 @@ export async function startAgent({
2252
2337
  const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot });
2253
2338
  await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
2254
2339
  registry.create(run);
2340
+ await authorizeDesktopWork(scope);
2255
2341
  void startLocalCodingRun(run, runOptions);
2256
2342
  return run;
2257
2343
  }),
@@ -2265,7 +2351,10 @@ export async function startAgent({
2265
2351
  await requireRunProvider(run);
2266
2352
  return applyLocalHumanDecision(run, decision, runOptions);
2267
2353
  }),
2268
- queueGuidance: queueLocalCodingGuidance,
2354
+ queueGuidance: (run, guidance) => {
2355
+ if (enforceDesktopAccess) requireDesktopAccess(desktopAccess.snapshot());
2356
+ return queueLocalCodingGuidance(run, guidance);
2357
+ },
2269
2358
  pause: pauseLocalCodingRun,
2270
2359
  resume: (run) => withUpdateWork(updateManager, async () => {
2271
2360
  await requireRunProvider(run);
@@ -2273,9 +2362,10 @@ export async function startAgent({
2273
2362
  }),
2274
2363
  stop: stopLocalCodingRun,
2275
2364
  recheck: (run) =>
2276
- withUpdateWork(updateManager, () =>
2277
- recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
2278
- ),
2365
+ withUpdateWork(updateManager, async () => {
2366
+ await authorizeDesktopWork();
2367
+ return recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence });
2368
+ }),
2279
2369
  },
2280
2370
  };
2281
2371
  }
@@ -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.5.47";
16
+ export const CLI_VERSION = "0.5.49";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -54,7 +54,7 @@ export function parseAgentCheckpoint(output, expectedSession) {
54
54
 
55
55
  // The provider CLI is a new process. It is used only for an exact Codex UUID
56
56
  // enrolled for this recovery channel after a corroborated process exit.
57
- export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {} } = {}) {
57
+ export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {} } = {}) {
58
58
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
59
59
  const binary = await executable('codex');
60
60
  if (!binary) throw new Error('Codex executable unavailable.');
@@ -70,6 +70,9 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
70
70
  const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
71
71
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
72
72
  if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
73
+ await beforeDispatch();
74
+ if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
75
+ if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
73
76
  return new Promise((resolve, reject) => {
74
77
  const child = spawn(binary, args, { cwd: record.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
75
78
  env: { ...process.env, SIMY_HOME: dirname(root) } });
@@ -116,7 +119,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
116
119
  });
117
120
  }
118
121
 
119
- export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim } = {}) {
122
+ export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch } = {}) {
120
123
  const tickWallStart = Date.now();
121
124
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
122
125
  if (signal?.aborted) return;
@@ -152,6 +155,16 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
152
155
  if (run.managed.executionUncertain && run.managed.recoveryDiagnosisDecisionId !== run.managed.decision?.id) continue;
153
156
  if (['pending', 'transferred', 'pending_received'].includes(run.managed.handoff?.status)) continue;
154
157
  if (run.state !== 'recovery_required' || !causeAllowed || !run.managed.autoRecovery || run.managed.pendingApproval || run.managed.autonomyPolicy && run.managed.decision?.outcome === 'wait' && !approvedWait || appServer && run.managed.nextAttemptAt > claimNow) continue;
158
+ let authorizationScope;
159
+ const authorizeClaim = async () => {
160
+ if (!authorizeDispatch) return;
161
+ const latest = await authorizeDispatch();
162
+ if (authorizationScope !== undefined && latest !== authorizationScope) throw Object.assign(new Error("Desktop authorization scope changed."), { code: "desktop_access_required" });
163
+ authorizationScope = latest;
164
+ };
165
+ if (authorizeDispatch) {
166
+ try { await authorizeClaim(); } catch { continue; }
167
+ }
155
168
  if (beforeClaim) {
156
169
  try {
157
170
  const prepared = await beforeClaim(record);
@@ -237,6 +250,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
237
250
  else if (['local_edit', 'local_test'].includes(claimed.run.managed.decision?.action.kind) &&
238
251
  (await listRecords(root)).some(other => other.cwd === claimed.cwd && other.run?.managed?.executionUncertain)) result = { code: -3, busy: true };
239
252
  if (!result) {
253
+ await authorizeClaim();
240
254
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
241
255
  result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
242
256
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
@@ -268,11 +282,14 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
268
282
  }
269
283
  return current;
270
284
  });
285
+ await authorizeClaim();
271
286
  } });
272
287
  }
273
288
  }
274
- } catch (error) { result = { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
275
- errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
289
+ } catch (error) { result = error?.code === 'desktop_access_required'
290
+ ? { code: -3, busy: true, accessDenied: true }
291
+ : { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
292
+ errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
276
293
  finally { clearTimeout(timeout); clearInterval(watch); signal?.removeEventListener('abort', abort); }
277
294
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
278
295
  if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
@@ -319,7 +336,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
319
336
  checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
320
337
  errorCode: result.code === 0 || result.busy || result.cancelled ? null : safeProviderCode(result.errorCode),
321
338
  restorationUnverified: result.restorationUnverified === true,
322
- reason: result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
339
+ reason: result.accessDenied ? 'Desktop organization access is required before another provider turn.' : result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
323
340
  nextStep: live.nextStep });
324
341
  live.managed.attempts = live.managed.attempts.slice(-20);
325
342
  if (result.restorationUnverified) {
@@ -340,8 +357,16 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
340
357
  live.state = 'recovery_required'; live.managed.autoRecovery = false;
341
358
  live.reason = 'Provider usage was unavailable; token-budgeted continuation cannot safely start another turn.';
342
359
  } else if (result.busy) {
360
+ if (result.accessDenied && dispatched) {
361
+ // The final guard rejected before the provider call. Keep its one-shot approval unused.
362
+ if (live.managed.approvedAction?.id === live.managed.decision?.id) delete live.managed.approvedAction.dispatchedAt;
363
+ if (live.managed.executionAttempt?.id === claimed.run.managed.executionAttempt?.id) {
364
+ delete live.managed.executionAttempt.dispatchedAt;
365
+ live.managed.executionAttempt.phase = 'claimed';
366
+ }
367
+ }
343
368
  live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
344
- live.reason = 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
369
+ live.reason = result.accessDenied ? 'Sign in to a SIMY organization with SQM and Developer access before continuing.' : 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
345
370
  live.managed.nextAttemptAt = Date.now() + 15_000;
346
371
  } else if (result.errorCode === 'provider_approval') {
347
372
  live.state = 'blocked'; live.blocker = 'authorization'; live.managed.autoRecovery = false;
@@ -0,0 +1,65 @@
1
+ // Desktop entitlement is a server-authenticated organization/member decision.
2
+ // Local provider readiness and the local SQM consent switch never grant access.
3
+ const SERVER_STATES = new Set(['ready', 'sqm_required', 'developer_required']);
4
+ export function validDesktopAccessPath(path, slug) {
5
+ if (typeof slug !== 'string' || !slug || ['.', '..'].includes(slug) || slug.length > 160 || /[\/\\?#\x00-\x1f]/.test(slug)) return false;
6
+ const expected = `/org/${encodeURIComponent(slug)}/settings/plan`;
7
+ return path === expected || path === `${expected}/`;
8
+ }
9
+ export const desktopAccessReady = value => value?.state === 'ready';
10
+ const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
+ 'check_update', 'update', 'quit', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
+ 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
+ export function desktopActionAllowed(access, command) {
14
+ return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
15
+ command?.action === 'session_set_auto_continue' && command.enabled === false;
16
+ }
17
+ export function requireDesktopAccess(access) {
18
+ if (!desktopAccessReady(access)) throw Object.assign(new Error('Sign in to an organization with SQM and Developer access before using SIMY.'), { code: 'desktop_access_required' });
19
+ }
20
+ export function createDesktopAccessPolicy({ readSession, isValid, fetchAccess, isSwitching = () => false, now = Date.now, ttlMs = 30_000, onChange = () => {} }) {
21
+ let verified = null, verifiedSession = null, verifiedAt = 0, pending = null;
22
+ const basic = () => {
23
+ const session = readSession();
24
+ if (!isValid(session)) return { state: 'signed_out', organizationId: null, organizationName: null };
25
+ const organizationId = typeof session.organization_id === 'string' && session.organization_id ? session.organization_id : null;
26
+ if (!organizationId) return { state: 'organization_required', organizationId: null, organizationName: null };
27
+ return { state: 'checking', organizationId, organizationName: null };
28
+ };
29
+ const snapshot = () => {
30
+ const base = basic();
31
+ if (base.state !== 'checking' || isSwitching()) return base;
32
+ if (readSession() !== verifiedSession || now() - verifiedAt >= ttlMs || !verified) return base;
33
+ return { ...verified };
34
+ };
35
+ const refresh = async (force = false) => {
36
+ const base = basic();
37
+ if (base.state !== 'checking' || isSwitching()) return base;
38
+ const session = readSession();
39
+ if (pending?.session === session) return pending.promise;
40
+ if (!force && readSession() === verifiedSession && now() - verifiedAt < ttlMs && verified) return snapshot();
41
+ onChange();
42
+ const job = { session, promise: null };
43
+ job.promise = (async () => {
44
+ let result;
45
+ try {
46
+ const payload = await fetchAccess(session);
47
+ if (!payload || payload.schemaVersion !== 1 || payload.organizationId !== session.organization_id ||
48
+ typeof payload.organizationName !== 'string' || !SERVER_STATES.has(payload.state) ||
49
+ !validDesktopAccessPath(payload.settingsPath, payload.organizationSlug) || !validDesktopAccessPath(payload.membersPath, payload.organizationSlug)) throw new Error('Invalid entitlement response');
50
+ result = { state: payload.state,
51
+ organizationId: payload.organizationId, organizationName: payload.organizationName.slice(0, 120), organizationSlug: payload.organizationSlug, settingsPath: payload.settingsPath, membersPath: payload.membersPath };
52
+ } catch (error) {
53
+ result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable' };
54
+ }
55
+ // Late responses never authorize a new account or a switched organization.
56
+ if (readSession() === session && basic().state === 'checking') {
57
+ verified = result; verifiedSession = session; verifiedAt = now(); onChange();
58
+ }
59
+ return snapshot();
60
+ })().finally(() => { if (pending === job) pending = null; });
61
+ pending = job;
62
+ return job.promise;
63
+ };
64
+ return { snapshot, refresh };
65
+ }
@@ -1,3 +1,4 @@
1
+ import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
1
2
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
2
3
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
3
4
  import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
@@ -5,7 +6,7 @@ import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
5
6
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
6
7
  import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
7
8
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
8
- import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
9
+ import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
9
10
  import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
10
11
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
11
12
  import { spawn, execFile } from "node:child_process";
@@ -38,6 +39,9 @@ const silentLogger = { log() {}, error() {} };
38
39
  const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
39
40
 
40
41
  function desktopActionError(action, error) {
42
+ if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
43
+ if (action === "session_deadline_decide") return error?.code === "conflict"
44
+ ? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
41
45
  if (typeof action === "string" && action.startsWith("session_")) {
42
46
  return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
43
47
  }
@@ -66,18 +70,55 @@ export async function startDesktopHost({ input = process.stdin, output = process
66
70
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
67
71
  const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
68
72
  let actionAssociations = makeActionAssociations();
73
+ let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
74
+ const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
75
+ const setAccessSnapshot = async access => {
76
+ const previousScope = accessScope(currentAccess);
77
+ currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
78
+ if (previousScope !== accessScope(currentAccess)) {
79
+ for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
80
+ sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
81
+ await analysisManager?.update([]);
82
+ }
83
+ return currentAccess;
84
+ };
85
+ const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
86
+ const authorizeWork = async expectedScope => {
87
+ if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
88
+ const access = await accessSnapshot();
89
+ requireDesktopAccess(access);
90
+ const scope = accessScope(access);
91
+ if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
92
+ return scope;
93
+ };
94
+ const selectedScopes = new WeakMap();
95
+ const guardedEnroll = async options => {
96
+ await authorizeWork();
97
+ return (dependencies.continueSession || continueSession)(options);
98
+ };
99
+
69
100
  const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
70
101
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
102
+ authorizeDispatch: authorizeWork,
71
103
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
72
104
  let shuttingDown = null;
73
105
  let commands;
74
106
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
75
107
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
76
108
  const refreshSessions = (force = false) => {
109
+ if (!desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
77
110
  if (force && sessionRefresh) sessionRefreshAgain = true;
78
111
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
112
+ const requestedScope = accessScope(currentAccess);
79
113
  sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
80
- .then(async value => { await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
114
+ .then(async value => {
115
+ const latestAccess = await accessSnapshot();
116
+ if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
117
+ await analysisManager.update([]);
118
+ sessionRefreshAgain = desktopAccessReady(latestAccess);
119
+ return;
120
+ }
121
+ await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
81
122
  .catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
82
123
  .finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
83
124
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
@@ -85,19 +126,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
85
126
  return sessionStatus;
86
127
  };
87
128
  const selectedSession = async command => {
129
+ const scope = await authorizeWork();
88
130
  refreshSessions(true);
89
131
  await sessionRefresh;
90
132
  // A forced refresh requested during discovery schedules one follow-up.
91
133
  if (sessionRefresh) await sessionRefresh;
92
134
  const candidate = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
93
135
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
94
- return candidate;
136
+ await authorizeWork(scope);
137
+ const selected = { ...candidate };
138
+ selectedScopes.set(selected, scope);
139
+ return selected;
95
140
  };
96
141
  const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
97
142
  getManager: () => analysisManager, selectCandidate: selectedSession,
98
143
  readSetting: dependencies.readSessionAutoContinue,
99
144
  readContext: dependencies.readContinuationContext || readContinuationContext,
100
- enroll: dependencies.continueSession || continueSession,
145
+ enroll: guardedEnroll, beforeEnroll: candidate => authorizeWork(selectedScopes.get(candidate)),
101
146
  onChange: () => { if (!closed && !relay) refreshSessions(true); },
102
147
  });
103
148
  let pluginSetup = { state: "unknown" };
@@ -110,14 +155,31 @@ export async function startDesktopHost({ input = process.stdin, output = process
110
155
  };
111
156
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
112
157
  const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
113
- root: join(root, 'session-analysis'), analyze: dependencies.analyzeContinuation,
158
+ root: join(root, 'session-analysis'), analyze: async (...args) => {
159
+ const scope = await authorizeWork();
160
+ const [candidate, options] = args;
161
+ return (dependencies.analyzeContinuation || analyzeContinuation)(candidate, { ...options, beforeProviderDispatch: () => authorizeWork(scope) });
162
+ },
114
163
  onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
115
164
  });
116
165
  const publish = () => {
117
166
  if (closed || relay || !agent) return Promise.resolve();
118
167
  if (publishing) { publishAgain = true; return publishing; }
119
168
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
169
+ await setAccessSnapshot(status.access);
170
+ status.access = currentAccess;
171
+ const requestedScope = accessScope(currentAccess);
120
172
  status.pluginSetup = { ...pluginSetup };
173
+ if (!desktopAccessReady(currentAccess)) {
174
+ for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
175
+ if (currentAccess.state !== 'checking') await analysisManager.update([]);
176
+ status.tasks = { activeCount: status.tasks?.activeCount || 0, items: [] };
177
+ status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
178
+ status.deliveryHooks = { runs: [], providers: {} };
179
+ status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
180
+ if (!closed && !relay) emit({ type: 'status', status });
181
+ return;
182
+ }
121
183
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
122
184
  await actionAssociations.decorate(status.deliveryHooks);
123
185
  const discovery = refreshSessions();
@@ -132,6 +194,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
132
194
  if (run) run.actionDeadline = session.actionDeadline;
133
195
  }
134
196
  status.sessionSupervision = { ...discovery, sessions };
197
+ const latestAccess = await accessSnapshot();
198
+ if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) { publishAgain = true; return; }
135
199
  if (!closed && !relay) emit({ type: "status", status });
136
200
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
137
201
  return publishing;
@@ -173,7 +237,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
173
237
  });
174
238
  agent = await (dependencies.startAgent || startAgent)({
175
239
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
176
- deferStartupSynchronization: true,
240
+ deferStartupSynchronization: true, enforceDesktopAccess: true,
177
241
  onDesktopStatusChange: () => {
178
242
  // Let an in-progress frame finish before publishing its new snapshot.
179
243
  setImmediate(() => { void publish().catch(() => {}); });
@@ -251,13 +315,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
251
315
  await publish();
252
316
  void actionAssociations.tick().then(publish).catch(() => {});
253
317
  timer = setInterval(() => {
254
- void actionAssociations.tick().then(publish).catch(() => {});
255
- void managedSupervisor.tick().then(publish).catch(() => {});
318
+ if (desktopAccessReady(currentAccess)) {
319
+ void actionAssociations.tick().then(publish).catch(() => {});
320
+ void managedSupervisor.tick().then(publish).catch(() => {});
321
+ }
256
322
  void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
257
323
  }, 3_000);
258
324
  } catch (error) { await close(); throw error; }
259
325
 
260
326
  let queue = Promise.resolve();
327
+ // Authorization must not wait behind remote refresh/detail/provider work.
328
+ // Keep these commands ordered with each other; the agent still owns all
329
+ // membership, active-work and nonce checks.
330
+ let authorizationQueue = Promise.resolve();
331
+ let activeQueuedAction = null;
261
332
  commands = createInterface({ input, crlfDelay: Infinity });
262
333
  commands.on("line", (line) => {
263
334
  if (closed) return;
@@ -268,28 +339,48 @@ export async function startDesktopHost({ input = process.stdin, output = process
268
339
  if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
269
340
  return;
270
341
  }
271
- queue = queue.then(async () => {
342
+ const execute = async () => {
343
+ if (closed) return;
272
344
  try {
273
345
  const action = command.action;
346
+ const access = await accessSnapshot();
347
+ const commandScope = accessScope(access);
348
+ if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
349
+ if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
350
+
274
351
  if (action === "task_detail") {
275
352
  const task = await agent.getDesktopTask?.(command.taskKey);
353
+ await authorizeWork(commandScope);
276
354
  emit({ type: "task_detail", id: command.id, taskKey: command.taskKey, task: task ?? null });
277
355
  emit({ type: "result", id: command.id, ok: Boolean(task) });
278
356
  return;
279
357
  }
280
- if (action === "refresh" || action === "reconnect") {
281
- if (agent.refreshProviderStatus) await agent.refreshProviderStatus();
282
- else await agent.refreshCodexStatus?.();
358
+ if (action === 'desktop_access_refresh') {
359
+ await agent.refreshDesktopAccess?.();
360
+ } else if (action === 'desktop_access_settings' || action === 'desktop_access_members') {
361
+ const verified = await agent.refreshDesktopAccess?.();
362
+ const path = verified?.[action === 'desktop_access_settings' ? 'settingsPath' : 'membersPath'];
363
+ if (!validDesktopAccessPath(path, verified?.organizationSlug)) throw new Error('Organization settings are unavailable; refresh access first.');
364
+ const url = new URL(path, webOrigin);
365
+ if (url.origin !== webOrigin) throw new Error('Organization settings are unavailable.');
366
+ const result = await (dependencies.openLogin || openAuthorizationUrl)(url.toString(), { logger: silentLogger });
367
+ if (!result.opened) throw new Error('Could not open organization settings.');
368
+ } else if (action === "refresh" || action === "reconnect") {
369
+ if (desktopAccessReady(access) && agent.refreshProviderStatus) await agent.refreshProviderStatus();
370
+ else if (desktopAccessReady(access)) await agent.refreshCodexStatus?.();
283
371
  await agent.refreshDesktopStatus();
284
372
  await refreshPlugins();
285
373
  refreshSessions(true);
286
- void actionAssociations.tick().then(publish).catch(() => {});
374
+ if (desktopAccessReady(currentAccess)) void actionAssociations.tick().then(publish).catch(() => {});
287
375
  }
288
376
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
289
377
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
290
378
  else if (action === "organization_cancel") agent.cancelOrganizationSelection();
291
379
  else if (action === "login" || action === "organization_select") {
292
380
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
381
+ if (action === "organization_select" && activeQueuedAction && !["refresh", "reconnect", "task_detail", "organizations_refresh", "check_update"].includes(activeQueuedAction)) {
382
+ throw Object.assign(new Error("Wait for the current action to finish before switching organizations."), { code: "desktop_organization_busy" });
383
+ }
293
384
  const url = await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
294
385
  if (url) {
295
386
  if (new URL(url).origin !== webOrigin) throw new Error("Invalid login origin.");
@@ -332,6 +423,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
332
423
  taskId: command.taskId, criterionId: command.criterionId });
333
424
  } else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
334
425
  const candidate = await selectedSession(command);
426
+ await authorizeWork(commandScope);
335
427
  await actionAssociations.correct(command, analysisManager.project(candidate));
336
428
  } else if (action === "session_set_auto_continue") {
337
429
  if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
@@ -339,6 +431,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
339
431
  const candidate = await selectedSession(command);
340
432
  if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
341
433
  }
434
+ if (command.enabled) await authorizeWork(commandScope);
342
435
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
343
436
  provider: command.provider, session: command.session, enabled: command.enabled });
344
437
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
@@ -356,11 +449,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
356
449
  const context = await actionAssociations.deadlineContext?.(projected);
357
450
  if (!context || context.taskId !== command.taskId) throw new Error('Refresh this conversation before deciding its deadline.');
358
451
  await deadlines.reconcile(context);
452
+ await authorizeWork(commandScope);
359
453
  const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
360
454
  if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
361
455
  } else if (action === "session_track" || action === "session_untrack") {
362
456
  const candidate = await selectedSession(command);
363
457
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
458
+ await authorizeWork(commandScope);
364
459
  await (dependencies.trackSession || trackSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
365
460
  session: command.session, tracked: action === "session_track" });
366
461
  await refreshSessions(true);
@@ -383,7 +478,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
383
478
  const manager = analysisManager;
384
479
  await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
385
480
  readContext: dependencies.readContinuationContext || readContinuationContext,
386
- enroll: dependencies.continueSession || continueSession,
481
+ enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
387
482
  settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
388
483
  maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
389
484
  maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
@@ -404,7 +499,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
404
499
  } catch (error) {
405
500
  emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
406
501
  }
407
- }).catch(() => {});
502
+ };
503
+ if (["login", "organization_select", "organization_cancel"].includes(command.action)) {
504
+ authorizationQueue = authorizationQueue.then(execute).catch(() => {});
505
+ } else {
506
+ queue = queue.then(async () => {
507
+ activeQueuedAction = command.action;
508
+ try { await execute(); } finally { activeQueuedAction = null; }
509
+ }).catch(() => {});
510
+ }
408
511
  });
409
512
  commands.on("close", () => { if (!closed) void close().then(() => exit(0)); });
410
513
  return { close, publish, agent };
@@ -49,7 +49,7 @@ export function createOrdinaryAssociationStore({
49
49
  now = Date.now,
50
50
  onChange = () => {},
51
51
  }) {
52
- const inFlight = new Map();
52
+ const inFlight = new Map(), syncTails = new Map();
53
53
  const views = new Map(),
54
54
  abort = new AbortController();
55
55
  let serial = Promise.resolve(),
@@ -90,9 +90,16 @@ export function createOrdinaryAssociationStore({
90
90
  },
91
91
  );
92
92
  }
93
- async function sync(candidate, force = false, known) {
94
- const task = candidate.taskId || known?.associationTaskId;
95
- if (inFlight.has(task)) await inFlight.get(task);
93
+ function sync(candidate, force = false, known) {
94
+ // Forced user refreshes and background decoration share the same local
95
+ // binding writer; an in-flight check alone leaves a race after its await.
96
+ const key = `${candidate.provider}:${candidate.session}`;
97
+ const work = (syncTails.get(key) || Promise.resolve()).then(() => performSync(candidate, force, known));
98
+ const tail = work.catch(() => {}).finally(() => { if (syncTails.get(key) === tail) syncTails.delete(key); });
99
+ syncTails.set(key, tail);
100
+ return work;
101
+ }
102
+ async function performSync(candidate, force = false, known) {
96
103
  if (closed) return;
97
104
  const record = known || (await ensure(candidate)),
98
105
  taskId = record.run?.managed?.taskId || record.associationTaskId;
@@ -13,7 +13,7 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
13
13
  throw new Error('This conversation changed; wait for its updated analysis.');
14
14
  }
15
15
  manager.requireRecommendation(candidate);
16
- await beforeEnroll();
16
+ await beforeEnroll(candidate);
17
17
  if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
18
18
  return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
19
19
  }
@@ -22,7 +22,7 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
22
22
  // context/opt-in changes; an existing (including terminal) run is never reset.
23
23
  export function createAutomaticContinuation({ root, getManager, selectCandidate,
24
24
  readSetting = readSessionAutoContinue, readContext = readContinuationContext, enroll = continueSession,
25
- onChange = () => {} }) {
25
+ beforeEnroll = async () => {}, onChange = () => {} }) {
26
26
  const pending = new Map(), attempted = new Map(), errors = new Map();
27
27
  let closed = false;
28
28
  const tick = candidates => {
@@ -51,7 +51,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
51
51
  const fresh = await selectCandidate(candidate);
52
52
  if (!fresh.autoContinue || fresh.autoContinueEpoch !== candidate.autoContinueEpoch) throw new Error('Automatic continuation setting changed.');
53
53
  await continueAnalyzedSession({ root, candidate: fresh, manager, signal: controller.signal,
54
- readContext, enroll, beforeEnroll: guard,
54
+ readContext, enroll, beforeEnroll: async selected => { await guard(); await beforeEnroll(selected); },
55
55
  settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
56
56
  }).catch(error => {
57
57
  if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
@@ -315,13 +315,16 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
315
315
  for (const [index, turn] of settled.toReversed().entries()) {
316
316
  if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
317
317
  if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
318
- if (!Array.isArray(turn?.items)) { incomplete = true; continue; }
318
+ if (!Array.isArray(turn?.items)) { if (turn === boundary) incomplete = true; continue; }
319
319
  if (turn === boundary && !turn.items.length) incomplete = true;
320
320
  for (const [itemIndex, item] of turn.items.entries()) {
321
321
  const role = item?.type === 'userMessage' ? 'user' : item?.type === 'agentMessage' ? 'assistant' : null;
322
322
  if (!role) continue;
323
323
  const text = role === 'user' ? textContent(item.content) : item.text;
324
- if (typeof text !== 'string' || !text.trim()) { incomplete = true; continue; }
324
+ // An older interrupted/empty answer is an omission in the display
325
+ // history, not a missing latest exchange. Retain human corrections;
326
+ // the classifier must still establish a self-contained current goal.
327
+ if (typeof text !== 'string' || !text.trim()) { if (turn === boundary) incomplete = true; continue; }
325
328
  messages.push({ role, turnRef: `${turn.id || index}:${item.id || itemIndex}`, text });
326
329
  }
327
330
  }
@@ -2,9 +2,10 @@ import { readCodexConversation } from './codex-app-server.js';
2
2
  import { readClaudeConversation } from './claude-sessions.js';
3
3
  import { boundConversation, boundProviderGoal, sanitizedText } from './conversation-context.js';
4
4
  import { runStructuredProviderReview } from '../sqm/review-worker.js';
5
+ import { inspectProviderStatus } from '../desktop-status.js';
5
6
  import { createHash } from 'node:crypto';
6
7
 
7
- export const CONTINUATION_ANALYSIS_VERSION = 2;
8
+ export const CONTINUATION_ANALYSIS_VERSION = 3;
8
9
  const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
9
10
  const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
10
11
  const string = maximum => ({ type: 'string', maxLength: maximum });
@@ -50,8 +51,22 @@ Older history may be omitted. Set assessment.contextSufficient=true only if the
50
51
  Return blocker=null unless concrete unfinished original work is currently prevented by a specific unresolved approval, missing_information, external_dependency or execution_failure. State and needsUser/externalWait flags alone are not blockers. Running/status-only waiting, future reminders, voluntary pauses, budget limits, uncertainty, optional follow-ups and resolved historical failures must have blocker=null. A recoverable failure with a feasible next step is not a blocker. A blocker never changes continuation authority. Its reason must describe the actual obstacle concisely; basis must quote exact original conversation excerpts that establish the obstacle, checking all later supplied messages to establish that it is still current. Evidence may originate in an earlier turn when no later message resolves the obstacle. Goal metadata alone cannot establish a blocker. Later authorization, supplied information, resolution or completion clears older blockers. For execution_failure, require explicit evidence that the failure prevents further useful progress, not simply that a command failed.
51
52
  Return concise reasons and exact short supporting excerpts, each tied to the role and turnRef provided. Excerpts must occur verbatim in the supplied message. Do not reproduce secrets, credentials or large private passages. Set nextAction=null except for continue. Populate assessment honestly; nextActionKind names the concrete action, not merely a plan to act.`;
52
53
 
53
- function uncertain(reason) {
54
- return { state: 'uncertain', reason, objective: '', remaining: [], nextAction: null, basis: [], blocker: null, analysisVersion: CONTINUATION_ANALYSIS_VERSION };
54
+ function uncertain(reason, reasonCode) {
55
+ return { state: 'uncertain', reason, ...(reasonCode ? { reasonCode } : {}), objective: '', remaining: [], nextAction: null, basis: [], blocker: null, analysisVersion: CONTINUATION_ANALYSIS_VERSION };
56
+ }
57
+
58
+ async function unavailableAnalysis(candidate, error, inspectProvider, signal) {
59
+ if (error?.code === 'analysis_cancelled') return uncertain('Conversation analysis was cancelled.', 'cancelled');
60
+ if (error?.code === 'analysis_timeout') return uncertain('The local AI app did not finish checking this turn in time. Retry when it is available.', 'timeout');
61
+ if (error?.code === `${candidate.provider}_local_unavailable`) return uncertain('The local AI app is unavailable. Open or update it, then retry.', 'unavailable');
62
+ let status;
63
+ try { status = await inspectProvider(candidate.provider); } catch { /* Unknown is never proof of sign-out. */ }
64
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
65
+ if (error?.code === 'provider_sign_in_required' || status?.state === 'signed_out') {
66
+ const label = candidate.provider === 'claude' ? 'Claude Code' : 'Codex';
67
+ return uncertain(`Sign in to ${label} to check this conversation and continue its unfinished work. After signing in, retry this check.`, 'provider_sign_in_required');
68
+ }
69
+ return uncertain('The local AI app could not check this turn. Check its connection, then retry.', 'analysis_unavailable');
55
70
  }
56
71
 
57
72
  function unreadableContext(error) {
@@ -143,7 +158,7 @@ export async function readContinuationContext(candidate, { signal, readContext,
143
158
 
144
159
  export async function analyzeContinuation(candidate, {
145
160
  signal, readContext, openCodexClient, claudeHome, previousAnalysis,
146
- runProvider = runStructuredProviderReview, providerOptions = {},
161
+ runProvider = runStructuredProviderReview, providerOptions = {}, inspectProvider = inspectProviderStatus, beforeProviderDispatch = async () => {},
147
162
  } = {}) {
148
163
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
149
164
  if (!['codex', 'claude'].includes(candidate?.provider) || typeof candidate?.session !== 'string' || typeof candidate?.cwd !== 'string') {
@@ -172,18 +187,29 @@ export async function analyzeContinuation(candidate, {
172
187
  if (previousAnalysis?.analysisVersion === CONTINUATION_ANALYSIS_VERSION && previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
173
188
  return { ...snapshot, ...previousAnalysis };
174
189
  }
175
- if (context.incomplete || !context.messages.length) return uncertain('The latest conversation request or response is missing or incomplete.');
190
+ if (context.incomplete || !context.messages.length) return uncertain('The latest request or reply could not be read completely. Open the original conversation to review it, then retry.', 'context_incomplete');
191
+ let result;
176
192
  try {
177
- const result = await runProvider(candidate.provider, {
193
+ await beforeProviderDispatch();
194
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
195
+ result = await runProvider(candidate.provider, {
178
196
  system: SYSTEM, prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
179
- }, { ...providerOptions, signal, readOnlyAnalysis: true });
180
- if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
197
+ }, { ...providerOptions, signal, readOnlyAnalysis: true, beforeDispatch: beforeProviderDispatch });
198
+ } catch (error) {
199
+ if (error?.code === 'desktop_access_required') return uncertain('SIMY organization access is required before checking this conversation.', 'desktop_access_required');
200
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
201
+ // Only a known machine code or the provider's own login probe can
202
+ // establish sign-out. SIMY account login is unrelated to local analysis.
203
+ return unavailableAnalysis(candidate, error, inspectProvider, signal);
204
+ }
205
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
206
+ try {
181
207
  const analysis = validateContinuationAnalysis(result?.output, context);
182
208
  const model = typeof result?.model === 'string' && /^[a-zA-Z0-9._/-]{1,120}$/.test(result.model) ? result.model : null;
183
209
  return { ...snapshot, ...analysis, ...(model ? { model } : {}) };
184
210
  } catch {
185
211
  // Provider stderr/errors may contain transcript text or credentials. Never
186
212
  // expose them to the desktop, cache, diagnostics or control plane.
187
- return uncertain('Local conversation analysis is unavailable or returned an invalid result.');
213
+ return uncertain('The AI app returned a check that could not be verified. Retry this check or review the original conversation.', 'analysis_invalid_result');
188
214
  }
189
215
  }
@@ -208,6 +208,7 @@ export async function runStructuredProviderReview(
208
208
  signal,
209
209
  readOnlyAnalysis = false,
210
210
  terminationGraceMs = 1_000,
211
+ beforeDispatch = async () => {},
211
212
  } = {},
212
213
  ) {
213
214
  assertProvider(provider);
@@ -230,6 +231,8 @@ export async function runStructuredProviderReview(
230
231
  "Return only JSON that strictly matches the provided output schema.",
231
232
  request.prompt,
232
233
  ].join("\n\n");
234
+ await beforeDispatch();
235
+ if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
233
236
  const stdout = await spawnReview(
234
237
  inspection.executable,
235
238
  provider === "claude" ? [
@@ -260,6 +263,9 @@ export async function runStructuredProviderReview(
260
263
  );
261
264
  const envelope = provider === "claude" ? JSON.parse(stdout) : null;
262
265
  if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
266
+ if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
267
+ throw new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' });
268
+ }
263
269
  throw new Error("Claude Code did not complete a structured review");
264
270
  }
265
271
  const output = provider === "claude"
@@ -273,7 +279,7 @@ export async function runStructuredProviderReview(
273
279
  model: `${provider}-local/${inspection.installed_version || "unknown"}`,
274
280
  };
275
281
  } catch (error) {
276
- if (error instanceof SqmLocalReviewError) throw error;
282
+ if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
277
283
  throw new SqmLocalReviewError(`Local ${label} review failed: ${errorMessage(error)}`, {
278
284
  code: `${provider}_local_review_failed`,
279
285
  });
@@ -322,7 +328,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
322
328
  };
323
329
  const cancelled = () => halt(new Error('Local analysis was cancelled'));
324
330
  const timer = setTimeout(() => {
325
- halt(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
331
+ halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
326
332
  }, executionTimeoutMs);
327
333
  timer.unref?.();
328
334
  const finish = (error = null) => {
@@ -341,6 +347,10 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
341
347
  for (const line of stdout.split('\n').filter(Boolean)) {
342
348
  let event;
343
349
  try { event = JSON.parse(line); } catch { continue; }
350
+ if (['error', 'turn.failed'].includes(event.type) && providerSignInRequired(event.message ?? event.error?.message)) {
351
+ halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
352
+ return;
353
+ }
344
354
  if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
345
355
  halt(new Error('Read-only analysis attempted to use a tool'));
346
356
  return;
@@ -358,6 +368,14 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
358
368
  });
359
369
  child.on("error", finish);
360
370
  child.on("close", (code) => {
371
+ if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
372
+ let envelope;
373
+ try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
374
+ if (envelope?.is_error === true && providerSignInRequired(envelope.result)) {
375
+ finish(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
376
+ return;
377
+ }
378
+ }
361
379
  finish(terminalError || (code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`)));
362
380
  });
363
381
  signal?.addEventListener('abort', cancelled, { once: true });
@@ -366,6 +384,12 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
366
384
  });
367
385
  }
368
386
 
387
+ function providerSignInRequired(message) {
388
+ // Match explicit provider authentication failures only, never general 403s
389
+ // (which can be permissions/quotas) or model-generated prose/tool output.
390
+ return typeof message === 'string' && /(?:not logged in|please (?:log|sign) in|invalid api key|authentication token (?:is )?(?:invalid|expired)|refresh token (?:has )?expired)/i.test(message.slice(0, 4000));
391
+ }
392
+
369
393
  function safeReviewEnvironment(environment, provider) {
370
394
  const allowed = [
371
395
  "HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR",