@awak-app/simy-cli 0.7.32 → 0.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +22 -3
  2. package/package.json +3 -2
  3. package/src/auto-update.js +10 -1
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +44 -12
  7. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +13 -4
  8. package/src/delivery-hooks/desktop.js +1 -1
  9. package/src/desktop-access.js +1 -1
  10. package/src/desktop-frame-writer.js +29 -0
  11. package/src/desktop-host.js +252 -77
  12. package/src/orchestrator/loop.js +2 -0
  13. package/src/session-supervision/analysis-manager.js +83 -18
  14. package/src/session-supervision/claude-desktop-lineage.js +1 -1
  15. package/src/session-supervision/claude-sessions.js +16 -4
  16. package/src/session-supervision/codex-app-server.js +45 -30
  17. package/src/session-supervision/continuation-readiness.js +58 -0
  18. package/src/session-supervision/decision-presentation.js +11 -3
  19. package/src/session-supervision/index.js +126 -28
  20. package/src/session-supervision/owner-transport.js +23 -8
  21. package/src/session-supervision/progressive-discovery.js +79 -0
  22. package/src/session-supervision/remote-entry.js +7 -4
  23. package/src/session-supervision/remote-host-recovery.js +21 -0
  24. package/src/session-supervision/remote-hosts.js +28 -8
  25. package/src/session-supervision/remote-owner-entry.js +20 -0
  26. package/src/session-supervision/remote-service.js +36 -0
  27. package/src/session-supervision/statistics.js +19 -3
  28. package/src/session-supervision/work-state.js +7 -1
  29. package/src/sqm/local-tests.js +2 -1
  30. package/src/update-connection.js +16 -0
@@ -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) } = {}) {
@@ -196,11 +171,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
196
171
  // volatile; reuse the encoded body after comparing freshly authorized data.
197
172
  // Detach the comparison value because registries may mutate nested objects.
198
173
  let encodedStatus = null, lastStatus = null, updateProgress = null;
174
+ let projectCatalogFrame = status => status;
175
+ let resolveFirstStatus;
176
+ const firstStatus = new Promise(resolve => { resolveFirstStatus = resolve; });
177
+ let startupPublication;
199
178
  // Per verified source reader, never accepted from a projection or command.
200
179
  const acceptanceReaderId = randomUUID();
201
180
  let acceptanceReadbackSequence = 0;
202
181
  let remoteHosts;
203
182
  const emitStatus = status => {
183
+ status = projectCatalogFrame(status);
204
184
  const updateSettings = updater?.snapshot?.() || {};
205
185
  status = { ...status, update: { ...status.update,
206
186
  auto_update_supported: updateSettings.auto_update_supported === true,
@@ -232,6 +212,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
232
212
  }
233
213
  const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
234
214
  frames.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
215
+ resolveFirstStatus?.(); resolveFirstStatus = null;
235
216
  };
236
217
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
237
218
  const controllerPreferences = createControllerStore(root);
@@ -249,6 +230,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
249
230
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
250
231
  const deliveryRoot = join(root, "delivery-hooks");
251
232
  const recordReader = createRecordReader(deliveryRoot);
233
+ let frameRecordScope = null, frameRecords = new Map();
234
+ const pendingCatalogPreferences = new Map();
252
235
  let providerLinks = await readProviderLinks(root);
253
236
  const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
254
237
  const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
@@ -286,6 +269,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
286
269
  const remoteMetadata = createRemoteMetadata({ root, webOrigin, onChange: () => { if (!closed && !relay) setImmediate(() => void publish().catch(() => {})); } });
287
270
  let teamThreads;
288
271
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
272
+ let updateIdentityRevision = 0;
289
273
  let preferenceScope = null, emailSync = null, emailSyncRetryAt = 0;
290
274
  let nativeDevelopmentReports = { state: 'loading', enrollments: [] };
291
275
  let nativeDevelopmentReportsScope = null;
@@ -304,6 +288,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
304
288
  const previouslyReady = desktopAccessReady(currentAccess);
305
289
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
306
290
  if (previousScope !== accessScope(currentAccess) || previouslyReady && !desktopAccessReady(currentAccess)) {
291
+ updateIdentityRevision++;
307
292
  // A privilege loss also invalidates consent reads, even when the
308
293
  // credential/organization revision itself did not change.
309
294
  nativeDevelopmentReports = { state: 'loading', enrollments: [] };
@@ -314,10 +299,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
314
299
  nativeCompletionInFlight.clear();
315
300
  }
316
301
  if (previousScope !== accessScope(currentAccess)) {
302
+ frameRecordScope = null; frameRecords.clear();
303
+ pendingCatalogPreferences.clear();
317
304
  statisticsCache.clear();
318
305
  activeStatisticsRead?.controller.abort();
319
306
  explicitAnalyses.clear();
320
- discoveryController?.abort();
307
+ discoveryController?.abort(); enrichmentController?.abort();
321
308
  sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
322
309
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
323
310
  sessionStatus = { sessions: [], providers: {}, refreshedAt: null, initialLoading: true }; sessionRefreshedAt = 0;
@@ -562,12 +549,51 @@ export async function startDesktopHost({ input = process.stdin, output = process
562
549
  let commands;
563
550
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
564
551
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
552
+ let catalogRevision = 0;
553
+ let enrichmentController = null, sessionEnrichment = null;
554
+ let catalogAnalysisJob = null, catalogAnalysisAgain = false;
555
+ const withCurrentSettings = (sessions, records) => {
556
+ const byKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
557
+ return sessions.map(candidate => projectSessionAutoContinue(candidate, byKey.get(`${candidate.provider}:${candidate.session}`)));
558
+ };
559
+ const refreshCatalogAnalysis = () => {
560
+ catalogAnalysisAgain = true;
561
+ if (catalogAnalysisJob) return catalogAnalysisJob;
562
+ const scope = accessScope(currentAccess), manager = analysisManager;
563
+ catalogAnalysisJob = Promise.resolve().then(async () => {
564
+ while (catalogAnalysisAgain) {
565
+ catalogAnalysisAgain = false;
566
+ const records = await recordReader.list();
567
+ if (closed || relay || discoveryStopped || manager !== analysisManager ||
568
+ !desktopAccessReady(currentAccess) || accessScope(currentAccess) !== scope || !coordinator.acceptsNewWork()) return;
569
+ await manager.update(analysisSessions(withCurrentSettings(sessionStatus.sessions, records)));
570
+ }
571
+ }).catch(() => {}).finally(() => {
572
+ catalogAnalysisJob = null;
573
+ if (catalogAnalysisAgain && !closed && !relay && !discoveryStopped) void refreshCatalogAnalysis();
574
+ });
575
+ return catalogAnalysisJob;
576
+ };
565
577
  const hasUnfinishedSessions = records => records.some(record => record.autoContinue === true || record.run && !TERMINAL.has(record.run.state)) ||
566
578
  sessionStatus.sessions.some(candidate => candidate.autoContinue === true || ['queued', 'analyzing'].includes(analysisManager.project(candidate).analysis?.state));
579
+ const discoveryDrains = new Set();
580
+ const trackDiscoveryDrain = operation => {
581
+ const settled = Promise.resolve(operation).then(() => {}, () => {});
582
+ discoveryDrains.add(settled);
583
+ void settled.then(() => discoveryDrains.delete(settled));
584
+ };
567
585
  const stopDiscovery = async ({ drain = false } = {}) => {
568
- discoveryController?.abort();
586
+ discoveryController?.abort(); enrichmentController?.abort();
569
587
  sessionRefreshAgain = false;
570
- if (drain && !dependencies.discoverSessions) await sessionRefresh;
588
+ if (drain) {
589
+ let timer;
590
+ try {
591
+ await Promise.race([
592
+ Promise.allSettled([sessionRefresh, sessionEnrichment, catalogAnalysisJob, ...discoveryDrains]),
593
+ 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); }),
594
+ ]);
595
+ } finally { clearTimeout(timer); }
596
+ }
571
597
  };
572
598
  let confirmedUpdateStop;
573
599
  const stopWorkForUpdate = () => confirmedUpdateStop ||= (async () => {
@@ -604,33 +630,96 @@ export async function startDesktopHost({ input = process.stdin, output = process
604
630
  const controller = discoveryController = new AbortController();
605
631
  const attempt = { id: randomUUID(), requestedAt: Date.now(), state: 'pending' };
606
632
  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) }); })
633
+ enrichmentController?.abort();
634
+ const applyCatalog = async (value, sourceSignal = controller.signal) => {
635
+ if (closed || relay || controller.signal.aborted || sourceSignal.aborted || sessionStatus.discoveryAttempt?.id !== attempt.id) return;
636
+ const latestAccess = await accessSnapshot();
637
+ if (closed || relay || controller.signal.aborted || sourceSignal.aborted || sessionStatus.discoveryAttempt?.id !== attempt.id) return;
638
+ if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
639
+ sessionRefreshAgain = desktopAccessReady(latestAccess); return;
640
+ }
641
+ const complete = new Set(value.completeProviders || (value.historyLoading === undefined ? PROVIDER_LINKS : []));
642
+ const merge = (previous, incoming) => {
643
+ const rows = new Map(previous.filter(item => !complete.has(item.provider)).map(item => [`${item.provider}:${item.session}`, item]));
644
+ for (const item of incoming || []) rows.set(`${item.provider}:${item.session}`, item);
645
+ return [...rows.values()];
646
+ };
647
+ sessionStatus = { ...sessionStatus, ...value,
648
+ sessions: linkedSessions(merge(sessionStatus.sessions, value.sessions)).sort(compareSessionWork),
649
+ selectionCandidates: merge(sessionStatus.selectionCandidates || [], value.selectionCandidates),
650
+ initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt, catalogRevision: ++catalogRevision };
651
+ sessionRefreshedAt = Date.now();
652
+ void publish().catch(() => {});
653
+ void refreshCatalogAnalysis();
654
+ };
655
+ const firstPageTimeoutMs = dependencies.discoveryTimeoutMs ?? 30_000;
656
+ const historyTimeoutMs = dependencies.historyDiscoveryTimeoutMs ?? dependencies.discoveryTimeoutMs ?? 300_000;
657
+ sessionRefresh = boundedDiscovery((signal, startHistory) => (dependencies.discoverSessions || discoverSessions)({
658
+ root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true,
659
+ readRecords: () => recordReader.list(),
660
+ connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected),
661
+ timeoutMs: firstPageTimeoutMs, historyTimeoutMs,
662
+ onProgress: value => { if (value.sessions?.length) startHistory(); return applyCatalog(value, signal); },
663
+ }), { signal: controller.signal, timeoutMs: firstPageTimeoutMs + 1000, historyTimeoutMs: historyTimeoutMs + 1000,
664
+ onDrain: dependencies.discoverSessions ? undefined : trackDiscoveryDrain })
608
665
  .then(async value => {
666
+ await applyCatalog(value);
609
667
  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(); })
668
+ const failed = Object.values(value.providers || {}).some(provider => provider.discovery === 'unavailable');
669
+ sessionStatus = { ...sessionStatus, historyLoading: false,
670
+ discoveryAttempt: { ...attempt, state: failed ? 'failed' : 'succeeded', completedAt: Date.now() } };
671
+ // Publish before PR/network observation and queue maintenance. Neither
672
+ // operation owns the first usable catalog or the discovery promise.
673
+ void publish().catch(() => {});
674
+ const enrichment = enrichmentController = new AbortController();
675
+ const catalog = sessionStatus;
676
+ sessionEnrichment = boundedDiscovery(async signal => {
677
+ // Queue maintenance and PR checks are independent background work.
678
+ const queue = signal.aborted ? Promise.resolve() : refreshCatalogAnalysis();
679
+ const sessions = await observePullRequests(catalog.sessions, { signal });
680
+ const latestAccess = await accessSnapshot();
681
+ if (closed || relay || signal.aborted || !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope || sessionStatus !== catalog) return;
682
+ sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessions) };
683
+ await queue;
684
+ if (!signal.aborted) void publish().catch(() => {});
685
+ }, { signal: enrichment.signal, timeoutMs: dependencies.enrichmentTimeoutMs ?? 10_000, onDrain: trackDiscoveryDrain })
686
+ .catch(() => {}).finally(() => { if (enrichmentController === enrichment) { enrichmentController = null; sessionEnrichment = null; } });
687
+ })
626
688
  .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false,
627
- discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
689
+ historyLoading: false, discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
628
690
  providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
629
691
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
630
692
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
631
693
  }
632
694
  return sessionStatus;
633
695
  };
696
+ // Provider metadata is already scoped and cancelable. A connection heartbeat
697
+ // can publish it while optional Action/deadline enrichment owns a slow full
698
+ // projection. These rows do not authorize execution or mint record readbacks.
699
+ projectCatalogFrame = status => {
700
+ if (!desktopAccessReady(status.access) || accessScope(status.access) !== accessScope(currentAccess) ||
701
+ sessionStatus.initialLoading !== false || !sessionStatus.discoveryAttempt) return status;
702
+ const previous = status.sessionSupervision;
703
+ if (!pendingCatalogPreferences.size && previous?.discoveryAttempt?.id === sessionStatus.discoveryAttempt.id &&
704
+ previous.catalogRevision === sessionStatus.catalogRevision && previous.initialLoading === false &&
705
+ previous.historyLoading === sessionStatus.historyLoading) return status;
706
+ const prior = new Map((previous?.sessions || []).map(row => [`${row.provider}:${row.session}`, row]));
707
+ const records = frameRecordScope === accessScope(currentAccess) ? frameRecords : new Map();
708
+ const sessions = linkedSessions(sessionStatus.sessions).map(candidate => {
709
+ const key = `${candidate.provider}:${candidate.session}`, record = pendingCatalogPreferences.get(key) || records.get(key);
710
+ const observed = projectSessionAutoContinue({ ...prior.get(key), ...candidate }, record);
711
+ const analyzed = analysisManager?.project(observed) || observed;
712
+ const row = automaticContinuation?.project(analyzed) || analyzed;
713
+ const projected = { ...row, actionDeadline: row.actionDeadline || { status: 'unavailable', online: false, reminder: null },
714
+ workState: sessionWorkState(row, record?.run, { provider: sessionStatus.providers?.[candidate.provider] }) };
715
+ return { ...projected, conversationPresentation: conversationPresentation(projected, record?.run) };
716
+ }).sort(compareSessionWork);
717
+ const providers = { ...sessionStatus.providers };
718
+ for (const provider of PROVIDER_LINKS) {
719
+ if (!providerLinks[provider].connected) providers[provider] = { discovery: 'disconnected', reason: null };
720
+ }
721
+ return { ...status, sessionSupervision: { ...sessionStatus, sessions, providers } };
722
+ };
634
723
  const claudeDesktopNative = dependencies.claudeDesktopNative || createClaudeDesktopNativeAdapter();
635
724
  const archiveClaudeDesktop = createClaudeDesktopArchiveController({ native: claudeDesktopNative, ...(dependencies.readClaudeDesktopTarget ? { readTarget: dependencies.readClaudeDesktopTarget } : {}) });
636
725
  const selectedSession = async (command, { reuseDiscovery = false, preferObserved = false } = {}) => {
@@ -639,7 +728,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
639
728
  // Preference changes may use the observation already displayed by the UI.
640
729
  // Execution still takes the fresh-discovery path and rechecks opt-in epochs.
641
730
  const matches = item => item.provider === command.provider && item.session === command.session;
642
- let candidate = preferObserved ? sessionStatus.sessions.find(matches) : null;
731
+ let candidate = preferObserved || reuseDiscovery ? sessionStatus.sessions.find(matches) : null;
643
732
  if (!candidate) {
644
733
  // Analysis joins an existing discovery instead of scheduling a second
645
734
  // full scan. Enrollment retains its fresh-discovery boundary.
@@ -650,6 +739,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
650
739
  candidate = sessionStatus.sessions.find(matches);
651
740
  }
652
741
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
742
+ candidate = projectSessionAutoContinue(candidate, await readRecord(deliveryRoot, command.provider, command.session));
653
743
  await authorizeProvider(command.provider, scope, link.epoch);
654
744
  const selected = { ...candidate };
655
745
  selectedScopes.set(selected, scope);
@@ -668,7 +758,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
668
758
  phase = "save_disconnect";
669
759
  if (!connected) providerLinks = await writeProviderLink(root, provider, false);
670
760
  if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
671
- discoveryController?.abort();
761
+ discoveryController?.abort(); enrichmentController?.abort();
672
762
  sessionRefreshAgain = false;
673
763
  for (const item of sessionStatus.sessions.filter(item => item.provider === provider)) automaticContinuation?.revoke(provider, item.session);
674
764
  for (const key of explicitAnalyses) if (key.startsWith(`${provider}:`)) explicitAnalyses.delete(key);
@@ -697,7 +787,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
697
787
  } finally { providerLinkBusy.delete(provider); await publish(); }
698
788
  };
699
789
  const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
700
- getManager: () => analysisManager, selectCandidate: selectedSession,
790
+ getManager: () => analysisManager,
791
+ // Fresh exact context and enrollment still validate the provider below;
792
+ // unrelated history pagination is not an execution-readiness boundary.
793
+ selectCandidate: candidate => selectedSession(candidate, { reuseDiscovery: true }),
701
794
  readSetting: dependencies.readSessionAutoContinue,
702
795
  readContext: dependencies.readContinuationContext || readContinuationContext,
703
796
  enroll: guardedEnroll, beforeEnroll: candidate => authorizeProvider(candidate.provider, selectedScopes.get(candidate), selectedLinkEpochs.get(candidate)),
@@ -788,7 +881,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
788
881
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
789
882
  diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
790
883
  const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
791
- root: join(root, 'session-analysis'), analyze: async (...args) => {
884
+ root: join(root, 'session-analysis'), waitNoticeAfterMs: dependencies.analysisWaitNoticeAfterMs, analyze: async (...args) => {
792
885
  const scope = await authorizeWork();
793
886
  const [candidate, options] = args;
794
887
  const link = await requireProviderLink({ root: deliveryRoot, provider: candidate.provider });
@@ -832,10 +925,24 @@ export async function startDesktopHost({ input = process.stdin, output = process
832
925
  publishAgain = true;
833
926
  return;
834
927
  }
928
+ for (const [key, saved] of pendingCatalogPreferences) {
929
+ const current = await readRecord(deliveryRoot, saved.provider, saved.session);
930
+ if (pendingCatalogPreferences.get(key) === saved) pendingCatalogPreferences.set(key, current || {
931
+ provider: saved.provider, session: saved.session, cwd: saved.cwd,
932
+ autoContinue: false, autoContinueEpoch: null, tracked: false, monitor: false });
933
+ }
934
+ if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
935
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
936
+ const preferenceAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : await accessSnapshot();
937
+ if (!sameAccess(preferenceAccess, status.access)) { publishAgain = true; return; }
835
938
  // Select cached record evidence after all asynchronous authorization:
836
939
  // a full projection may have advanced it while those reads were waiting.
837
940
  const sameScope = desktopAccessReady(currentAccess) &&
838
941
  accessScope(lastStatus?.access) === accessScope(currentAccess);
942
+ const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? structuredClone(lastStatus) : null;
943
+ // A heartbeat is a complete frame: retain optional scoped fields until
944
+ // their replacement is ready, while fresh access/provider data wins.
945
+ if (cached) status = { ...cached, ...status };
839
946
  status.deliveryHooks = sameScope && lastStatus?.deliveryHooks
840
947
  ? structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
841
948
  status.sessionSupervision = sameScope && lastStatus?.sessionSupervision
@@ -845,7 +952,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
845
952
  status.nativeDevelopmentReports = sameScope && sameReportController(nativeDevelopmentReportsController, {
846
953
  scope: accessScope(status.access), controllerScope: controllerScope(status), deviceSession: reportDeviceSession(),
847
954
  }) && nativeDevelopmentReportsScope === accessScope(currentAccess)
848
- ? structuredClone(nativeDevelopmentReports) : { state: 'loading', enrollments: [] };
955
+ ? { ...structuredClone(cached?.nativeDevelopmentReports || {}), ...structuredClone(nativeDevelopmentReports) } : { state: 'loading', enrollments: [] };
849
956
  // Cached enrichment must follow the current connection preference even
850
957
  // while a full projection is waiting on records or remote metadata.
851
958
  status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
@@ -885,6 +992,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
885
992
  if (preferenceScope) await controllerPreferences.load(preferenceScope);
886
993
  const requestedLinks = await decorateProviderSettings(status);
887
994
  const allRecords = await recordReader.list();
995
+ if (accessScope(currentAccess) === requestedScope) {
996
+ // Keep explicit preference overrides until the scope changes. A full
997
+ // projection that started before the save can finish after this read.
998
+ // Heartbeats refresh these identities from disk, including external OFF.
999
+ frameRecordScope = requestedScope;
1000
+ frameRecords = new Map(allRecords.map(record => [`${record.provider}:${record.session}`, record]));
1001
+ }
1002
+ // Begin the usable catalog before optional record/Action enrichment.
1003
+ refreshSessions();
888
1004
  const records = linkedSessions(allRecords);
889
1005
  const update = status.update || updater.snapshot?.() || {};
890
1006
  if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(allRecords);
@@ -915,7 +1031,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
915
1031
  if (reportController.controllerScope) void syncEmailPreferences(reportController);
916
1032
  const preferenceState = reportController.controllerScope ? await controllerPreferences.load(reportController.controllerScope) : null;
917
1033
  status.notificationPreferences = preferenceState?.notificationPreferences || {};
918
- status.nativeDevelopmentReports = { ...structuredClone(sameReportController(reportController, nativeDevelopmentReportsController) ? nativeDevelopmentReports : { state: 'loading', enrollments: [] }), deliveries: Object.values(preferenceState?.conversations || {}).flatMap(row => Object.values(row.emailDeliveries || {})), preferences: Object.values(preferenceState?.conversations || {}).flatMap(row => row.emailPreference ? [{ ...row.emailPreference.request, sync: row.emailPreference.state }] : []) };
1034
+ status.nativeDevelopmentReports = { ...structuredClone(sameReportController(reportController, nativeDevelopmentReportsController) ? nativeDevelopmentReports : { state: 'loading', enrollments: [] }), deliveries: Object.values(preferenceState?.conversations || {}).flatMap(row => Object.values(row.emailDeliveries || {})).map(({ receipt_id, ...delivery }) => delivery), preferences: Object.values(preferenceState?.conversations || {}).flatMap(row => row.emailPreference ? [{ ...row.emailPreference.request, sync: row.emailPreference.state }] : []) };
919
1035
  // This is an asynchronous local attestation. It neither changes a
920
1036
  // Delivery Loop nor places receipt or device data in status frames.
921
1037
  let reportCompletions = [];
@@ -948,9 +1064,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
948
1064
  ...(readbackOrder ? { readbackId: acceptanceReadbackId, readbackOrder } : {}) } };
949
1065
  });
950
1066
  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)));
1067
+ const catalog = refreshSessions();
1068
+ const discovery = { ...catalog, sessions: withCurrentSettings(catalog.sessions, records) };
1069
+ if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions.filter(candidate => Array.isArray(discovery.completeProviders) && discovery.completeProviders.includes(candidate.provider)));
1070
+ let sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
1071
+ await analysisManager.observeContinuationWaits?.(sessions);
1072
+ sessions = sessions.map(candidate => analysisManager.projectContinuationWait?.(candidate) || candidate);
954
1073
  await actionAssociations.decorateSessions?.(sessions, projection);
955
1074
  const stateRecordsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record]));
956
1075
  const stateRunsBySession = new Map([...stateRecordsBySession].map(([key, record]) => [key, record.run]));
@@ -980,7 +1099,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
980
1099
  const run = runsBySession.get(`${session.provider}:${session.session}`);
981
1100
  if (run) run.actionDeadline = session.actionDeadline;
982
1101
  }
983
- status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
1102
+ status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => projectSessionAutoContinue(candidate,
1103
+ stateRecordsBySession.get(`${candidate.provider}:${candidate.session}`))).map(candidate => analysisManager.projectContinuationWait?.(candidate) || candidate).map(candidate => ({ ...candidate,
984
1104
  decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
985
1105
  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
1106
  runsBySession.get(`${candidate.provider}:${candidate.session}`), { rawRecord: stateRecordsBySession.get(`${candidate.provider}:${candidate.session}`) }) })).sort(compareSessionWork) };
@@ -1020,6 +1140,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
1020
1140
  // running frame cannot suppress a newly stopped conversation.
1021
1141
  stopNotifier.observe(records);
1022
1142
  emitStatus(status);
1143
+ const notices = await analysisManager.takeContinuationWaitNotifications?.(sessions) || [];
1144
+ const noticeAccess = await accessSnapshot();
1145
+ if (!closed && !relay && desktopAccessReady(noticeAccess) && accessScope(noticeAccess) === requestedScope) {
1146
+ for (const event of notices) {
1147
+ const scoped = { ...event, scope: reportController.controllerScope };
1148
+ await notificationJournal.append(scoped).catch(() => {});
1149
+ emit(event);
1150
+ }
1151
+ }
1023
1152
  }
1024
1153
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
1025
1154
  return publishing;
@@ -1108,6 +1237,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
1108
1237
  updater.start({ registry: agent.registry, coordinator, isDesktopIdle: () => !unfinishedDesktopWork,
1109
1238
  restartDaemon: async (targetVersion, installation) => {
1110
1239
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
1240
+ const updateIdentity = await agent.getDesktopStatus();
1241
+ if (!await acceptStatusAccess(updateIdentity.access)) throw new Error("Updated desktop runtime identity changed during handoff.");
1242
+ const expectedIdentityRevision = updateIdentityRevision;
1111
1243
  discoveryStopped = true;
1112
1244
  await stopDiscovery({ drain: true });
1113
1245
  // The candidate shares this cache. Stop inference before it starts so a
@@ -1122,7 +1254,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1122
1254
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
1123
1255
  [join(dirname(installation.entryPath), "desktop-host.js")], {
1124
1256
  stdio: ["pipe", "pipe", "pipe"],
1125
- env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
1257
+ env: { ...updateConnectionEnvironment({ env, root, webOrigin }), SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
1126
1258
  }); } catch (error) {
1127
1259
  if(transferToken)await updateRuntime.reclaimFailedHandoff();
1128
1260
  actionAssociations = makeActionAssociations();
@@ -1149,6 +1281,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1149
1281
  });
1150
1282
  try {
1151
1283
  const first = await new Promise((accept, reject) => {
1284
+ let validating = false;
1152
1285
  const limit = setTimeout(() => reject(new Error("Updated desktop runtime did not start in time.")), 30_000);
1153
1286
  const finish = (fn, value) => { clearTimeout(limit); fn(value); };
1154
1287
  child.once("error", () => finish(reject, new Error("Updated desktop runtime could not start.")));
@@ -1161,13 +1294,27 @@ export async function startDesktopHost({ input = process.stdin, output = process
1161
1294
  }
1162
1295
  let event; try { event = JSON.parse(line); } catch { return; }
1163
1296
  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)) {
1297
+ if (event.type === "status" && !validating) {
1298
+ 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
1299
  finish(reject, new Error("Updated desktop runtime identity mismatch."));
1167
- } else { accepted = true; finish(accept, event); }
1300
+ } else {
1301
+ validating = true;
1302
+ void (async () => {
1303
+ // Sign-in and organization changes use a separate command
1304
+ // queue; re-read after all candidate startup awaits.
1305
+ const currentIdentity = await agent.getDesktopStatus();
1306
+ if (!await acceptStatusAccess(currentIdentity.access)) {
1307
+ finish(reject, new Error("Updated desktop runtime identity changed during handoff.")); return;
1308
+ }
1309
+ if (closed || child.exitCode !== null || expectedIdentityRevision !== updateIdentityRevision || !sameUpdateIdentity(updateIdentity, currentIdentity)) {
1310
+ finish(reject, new Error("Updated desktop runtime identity changed during handoff."));
1311
+ } else { accepted = true; finish(accept, event); }
1312
+ })().catch(error => finish(reject, error));
1313
+ }
1168
1314
  }
1169
1315
  });
1170
1316
  });
1317
+ if (expectedIdentityRevision !== updateIdentityRevision || agent.getDesktopAccess && !sameAccess(updateIdentity.access, agent.getDesktopAccess())) throw new Error("Updated desktop runtime identity changed during handoff.");
1171
1318
  relay = child;
1172
1319
  await stopAgent();
1173
1320
  if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
@@ -1184,7 +1331,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
1184
1331
  }
1185
1332
  } });
1186
1333
  await refreshPlugins();
1187
- await publish();
1334
+ startupPublication = publish();
1335
+ void startupPublication.catch(() => {});
1188
1336
  const maintenance = new Map();
1189
1337
  const tick = worker => {
1190
1338
  if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
@@ -1579,18 +1727,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
1579
1727
  return;
1580
1728
  }
1581
1729
  if (action === 'session_get_status') {
1582
- await selectedSession(command);
1583
- await publish();
1584
1730
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1585
- const row = lastStatus?.sessionSupervision?.sessions?.find(item =>
1731
+ const cached = lastStatus?.sessionSupervision?.sessions?.find(item =>
1586
1732
  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.');
1733
+ const record = await readRecord(deliveryRoot, command.provider, command.session);
1734
+ const observed = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
1735
+ if (!record && !observed) throw new Error('This conversation status is unavailable; refresh its provider.');
1736
+ // Read one durable task without scheduling a complete provider scan
1737
+ // or waiting for optional account-wide enrichment. Observation times
1738
+ // remain original, so a cached running flag cannot become fresh.
1739
+ const candidate = { ...(cached || { provider: command.provider, session: command.session, cwd: record?.cwd,
1740
+ observation: { observedAt: record?.runtime?.observedAt }, isRunning: record?.runtime?.isRunning }), ...observed };
1741
+ const row = { ...projectSessionAutoContinue(automaticContinuation.project(analysisManager.project(candidate)), record),
1742
+ workState: sessionWorkState(candidate, record?.run, { provider: sessionStatus.providers?.[command.provider] }) };
1743
+ const controls = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: record ? [record] : [] });
1744
+ const selectedRun = controls.runs?.find(run => run.provider === command.provider && run.session === command.session);
1745
+ row.decisionRequest = sessionDecisionPresentation(row, selectedRun);
1746
+ row.conversationPresentation = conversationPresentation(row, selectedRun, { rawRecord: record });
1747
+ await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1588
1748
  // The same app projection carries observation time and next action;
1589
1749
  // saved consent and an active record cannot prove a provider send.
1590
1750
  const { provider, session, title, autoContinue, autoContinueStatus, autoContinueReason,
1591
- binding, workState, dispatchReadiness, conversationPresentation } = row;
1751
+ binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest } = row;
1592
1752
  emit({ type: 'result', id: command.id, ok: true, data: { provider, session, title,
1593
- autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation } });
1753
+ autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation: presentation, decisionRequest } });
1594
1754
  return;
1595
1755
  }
1596
1756
  if (action === 'session_read_context') {
@@ -1666,7 +1826,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1666
1826
  await agent.refreshDesktopAccess?.();
1667
1827
  } else if (action === 'desktop_sqm_settings') {
1668
1828
  const verified = await agent.refreshDesktopAccess?.();
1669
- await authorizeWork(commandScope);
1829
+ // Navigation starts no work. Keep scope and entitlement checks while
1830
+ // allowing users to inspect SQM during an update admission hold.
1831
+ const currentAccess = await accessSnapshot();
1832
+ requireDesktopAccess(currentAccess);
1833
+ if (accessScope(currentAccess) !== commandScope) throw Object.assign(new Error('Desktop account or organization changed.'), { code: 'desktop_access_required' });
1670
1834
  if (!desktopAccessReady(verified) || verified.organizationId !== access.organizationId ||
1671
1835
  !validDesktopAccessPath(verified.settingsPath, verified.organizationSlug)) throw new Error('Organization settings are unavailable; refresh access first.');
1672
1836
  const url = new URL(`/org/${encodeURIComponent(verified.organizationSlug)}/quality-monitor`, webOrigin);
@@ -1807,23 +1971,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
1807
1971
  await actionAssociations.correct(command, analysisManager.project(candidate));
1808
1972
  } else if (action === "session_set_auto_continue") {
1809
1973
  if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
1810
- let admission, providerEpoch;
1974
+ let admission, target, providerEpoch;
1811
1975
  if (command.enabled) {
1812
1976
  const candidate = await selectedSession(command, { preferObserved: true });
1813
1977
  const canEnable = candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue;
1814
- if (canEnable !== true) {
1978
+ if (candidate.controllerCanSetAutoContinue !== true && canEnable !== true) {
1815
1979
  throw new AutoContinueUnavailableError({ reason: candidate.autoContinueAvailability?.reason || candidate.reason,
1816
1980
  reasonCode: candidate.autoContinueAvailability?.reasonCode || candidate.continuationReasonCode });
1817
1981
  }
1818
1982
  admission = candidate.continuationAdmission;
1983
+ target = { provider: candidate.provider, session: candidate.session, cwd: candidate.cwd };
1819
1984
  providerEpoch = selectedLinkEpochs.get(candidate);
1820
1985
  }
1821
1986
  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 });
1987
+ const saved = await (dependencies.setSessionAutoContinue || saveSessionAutoContinuePreference)({ root: join(root, 'delivery-hooks'),
1988
+ provider: command.provider, session: command.session, enabled: command.enabled, initialContinuationPolicy: command.continuationPolicy, admission, target, expectedProviderEpoch: providerEpoch });
1989
+ if (saved) {
1990
+ pendingCatalogPreferences.set(`${saved.provider}:${saved.session}`, saved);
1991
+ sessionStatus = { ...sessionStatus, catalogRevision: ++catalogRevision,
1992
+ sessions: sessionStatus.sessions.map(candidate => projectSessionAutoContinue(candidate, saved)) };
1993
+ }
1824
1994
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
1825
1995
  // Acknowledge the durable preference before discovery or analysis.
1826
1996
  emit({ type: "result", id: command.id, ok: true });
1997
+ void refreshCatalogAnalysis();
1827
1998
  refreshSessions(true);
1828
1999
  // Discovery's tracking transition schedules analysis. A second
1829
2000
  // forced request could invalidate a concurrent manual recommendation.
@@ -2150,6 +2321,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
2150
2321
  ownerTransport = await createOwnerTransport({ root, nonce: lock.nonce, allowedActions, allowControllerArchive: env.SIMY_REMOTE_CHILD !== '1', dispatch: command => commands.emit('line', JSON.stringify(command)) });
2151
2322
  await publish();
2152
2323
  }
2324
+ // Register controls before awaiting initial presentation. A fast, authorized
2325
+ // catalog frame is sufficient; unrelated optional enrichment may still wait.
2326
+ try { await Promise.race([startupPublication, firstStatus]); }
2327
+ catch (error) { await close(); throw error; }
2153
2328
  return { close, publish, agent };
2154
2329
  }
2155
2330
 
@@ -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;