@awak-app/simy-cli 0.6.8 → 0.6.10

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
@@ -60,9 +60,11 @@ that a task can execute successfully; permissions and service availability are
60
60
  checked when work starts.
61
61
 
62
62
  The sidebar's **Organization** selector is global across Sessions, Tasks and SQM
63
- and shows active memberships of the connected SIMY account. Switching opens browser authorization for the selected organization
64
- and waits for active tasks to finish. Use the same account in the browser; cancel
65
- an unfinished switch from the sidebar.
63
+ and shows active memberships of the connected SIMY account. Switching is available
64
+ while work is running and opens browser authorization for the selected organization.
65
+ It cancels running and queued SIMY analyses before changing organizations; ordinary
66
+ implementation tasks keep their original organization. Use the same account in the
67
+ browser; cancel an unfinished switch from the sidebar.
66
68
 
67
69
  Browser authorization links expire after **20 minutes**. The app shows **Connection
68
70
  timed out** and **Reconnect** when an active request expires. Reconnect starts a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
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
@@ -174,6 +174,8 @@ export async function startAgent({
174
174
  deferStartupSynchronization = false,
175
175
  enforceDesktopAccess = false,
176
176
  onDesktopStatusChange = null,
177
+ suspendOrganizationAnalyses = async () => {},
178
+ resumeOrganizationAnalyses = async () => {},
177
179
  dependencies = {},
178
180
  quiet = false,
179
181
  } = {}) {
@@ -230,6 +232,7 @@ export async function startAgent({
230
232
  pendingOrganization = null;
231
233
  desktopLoginPending = false;
232
234
  loginUrl = null;
235
+ void resumeOrganizationWork().catch(() => {});
233
236
  refreshAvailableCapabilities();
234
237
  notifyDesktopStatusChange();
235
238
  },
@@ -315,8 +318,9 @@ export async function startAgent({
315
318
  session?.account_id ?? null, session?.organization_id ?? null, session?.device_id ?? null, session?.token ?? null,
316
319
  pendingOrganization?.id ?? null, isSessionValid(session, Date.now(), apiOrigin),
317
320
  ])).digest('hex');
318
- const desktopWorkAllowed = () => !desktopWorkSuspended && (!enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot()));
321
+ const desktopWorkAllowed = () => !desktopWorkSuspended && !pendingOrganization && (!enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot()));
319
322
  const authorizeDesktopWork = async expectedScope => {
323
+ if (pendingOrganization) throw Object.assign(new Error("Organization switching is in progress."), { code: "desktop_access_required" });
320
324
  if (desktopWorkSuspended || shuttingDown) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
321
325
  if (enforceDesktopAccess) {
322
326
  requireDesktopAccess(await desktopAccess.refresh());
@@ -325,6 +329,9 @@ export async function startAgent({
325
329
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
326
330
  return scope;
327
331
  }
332
+ const scope = desktopScopeRevision();
333
+ if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
334
+ return scope;
328
335
  };
329
336
  refreshAvailableCapabilities();
330
337
  let durableLocalTaskWorker = null;
@@ -482,9 +489,30 @@ export async function startAgent({
482
489
  }
483
490
  };
484
491
 
485
- const organizationBusy = () => Boolean(sqmLocalReviewWorker?.snapshot?.().polling ||
486
- durableLocalTaskWorker?.snapshot?.().polling || durableLocalTaskWorker?.snapshot?.().active_turn_id ||
487
- desktopTaskStatus([registry, directRegistry, localTaskRegistry]).activeCount);
492
+ let organizationCancellation = Promise.resolve();
493
+ const suspendOrganizationWork = () => {
494
+ durableLocalTaskWorker?.pause?.();
495
+ const cancellation = Promise.all([
496
+ sqmLocalReviewWorker?.suspend?.(), suspendOrganizationAnalyses(),
497
+ ]);
498
+ // Preserve failures for the authorization callback without an unhandled rejection.
499
+ cancellation.catch(() => {});
500
+ organizationCancellation = cancellation;
501
+ return cancellation;
502
+ };
503
+ const resumeOrganizationWork = async () => {
504
+ const nonce = authNonce;
505
+ await organizationCancellation;
506
+ const shouldResume = () => !pendingOrganization && nonce === authNonce && !shuttingDown;
507
+ if (!shouldResume()) return;
508
+ await sqmLocalReviewWorker?.resume?.({ shouldResume });
509
+ if (!shouldResume()) return;
510
+ await durableLocalTaskWorker?.resume?.();
511
+ if (!shouldResume()) return;
512
+ await resumeOrganizationAnalyses({ shouldResume });
513
+ };
514
+ const sameRunScope = (left, right) => ["account_id", "organization_id", "device_id"].every(field =>
515
+ (left?.[field] ?? null) === (right?.[field] ?? null));
488
516
  const refreshOrganizations = async () => {
489
517
  if (!isSessionValid(session, Date.now(), apiOrigin)) return;
490
518
  if (organizationRefresh) return organizationRefresh;
@@ -531,9 +559,9 @@ export async function startAgent({
531
559
  if (choice.desktopAccess && choice.desktopAccess !== "ready") throw new Error("This organization does not have SIMY Developer access.");
532
560
  organizationSlug = choice.slug;
533
561
  if (!organizationSlug) throw new Error('Refresh organizations before switching; its verified slug is unavailable.');
534
- if (organizationBusy()) throw Object.assign(new Error("Wait for running tasks to finish before switching organizations."), { code: "desktop_organization_busy" });
535
562
  authNonce = randomBytes(16).toString("base64url");
536
563
  pendingOrganization = { id: organizationId, accountId: session.account_id };
564
+ suspendOrganizationWork();
537
565
  loginUrl = null;
538
566
  refreshAvailableCapabilities();
539
567
  notifyDesktopStatusChange();
@@ -575,6 +603,7 @@ export async function startAgent({
575
603
  const restoreRemoteRuns = async () => {
576
604
  if (!desktopWorkAllowed()) return;
577
605
  if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
606
+ const requestedSession = session;
578
607
  const snapshots = dependencies.listRemoteRuns
579
608
  ? await dependencies.listRemoteRuns({
580
609
  apiOrigin,
@@ -586,15 +615,17 @@ export async function startAgent({
586
615
  apiBaseUrl: session.api_base_url,
587
616
  token: session.token,
588
617
  });
618
+ if (session !== requestedSession || !desktopWorkAllowed()) return;
589
619
  const remoteIds = new Set();
590
620
  let restored = false;
591
621
  for (const snapshot of snapshots) {
592
- if (shuttingDown) return;
622
+ if (shuttingDown || session !== requestedSession || !desktopWorkAllowed()) return;
593
623
  if (!snapshot?.id) continue;
594
624
  remoteIds.add(snapshot.id);
595
625
  if (registry.has(snapshot.id)) {
596
626
  const localRun = registry.get(snapshot.id);
597
- localRun.session = session;
627
+ if (!sameRunScope(localRun.session, requestedSession)) continue;
628
+ localRun.session = requestedSession;
598
629
  localRun.apiOrigin = apiOrigin;
599
630
  // A newer ledger is not permission to replace an active local operation.
600
631
  // Keep the owner, child, checkpoint and approval state until it settles.
@@ -644,16 +675,21 @@ export async function startAgent({
644
675
 
645
676
  const restoreLocalCodingRuns = async () => {
646
677
  if (!desktopWorkAllowed()) return;
678
+ const requestedSession = session;
647
679
  const { snapshots, errors } = await loadCodingRunSnapshots(codingRunStateRoot);
680
+ if (session !== requestedSession || !desktopWorkAllowed()) return;
648
681
  for (const error of errors) {
649
682
  reportStartupConnectionError(new Error(`Coding run ${error.id}: ${error.message}`), quiet);
650
683
  }
651
684
  for (const snapshot of snapshots) {
685
+ const savedActor = snapshot?.metadata?.recovery_actor;
686
+ if (savedActor && ["account_id", "organization_id", "device_id"].some(field => savedActor[field] && savedActor[field] !== requestedSession?.[field])) continue;
652
687
  const boundDevice = snapshot?.metadata?.execution_device_id;
653
688
  if (boundDevice && boundDevice !== session?.device_id) continue;
654
689
  if (registry.has(snapshot.id)) {
655
690
  const existing = registry.get(snapshot.id);
656
- existing.session = session;
691
+ if (!sameRunScope(existing.session, requestedSession)) continue;
692
+ existing.session = requestedSession;
657
693
  existing.apiOrigin = apiOrigin;
658
694
  continue;
659
695
  }
@@ -705,6 +741,7 @@ export async function startAgent({
705
741
  connectionStatus.attempt();
706
742
  notifyDesktopStatusChange();
707
743
  let deviceHeartbeatSucceeded = false;
744
+ const requestedSession = session;
708
745
  const advertisedProviders = JSON.stringify(providerStatuses);
709
746
  heartbeatInFlight = Promise.resolve()
710
747
  .then(async () => { if (enforceDesktopAccess) await desktopAccess.refresh(true); })
@@ -742,12 +779,12 @@ export async function startAgent({
742
779
  const sqmResult = sqmEnabled && sqmLocalReviewWorker
743
780
  ? await sqmLocalReviewWorker.poll()
744
781
  : null;
745
- if (shuttingDown) return false;
782
+ if (shuttingDown || pendingOrganization || !desktopWorkAllowed() || session !== requestedSession) return false;
746
783
  if (!sqmResult && durableLocalTaskWorker) await durableLocalTaskWorker.poll();
747
784
  return true;
748
785
  })
749
786
  .catch((error) => {
750
- if (!deviceHeartbeatSucceeded) {
787
+ if (!deviceHeartbeatSucceeded && session === requestedSession) {
751
788
  const state = connectionStatus.failure(error);
752
789
  notifyDesktopStatusChange();
753
790
  if (state === "unauthorized") {
@@ -918,7 +955,7 @@ export async function startAgent({
918
955
  }
919
956
  };
920
957
  return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, {
921
- beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
958
+ signal: context.signal, beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
922
959
  allowedFallbackProviders: settings.allowed_fallback_providers, providerReportingSupported: context.providerReportingSupported === true,
923
960
  });
924
961
  },
@@ -1248,11 +1285,16 @@ export async function startAgent({
1248
1285
  const nextOrganization = body.organization_id ?? body.org_id;
1249
1286
  const nextAccount = body.account_id ?? body.auth_user_id;
1250
1287
  const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
1251
- if ((changingScope && organizationBusy()) || (pendingOrganization &&
1252
- (pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))) {
1253
- json(res, 409, { error: "Organization authorization does not match, or tasks are still running." });
1288
+ if (pendingOrganization &&
1289
+ (pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount)) {
1290
+ json(res, 409, { error: "Organization authorization does not match." });
1254
1291
  return;
1255
1292
  }
1293
+ if (changingScope && !pendingOrganization) {
1294
+ pendingOrganization = { id: nextOrganization, accountId: nextAccount };
1295
+ suspendOrganizationWork();
1296
+ }
1297
+ await organizationCancellation;
1256
1298
  const previousSession = session ?? reclaimableSession;
1257
1299
  const authorizedSession = {
1258
1300
  token: String(body.token || ""),
@@ -1310,6 +1352,10 @@ export async function startAgent({
1310
1352
  json(res, 410, { error: "Connection expired. Reconnect from SIMY." }); return;
1311
1353
  }
1312
1354
  await writeSession(apiOrigin, authorizedSession, sessionRoot);
1355
+ if (authorizationWindow.snapshot().state === "timed_out" || requestNonce !== authNonce) {
1356
+ await writeSession(apiOrigin, session ?? {}, sessionRoot);
1357
+ json(res, 410, { error: "Connection expired. Reconnect from SIMY." }); return;
1358
+ }
1313
1359
  session = authorizedSession;
1314
1360
  reclaimableSession = null;
1315
1361
  authorizationWindow.clear();
@@ -1327,6 +1373,7 @@ export async function startAgent({
1327
1373
  startHeartbeatTimer();
1328
1374
  loginUrl = null;
1329
1375
  registry.emit("change", registry.list());
1376
+ await resumeOrganizationWork();
1330
1377
  json(res, 200, {
1331
1378
  ok: true,
1332
1379
  expires_at: session.expires_at,
@@ -2517,7 +2564,7 @@ export async function startAgent({
2517
2564
  connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
2518
2565
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
2519
2566
  organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
2520
- switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
2567
+ switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" },
2521
2568
  sqm: { enabled: sqmEnabled, engineFallbackEnabled: sqmEngineSettings.enable_engine_fallback,
2522
2569
  allowedFallbackProviders: sqmEngineSettings.allowed_fallback_providers,
2523
2570
  paused: sqmLocalReviewWorker?.snapshot?.().paused === true, pauseReason: sqmLocalReviewWorker?.snapshot?.().pause_reason ?? null, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
@@ -2564,7 +2611,7 @@ export async function startAgent({
2564
2611
  getDesktopAccess: () => ({ ...desktopAccess.snapshot(), revision: desktopScopeRevision() }),
2565
2612
  requireDesktopAccess: authorizeDesktopWork,
2566
2613
  refreshOrganizations,
2567
- cancelOrganizationSelection() { authorizationWindow.clear(); expiredOrganizationId = null; expiredInteractiveAuthorization = false; authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; desktopLoginPending = false; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
2614
+ async cancelOrganizationSelection() { authorizationWindow.clear(); expiredOrganizationId = null; expiredInteractiveAuthorization = false; authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; desktopLoginPending = false; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); await resumeOrganizationWork(); },
2568
2615
  async setSqmEnabled(enabled) {
2569
2616
  if (enabled) await authorizeDesktopWork();
2570
2617
  if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
@@ -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.8";
16
+ export const CLI_VERSION = "0.6.10";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -115,11 +115,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
115
115
  };
116
116
  const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
117
117
  let stoppingForUpdate = false;
118
+ let organizationAnalysesSuspended = false;
119
+ let organizationAnalysisDrain = Promise.resolve();
120
+ let organizationAnalysisEpoch = 0;
118
121
  const authorizeWork = async expectedScope => {
119
- if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
122
+ if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
120
123
  if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
121
124
  const access = await accessSnapshot();
122
- if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
125
+ if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
123
126
  requireDesktopAccess(access);
124
127
  const scope = accessScope(access);
125
128
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
@@ -196,6 +199,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
196
199
  enroll: guardedEnroll, beforeEnroll: candidate => authorizeWork(selectedScopes.get(candidate)),
197
200
  onChange: () => { if (!closed && !relay) refreshSessions(true); },
198
201
  });
202
+ const suspendOrganizationAnalyses = () => {
203
+ organizationAnalysisEpoch++;
204
+ organizationAnalysesSuspended = true;
205
+ discoveryStopped = true;
206
+ organizationAnalysisDrain = Promise.all([
207
+ stopDiscovery({ drain: true }), automaticContinuation?.close(), analysisManager?.suspend?.(),
208
+ ]);
209
+ return organizationAnalysisDrain;
210
+ };
211
+ const resumeOrganizationAnalyses = async ({ shouldResume = () => true } = {}) => {
212
+ const epoch = organizationAnalysisEpoch;
213
+ const current = () => epoch === organizationAnalysisEpoch && shouldResume() && !closed && !stoppingForUpdate;
214
+ await organizationAnalysisDrain;
215
+ if (!current()) return;
216
+ await accessSnapshot();
217
+ if (!current()) return;
218
+ await analysisManager?.resume?.({ shouldResume: current });
219
+ if (!current()) return;
220
+ automaticContinuation = makeAutomaticContinuation();
221
+ organizationAnalysesSuspended = false;
222
+ discoveryStopped = false;
223
+ refreshSessions(true);
224
+ };
199
225
  let pluginSetup = { state: "unknown" };
200
226
  let providerPlugins = { codex: { state: 'checking' }, claude: { state: 'checking' } };
201
227
  let pluginInspection = null, lastPluginInspectionAt = -Infinity;
@@ -344,6 +370,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
344
370
  agent = await (dependencies.startAgent || startAgent)({
345
371
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
346
372
  deferStartupSynchronization: true, enforceDesktopAccess: true,
373
+ suspendOrganizationAnalyses, resumeOrganizationAnalyses,
347
374
  onDesktopStatusChange: () => {
348
375
  // Let an in-progress frame finish before publishing its new snapshot.
349
376
  setImmediate(() => { void publish().catch(() => {}); });
@@ -551,12 +578,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
551
578
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
552
579
  else if (action === "sqm_set_engine_fallback") await agent.setSqmEngineFallback({ enabled: command.enabled, allowedProviders: command.allowedProviders });
553
580
  else if (action === "sqm_retry_environment") await agent.retrySqmEnvironment();
554
- else if (action === "organization_cancel") agent.cancelOrganizationSelection();
581
+ else if (action === "organization_cancel") await agent.cancelOrganizationSelection();
555
582
  else if (action === "login" || action === "organization_select" || action === "authorization_open") {
556
583
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
557
- if (action === "organization_select" && activeQueuedAction && !["refresh", "reconnect", "task_detail", "organizations_refresh", "check_update"].includes(activeQueuedAction)) {
558
- throw Object.assign(new Error("Wait for the current action to finish before switching organizations."), { code: "desktop_organization_busy" });
559
- }
560
584
  const url = action === "authorization_open" ? (agent.getPendingAuthorizationUrl?.() ?? agent.retryAuthorization?.())
561
585
  : await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
562
586
  if (action === 'authorization_open' && !url) throw new Error('No pending authorization.');
@@ -158,6 +158,17 @@ export function createDurableLocalTaskApi({
158
158
  const stepContract = durableLocalTaskStepApiContract();
159
159
 
160
160
  return {
161
+ // A claimed task owns its original identity through every heartbeat,
162
+ // artifact request and final status, even if the desktop changes accounts.
163
+ capture() {
164
+ const connection = { ...getConnection() };
165
+ return createDurableLocalTaskApi({
166
+ getConnection: () => connection,
167
+ fetchImpl,
168
+ requestTimeoutMs,
169
+ artifactUploadTimeoutMs,
170
+ });
171
+ },
161
172
  async claimSubtaskParent({
162
173
  workerId,
163
174
  leaseSeconds = DEFAULT_LEASE_SECONDS,
@@ -633,11 +644,13 @@ export function createDurableLocalTaskWorker({
633
644
  }
634
645
 
635
646
  let closed = false;
647
+ let paused = false;
636
648
  let pollPromise = null;
637
649
  let active = null;
638
650
  let activeExecutionPromise = null;
639
651
  let lastStepDisposition = null;
640
652
  let pendingStepTurnId = null;
653
+ let pendingStepContext = null;
641
654
  const boundedTasks = new Map();
642
655
 
643
656
  const executeBoundedRuntime = runBoundedSubtask ?? (async (runtime, { signal }) => {
@@ -700,7 +713,8 @@ export function createDurableLocalTaskWorker({
700
713
  }
701
714
  });
702
715
 
703
- const executeStepClaim = async (claim, releaseWork) => {
716
+ const executeStepClaim = async (claim, releaseWork, context) => {
717
+ const { api } = context;
704
718
  let task = null;
705
719
  let heartbeatTimer = null;
706
720
  let heartbeatInFlight = null;
@@ -1010,6 +1024,7 @@ export function createDurableLocalTaskWorker({
1010
1024
  return;
1011
1025
  }
1012
1026
  pendingStepTurnId = normalized.turnId;
1027
+ pendingStepContext = context;
1013
1028
  }
1014
1029
  } catch (error) {
1015
1030
  if (!leaseLost) {
@@ -1051,9 +1066,10 @@ export function createDurableLocalTaskWorker({
1051
1066
  }
1052
1067
  };
1053
1068
 
1054
- const executeClaim = async (claim, releaseWork) => {
1069
+ const executeClaim = async (claim, releaseWork, context) => {
1070
+ const { api, attachmentStore, deviceId } = context;
1055
1071
  if (isDurableLocalTaskStepClaim(claim)) {
1056
- return executeStepClaim(claim, releaseWork);
1072
+ return executeStepClaim(claim, releaseWork, context);
1057
1073
  }
1058
1074
  let task = null;
1059
1075
  let heartbeatTimer = null;
@@ -1094,7 +1110,6 @@ export function createDurableLocalTaskWorker({
1094
1110
  "durable_local_task_attachments_unavailable",
1095
1111
  );
1096
1112
  }
1097
- const deviceId = getDeviceId();
1098
1113
  attachments = await attachmentStore.materialize({
1099
1114
  refs: normalized.attachmentRefs,
1100
1115
  deviceId,
@@ -1374,7 +1389,7 @@ export function createDurableLocalTaskWorker({
1374
1389
  ) {
1375
1390
  await attachmentStore.cleanup({
1376
1391
  refs: materializedAttachmentRefs,
1377
- deviceId: getDeviceId(),
1392
+ deviceId,
1378
1393
  }).catch((error) => onError(recoveryError(
1379
1394
  "SIMY could not remove the staged attachment copy.",
1380
1395
  error,
@@ -1393,6 +1408,7 @@ export function createDurableLocalTaskWorker({
1393
1408
  snapshot() {
1394
1409
  return {
1395
1410
  closed,
1411
+ paused,
1396
1412
  polling: Boolean(pollPromise),
1397
1413
  active_turn_id: active?.claim?.turn_id || null,
1398
1414
  durable_step_disposition: lastStepDisposition,
@@ -1401,10 +1417,16 @@ export function createDurableLocalTaskWorker({
1401
1417
  };
1402
1418
  },
1403
1419
  async poll() {
1404
- if (closed || active || pollPromise) return false;
1420
+ if (closed || paused || active || pollPromise) return false;
1405
1421
  const releaseWork = beginWork?.();
1406
1422
  if (!releaseWork) return false;
1423
+ const context = pendingStepContext || {
1424
+ api: typeof api.capture === "function" ? api.capture() : api,
1425
+ deviceId: getDeviceId(),
1426
+ attachmentStore,
1427
+ };
1407
1428
  pollPromise = (async () => {
1429
+ const { api, attachmentStore, deviceId } = context;
1408
1430
  try {
1409
1431
  const requestedTurnId = pendingStepTurnId;
1410
1432
  if (
@@ -1491,7 +1513,7 @@ export function createDurableLocalTaskWorker({
1491
1513
  workspaceRoot,
1492
1514
  prepareWorkspace,
1493
1515
  attachmentStore,
1494
- deviceId: getDeviceId(),
1516
+ deviceId,
1495
1517
  onError,
1496
1518
  });
1497
1519
  claim = await claimControlledPlanWithReplay(api, {
@@ -1524,7 +1546,7 @@ export function createDurableLocalTaskWorker({
1524
1546
  leaseSeconds,
1525
1547
  });
1526
1548
  lastStepDisposition = normalized.disposition;
1527
- if (requestedTurnId) pendingStepTurnId = null;
1549
+ if (requestedTurnId) { pendingStepTurnId = null; pendingStepContext = null; }
1528
1550
  if (normalized.disposition !== "execute") {
1529
1551
  // Approval checkpoints and unknown Provider results are durable
1530
1552
  // Backend states, not executable leases. Keep no process,
@@ -1543,7 +1565,7 @@ export function createDurableLocalTaskWorker({
1543
1565
  }
1544
1566
  if (closed) { releaseWork(); return false; }
1545
1567
  active = { claim, task: null };
1546
- const executionPromise = executeClaim(claim, releaseWork)
1568
+ const executionPromise = executeClaim(claim, releaseWork, context)
1547
1569
  .catch((error) => {
1548
1570
  onError(
1549
1571
  recoveryError(
@@ -1577,6 +1599,10 @@ export function createDurableLocalTaskWorker({
1577
1599
  })();
1578
1600
  return pollPromise;
1579
1601
  },
1602
+ // Pause claims without interrupting an ordinary implementation task.
1603
+ // Already claimed work and a durable plan handoff keep their old scope.
1604
+ pause() { paused = true; },
1605
+ resume() { if (!closed) paused = false; },
1580
1606
  async waitForIdle() {
1581
1607
  await pollPromise;
1582
1608
  await activeExecutionPromise;
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { analyzeContinuation, CONTINUATION_ANALYSIS_VERSION } from './continuation-analysis.js';
5
+ import { providerGoalEvidence } from './conversation-context.js';
5
6
 
6
7
  const BACKFILL_LIMIT = 10;
7
8
  const MAX_CACHE_ENTRIES = 2000;
@@ -9,12 +10,17 @@ const AUTH_RECOVERY_COOLDOWN_MS = 60_000;
9
10
  const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
10
11
  const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
11
12
  const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
12
- const goalOf = candidate => candidate.providerGoal && typeof candidate.providerGoal === 'object'
13
- ? [candidate.providerGoal.objective ?? null, candidate.providerGoal.status ?? null, candidate.providerGoal.updatedAt ?? null,
14
- candidate.providerGoal.textTruncated === true] : null;
13
+ const turnOf = candidate => candidate.provider === 'codex' && typeof candidate.latestTurnId === 'string' && candidate.latestTurnId
14
+ ? candidate.latestTurnId : null;
15
+ const goalOf = candidate => providerGoalEvidence(candidate.providerGoal);
16
+ const legacyRevision = candidate => createHash('sha256').update(JSON.stringify([
17
+ candidate.provider, candidate.session, candidate.cwd, activityOf(candidate), ...(candidate.isRunning === true ? ['running'] : []),
18
+ ...(goalOf(candidate) === null ? [] : ['goal', [candidate.providerGoal.objective ?? null, candidate.providerGoal.status ?? null,
19
+ candidate.providerGoal.updatedAt ?? null, candidate.providerGoal.textTruncated === true]]),
20
+ ])).digest('hex');
15
21
  const goalRevisionOf = candidate => goalOf(candidate) === null ? null : createHash('sha256').update(JSON.stringify(goalOf(candidate))).digest('hex');
16
22
  export const continuationAnalysisRevision = candidate => createHash('sha256')
17
- .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate),
23
+ .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, turnOf(candidate) ? ['turn', turnOf(candidate)] : activityOf(candidate),
18
24
  ...(candidate.isRunning === true ? ['running'] : []), ...(goalOf(candidate) === null ? [] : ['goal', goalOf(candidate)])]))
19
25
  .digest('hex');
20
26
  const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
@@ -50,11 +56,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
50
56
  const known = new Map(document.known.slice(-MAX_CACHE_ENTRIES));
51
57
  const entries = new Map(document.entries.slice(-MAX_CACHE_ENTRIES));
52
58
  const candidates = new Map();
59
+ const observedCandidate = candidate => {
60
+ const previous = known.get(keyOf(candidate));
61
+ // A bounded discovery may skip this turn probe. Preserve its prior ID
62
+ // only while identity and activity still match; changed activity must be read.
63
+ return !turnOf(candidate) && candidate.provider === 'codex' && previous?.latestTurnId &&
64
+ previous.cwd === candidate.cwd && previous.activity === activityOf(candidate)
65
+ ? { ...candidate, latestTurnId: previous.latestTurnId } : candidate;
66
+ };
53
67
  const providerStatuses = new Map(), signedOutChecks = new Map();
54
68
  for (const [key, entry] of entries) {
55
69
  if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
56
70
  }
57
- let serial = Promise.resolve(), draining = null, closed = false, wakeDrain = null, wakeEpoch = 0;
71
+ let serial = Promise.resolve(), draining = null, closed = false, suspended = false, wakeDrain = null, wakeEpoch = 0;
58
72
  const jobs = new Map();
59
73
  const wake = () => { wakeEpoch++; wakeDrain?.(); };
60
74
  const exclusive = fn => {
@@ -147,7 +161,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
147
161
  }
148
162
  await exclusive(async () => {
149
163
  const current = candidates.get(selected.key), entry = entries.get(selected.key);
150
- if (closed) return;
164
+ if (closed || selected.cancelled) return;
151
165
  if (!current || !eligible(current) || current.isRunning === true || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision || entry !== selected.entry) {
152
166
  // A stale snapshot can help display/reuse a finished boundary but is
153
167
  // never the current recommendation or authority to dispatch work.
@@ -170,13 +184,13 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
170
184
  };
171
185
  const startDrain = () => {
172
186
  wake();
173
- if (closed || draining) return;
187
+ if (closed || suspended || draining) return;
174
188
  let drainFailed = false;
175
189
  draining = (async () => {
176
190
  while (!closed || jobs.size) {
177
191
  const observedWake = wakeEpoch;
178
192
  const selected = await exclusive(async () => {
179
- if (closed) return [];
193
+ if (closed || suspended) return [];
180
194
  const batch = [];
181
195
  // Explicit retries precede historical backfill without interrupting owned jobs.
182
196
  const ordered = [...entries].sort((a, b) => Number(Boolean(b[1].requestId)) - Number(Boolean(a[1].requestId)));
@@ -207,13 +221,13 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
207
221
  }
208
222
  })().catch(() => { drainFailed = true; }).finally(() => {
209
223
  draining = null;
210
- if (!closed && !drainFailed && [...entries].some(([key, entry]) => runnable(key, entry))) startDrain();
224
+ if (!closed && !suspended && !drainFailed && [...entries].some(([key, entry]) => runnable(key, entry))) startDrain();
211
225
  });
212
226
  };
213
227
  return {
214
228
  async updateProviderStatuses(statuses = {}) {
215
229
  await exclusive(async () => {
216
- if (closed) return;
230
+ if (closed || suspended) return;
217
231
  for (const provider of ['codex', 'claude']) {
218
232
  const status = statuses[provider];
219
233
  if (!status) continue;
@@ -229,9 +243,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
229
243
  },
230
244
  async update(sessions) {
231
245
  await exclusive(async () => {
232
- if (closed) return;
246
+ if (closed || suspended) return;
233
247
  candidates.clear();
234
- for (const candidate of sessions) candidates.set(keyOf(candidate), candidate);
248
+ for (const candidate of sessions) candidates.set(keyOf(candidate), observedCandidate(candidate));
235
249
  const sorted = [...candidates.values()].sort((a, b) => activityOf(b) - activityOf(a) || keyOf(a).localeCompare(keyOf(b)));
236
250
  const backfill = document.initialized ? new Set() : new Set(sorted.slice(0, BACKFILL_LIMIT).map(keyOf));
237
251
  let dirty = !document.initialized;
@@ -242,10 +256,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
242
256
  // Old cache formats did not observe goal/tracking metadata. Establish
243
257
  // their baseline once, rather than mistaking the upgrade for activity.
244
258
  const goalObserved = candidate.goalReadState !== 'unavailable';
245
- const goalChanged = previous && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
259
+ const goalChanged = previous && previous.goalEvidenceVersion === 1 && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
246
260
  const trackingEnabled = candidate.tracked === true && (!previous || Object.hasOwn(previous, 'tracked') && previous.tracked !== true);
247
- const newActivity = document.initialized && (previous ? active > previous.activity || (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
261
+ // A known turn boundary is stronger than updated_at: tool/status
262
+ // events in that same turn must not enqueue or cancel paid inference.
263
+ const newActivity = document.initialized && (previous ?
264
+ (turnOf(candidate) && previous.latestTurnId ? turnOf(candidate) !== previous.latestTurnId : active > previous.activity) ||
265
+ (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
248
266
  const entry = entries.get(key);
267
+ // Rebase unchanged old cache keys once without rerunning historical
268
+ // inference when semantic goal/turn keys become available.
269
+ if (entry && entry.revision !== revision && entry.cwd === candidate.cwd && entry.revision === legacyRevision(candidate)) {
270
+ entry.revision = revision; dirty = true;
271
+ }
249
272
  // Migrate prior idle cache entries without treating historical sessions
250
273
  // as fresh activity. Its old revision already proves this exact cwd.
251
274
  if (entry && !entry.cwd && entry.revision === continuationAnalysisRevision({ ...candidate, isRunning: false })) {
@@ -261,9 +284,10 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
261
284
  if (eligible(candidate) && (backfill.has(key) || newActivity || recheck || trackingEnabled || goalChanged || schemaRefresh)) {
262
285
  dirty = enqueue(candidate, recheck || trackingEnabled || goalChanged || schemaRefresh, trackingEnabled || goalChanged || schemaRefresh) || dirty;
263
286
  }
264
- if (previous?.revision !== revision || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
287
+ if (previous?.revision !== revision || previous?.activity !== active || previous?.goalEvidenceVersion !== 1 || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
265
288
  previous && (!Object.hasOwn(previous, 'goalRevision') || !Object.hasOwn(previous, 'tracked'))) {
266
- known.delete(key); known.set(key, { revision, activity: active, isRunning: candidate.isRunning, tracked: candidate.tracked === true, goalRevision, goalObserved }); dirty = true;
289
+ known.delete(key); known.set(key, { revision, cwd: candidate.cwd, activity: active, isRunning: candidate.isRunning, latestTurnId: turnOf(candidate),
290
+ tracked: candidate.tracked === true, goalRevision, goalObserved, goalEvidenceVersion: 1 }); dirty = true;
267
291
  }
268
292
  }
269
293
  // Cancelling owned analysis is separate from stopping the original
@@ -280,6 +304,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
280
304
  startDrain();
281
305
  },
282
306
  project(candidate) {
307
+ candidate = observedCandidate(candidate);
283
308
  const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
284
309
  let analysis = entry?.revision === revision ? { ...entry.result, revision, requestId: entry.requestId } : { state: 'unanalysed', revision };
285
310
  const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
@@ -295,8 +320,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
295
320
  async request(candidate, { requestId } = {}) {
296
321
  if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) throw new Error('Invalid analysis request identity.');
297
322
  if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
323
+ candidate = observedCandidate(candidate);
298
324
  await exclusive(async () => {
299
- if (closed) throw new Error('Session analysis is unavailable.');
325
+ if (closed || suspended) throw new Error('Session analysis is unavailable.');
300
326
  candidates.set(keyOf(candidate), candidate);
301
327
  let dirty = enqueue(candidate, true, true);
302
328
  const entry = entries.get(keyOf(candidate));
@@ -306,8 +332,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
306
332
  startDrain();
307
333
  },
308
334
  async invalidate(candidate) {
335
+ candidate = observedCandidate(candidate);
309
336
  await exclusive(async () => {
310
- if (closed) throw new Error('Session analysis is unavailable.');
337
+ if (closed || suspended) throw new Error('Session analysis is unavailable.');
311
338
  entries.delete(keyOf(candidate));
312
339
  candidates.set(keyOf(candidate), candidate);
313
340
  enqueue(candidate); await persist(); changed();
@@ -316,7 +343,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
316
343
  startDrain();
317
344
  },
318
345
  requireRecommendation(candidate) {
319
- if (closed) throw new Error('Session analysis is unavailable.');
346
+ if (closed || suspended) throw new Error('Session analysis is unavailable.');
320
347
  const analysis = this.project(candidate).analysis;
321
348
  if (!eligible(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
322
349
  return analysis;
@@ -329,6 +356,36 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
329
356
  } while (draining || jobs.size);
330
357
  await serial;
331
358
  },
359
+ async suspend() {
360
+ // Gate new work before waiting on cache writes. Provider cleanup retains
361
+ // its slot, and cancelled results never publish after an organization switch.
362
+ suspended = true;
363
+ for (const job of jobs.values()) { job.cancelled = true; job.controller.abort(); }
364
+ wake();
365
+ try {
366
+ await exclusive(async () => {
367
+ if (closed) return;
368
+ candidates.clear();
369
+ let dirty = false;
370
+ for (const entry of entries.values()) {
371
+ if (!['queued', 'analyzing'].includes(entry.result?.state)) continue;
372
+ entry.result = { state: 'failed', reasonCode: 'analysis_cancelled',
373
+ reason: 'The analysis was stopped for an organization switch. Request analysis again to retry.' };
374
+ entry.completedAt = now();
375
+ dirty = true;
376
+ }
377
+ if (dirty) { await persist(); changed(); }
378
+ });
379
+ } finally { await this.whenIdle(); }
380
+ },
381
+ async resume({ shouldResume = () => true } = {}) {
382
+ await this.whenIdle();
383
+ await exclusive(() => {
384
+ if (!shouldResume()) return;
385
+ if (closed) throw new Error('Session analysis is unavailable.');
386
+ suspended = false;
387
+ });
388
+ },
332
389
  async close() { closed = true; for (const job of jobs.values()) job.controller.abort(); wake(); await this.whenIdle(); },
333
390
  };
334
391
  }
@@ -262,6 +262,7 @@ export async function readCodexObservation(client, session, { deadline = Date.no
262
262
  const page = await client.request('thread/turns/list', { threadId: session, cursor: null, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }, timeout());
263
263
  if (Array.isArray(page?.data) && page.data.length === 1 && ['inProgress', 'completed', 'interrupted', 'failed'].includes(page.data[0]?.status)) {
264
264
  observation.isRunning = page.data[0].status === 'inProgress';
265
+ if (typeof page.data[0].id === 'string' && page.data[0].id && page.data[0].id.length <= 160) observation.latestTurnId = page.data[0].id;
265
266
  }
266
267
  } catch (error) {
267
268
  if (['timeout', 'closed', 'unavailable'].includes(error?.code)) return { goalReadState: 'unavailable', providerUnavailable: true };
@@ -1,6 +1,6 @@
1
1
  import { readCodexConversation } from './codex-app-server.js';
2
2
  import { readClaudeConversation } from './claude-sessions.js';
3
- import { boundConversation, boundProviderGoal, sanitizedText } from './conversation-context.js';
3
+ import { boundConversation, boundProviderGoal, providerGoalEvidence, sanitizedText } from './conversation-context.js';
4
4
  import { runStructuredProviderReview } from '../sqm/review-worker.js';
5
5
  import { inspectProviderStatus } from '../desktop-status.js';
6
6
  import { createHash } from 'node:crypto';
@@ -152,7 +152,7 @@ export async function readContinuationContext(candidate, { signal, readContext,
152
152
  const context = boundConversation(raw?.messages, { truncated: raw?.truncated === true, incomplete: raw?.incomplete === true });
153
153
  const providerGoal = boundProviderGoal(raw?.providerGoal);
154
154
  const contextDigest = createHash('sha256').update(JSON.stringify({ provider: candidate.provider, session: candidate.session, cwd: candidate.cwd, ...context,
155
- ...(providerGoal ? { providerGoal } : {}) })).digest('hex');
155
+ ...(providerGoal ? { providerGoal: providerGoalEvidence(providerGoal) } : {}) })).digest('hex');
156
156
  return { ...context, providerGoal, contextDigest, awaitingTurn: raw?.awaitingTurn === true, settledTurnId: typeof raw?.settledTurnId === 'string' ? raw.settledTurnId.slice(0, 160) : null };
157
157
  }
158
158
 
@@ -185,7 +185,7 @@ export async function analyzeContinuation(candidate, {
185
185
  basis: [{ role: 'goal', turnRef: 'provider-goal', snippet: context.providerGoal.objective.slice(0, 280) }],
186
186
  };
187
187
  if (previousAnalysis?.analysisVersion === CONTINUATION_ANALYSIS_VERSION && previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
188
- return { ...snapshot, ...previousAnalysis };
188
+ return { ...previousAnalysis, ...snapshot };
189
189
  }
190
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
191
  let result;
@@ -21,6 +21,13 @@ export function textContent(content) {
21
21
  }
22
22
 
23
23
  const GOAL_STATES = new Set(['active', 'paused', 'blocked', 'usageLimited', 'budgetLimited', 'complete']);
24
+ // Goal timestamps can advance with provider bookkeeping without changing the
25
+ // evidence relevant to triage. Keep timestamps for provenance, not cache keys.
26
+ export function providerGoalEvidence(goal) {
27
+ return goal && typeof goal === 'object' ? { objective: goal.objective ?? null, status: goal.status ?? null,
28
+ textTruncated: goal.textTruncated === true } : null;
29
+ }
30
+
24
31
  // Original provider metadata is evidence about the goal, never permission for
25
32
  // this separate read-only classifier to execute work or change provider limits.
26
33
  export function boundProviderGoal(goal) {
@@ -114,6 +114,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
114
114
  return { provider: item.provider, session: item.session, cwd: item.cwd,
115
115
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
116
116
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
117
+ ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
117
118
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
118
119
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
119
120
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
@@ -11,7 +11,7 @@ export function safeProviderDiagnostic(value) {
11
11
  if (SIGNALS.has(value.signal)) result.signal = value.signal;
12
12
  if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
13
13
  if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
14
- if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
14
+ if (typeof value.model === "string" && /^(?:gpt-6-(?:astra|sol)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
15
15
  return result;
16
16
  }
17
17
 
@@ -20,7 +20,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
20
20
  const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
21
21
  // Keep analysis independent of interactive config. Availability varies by
22
22
  // account and CLI; only an explicit model rejection may try the next model.
23
- export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
23
+ // Background SQM and conversation triage use Sol models to limit usage.
24
+ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6.1-sol", "gpt-6-sol"]);
24
25
  export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
25
26
 
26
27
  export class SqmLocalReviewError extends Error {
@@ -113,6 +114,7 @@ export function createSqmLocalReviewWorker({
113
114
  let activeJobId = null;
114
115
  let pollPromise = null;
115
116
  let closed = false;
117
+ let suspended = false;
116
118
  let reviewController = null;
117
119
  let issue = null, pausedEnvironment = null, pauseReason = null;
118
120
  let issueContext = null;
@@ -167,7 +169,9 @@ export function createSqmLocalReviewWorker({
167
169
  if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
168
170
  setStage(true);
169
171
  try {
170
- return { review, response: await submit(review) };
172
+ const response = await submit(review);
173
+ if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
174
+ return { review, response };
171
175
  } catch (error) {
172
176
  if (attempt >= 1 || error.status !== 422 || error.code !== code || !error.validationFeedback) throw error;
173
177
  nextRequest = { ...request, prompt: `${request.prompt}\n\nThe previous result failed validation. Return a corrected complete JSON object using only the supplied evidence. Fix these constraints:\n${error.validationFeedback}` };
@@ -246,7 +250,7 @@ export function createSqmLocalReviewWorker({
246
250
  return {
247
251
  snapshot() {
248
252
  const pause = currentPause();
249
- return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
253
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
250
254
  ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
251
255
  },
252
256
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
@@ -254,10 +258,10 @@ export function createSqmLocalReviewWorker({
254
258
  const item = visibleHistory().find(item => item.key === key);
255
259
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
256
260
  },
257
- retry() { if (closed || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
261
+ retry() { if (closed || suspended || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
258
262
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
259
263
  poll() {
260
- if (closed || pollPromise) return pollPromise || Promise.resolve(null);
264
+ if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
261
265
  const environmentKey = getEnvironmentKey();
262
266
  if (pausedEnvironment !== null && pausedEnvironment === environmentKey) return Promise.resolve(null);
263
267
  if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
@@ -325,6 +329,21 @@ export function createSqmLocalReviewWorker({
325
329
  reviewController?.abort();
326
330
  await pollPromise;
327
331
  },
332
+ async suspend() {
333
+ suspended = true;
334
+ reviewController?.abort();
335
+ onChange();
336
+ // Keep the original connection until provider cleanup and the final
337
+ // cancellation report finish, including a claim that returns after abort.
338
+ await pollPromise;
339
+ },
340
+ async resume({ shouldResume = () => true } = {}) {
341
+ await pollPromise;
342
+ if (!shouldResume()) return;
343
+ if (closed) throw new Error("SQM analysis is unavailable.");
344
+ suspended = false;
345
+ onChange();
346
+ },
328
347
  async close() {
329
348
  closed = true;
330
349
  reviewController?.abort();