@awak-app/simy-cli 0.6.16 → 0.6.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
2
+ import { randomUUID } from "node:crypto";
1
3
  import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
2
4
  import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
3
5
  import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-plugin-status.js';
@@ -8,11 +10,11 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
8
10
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
9
11
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
10
12
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
11
- import { controlDelivery, verifyDelivery, createRecordReader, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
13
+ import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
12
14
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
13
15
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
14
16
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
15
- import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
17
+ import { AutoContinueUnavailableError, answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
16
18
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
17
19
  import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
18
20
  import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
@@ -48,9 +50,37 @@ const silentLogger = { log() {}, error() {} };
48
50
  // exceed 128 KiB, including after a signed runtime handoff.
49
51
  const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
50
52
 
53
+ async function withReadCancellation(signal, work) {
54
+ signal.throwIfAborted();
55
+ let cancelled;
56
+ const cancellation = new Promise((_, reject) => {
57
+ cancelled = () => reject(Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' }));
58
+ signal.addEventListener('abort', cancelled, { once: true });
59
+ });
60
+ try { return await Promise.race([Promise.resolve().then(work), cancellation]); }
61
+ finally { signal.removeEventListener('abort', cancelled); }
62
+ }
63
+
51
64
  function desktopActionError(action, error) {
65
+ if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
66
+ if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
67
+ if (["provider_connect", "provider_disconnect"].includes(action)) return "Could not finish changing the SIMY connection. Refresh its status and try again.";
68
+ if (action === "session_set_auto_continue") {
69
+ const messages = {
70
+ session_workspace_changed: "The conversation workspace changed. Refresh and select it again.",
71
+ session_metadata_unavailable: "Conversation details are unavailable. Refresh this provider and select the conversation again.",
72
+ session_model_unavailable: "This provider did not expose the conversation model; automatic continuation is unavailable.",
73
+ existing_loop: "This conversation already has a Delivery Loop. Open its existing supervision instead.",
74
+ task_settled: "This task has finished. Start a new conversation before enabling automatic continuation.",
75
+ };
76
+ if (messages[error?.code]) return messages[error.code];
77
+ }
52
78
  if (action === 'authorization_open' || error?.code === 'desktop_browser_unavailable') return "Could not open your browser. Open the verification link again, or check your default browser.";
53
79
  if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
80
+ if (action === "codex_hooks" || action === "claude_hooks") return "Could not open the setup guide. Check your default browser, or use /hooks inside the provider.";
81
+ if (action === "install_plugins") return error?.code === "plugin_setup_busy"
82
+ ? "Another SIMY process is preparing plugins. Wait, then refresh to check the files."
83
+ : "Plugin setup could not finish. Refresh its status or retry setup.";
54
84
  if (action === "session_deadline_decide") return error?.code === "conflict"
55
85
  ? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
56
86
  if (typeof action === "string" && action.startsWith("session_")) {
@@ -65,6 +95,10 @@ function desktopActionError(action, error) {
65
95
  return "Could not save SQM settings. Check your SIMY connection and try again.";
66
96
  }
67
97
  if (action === 'update') return 'Update could not finish. Restart SIMY and retry; stopped tasks stay stopped.';
98
+ if (/^(codex|claude)_login$/.test(action || "")) {
99
+ if (error?.code === "provider_not_installed") return "Provider CLI is not installed. Install it, then refresh connections.";
100
+ return "Could not open the sign-in terminal. Check your Terminal / PowerShell app, then try again.";
101
+ }
68
102
  return "Action could not finish. Refresh the status and try again.";
69
103
  }
70
104
 
@@ -74,8 +108,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
74
108
  // A heartbeat remains a complete native frame. Only its observation time is
75
109
  // volatile; reuse the encoded body after comparing freshly authorized data.
76
110
  // Detach the comparison value because registries may mutate nested objects.
77
- let encodedStatus = null, lastStatus = null;
111
+ let encodedStatus = null, lastStatus = null, updateProgress = null;
78
112
  const emitStatus = status => {
113
+ if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
79
114
  const { sampledAt, ...content } = status;
80
115
  if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
81
116
  encodedStatus = JSON.stringify(content).slice(1, -1);
@@ -95,10 +130,21 @@ export async function startDesktopHost({ input = process.stdin, output = process
95
130
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
96
131
  const deliveryRoot = join(root, "delivery-hooks");
97
132
  const recordReader = createRecordReader(deliveryRoot);
133
+ let providerLinks = await readProviderLinks(root);
134
+ const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
135
+ const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
136
+ const analysisSessions = sessions => linkedSessions(sessions).filter(item => !providerLinks[item.provider].requiresConsent ||
137
+ item.autoContinue === true || item.tracked === true || item.monitor === true || explicitAnalyses.has(`${item.provider}:${item.session}`));
138
+ const authorizeProvider = async (provider, scope, expectedEpoch) => {
139
+ await authorizeWork(scope);
140
+ return requireProviderLink({ root: deliveryRoot, provider, expectedEpoch });
141
+ };
98
142
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
99
- const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
143
+ const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: async () => linkedSessions(await recordReader.list()), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
100
144
  let actionAssociations = makeActionAssociations();
101
145
  const statisticsCache = new Map();
146
+ let activeStatisticsRead = null;
147
+ let statisticsSelection = { key: null, generation: 0 };
102
148
  const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
103
149
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
104
150
  const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
@@ -107,6 +153,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
107
153
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
108
154
  if (previousScope !== accessScope(currentAccess)) {
109
155
  statisticsCache.clear();
156
+ activeStatisticsRead?.controller.abort();
157
+ explicitAnalyses.clear();
110
158
  discoveryController?.abort();
111
159
  sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
112
160
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
@@ -115,9 +163,44 @@ export async function startDesktopHost({ input = process.stdin, output = process
115
163
  }
116
164
  return currentAccess;
117
165
  };
118
- const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
119
- let stoppingForUpdate = false;
166
+ const sameAccess = (left, right) => accessScope(left) === accessScope(right) && left?.state === right?.state;
167
+ const readAccess = async () => agent?.getDesktopAccess
168
+ ? agent.getDesktopAccess() : (await agent?.getDesktopStatus?.())?.access;
169
+ const accessSnapshot = async () => {
170
+ await setAccessSnapshot(agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess());
171
+ // Cleanup can yield while the account changes. Never return that obsolete
172
+ // access, or reapply it after another caller installed the new scope.
173
+ if (agent?.getDesktopAccess && !sameAccess(currentAccess, agent.getDesktopAccess())) return accessSnapshot();
174
+ return currentAccess;
175
+ };
176
+ const acceptStatusAccess = async sampledAccess => {
177
+ // Validate before setAccessSnapshot: it clears the catalog/statistics and
178
+ // revokes continuations, so rejecting only the emitted frame is too late.
179
+ const before = agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess();
180
+ if (!sameAccess(sampledAccess, before)) return false;
181
+ await setAccessSnapshot(sampledAccess);
182
+ const latestAccess = agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess();
183
+ if (!sameAccess(sampledAccess, latestAccess)) {
184
+ await accessSnapshot();
185
+ return false;
186
+ }
187
+ return sameAccess(currentAccess, sampledAccess);
188
+ };
189
+ let stoppingForUpdate = false, updateOperation = null, updateOperationKind = null, updateDrain = null, releaseUpdateHold = null;
120
190
  let unfinishedDesktopWork = true;
191
+ const runningUpdateDeliveryTask = record => record.run && ['active', 'auto_resuming', 'recovery_required'].includes(record.run.state);
192
+ const stopUpdateDeliveryTasks = async () => {
193
+ const records = await recordReader.list();
194
+ const results = await Promise.allSettled(records.map(async record => {
195
+ if (record.autoContinue === true) await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: deliveryRoot, provider: record.provider, session: record.session, enabled: false });
196
+ if (record.run && !TERMINAL.has(record.run.state)) await controlDelivery({
197
+ root: deliveryRoot, provider: record.provider, session: record.session,
198
+ taskId: record.run.managed?.taskId, action: 'stop',
199
+ });
200
+ }));
201
+ const failure = results.find(result => result.status === 'rejected');
202
+ if (failure) throw failure.reason;
203
+ };
121
204
  let organizationAnalysesSuspended = false;
122
205
  let organizationAnalysisDrain = Promise.resolve();
123
206
  let organizationAnalysisEpoch = 0;
@@ -131,15 +214,19 @@ export async function startDesktopHost({ input = process.stdin, output = process
131
214
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
132
215
  return scope;
133
216
  };
134
- const selectedScopes = new WeakMap();
217
+ const selectedScopes = new WeakMap(), selectedLinkEpochs = new WeakMap();
135
218
  const guardedEnroll = async options => {
136
- await authorizeWork();
219
+ await authorizeProvider(options.provider);
137
220
  return (dependencies.continueSession || continueSession)(options);
138
221
  };
139
222
 
140
223
  const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
141
224
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
142
- authorizeDispatch: authorizeWork, readRecords: () => recordReader.list(),
225
+ authorizeDispatch: async record => {
226
+ const scope = await authorizeWork();
227
+ const link = await requireProviderLink({ root: deliveryRoot, provider: record.provider });
228
+ return JSON.stringify([scope, link.epoch]);
229
+ }, readRecords: () => recordReader.list(),
143
230
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
144
231
  let shuttingDown = null;
145
232
  let commands;
@@ -155,6 +242,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
155
242
  let confirmedUpdateStop;
156
243
  const stopWorkForUpdate = () => confirmedUpdateStop ||= (async () => {
157
244
  stoppingForUpdate = true; discoveryStopped = true;
245
+ activeStatisticsRead?.controller.abort();
158
246
  void stopDiscovery();
159
247
  await stopDesktopWorkForUpdate({
160
248
  stopWork: () => Promise.all([
@@ -183,7 +271,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
183
271
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
184
272
  const requestedScope = accessScope(currentAccess);
185
273
  const controller = discoveryController = new AbortController();
186
- sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal }); })
274
+ sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
187
275
  .then(async value => {
188
276
  if (closed || relay || controller.signal.aborted) return;
189
277
  const latestAccess = await accessSnapshot();
@@ -193,7 +281,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
193
281
  sessionRefreshAgain = desktopAccessReady(latestAccess);
194
282
  return;
195
283
  }
196
- await analysisManager.update(value.sessions); sessionStatus = { ...value, initialLoading: false }; sessionRefreshedAt = Date.now(); })
284
+ const sessions = linkedSessions(value.sessions);
285
+ await analysisManager.update(analysisSessions(sessions));
286
+ if (controller.signal.aborted) return;
287
+ sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
197
288
  .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
198
289
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
199
290
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
@@ -202,6 +293,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
202
293
  };
203
294
  const selectedSession = async (command, { reuseDiscovery = false, preferObserved = false } = {}) => {
204
295
  const scope = await authorizeWork();
296
+ const link = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
205
297
  // Preference changes may use the observation already displayed by the UI.
206
298
  // Execution still takes the fresh-discovery path and rechecks opt-in epochs.
207
299
  const matches = item => item.provider === command.provider && item.session === command.session;
@@ -216,21 +308,72 @@ export async function startDesktopHost({ input = process.stdin, output = process
216
308
  candidate = sessionStatus.sessions.find(matches);
217
309
  }
218
310
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
219
- await authorizeWork(scope);
311
+ await authorizeProvider(command.provider, scope, link.epoch);
220
312
  const selected = { ...candidate };
221
313
  selectedScopes.set(selected, scope);
314
+ selectedLinkEpochs.set(selected, link.epoch);
222
315
  return selected;
223
316
  };
317
+ const revokeProviderConsent = async provider => {
318
+ const records = (await recordReader.list()).filter(record => record.provider === provider);
319
+ for (const record of records) {
320
+ automaticContinuation?.revoke(provider, record.session);
321
+ await updateRecord(deliveryRoot, provider, record.session, previous => {
322
+ if (!previous) return previous;
323
+ const next = { ...previous, autoContinue: false, autoContinueEpoch: randomUUID(), tracked: false, monitor: false };
324
+ if (previous.run && !TERMINAL.has(previous.run.state)) {
325
+ next.run = { ...previous.run, state: "paused", reason: "Provider disconnected from SIMY.",
326
+ revision: (previous.run.revision || 0) + 1, updatedAt: Date.now(),
327
+ ...(previous.run.managed ? { managed: { ...previous.run.managed, autoRecovery: false } } : {}) };
328
+ }
329
+ return next;
330
+ });
331
+ }
332
+ };
333
+ const changeProviderLink = async (provider, connected) => {
334
+ if (!PROVIDER_LINKS.includes(provider)) throw new Error("Unsupported SIMY provider connection.");
335
+ if (providerLinkBusy.has(provider)) throw new Error("SIMY connection is already changing.");
336
+ providerLinkBusy.add(provider);
337
+ providerLinkFeedback[provider] = { state: connected ? "connecting" : "disconnecting" };
338
+ try {
339
+ providerLinks = await readProviderLinks(root);
340
+ if (!connected) providerLinks = await writeProviderLink(root, provider, false);
341
+ if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
342
+ discoveryController?.abort();
343
+ sessionRefreshAgain = false;
344
+ for (const item of sessionStatus.sessions.filter(item => item.provider === provider)) automaticContinuation?.revoke(provider, item.session);
345
+ for (const key of explicitAnalyses) if (key.startsWith(`${provider}:`)) explicitAnalyses.delete(key);
346
+ statisticsCache.clear();
347
+ if (!connected) sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessionStatus.sessions) };
348
+ await analysisManager.update(analysisSessions(sessionStatus.sessions));
349
+ await publish();
350
+ // A crash after saving disconnected must not restore old session consent
351
+ // when the user reconnects. Revoke before persisting connected again.
352
+ if (!connected || !providerLinks[provider].connected) await revokeProviderConsent(provider);
353
+ if (connected && !providerLinks[provider].connected) {
354
+ await authorizeWork();
355
+ providerLinks = await writeProviderLink(root, provider, true);
356
+ }
357
+ delete providerLinkFeedback[provider];
358
+ try { await agent.refreshProviderStatus?.(); }
359
+ catch { providerLinkFeedback[provider] = { message: "SIMY connection saved. Provider status could not be verified; refresh connections to check it." }; }
360
+ refreshSessions(true);
361
+ } catch (error) {
362
+ providerLinkFeedback[provider] = { state: "error", message: desktopActionError(connected ? "provider_connect" : "provider_disconnect", error) };
363
+ throw error;
364
+ } finally { providerLinkBusy.delete(provider); await publish(); }
365
+ };
224
366
  const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
225
367
  getManager: () => analysisManager, selectCandidate: selectedSession,
226
368
  readSetting: dependencies.readSessionAutoContinue,
227
369
  readContext: dependencies.readContinuationContext || readContinuationContext,
228
- enroll: guardedEnroll, beforeEnroll: candidate => authorizeWork(selectedScopes.get(candidate)),
370
+ enroll: guardedEnroll, beforeEnroll: candidate => authorizeProvider(candidate.provider, selectedScopes.get(candidate), selectedLinkEpochs.get(candidate)),
229
371
  onChange: () => { if (!closed && !relay) refreshSessions(true); },
230
372
  });
231
373
  const suspendOrganizationAnalyses = () => {
232
374
  organizationAnalysisEpoch++;
233
375
  organizationAnalysesSuspended = true;
376
+ activeStatisticsRead?.controller.abort();
234
377
  discoveryStopped = true;
235
378
  organizationAnalysisDrain = Promise.all([
236
379
  stopDiscovery({ drain: true }), automaticContinuation?.close(), analysisManager?.suspend?.(),
@@ -252,6 +395,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
252
395
  refreshSessions(true);
253
396
  };
254
397
  let pluginSetup = { state: "unknown" };
398
+ const providerAuth = {};
399
+ const providerAuthBusy = new Set();
400
+ let pluginInstallationActive = false, pluginSetupEpoch = 0;
255
401
  let providerPlugins = { codex: { state: 'checking' }, claude: { state: 'checking' } };
256
402
  let pluginInspection = null, lastPluginInspectionAt = -Infinity;
257
403
  let pluginInspectionAbort = new AbortController();
@@ -267,20 +413,40 @@ export async function startDesktopHost({ input = process.stdin, output = process
267
413
  return pluginInspection;
268
414
  };
269
415
  const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
270
- const refreshPlugins = async () => {
416
+ const refreshPlugins = async ({ settleInstall = false } = {}) => {
417
+ const epoch = pluginSetupEpoch;
418
+ const canApply = () => epoch === pluginSetupEpoch && (!pluginInstallationActive || settleInstall);
271
419
  try {
272
420
  const status = await inspectPlugins();
273
- pluginSetup = { state: status.installed ? "ready" : "missing", refreshPending: status.installed && pluginSetup.refreshPending === true };
274
- } catch { pluginSetup = { state: "error" }; }
421
+ if (canApply()) pluginSetup = { state: status.installed ? "ready" : "missing", refreshPending: status.installed && pluginSetup.refreshPending === true };
422
+ } catch { if (canApply()) pluginSetup = { state: "error" }; }
275
423
  void refreshProviderPlugins();
276
424
  };
425
+ const decorateProviderSettings = async status => {
426
+ providerLinks = await readProviderLinks(root);
427
+ sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessionStatus.sessions) };
428
+ status.providerLinks = Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
429
+ ...providerLinks[provider], state: providerLinks[provider].connected ? "connected" : "disconnected",
430
+ ...(providerLinks[provider].settingsError ? { state: "error", message: "SIMY connection settings could not be read. Restore the settings before reconnecting." } : {}),
431
+ ...providerLinkFeedback[provider], busy: providerLinkBusy.has(provider),
432
+ }]));
433
+ status.pluginSetup = { ...pluginSetup };
434
+ status.providerPlugins = structuredClone(providerPlugins);
435
+ for (const provider of ["codex", "claude"]) {
436
+ if (providerAuth[provider]?.state === "awaiting_sign_in" && !providerAuthBusy.has(provider) && status.providers?.[provider]?.state === "ready") delete providerAuth[provider];
437
+ }
438
+ status.providerAuth = Object.fromEntries(Object.entries(providerAuth).map(([provider, value]) => [provider, { ...value, busy: providerAuthBusy.has(provider) }]));
439
+ return JSON.stringify(providerLinks);
440
+ };
277
441
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
278
442
  diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
279
443
  const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
280
444
  root: join(root, 'session-analysis'), analyze: async (...args) => {
281
445
  const scope = await authorizeWork();
282
446
  const [candidate, options] = args;
283
- return (dependencies.analyzeContinuation || analyzeContinuation)(candidate, { ...options, beforeProviderDispatch: () => authorizeWork(scope) });
447
+ const link = await requireProviderLink({ root: deliveryRoot, provider: candidate.provider });
448
+ return (dependencies.analyzeContinuation || analyzeContinuation)(candidate, { ...options,
449
+ beforeProviderDispatch: () => authorizeProvider(candidate.provider, scope, link.epoch) });
284
450
  },
285
451
  onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
286
452
  });
@@ -296,21 +462,72 @@ export async function startDesktopHost({ input = process.stdin, output = process
296
462
  return scheduledPublish.promise;
297
463
  };
298
464
  let lastDeadlineFailureAt = -Infinity;
465
+ let connectionPublishing = null;
466
+ let connectionRevision = 0;
467
+ let latestConnectionStatus = null;
468
+ // Optional conversation enrichment can wait on local records or remote
469
+ // metadata. Keep sampling the real connection while that projection waits;
470
+ // replaying an old frame alone would hide sign-out or organization changes.
471
+ const publishConnectionStatus = () => {
472
+ if (connectionPublishing) return connectionPublishing;
473
+ connectionPublishing = Promise.resolve().then(() => agent.getDesktopStatus()).then(async status => {
474
+ if (closed || relay) return;
475
+ const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
476
+ if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
477
+ status.access = sampledAccess;
478
+ const requestedLinks = await decorateProviderSettings(status);
479
+ const sameScope = desktopAccessReady(currentAccess) &&
480
+ accessScope(lastStatus?.access) === accessScope(currentAccess);
481
+ status.deliveryHooks = sameScope && lastStatus?.deliveryHooks
482
+ ? structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
483
+ status.sessionSupervision = sameScope && lastStatus?.sessionSupervision
484
+ ? structuredClone(lastStatus.sessionSupervision) : { sessions: [], providers: {}, refreshedAt: null, initialLoading: desktopAccessReady(currentAccess) };
485
+ // Cached enrichment must follow the current connection preference even
486
+ // while a full projection is waiting on records or remote metadata.
487
+ status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
488
+ status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []);
489
+ status.sessionSupervision.providers ||= {};
490
+ for (const provider of PROVIDER_LINKS) {
491
+ if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
492
+ }
493
+ const update = status.update || updater.snapshot?.() || {};
494
+ status.update = { ...update, requires_task_stop: update.requires_task_stop === true || unfinishedDesktopWork ||
495
+ (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 };
496
+ status.updateActiveTaskCount = Math.max(lastStatus?.updateActiveTaskCount || 0,
497
+ (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0));
498
+ if (!desktopAccessReady(currentAccess)) {
499
+ status.tasks = { activeCount: status.tasks?.activeCount || 0, items: [] };
500
+ status.sqmChecks = { activeCount: 0, items: [] };
501
+ status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
502
+ }
503
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
504
+ const latestAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : await accessSnapshot();
505
+ if (accessScope(latestAccess) !== accessScope(status.access) || latestAccess?.state !== status.access?.state) {
506
+ publishAgain = true;
507
+ return;
508
+ }
509
+ if (!closed && !relay) { connectionRevision += 1; latestConnectionStatus = structuredClone(status); emitStatus(status); }
510
+ }).finally(() => { connectionPublishing = null; });
511
+ return connectionPublishing;
512
+ };
299
513
  const projectStatus = () => {
300
514
  if (closed || relay || !agent) return Promise.resolve();
301
- if (publishing) { publishAgain = true; return publishing; }
515
+ if (publishing) { publishAgain = true; return publishConnectionStatus(); }
516
+ const requestedConnectionRevision = connectionRevision;
302
517
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
303
518
  if (closed || relay) return;
304
- await setAccessSnapshot(status.access);
305
- status.access = currentAccess;
519
+ const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
520
+ if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
521
+ status.access = sampledAccess;
306
522
  const requestedScope = accessScope(currentAccess);
307
- status.pluginSetup = { ...pluginSetup };
308
- status.providerPlugins = structuredClone(providerPlugins);
309
- const records = await recordReader.list();
523
+ const requestedLinks = await decorateProviderSettings(status);
524
+ const allRecords = await recordReader.list();
525
+ const records = linkedSessions(allRecords);
310
526
  const update = status.update || updater.snapshot?.() || {};
311
- if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(records);
527
+ if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(allRecords);
312
528
  status.update = { ...update, requires_task_stop: update.requires_task_stop === true ||
313
- (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || hasUnfinishedSessions(records) };
529
+ (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || hasUnfinishedSessions(allRecords) };
530
+ status.updateActiveTaskCount = (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) + allRecords.filter(runningUpdateDeliveryTask).length;
314
531
  if (!desktopAccessReady(currentAccess)) {
315
532
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
316
533
  if (currentAccess.state !== 'checking') await analysisManager.update([]);
@@ -319,6 +536,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
319
536
  status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
320
537
  status.deliveryHooks = { runs: [], providers: {} };
321
538
  status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
539
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
540
+ if (connectionRevision !== requestedConnectionRevision || !sameAccess(sampledAccess, agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess())) { publishAgain = true; return; }
322
541
  if (!closed && !relay) emitStatus(status);
323
542
  return;
324
543
  }
@@ -333,7 +552,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
333
552
  await actionAssociations.decorateSessions?.(sessions, projection);
334
553
  const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
335
554
  for (const session of sessions) {
336
- session.statistics = statisticsCache.get(`${session.provider}:${session.session}`) || unavailableStatistics();
555
+ const key = `${session.provider}:${session.session}`;
556
+ session.statistics = activeStatisticsRead?.key === key && !activeStatisticsRead.controller.signal.aborted
557
+ ? { state: 'loading', hours: [], activity: [] }
558
+ : statisticsCache.get(key) || unavailableStatistics();
337
559
  session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
338
560
  session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
339
561
  try {
@@ -350,10 +572,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
350
572
  const run = runsBySession.get(`${session.provider}:${session.session}`);
351
573
  if (run) run.actionDeadline = session.actionDeadline;
352
574
  }
353
- status.sessionSupervision = { ...discovery, sessions };
575
+ status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions };
354
576
  const latestAccess = await accessSnapshot();
355
- if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
577
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
578
+ !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
356
579
  actionAssociations.validateProjection && !await actionAssociations.validateProjection(projection)) { publishAgain = true; return; }
580
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
581
+ // Validation may wait for synchronization. Recheck access after that
582
+ // await so a sign-out or scope change cannot publish the old projection.
583
+ const validatedAccess = agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess();
584
+ if (!sameAccess(sampledAccess, validatedAccess)) { publishAgain = true; return; }
585
+ if (connectionRevision !== requestedConnectionRevision) {
586
+ // Keep successfully enriched metadata, but never roll fresh connection
587
+ // observations back to the status captured before that work began.
588
+ if (accessScope(latestConnectionStatus?.access) !== requestedScope || !desktopAccessReady(latestConnectionStatus?.access)) { publishAgain = true; return; }
589
+ status = { ...structuredClone(latestConnectionStatus), deliveryHooks: status.deliveryHooks, sessionSupervision: status.sessionSupervision };
590
+ }
357
591
  if (!closed && !relay) emitStatus(status);
358
592
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
359
593
  return publishing;
@@ -361,12 +595,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
361
595
  const stopAgent = async () => {
362
596
  clearInterval(timer);
363
597
  pluginInspectionAbort.abort();
598
+ activeStatisticsRead?.controller.abort();
364
599
  discoveryStopped = true;
365
600
  encodedStatus = null; lastStatus = null;
366
601
  if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
367
602
  // Start every cancellation before draining discovery/network work. A hung
368
603
  // reader must never postpone cancellation of an owned Provider process.
369
- const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing,
604
+ const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing, connectionPublishing,
370
605
  pluginInspection,
371
606
  actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
372
607
  managedSupervisor.close(), recordReader.close(), stopWorkflows(),
@@ -505,6 +740,73 @@ export async function startDesktopHost({ input = process.stdin, output = process
505
740
  }, 3_000);
506
741
  } catch (error) { await close(); throw error; }
507
742
 
743
+ const drainForUpdate = async ({ releaseHold = true } = {}) => {
744
+ // Acquire the hold before suspending dispatch: an automatic update may
745
+ // already have started since the last native status frame.
746
+ releaseUpdateHold ||= updater.holdApply?.() || (() => {});
747
+ stoppingForUpdate = true; discoveryStopped = true;
748
+ activeStatisticsRead?.controller.abort();
749
+ updateProgress = { state: 'stopping_tasks', available: true, message: 'Stopping running tasks before updating…' };
750
+ void publish().catch(() => {});
751
+ // Start every cancellation immediately. A blocked stop/detail command or
752
+ // discovery request must not postpone cancellation in another worker.
753
+ updateDrain ||= (async () => {
754
+ const results = await Promise.allSettled([stopDiscovery(), sessionRefresh,
755
+ stopUpdateDeliveryTasks(), agent.stopDesktopTasks(), managedSupervisor.close(),
756
+ automaticContinuation?.close(), analysisManager?.close()]);
757
+ const failure = results.find(result => result.status === 'rejected');
758
+ if (failure) throw failure.reason;
759
+ const status = await agent.getDesktopStatus();
760
+ if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
761
+ (await recordReader.list()).some(record => record.autoContinue === true || record.run && !TERMINAL.has(record.run.state))) throw new Error('Tasks are still running.');
762
+ })();
763
+ const draining = updateDrain;
764
+ void draining.finally(() => { if (updateDrain === draining) updateDrain = null; }).catch(() => {});
765
+ let timeout, timedOut = false;
766
+ try {
767
+ await Promise.race([updateDrain, new Promise((_, reject) => {
768
+ timeout = setTimeout(() => { timedOut = true; reject(new Error('Task shutdown timed out.')); }, dependencies.updateDrainTimeoutMs ?? dependencies.updateStopTimeoutMs ?? 20_000);
769
+ })]);
770
+ } catch {
771
+ if (timedOut) await (dependencies.stopOwnedHost || stopOwnedDesktopHost)(process.pid, { parentPid: process.ppid, includeRoot: false });
772
+ // Keep the hold after a timeout: a late lease release must never start
773
+ // installation after the native UI has reported that shutdown failed.
774
+ updateProgress = { state: 'update_stop_failed', available: true,
775
+ message: 'Tasks could not stop. The update has not started. Retry, or reopen SIMY.' };
776
+ void publish().catch(() => {});
777
+ throw new Error(updateProgress.message);
778
+ } finally { clearTimeout(timeout); }
779
+ unfinishedDesktopWork = false;
780
+ if (releaseHold) { releaseUpdateHold(); releaseUpdateHold = null; }
781
+ };
782
+ const runUpdate = async force => {
783
+ try {
784
+ if ((force || stoppingForUpdate) && updater.applyConfirmed) {
785
+ await updater.applyConfirmed(async () => { await drainForUpdate(); updateProgress = null; });
786
+ if (updateProgress?.state === 'update_stop_failed') throw new Error(updateProgress.message);
787
+ } else {
788
+ if (force || stoppingForUpdate) await drainForUpdate();
789
+ updateProgress = null;
790
+ if (updater.snapshot().state === 'waiting_for_idle') await updater.maybeApply();
791
+ else await updater.performAction();
792
+ }
793
+ if (stoppingForUpdate && ['failed', 'error', 'waiting_for_idle'].includes(updater.snapshot().state)) throw new Error('Update did not finish.');
794
+ } catch (error) {
795
+ if (!stoppingForUpdate || updateProgress?.state === 'update_stop_failed') throw error;
796
+ updateProgress = { state: 'update_failed', available: true,
797
+ message: 'The update could not finish. Tasks remain stopped. Retry, or reopen SIMY.' };
798
+ throw new Error(updateProgress.message);
799
+ }
800
+ };
801
+ const executeUpdate = (prepareRestart = false) => {
802
+ const kind = prepareRestart ? 'restart' : 'install';
803
+ if (updateOperation && updateOperationKind !== kind) throw new Error('Another update operation is in progress. Retry after it finishes.');
804
+ if (updateOperation) return updateOperation;
805
+ updateOperationKind = kind;
806
+ return updateOperation = (prepareRestart ? drainForUpdate({ releaseHold: false }) : runUpdate(true))
807
+ .finally(() => { updateOperation = null; updateOperationKind = null; });
808
+ };
809
+
508
810
  let queue = Promise.resolve();
509
811
  // Authorization must not wait behind remote refresh/detail/provider work.
510
812
  // Keep these commands ordered with each other; the agent still owns all
@@ -528,6 +830,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
528
830
  if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
529
831
  return;
530
832
  }
833
+ // Read-only activity must never hold the ordered control queue. Share an
834
+ // identical pending selection and cancel superseded selections immediately.
835
+ let statisticsRequest, requestedStatisticsSelection;
836
+ if (command.action === 'session_statistics') {
837
+ const key = `${command.provider}:${command.session}`;
838
+ if (statisticsSelection.key !== key) {
839
+ statisticsSelection = { key, generation: statisticsSelection.generation + 1 };
840
+ activeStatisticsRead?.controller.abort();
841
+ }
842
+ requestedStatisticsSelection = statisticsSelection;
843
+ }
531
844
  const execute = async () => {
532
845
  if (closed) return;
533
846
  try {
@@ -559,23 +872,67 @@ export async function startDesktopHost({ input = process.stdin, output = process
559
872
  emit({ type: "result", id: command.id, ok: true });
560
873
  void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); return;
561
874
  }
875
+ if (action === 'prepare_update_restart' || action === 'update' && (command.force === true || stoppingForUpdate)) {
876
+ if (action === 'prepare_update_restart' && command.force !== true) {
877
+ const status = await agent.getDesktopStatus();
878
+ if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
879
+ hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping tasks to restart.');
880
+ }
881
+ await executeUpdate(action === 'prepare_update_restart');
882
+ emit({ type: 'result', id: command.id, ok: true });
883
+ void publish().catch(() => {}); return;
884
+ }
562
885
  const access = await accessSnapshot();
563
886
  const commandScope = accessScope(access);
564
887
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
565
888
  if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
566
889
 
890
+ let commandProviderLink;
891
+ if (command.provider && !["provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
892
+ !(action === "session_set_auto_continue" && command.enabled === false)) {
893
+ commandProviderLink = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
894
+ }
895
+ if (action === "provider_connect" || action === "provider_disconnect") {
896
+ await changeProviderLink(command.provider, action === "provider_connect");
897
+ emit({ type: "result", id: command.id, ok: true });
898
+ return;
899
+ }
567
900
  if (action === "session_statistics") {
568
- const candidate = await selectedSession(command, { preferObserved: true });
569
- let statistics;
570
- if (dependencies.readSessionStatistics) statistics = await dependencies.readSessionStatistics(candidate);
571
- else if (candidate.provider === 'claude') statistics = await readClaudeStatistics(candidate);
572
- else {
573
- const client = await openCodexAppServer();
574
- try { statistics = await readCodexStatistics(client, candidate); } finally { await client.close(); }
575
- }
576
901
  await authorizeWork(commandScope);
577
- statisticsCache.clear(); // Only the selected conversation needs chart data.
578
- statisticsCache.set(`${candidate.provider}:${candidate.session}`, statistics);
902
+ // Authorization can wait for scope cleanup. Only the latest selection
903
+ // generation may register a reader; an old A cannot revive after A-B-A.
904
+ if (requestedStatisticsSelection.generation !== statisticsSelection.generation) {
905
+ throw Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' });
906
+ }
907
+ if (activeStatisticsRead?.key !== requestedStatisticsSelection.key ||
908
+ activeStatisticsRead.scope !== commandScope || activeStatisticsRead.controller.signal.aborted) {
909
+ activeStatisticsRead?.controller.abort();
910
+ activeStatisticsRead = { ...requestedStatisticsSelection, scope: commandScope, controller: new AbortController(), promise: null };
911
+ }
912
+ statisticsRequest = activeStatisticsRead;
913
+ const request = statisticsRequest, signal = request.controller.signal;
914
+ request.promise ||= withReadCancellation(signal, async () => {
915
+ const candidate = await selectedSession(command, { preferObserved: true });
916
+ signal.throwIfAborted();
917
+ void publish().catch(() => {});
918
+ let statistics;
919
+ if (dependencies.readSessionStatistics) statistics = await dependencies.readSessionStatistics(candidate, { signal });
920
+ else if (candidate.provider === 'claude') statistics = await readClaudeStatistics(candidate, { signal });
921
+ else {
922
+ const client = await openCodexAppServer({ signal, cancelOnAbort: true });
923
+ try { statistics = await readCodexStatistics(client, candidate, { signal }); } finally { await client.close(); }
924
+ }
925
+ signal.throwIfAborted();
926
+ await authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate));
927
+ signal.throwIfAborted();
928
+ if (activeStatisticsRead !== request) throw Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' });
929
+ statisticsCache.clear(); // Only the selected conversation needs chart data.
930
+ statisticsCache.set(request.key, statistics);
931
+ }).finally(() => {
932
+ if (activeStatisticsRead === request) activeStatisticsRead = null;
933
+ if (!closed) void publish().catch(() => {});
934
+ });
935
+ await request.promise;
579
936
  emit({ type: "result", id: command.id, ok: true });
580
937
  void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); return;
581
938
  }
@@ -633,35 +990,57 @@ export async function startDesktopHost({ input = process.stdin, output = process
633
990
  }
634
991
  } else if (action === "check_update") await updater.checkNow();
635
992
  else if (action === "update") {
636
- if (command.force === true && updater.applyConfirmed) await updater.applyConfirmed(stopWorkForUpdate);
637
- else if (command.force === true) { await stopWorkForUpdate(); await updater.performAction(); }
638
- else {
639
- const status = await agent.getDesktopStatus();
640
- if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
641
- updater.snapshot().requires_task_stop === true || hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping unfinished tasks.');
642
- if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
643
- else await updater.performAction();
644
- }
645
- if (command.force === true && ["failed", "error"].includes(updater.snapshot().state)) throw new Error("Update failed after stopping tasks.");
646
- } else if (action === "codex_login" || action === "claude_login") {
647
- const provider = action === "claude_login" ? "claude" : "codex";
648
- const login = dependencies[`${provider}Login`];
649
- if (login) await login(root);
650
- else await openProviderLogin(provider, root);
993
+ const status = await agent.getDesktopStatus();
994
+ if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || updater.snapshot().requires_task_stop === true || hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping unfinished tasks.');
995
+ await runUpdate(false);
996
+ } else if (/^(codex|claude)_login$/.test(action)) {
997
+ const [provider] = action.split("_");
998
+ if (providerAuthBusy.has(provider)) throw new Error("Provider authentication is already running.");
999
+ providerAuthBusy.add(provider);
1000
+ providerAuth[provider] = { state: "opening" };
1001
+ try {
1002
+ await publish();
1003
+ const custom = dependencies[`${provider}Login`];
1004
+ if (custom) await custom(root);
1005
+ else await openProviderLogin(provider, root);
1006
+ providerAuth[provider] = { state: "awaiting_sign_in" };
1007
+ await publish();
1008
+ // Terminal launch is not authentication success. The existing CLI
1009
+ // probes remain the authority for readiness after the handoff.
1010
+ try { await agent.refreshProviderStatus?.(); }
1011
+ catch {
1012
+ // The Terminal launch already finished. A failed verification
1013
+ // probe must not turn a real Terminal handoff into failure.
1014
+ providerAuth[provider] = { ...providerAuth[provider], statusRefreshFailed: true,
1015
+ message: "Provider sign-in status could not be verified. Refresh connections to check it." };
1016
+ }
1017
+ refreshSessions(true);
1018
+ } catch (error) {
1019
+ providerAuth[provider] = { state: "error", message: desktopActionError(action, error) };
1020
+ await publish();
1021
+ throw error;
1022
+ } finally { providerAuthBusy.delete(provider); await publish(); }
651
1023
  }
652
1024
  else if (action === "install_plugins") {
1025
+ pluginInstallationActive = true; pluginSetupEpoch++;
653
1026
  pluginSetup = { state: "installing" };
654
- await publish();
655
1027
  try {
656
- const result = await (dependencies.installPlugins || syncInstalledWorkflows)({ installMissing: true });
657
- if (result.outcome === "busy") throw new Error("Plugin installation is busy.");
658
- await refreshPlugins();
1028
+ await publish();
1029
+ const result = await (dependencies.installPlugins || syncInstalledWorkflows)({ installMissing: true, lockWaitMs: 30_000 });
1030
+ if (result.outcome === "busy") throw Object.assign(new Error("Plugin installation is busy."), { code: "plugin_setup_busy" });
1031
+ await refreshPlugins({ settleInstall: true });
659
1032
  if (pluginSetup.state !== "ready") throw new Error("Plugin files could not be verified.");
660
1033
  pluginSetup.refreshPending = result.refreshPending === true;
661
- } catch {
662
- pluginSetup = { state: "error" };
1034
+ } catch (error) {
1035
+ // A cache refresh or lock conflict does not undo prepared files.
1036
+ // Keep the result aligned with the source verification shown to users.
1037
+ await refreshPlugins({ settleInstall: true });
1038
+ if (pluginSetup.state === "ready") pluginSetup.refreshPending = true;
1039
+ else pluginSetup = { state: error?.code === "plugin_setup_busy" ? "waiting" : "error" };
663
1040
  await publish();
664
- throw new Error("Plugin setup failed.");
1041
+ if (pluginSetup.state !== "ready") throw error;
1042
+ } finally {
1043
+ pluginInstallationActive = false; pluginSetupEpoch++;
665
1044
  }
666
1045
  }
667
1046
  else if (action === "codex_hooks" || action === "claude_hooks") {
@@ -679,13 +1058,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
679
1058
  await actionAssociations.correct(command, analysisManager.project(candidate));
680
1059
  } else if (action === "session_set_auto_continue") {
681
1060
  if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
1061
+ let admission, providerEpoch;
682
1062
  if (command.enabled) {
683
1063
  const candidate = await selectedSession(command, { preferObserved: true });
684
- if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
1064
+ const canEnable = candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue;
1065
+ if (canEnable !== true) {
1066
+ throw new AutoContinueUnavailableError({ reason: candidate.autoContinueAvailability?.reason || candidate.reason,
1067
+ reasonCode: candidate.autoContinueAvailability?.reasonCode || candidate.continuationReasonCode });
1068
+ }
1069
+ admission = candidate.continuationAdmission;
1070
+ providerEpoch = selectedLinkEpochs.get(candidate);
685
1071
  }
686
1072
  if (command.enabled) await authorizeWork(commandScope);
687
1073
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
688
- provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy });
1074
+ provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, admission, expectedProviderEpoch: providerEpoch });
689
1075
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
690
1076
  // Acknowledge the durable preference before discovery or analysis.
691
1077
  emit({ type: "result", id: command.id, ok: true });
@@ -711,19 +1097,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
711
1097
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
712
1098
  await authorizeWork(commandScope);
713
1099
  await (dependencies.trackSession || trackSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
714
- session: command.session, tracked: action === "session_track" });
1100
+ session: command.session, tracked: action === "session_track", expectedProviderEpoch: selectedLinkEpochs.get(candidate) });
715
1101
  await refreshSessions(true);
716
1102
  } else if (action === "session_monitor" || action === "session_unmonitor") {
717
1103
  await (dependencies.monitorSession || monitorSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
718
- session: command.session, monitor: action === "session_monitor" });
1104
+ session: command.session, monitor: action === "session_monitor", expectedProviderEpoch: commandProviderLink?.epoch });
719
1105
  await refreshSessions(true);
720
1106
  } else if (action === "session_enroll") {
721
1107
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
722
1108
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
723
- autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy });
1109
+ autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy, expectedProviderEpoch: commandProviderLink?.epoch });
724
1110
  await refreshSessions(true);
725
1111
  } else if (action === "session_analyze") {
726
1112
  const candidate = await selectedSession(command, { reuseDiscovery: true });
1113
+ explicitAnalyses.add(`${candidate.provider}:${candidate.session}`);
727
1114
  await analysisManager.request(candidate, { requestId: command.analysisRequestId || command.id });
728
1115
  if (candidate.autoContinue === true && candidate.capabilities?.canContinue === true) automaticContinuation.retry(candidate.provider, candidate.session);
729
1116
  } else if (action === "session_continue") {
@@ -731,7 +1118,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
731
1118
  const manager = analysisManager;
732
1119
  await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
733
1120
  readContext: dependencies.readContinuationContext || readContinuationContext,
734
- enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
1121
+ enroll: async options => { await authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate)); return guardedEnroll(options); }, beforeEnroll: () => authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate)),
735
1122
  settings: { autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures,
736
1123
  maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy } });
737
1124
  await refreshSessions(true);
@@ -744,18 +1131,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
744
1131
  emit({ type: "result", id: command.id, ok: true });
745
1132
  await publish();
746
1133
  } catch (error) {
747
- emit({ type: "result", id: command.id, ok: false, message: command.action === "update" && stoppingForUpdate ? "Could not finish the update after stopping tasks. Reopen SIMY and retry the update." : desktopActionError(command.action, error) });
1134
+ emit({ type: "result", id: command.id, ok: false, ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
1135
+ } finally {
1136
+ // Access rejection can happen before the shared reader is constructed.
1137
+ if (statisticsRequest && !statisticsRequest.promise && activeStatisticsRead === statisticsRequest) {
1138
+ activeStatisticsRead = null;
1139
+ if (!closed) void publish().catch(() => {});
1140
+ }
748
1141
  }
749
1142
  };
750
- if (command.action === "quit" || command.action === "update" && command.force === true) {
1143
+ if (command.action === "quit" || command.action === "prepare_update_restart" || command.action === "update" && (command.force === true || stoppingForUpdate)) {
751
1144
  void execute();
752
1145
  } else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
753
1146
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
1147
+ } else if (/^(codex|claude)_login$/.test(command.action || "") || ["provider_connect", "provider_disconnect"].includes(command.action)) {
1148
+ // Opening an auth terminal must not wait for an unrelated provider turn.
1149
+ // Per-provider busy guards prevent overlapping connection changes.
1150
+ void execute();
754
1151
  } else if (command.action === "my_ai_session") {
755
1152
  myAiSessionQueue = myAiSessionQueue.then(execute).catch(() => {});
756
1153
  } else if (command.action === "session_set_auto_continue") {
757
1154
  preferenceQueue = preferenceQueue.then(execute).catch(() => {});
758
- } else if (["task_stop", "delivery_stop"].includes(command.action) || command.action === "update" && command.force === true) {
1155
+ } else if (command.action === 'session_statistics') {
1156
+ void execute();
1157
+ } else if (["task_stop", "delivery_stop"].includes(command.action)) {
759
1158
  controlQueue = controlQueue.then(execute).catch(() => {});
760
1159
  } else {
761
1160
  queue = queue.then(async () => {
@@ -776,6 +1175,7 @@ function associationActionError(error) {
776
1175
  }
777
1176
 
778
1177
  function sessionActionError(error) {
1178
+ if (error instanceof AutoContinueUnavailableError) return error.message.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 600);
779
1179
  const message = String(error?.message || "");
780
1180
  if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
781
1181
  if (/model.*unavailable|did not expose.*model/i.test(message)) return "This provider did not expose the conversation model; automatic continuation is unavailable.";
@@ -815,7 +1215,7 @@ export async function openProviderLogin(provider, root, {
815
1215
  } = {}) {
816
1216
  if (provider !== "codex" && provider !== "claude") throw new Error("Unsupported provider.");
817
1217
  const executable = await inspectExecutable(provider);
818
- if (!executable.available || !executable.executable) throw new Error("Provider is not installed.");
1218
+ if (!executable.available || !executable.executable) throw Object.assign(new Error("Provider is not installed."), { code: "provider_not_installed" });
819
1219
  const label = provider === "claude" ? "Claude Code" : "Codex";
820
1220
  const args = provider === "claude" ? ["auth", "login"] : ["login"];
821
1221
  if (platform === "win32") {
@@ -828,7 +1228,7 @@ export async function openProviderLogin(provider, root, {
828
1228
  const directory = await mkdtemp(join(root, `${provider}-login-`));
829
1229
  const script = join(directory, `Sign in to ${label}.command`);
830
1230
  const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
831
- await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
1231
+ await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nif [ "$result" -ne 0 ]; then\n echo "Sign-in failed. Check the error above, then press Return to close."\n read -r _\nfi\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
832
1232
  await chmod(script, 0o700);
833
1233
  try { await execFileImpl("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
834
1234
  catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }