@awak-app/simy-cli 0.7.32 → 0.7.33

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.
@@ -1,3 +1,5 @@
1
+ import { createDesktopFrameWriter } from './desktop-frame-writer.js';
2
+ import { boundedDiscovery } from './session-supervision/progressive-discovery.js';
1
3
  import { createClaudeDesktopArchiveController } from './session-supervision/claude-desktop-archive.js';
2
4
  import { createClaudeDesktopNativeAdapter } from './session-supervision/claude-desktop-native.js';
3
5
  import { applyControllerExecutionContext } from './session-supervision/execution-context.js';
@@ -8,6 +10,7 @@ import { createNotificationJournal } from './session-supervision/notification-jo
8
10
  import { createRemoteMetadata } from './session-supervision/remote-metadata.js';
9
11
  import { projectControllerCompletion } from './native-development-report-completion.js';
10
12
  import { remoteOperationFailure } from './session-supervision/remote-errors.js';
13
+ import { updateConnectionEnvironment, sameUpdateIdentity } from './update-connection.js';
11
14
  import { copyDesktopText } from './desktop-clipboard.js';
12
15
  import { createRemoteHostManager, allowedActions, controllerPolicyActions } from './session-supervision/remote-hosts.js';
13
16
  import { manualAcceptanceCommand, manualAcceptanceContinuation } from './session-supervision/manual-acceptance-command.js';
@@ -40,7 +43,7 @@ import { controlDelivery, verifyDelivery, decideManualAcceptance, createRecordRe
40
43
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
41
44
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
42
45
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
43
- import { AutoContinueUnavailableError, answerSession, continueSession, followupSession, resumeSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
46
+ import { AutoContinueUnavailableError, answerSession, continueSession, followupSession, resumeSession, discoverSessions, projectSessionAutoContinue, enrollSession, monitorSession, trackSession, setSessionAutoContinue, saveSessionAutoContinuePreference } from "./session-supervision/index.js";
44
47
  import { renewClaudeSessionOwner } from './session-supervision/claude-owner-renewal.js';
45
48
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
46
49
  import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
@@ -149,35 +152,7 @@ function desktopActionError(action, error) {
149
152
  return "Action could not finish. Refresh the status and try again.";
150
153
  }
151
154
 
152
- // Status snapshots are replaceable; command receipts and stop notifications are
153
- // not. Never enqueue periodic snapshots behind a native reader that has paused.
154
- // Control frames retain the stream's FIFO order and never wait for GUI drain.
155
- export function createDesktopFrameWriter(output) {
156
- let blocked = false, pendingStatus = null, statusClosed = false;
157
- const drain = () => {
158
- blocked = false;
159
- const frame = pendingStatus;
160
- pendingStatus = null;
161
- if (!statusClosed && frame !== null) write(frame, true);
162
- };
163
- const write = (frame, isStatus = false) => {
164
- if (isStatus && statusClosed) return;
165
- if (isStatus && blocked) { pendingStatus = frame; return; }
166
- // A control frame must not be followed by a snapshot captured before it.
167
- // The next authorized projection will publish fresh state.
168
- if (!isStatus) pendingStatus = null;
169
- const accepted = output.write(frame);
170
- if (accepted === false && !blocked && !statusClosed) {
171
- blocked = true;
172
- output.once('drain', drain);
173
- }
174
- };
175
- return { write, close() {
176
- statusClosed = true;
177
- pendingStatus = null;
178
- output.removeListener('drain', drain);
179
- } };
180
- }
155
+ export { createDesktopFrameWriter } from './desktop-frame-writer.js';
181
156
 
182
157
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
183
158
  updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
@@ -286,6 +261,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
286
261
  const remoteMetadata = createRemoteMetadata({ root, webOrigin, onChange: () => { if (!closed && !relay) setImmediate(() => void publish().catch(() => {})); } });
287
262
  let teamThreads;
288
263
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
264
+ let updateIdentityRevision = 0;
289
265
  let preferenceScope = null, emailSync = null, emailSyncRetryAt = 0;
290
266
  let nativeDevelopmentReports = { state: 'loading', enrollments: [] };
291
267
  let nativeDevelopmentReportsScope = null;
@@ -304,6 +280,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
304
280
  const previouslyReady = desktopAccessReady(currentAccess);
305
281
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
306
282
  if (previousScope !== accessScope(currentAccess) || previouslyReady && !desktopAccessReady(currentAccess)) {
283
+ updateIdentityRevision++;
307
284
  // A privilege loss also invalidates consent reads, even when the
308
285
  // credential/organization revision itself did not change.
309
286
  nativeDevelopmentReports = { state: 'loading', enrollments: [] };
@@ -317,7 +294,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
317
294
  statisticsCache.clear();
318
295
  activeStatisticsRead?.controller.abort();
319
296
  explicitAnalyses.clear();
320
- discoveryController?.abort();
297
+ discoveryController?.abort(); enrichmentController?.abort();
321
298
  sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
322
299
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
323
300
  sessionStatus = { sessions: [], providers: {}, refreshedAt: null, initialLoading: true }; sessionRefreshedAt = 0;
@@ -562,12 +539,50 @@ export async function startDesktopHost({ input = process.stdin, output = process
562
539
  let commands;
563
540
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
564
541
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
542
+ let enrichmentController = null, sessionEnrichment = null;
543
+ let catalogAnalysisJob = null, catalogAnalysisAgain = false;
544
+ const withCurrentSettings = (sessions, records) => {
545
+ const byKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
546
+ return sessions.map(candidate => projectSessionAutoContinue(candidate, byKey.get(`${candidate.provider}:${candidate.session}`)));
547
+ };
548
+ const refreshCatalogAnalysis = () => {
549
+ catalogAnalysisAgain = true;
550
+ if (catalogAnalysisJob) return catalogAnalysisJob;
551
+ const scope = accessScope(currentAccess), manager = analysisManager;
552
+ catalogAnalysisJob = Promise.resolve().then(async () => {
553
+ while (catalogAnalysisAgain) {
554
+ catalogAnalysisAgain = false;
555
+ const records = await recordReader.list();
556
+ if (closed || relay || discoveryStopped || manager !== analysisManager ||
557
+ !desktopAccessReady(currentAccess) || accessScope(currentAccess) !== scope || !coordinator.acceptsNewWork()) return;
558
+ await manager.update(analysisSessions(withCurrentSettings(sessionStatus.sessions, records)));
559
+ }
560
+ }).catch(() => {}).finally(() => {
561
+ catalogAnalysisJob = null;
562
+ if (catalogAnalysisAgain && !closed && !relay && !discoveryStopped) void refreshCatalogAnalysis();
563
+ });
564
+ return catalogAnalysisJob;
565
+ };
565
566
  const hasUnfinishedSessions = records => records.some(record => record.autoContinue === true || record.run && !TERMINAL.has(record.run.state)) ||
566
567
  sessionStatus.sessions.some(candidate => candidate.autoContinue === true || ['queued', 'analyzing'].includes(analysisManager.project(candidate).analysis?.state));
568
+ const discoveryDrains = new Set();
569
+ const trackDiscoveryDrain = operation => {
570
+ const settled = Promise.resolve(operation).then(() => {}, () => {});
571
+ discoveryDrains.add(settled);
572
+ void settled.then(() => discoveryDrains.delete(settled));
573
+ };
567
574
  const stopDiscovery = async ({ drain = false } = {}) => {
568
- discoveryController?.abort();
575
+ discoveryController?.abort(); enrichmentController?.abort();
569
576
  sessionRefreshAgain = false;
570
- if (drain && !dependencies.discoverSessions) await sessionRefresh;
577
+ if (drain) {
578
+ let timer;
579
+ try {
580
+ await Promise.race([
581
+ Promise.allSettled([sessionRefresh, sessionEnrichment, catalogAnalysisJob, ...discoveryDrains]),
582
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('Conversation discovery has not finished closing; runtime handoff was refused.'), { code: 'discovery_drain_incomplete' })), dependencies.discoveryDrainTimeoutMs ?? 4000); }),
583
+ ]);
584
+ } finally { clearTimeout(timer); }
585
+ }
571
586
  };
572
587
  let confirmedUpdateStop;
573
588
  const stopWorkForUpdate = () => confirmedUpdateStop ||= (async () => {
@@ -604,27 +619,59 @@ export async function startDesktopHost({ input = process.stdin, output = process
604
619
  const controller = discoveryController = new AbortController();
605
620
  const attempt = { id: randomUUID(), requestedAt: Date.now(), state: 'pending' };
606
621
  sessionStatus = { ...sessionStatus, discoveryAttempt: attempt };
607
- sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, includeArchived: true, includeSelectionCandidates: true, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
622
+ enrichmentController?.abort();
623
+ const applyCatalog = async (value, sourceSignal = controller.signal) => {
624
+ if (closed || relay || controller.signal.aborted || sourceSignal.aborted || sessionStatus.discoveryAttempt?.id !== attempt.id) return;
625
+ const latestAccess = await accessSnapshot();
626
+ if (closed || relay || controller.signal.aborted || sourceSignal.aborted || sessionStatus.discoveryAttempt?.id !== attempt.id) return;
627
+ if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
628
+ sessionRefreshAgain = desktopAccessReady(latestAccess); return;
629
+ }
630
+ const complete = new Set(value.completeProviders || (value.historyLoading === undefined ? PROVIDER_LINKS : []));
631
+ const merge = (previous, incoming) => {
632
+ const rows = new Map(previous.filter(item => !complete.has(item.provider)).map(item => [`${item.provider}:${item.session}`, item]));
633
+ for (const item of incoming || []) rows.set(`${item.provider}:${item.session}`, item);
634
+ return [...rows.values()];
635
+ };
636
+ sessionStatus = { ...sessionStatus, ...value,
637
+ sessions: linkedSessions(merge(sessionStatus.sessions, value.sessions)).sort(compareSessionWork),
638
+ selectionCandidates: merge(sessionStatus.selectionCandidates || [], value.selectionCandidates),
639
+ initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt };
640
+ sessionRefreshedAt = Date.now();
641
+ void publish().catch(() => {});
642
+ void refreshCatalogAnalysis();
643
+ };
644
+ sessionRefresh = boundedDiscovery(signal => (dependencies.discoverSessions || discoverSessions)({
645
+ root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true,
646
+ connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected),
647
+ timeoutMs: dependencies.discoveryTimeoutMs ?? 30_000,
648
+ onProgress: value => applyCatalog(value, signal),
649
+ }), { signal: controller.signal, timeoutMs: (dependencies.discoveryTimeoutMs ?? 30_000) + 1000, onDrain: dependencies.discoverSessions ? undefined : trackDiscoveryDrain })
608
650
  .then(async value => {
651
+ await applyCatalog(value);
609
652
  if (closed || relay || controller.signal.aborted) return;
610
- const latestAccess = await accessSnapshot();
611
- if (closed || relay || controller.signal.aborted) return;
612
- if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
613
- await analysisManager.update([]);
614
- sessionRefreshAgain = desktopAccessReady(latestAccess);
615
- return;
616
- }
617
- const sessions = await observePullRequests(linkedSessions(value.sessions), { signal: controller.signal });
618
- const afterObservation = await accessSnapshot();
619
- if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
620
- await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
621
- }
622
- if (coordinator.acceptsNewWork()) await analysisManager.update(analysisSessions(sessions));
623
- if (controller.signal.aborted) return;
624
- sessionStatus = { ...value, sessions, initialLoading: false,
625
- discoveryAttempt: { ...attempt, state: 'succeeded', completedAt: Date.now() } }; sessionRefreshedAt = Date.now(); })
653
+ const failed = Object.values(value.providers || {}).some(provider => provider.discovery === 'unavailable');
654
+ sessionStatus = { ...sessionStatus, historyLoading: false,
655
+ discoveryAttempt: { ...attempt, state: failed ? 'failed' : 'succeeded', completedAt: Date.now() } };
656
+ // Publish before PR/network observation and queue maintenance. Neither
657
+ // operation owns the first usable catalog or the discovery promise.
658
+ void publish().catch(() => {});
659
+ const enrichment = enrichmentController = new AbortController();
660
+ const catalog = sessionStatus;
661
+ sessionEnrichment = boundedDiscovery(async signal => {
662
+ // Queue maintenance and PR checks are independent background work.
663
+ const queue = signal.aborted ? Promise.resolve() : refreshCatalogAnalysis();
664
+ const sessions = await observePullRequests(catalog.sessions, { signal });
665
+ const latestAccess = await accessSnapshot();
666
+ if (closed || relay || signal.aborted || !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope || sessionStatus !== catalog) return;
667
+ sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessions) };
668
+ await queue;
669
+ if (!signal.aborted) void publish().catch(() => {});
670
+ }, { signal: enrichment.signal, timeoutMs: dependencies.enrichmentTimeoutMs ?? 10_000, onDrain: trackDiscoveryDrain })
671
+ .catch(() => {}).finally(() => { if (enrichmentController === enrichment) { enrichmentController = null; sessionEnrichment = null; } });
672
+ })
626
673
  .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false,
627
- discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
674
+ historyLoading: false, discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
628
675
  providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
629
676
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
630
677
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
@@ -639,7 +686,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
639
686
  // Preference changes may use the observation already displayed by the UI.
640
687
  // Execution still takes the fresh-discovery path and rechecks opt-in epochs.
641
688
  const matches = item => item.provider === command.provider && item.session === command.session;
642
- let candidate = preferObserved ? sessionStatus.sessions.find(matches) : null;
689
+ let candidate = preferObserved || reuseDiscovery ? sessionStatus.sessions.find(matches) : null;
643
690
  if (!candidate) {
644
691
  // Analysis joins an existing discovery instead of scheduling a second
645
692
  // full scan. Enrollment retains its fresh-discovery boundary.
@@ -650,6 +697,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
650
697
  candidate = sessionStatus.sessions.find(matches);
651
698
  }
652
699
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
700
+ candidate = projectSessionAutoContinue(candidate, await readRecord(deliveryRoot, command.provider, command.session));
653
701
  await authorizeProvider(command.provider, scope, link.epoch);
654
702
  const selected = { ...candidate };
655
703
  selectedScopes.set(selected, scope);
@@ -668,7 +716,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
668
716
  phase = "save_disconnect";
669
717
  if (!connected) providerLinks = await writeProviderLink(root, provider, false);
670
718
  if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
671
- discoveryController?.abort();
719
+ discoveryController?.abort(); enrichmentController?.abort();
672
720
  sessionRefreshAgain = false;
673
721
  for (const item of sessionStatus.sessions.filter(item => item.provider === provider)) automaticContinuation?.revoke(provider, item.session);
674
722
  for (const key of explicitAnalyses) if (key.startsWith(`${provider}:`)) explicitAnalyses.delete(key);
@@ -697,7 +745,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
697
745
  } finally { providerLinkBusy.delete(provider); await publish(); }
698
746
  };
699
747
  const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
700
- getManager: () => analysisManager, selectCandidate: selectedSession,
748
+ getManager: () => analysisManager,
749
+ // Fresh exact context and enrollment still validate the provider below;
750
+ // unrelated history pagination is not an execution-readiness boundary.
751
+ selectCandidate: candidate => selectedSession(candidate, { reuseDiscovery: true }),
701
752
  readSetting: dependencies.readSessionAutoContinue,
702
753
  readContext: dependencies.readContinuationContext || readContinuationContext,
703
754
  enroll: guardedEnroll, beforeEnroll: candidate => authorizeProvider(candidate.provider, selectedScopes.get(candidate), selectedLinkEpochs.get(candidate)),
@@ -788,7 +839,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
788
839
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
789
840
  diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
790
841
  const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
791
- root: join(root, 'session-analysis'), analyze: async (...args) => {
842
+ root: join(root, 'session-analysis'), waitNoticeAfterMs: dependencies.analysisWaitNoticeAfterMs, analyze: async (...args) => {
792
843
  const scope = await authorizeWork();
793
844
  const [candidate, options] = args;
794
845
  const link = await requireProviderLink({ root: deliveryRoot, provider: candidate.provider });
@@ -948,9 +999,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
948
999
  ...(readbackOrder ? { readbackId: acceptanceReadbackId, readbackOrder } : {}) } };
949
1000
  });
950
1001
  await actionAssociations.decorate(status.deliveryHooks, projection);
951
- const discovery = refreshSessions();
952
- if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions);
953
- const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
1002
+ const catalog = refreshSessions();
1003
+ const discovery = { ...catalog, sessions: withCurrentSettings(catalog.sessions, records) };
1004
+ if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions.filter(candidate => Array.isArray(discovery.completeProviders) && discovery.completeProviders.includes(candidate.provider)));
1005
+ let sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
1006
+ await analysisManager.observeContinuationWaits?.(sessions);
1007
+ sessions = sessions.map(candidate => analysisManager.projectContinuationWait?.(candidate) || candidate);
954
1008
  await actionAssociations.decorateSessions?.(sessions, projection);
955
1009
  const stateRecordsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record]));
956
1010
  const stateRunsBySession = new Map([...stateRecordsBySession].map(([key, record]) => [key, record.run]));
@@ -980,7 +1034,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
980
1034
  const run = runsBySession.get(`${session.provider}:${session.session}`);
981
1035
  if (run) run.actionDeadline = session.actionDeadline;
982
1036
  }
983
- status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
1037
+ status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => projectSessionAutoContinue(candidate,
1038
+ stateRecordsBySession.get(`${candidate.provider}:${candidate.session}`))).map(candidate => analysisManager.projectContinuationWait?.(candidate) || candidate).map(candidate => ({ ...candidate,
984
1039
  decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
985
1040
  workState: sessionWorkState(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`) || stateRunsBySession.get(`${candidate.provider}:${candidate.session}`), { provider: discovery.providers?.[candidate.provider] }) }))).map(candidate => ({ ...candidate, conversationPresentation: conversationPresentation(candidate,
986
1041
  runsBySession.get(`${candidate.provider}:${candidate.session}`), { rawRecord: stateRecordsBySession.get(`${candidate.provider}:${candidate.session}`) }) })).sort(compareSessionWork) };
@@ -1020,6 +1075,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
1020
1075
  // running frame cannot suppress a newly stopped conversation.
1021
1076
  stopNotifier.observe(records);
1022
1077
  emitStatus(status);
1078
+ const notices = await analysisManager.takeContinuationWaitNotifications?.(sessions) || [];
1079
+ const noticeAccess = await accessSnapshot();
1080
+ if (!closed && !relay && desktopAccessReady(noticeAccess) && accessScope(noticeAccess) === requestedScope) {
1081
+ for (const event of notices) {
1082
+ const scoped = { ...event, scope: reportController.controllerScope };
1083
+ await notificationJournal.append(scoped).catch(() => {});
1084
+ emit(event);
1085
+ }
1086
+ }
1023
1087
  }
1024
1088
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
1025
1089
  return publishing;
@@ -1108,6 +1172,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
1108
1172
  updater.start({ registry: agent.registry, coordinator, isDesktopIdle: () => !unfinishedDesktopWork,
1109
1173
  restartDaemon: async (targetVersion, installation) => {
1110
1174
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
1175
+ const updateIdentity = await agent.getDesktopStatus();
1176
+ if (!await acceptStatusAccess(updateIdentity.access)) throw new Error("Updated desktop runtime identity changed during handoff.");
1177
+ const expectedIdentityRevision = updateIdentityRevision;
1111
1178
  discoveryStopped = true;
1112
1179
  await stopDiscovery({ drain: true });
1113
1180
  // The candidate shares this cache. Stop inference before it starts so a
@@ -1122,7 +1189,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1122
1189
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
1123
1190
  [join(dirname(installation.entryPath), "desktop-host.js")], {
1124
1191
  stdio: ["pipe", "pipe", "pipe"],
1125
- env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
1192
+ env: { ...updateConnectionEnvironment({ env, root, webOrigin }), SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
1126
1193
  }); } catch (error) {
1127
1194
  if(transferToken)await updateRuntime.reclaimFailedHandoff();
1128
1195
  actionAssociations = makeActionAssociations();
@@ -1149,6 +1216,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1149
1216
  });
1150
1217
  try {
1151
1218
  const first = await new Promise((accept, reject) => {
1219
+ let validating = false;
1152
1220
  const limit = setTimeout(() => reject(new Error("Updated desktop runtime did not start in time.")), 30_000);
1153
1221
  const finish = (fn, value) => { clearTimeout(limit); fn(value); };
1154
1222
  child.once("error", () => finish(reject, new Error("Updated desktop runtime could not start.")));
@@ -1161,13 +1229,27 @@ export async function startDesktopHost({ input = process.stdin, output = process
1161
1229
  }
1162
1230
  let event; try { event = JSON.parse(line); } catch { return; }
1163
1231
  if (accepted) { emit(event); return; }
1164
- if (event.type === "status") {
1165
- if (!validCandidateStatus(event.status, targetVersion, webOrigin) || transferToken && (event.status.updateDetachment?.connectionState !== 'connected' || event.status.updateDetachment.operationId !== updateRuntime.snapshot(coordinator.snapshot()).operationId)) {
1232
+ if (event.type === "status" && !validating) {
1233
+ if (!validCandidateStatus(event.status, targetVersion, webOrigin) || !sameUpdateIdentity(updateIdentity, event.status) || transferToken && (event.status.updateDetachment?.connectionState !== 'connected' || event.status.updateDetachment.operationId !== updateRuntime.snapshot(coordinator.snapshot()).operationId)) {
1166
1234
  finish(reject, new Error("Updated desktop runtime identity mismatch."));
1167
- } else { accepted = true; finish(accept, event); }
1235
+ } else {
1236
+ validating = true;
1237
+ void (async () => {
1238
+ // Sign-in and organization changes use a separate command
1239
+ // queue; re-read after all candidate startup awaits.
1240
+ const currentIdentity = await agent.getDesktopStatus();
1241
+ if (!await acceptStatusAccess(currentIdentity.access)) {
1242
+ finish(reject, new Error("Updated desktop runtime identity changed during handoff.")); return;
1243
+ }
1244
+ if (closed || child.exitCode !== null || expectedIdentityRevision !== updateIdentityRevision || !sameUpdateIdentity(updateIdentity, currentIdentity)) {
1245
+ finish(reject, new Error("Updated desktop runtime identity changed during handoff."));
1246
+ } else { accepted = true; finish(accept, event); }
1247
+ })().catch(error => finish(reject, error));
1248
+ }
1168
1249
  }
1169
1250
  });
1170
1251
  });
1252
+ if (expectedIdentityRevision !== updateIdentityRevision || agent.getDesktopAccess && !sameAccess(updateIdentity.access, agent.getDesktopAccess())) throw new Error("Updated desktop runtime identity changed during handoff.");
1171
1253
  relay = child;
1172
1254
  await stopAgent();
1173
1255
  if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
@@ -1579,18 +1661,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
1579
1661
  return;
1580
1662
  }
1581
1663
  if (action === 'session_get_status') {
1582
- await selectedSession(command);
1583
- await publish();
1584
1664
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1585
- const row = lastStatus?.sessionSupervision?.sessions?.find(item =>
1665
+ const cached = lastStatus?.sessionSupervision?.sessions?.find(item =>
1586
1666
  item.provider === command.provider && item.session === command.session && (!item.hostId || item.hostId === 'local'));
1587
- if (!row) throw new Error('This conversation status is unavailable; refresh its provider.');
1667
+ const record = await readRecord(deliveryRoot, command.provider, command.session);
1668
+ const observed = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
1669
+ if (!record && !observed) throw new Error('This conversation status is unavailable; refresh its provider.');
1670
+ // Read one durable task without scheduling a complete provider scan
1671
+ // or waiting for optional account-wide enrichment. Observation times
1672
+ // remain original, so a cached running flag cannot become fresh.
1673
+ const candidate = { ...(cached || { provider: command.provider, session: command.session, cwd: record?.cwd,
1674
+ observation: { observedAt: record?.runtime?.observedAt }, isRunning: record?.runtime?.isRunning }), ...observed };
1675
+ const row = { ...projectSessionAutoContinue(automaticContinuation.project(analysisManager.project(candidate)), record),
1676
+ workState: sessionWorkState(candidate, record?.run, { provider: sessionStatus.providers?.[command.provider] }) };
1677
+ const controls = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: record ? [record] : [] });
1678
+ const selectedRun = controls.runs?.find(run => run.provider === command.provider && run.session === command.session);
1679
+ row.decisionRequest = sessionDecisionPresentation(row, selectedRun);
1680
+ row.conversationPresentation = conversationPresentation(row, selectedRun, { rawRecord: record });
1681
+ await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1588
1682
  // The same app projection carries observation time and next action;
1589
1683
  // saved consent and an active record cannot prove a provider send.
1590
1684
  const { provider, session, title, autoContinue, autoContinueStatus, autoContinueReason,
1591
- binding, workState, dispatchReadiness, conversationPresentation } = row;
1685
+ binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest } = row;
1592
1686
  emit({ type: 'result', id: command.id, ok: true, data: { provider, session, title,
1593
- autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation } });
1687
+ autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest } });
1594
1688
  return;
1595
1689
  }
1596
1690
  if (action === 'session_read_context') {
@@ -1666,7 +1760,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1666
1760
  await agent.refreshDesktopAccess?.();
1667
1761
  } else if (action === 'desktop_sqm_settings') {
1668
1762
  const verified = await agent.refreshDesktopAccess?.();
1669
- await authorizeWork(commandScope);
1763
+ // Navigation starts no work. Keep scope and entitlement checks while
1764
+ // allowing users to inspect SQM during an update admission hold.
1765
+ const currentAccess = await accessSnapshot();
1766
+ requireDesktopAccess(currentAccess);
1767
+ if (accessScope(currentAccess) !== commandScope) throw Object.assign(new Error('Desktop account or organization changed.'), { code: 'desktop_access_required' });
1670
1768
  if (!desktopAccessReady(verified) || verified.organizationId !== access.organizationId ||
1671
1769
  !validDesktopAccessPath(verified.settingsPath, verified.organizationSlug)) throw new Error('Organization settings are unavailable; refresh access first.');
1672
1770
  const url = new URL(`/org/${encodeURIComponent(verified.organizationSlug)}/quality-monitor`, webOrigin);
@@ -1807,23 +1905,27 @@ export async function startDesktopHost({ input = process.stdin, output = process
1807
1905
  await actionAssociations.correct(command, analysisManager.project(candidate));
1808
1906
  } else if (action === "session_set_auto_continue") {
1809
1907
  if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
1810
- let admission, providerEpoch;
1908
+ let admission, target, providerEpoch;
1811
1909
  if (command.enabled) {
1812
1910
  const candidate = await selectedSession(command, { preferObserved: true });
1813
1911
  const canEnable = candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue;
1814
- if (canEnable !== true) {
1912
+ if (candidate.controllerCanSetAutoContinue !== true && canEnable !== true) {
1815
1913
  throw new AutoContinueUnavailableError({ reason: candidate.autoContinueAvailability?.reason || candidate.reason,
1816
1914
  reasonCode: candidate.autoContinueAvailability?.reasonCode || candidate.continuationReasonCode });
1817
1915
  }
1818
1916
  admission = candidate.continuationAdmission;
1917
+ target = { provider: candidate.provider, session: candidate.session, cwd: candidate.cwd };
1819
1918
  providerEpoch = selectedLinkEpochs.get(candidate);
1820
1919
  }
1821
1920
  if (command.enabled) await authorizeWork(commandScope);
1822
- await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
1823
- provider: command.provider, session: command.session, enabled: command.enabled, initialContinuationPolicy: command.continuationPolicy, admission, expectedProviderEpoch: providerEpoch });
1921
+ const saved = await (dependencies.setSessionAutoContinue || saveSessionAutoContinuePreference)({ root: join(root, 'delivery-hooks'),
1922
+ provider: command.provider, session: command.session, enabled: command.enabled, initialContinuationPolicy: command.continuationPolicy, admission, target, expectedProviderEpoch: providerEpoch });
1923
+ if (saved) sessionStatus = { ...sessionStatus,
1924
+ sessions: sessionStatus.sessions.map(candidate => projectSessionAutoContinue(candidate, saved)) };
1824
1925
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
1825
1926
  // Acknowledge the durable preference before discovery or analysis.
1826
1927
  emit({ type: "result", id: command.id, ok: true });
1928
+ void refreshCatalogAnalysis();
1827
1929
  refreshSessions(true);
1828
1930
  // Discovery's tracking transition schedules analysis. A second
1829
1931
  // forced request could invalidate a concurrent manual recommendation.
@@ -121,10 +121,12 @@ async function runCodingLoopOwned({
121
121
  backend: snapshot.charter.backend, attempt_number: attemptNumber, instruction,
122
122
  });
123
123
  await publish(snapshot, "dispatching", onUpdate);
124
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
124
125
  appendEvent(snapshot, "coding", `${snapshot.charter.backend} is executing locally.`, {
125
126
  backend: snapshot.charter.backend, attempt_number: attemptNumber, instruction,
126
127
  });
127
128
  await publish(snapshot, "coding", onUpdate);
129
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
128
130
  const recordCompletion = async execution => {
129
131
  attempt = buildAttempt({ attemptNumber, charter: snapshot.charter, instruction, promptInterventions, execution });
130
132
  if (attempt.status !== "completed" || Array.isArray(attempt.raw_output) || !Object.keys(attempt.raw_output).length) return;