@awak-app/simy-cli 0.7.29 → 0.7.31

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 (66) hide show
  1. package/README.md +88 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +1 -0
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
  7. package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +389 -51
  9. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
  10. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
  11. package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
  12. package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
  13. package/src/delivery-hooks/claude-workflow.md +3 -3
  14. package/src/delivery-hooks/command.js +8 -0
  15. package/src/delivery-hooks/desktop.js +31 -9
  16. package/src/delivery-hooks/supervisor.js +3 -2
  17. package/src/desktop-access.js +4 -1
  18. package/src/desktop-host.js +494 -72
  19. package/src/native-development-report-completion.js +26 -0
  20. package/src/native-development-reports.js +15 -5
  21. package/src/orchestrator/completion-contract.js +8 -2
  22. package/src/orchestrator/contract.js +3 -1
  23. package/src/orchestrator/instruction.js +1 -1
  24. package/src/runner.js +2 -1
  25. package/src/session-supervision/action-deadline.js +18 -1
  26. package/src/session-supervision/autonomous-upgrade.js +4 -2
  27. package/src/session-supervision/claude-desktop-archive.js +57 -0
  28. package/src/session-supervision/claude-desktop-native.js +50 -0
  29. package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
  30. package/src/session-supervision/claude-desktop-target.js +87 -0
  31. package/src/session-supervision/claude-owner-renewal.js +197 -0
  32. package/src/session-supervision/claude-sessions.js +143 -34
  33. package/src/session-supervision/codex-app-server.js +20 -2
  34. package/src/session-supervision/continuation-analysis.js +7 -7
  35. package/src/session-supervision/continuation-command.js +45 -0
  36. package/src/session-supervision/controller-store.js +48 -0
  37. package/src/session-supervision/conversation-presentation.js +241 -0
  38. package/src/session-supervision/decision-presentation.js +96 -35
  39. package/src/session-supervision/execution-context.js +39 -0
  40. package/src/session-supervision/index.js +36 -7
  41. package/src/session-supervision/manual-acceptance-command.js +30 -0
  42. package/src/session-supervision/notification-authority.js +9 -0
  43. package/src/session-supervision/notification-journal.js +40 -0
  44. package/src/session-supervision/operation-journal.js +42 -0
  45. package/src/session-supervision/owner-transport.js +146 -0
  46. package/src/session-supervision/purpose-recovery.js +10 -2
  47. package/src/session-supervision/remote-control.js +1 -1
  48. package/src/session-supervision/remote-entry.js +2 -0
  49. package/src/session-supervision/remote-errors.js +15 -0
  50. package/src/session-supervision/remote-hosts.js +344 -36
  51. package/src/session-supervision/remote-metadata.js +103 -0
  52. package/src/session-supervision/remote-session-list.js +2 -4
  53. package/src/session-supervision/session-archive-command.js +45 -0
  54. package/src/session-supervision/session-list.js +40 -4
  55. package/src/session-supervision/sqm-presentation.js +46 -3
  56. package/src/session-supervision/stop-notifications.js +42 -11
  57. package/src/session-supervision/work-state.js +106 -6
  58. package/src/sqm/agent-skill.js +3 -3
  59. package/src/sqm/claude-plugin.js +5 -2
  60. package/src/sqm/history-store.js +6 -0
  61. package/src/team-threads.js +1 -1
  62. package/src/update-detachment/coordinator.js +7 -1
  63. package/src/update-detachment/history-recovery.js +18 -0
  64. package/src/update-detachment/ledger-adapter.js +1 -0
  65. package/src/update-detachment/retained-paused.js +290 -0
  66. package/src/update-detachment/runtime.js +78 -15
@@ -1,12 +1,24 @@
1
+ import { createClaudeDesktopArchiveController } from './session-supervision/claude-desktop-archive.js';
2
+ import { createClaudeDesktopNativeAdapter } from './session-supervision/claude-desktop-native.js';
3
+ import { applyControllerExecutionContext } from './session-supervision/execution-context.js';
4
+ import { createOwnerTransport } from './session-supervision/owner-transport.js';
5
+ import { createControllerStore, controllerScope } from './session-supervision/controller-store.js';
6
+ import { createOperationJournal } from './session-supervision/operation-journal.js';
7
+ import { createNotificationJournal } from './session-supervision/notification-journal.js';
8
+ import { createRemoteMetadata } from './session-supervision/remote-metadata.js';
9
+ import { projectControllerCompletion } from './native-development-report-completion.js';
1
10
  import { remoteOperationFailure } from './session-supervision/remote-errors.js';
2
11
  import { copyDesktopText } from './desktop-clipboard.js';
3
- import { createRemoteHostManager } from './session-supervision/remote-hosts.js';
12
+ import { createRemoteHostManager, allowedActions, controllerPolicyActions } from './session-supervision/remote-hosts.js';
13
+ import { manualAcceptanceCommand, manualAcceptanceContinuation } from './session-supervision/manual-acceptance-command.js';
4
14
  import { readDesktopAutoUpdate, writeDesktopAutoUpdate } from './desktop-update-settings.js';
5
15
  import { createUpdateRuntime } from './update-detachment/runtime.js';
6
16
  import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
7
17
  import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
8
18
  import { createUpdateCoordinator } from "./update-detachment/coordinator.js";
19
+ import { reconnectWithHistoryRecovery } from "./update-detachment/history-recovery.js";
9
20
  import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
21
+ import { conversationPresentation, expireConversationPresentation } from './session-supervision/conversation-presentation.js';
10
22
  import { createPullRequestObserver } from './session-supervision/pull-requests.js';
11
23
  import { createTeamThreads, teamThreadKey } from './team-threads.js';
12
24
  import { createTeamStatusFenceStore } from './team-status-fence-store.js';
@@ -17,18 +29,19 @@ import { readCodexStatistics, unavailableStatistics } from './session-supervisio
17
29
  import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
18
30
  import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-plugin-status.js';
19
31
  import { repairProviderPlugin } from './sqm/provider-plugin-repair.js';
20
- import { openCodexAppServer } from './session-supervision/codex-app-server.js';
21
- import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
32
+ import { openCodexAppServer, verifyCodexArchiveState } from './session-supervision/codex-app-server.js';
33
+ import { conversationSqmChecks, conversationSqmResult } from './session-supervision/sqm-presentation.js';
22
34
  import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
23
35
  import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
24
36
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
25
37
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
26
38
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
27
- import { controlDelivery, verifyDelivery, createRecordReader, readRecord, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
39
+ import { controlDelivery, verifyDelivery, decideManualAcceptance, createRecordReader, readRecord, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
28
40
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
29
41
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
30
42
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
31
43
  import { AutoContinueUnavailableError, answerSession, continueSession, followupSession, resumeSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
44
+ import { renewClaudeSessionOwner } from './session-supervision/claude-owner-renewal.js';
32
45
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
33
46
  import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
34
47
  import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
@@ -82,6 +95,7 @@ async function withReadCancellation(signal, work) {
82
95
  }
83
96
 
84
97
  function desktopActionError(action, error) {
98
+ if (String(error?.code).startsWith('claude_desktop_')) return error.message;
85
99
  if (String(error?.code).startsWith("remote_host_")) return error.message;
86
100
  if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
87
101
  if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
@@ -109,6 +123,7 @@ function desktopActionError(action, error) {
109
123
  if (action === 'authorization_copy') return 'Could not copy the connection link. Reopen the connection page and try again.';
110
124
  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.";
111
125
  if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
126
+ if (action === 'delivery_acceptance_decide') return 'Acceptance decision was not confirmed. Refresh this conversation before retrying.';
112
127
  if (action === "codex_hooks" || action === "claude_hooks") return "Could not open the setup guide. Check your default browser, or use /hooks inside the provider.";
113
128
  if (action === "install_plugins") return error?.code === "plugin_setup_busy"
114
129
  ? "Another SIMY process is preparing plugins. Wait, then refresh to check the files."
@@ -167,11 +182,23 @@ export function createDesktopFrameWriter(output) {
167
182
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
168
183
  updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
169
184
  const frames = createDesktopFrameWriter(output);
170
- const emit = (event) => frames.write(`${JSON.stringify(event)}\n`, event.type === "status");
185
+ const pendingRemoteReceipts = new Map();
186
+ let operationJournal, ownerTransport;
187
+ const writeEvent = event => { ownerTransport?.publish(event); frames.write(`${JSON.stringify(event)}\n`, event.type === 'status'); };
188
+ const emit = (event) => {
189
+ const pending = event.type === 'result' ? pendingRemoteReceipts.get(event.id) : null;
190
+ if (pending) {
191
+ pendingRemoteReceipts.delete(event.id);
192
+ void operationJournal.finish(pending.scope, pending.command, event).then(() => writeEvent(event)).catch(() => writeEvent({ type: 'result', id: event.id, ok: false, code: 'remote_operation_unknown' }));
193
+ } else writeEvent(event);
194
+ };
171
195
  // A heartbeat remains a complete native frame. Only its observation time is
172
196
  // volatile; reuse the encoded body after comparing freshly authorized data.
173
197
  // Detach the comparison value because registries may mutate nested objects.
174
198
  let encodedStatus = null, lastStatus = null, updateProgress = null;
199
+ // Per verified source reader, never accepted from a projection or command.
200
+ const acceptanceReaderId = randomUUID();
201
+ let acceptanceReadbackSequence = 0;
175
202
  let remoteHosts;
176
203
  const emitStatus = status => {
177
204
  const updateSettings = updater?.snapshot?.() || {};
@@ -179,10 +206,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
179
206
  auto_update_supported: updateSettings.auto_update_supported === true,
180
207
  auto_update_enabled: updateSettings.auto_update_enabled === true,
181
208
  auto_update_busy: updateSettings.auto_update_busy === true } };
182
- if (env.SIMY_REMOTE_CHILD === "1") status = { ...status, remoteSessionProtocol: 1 };
209
+ if (env.SIMY_REMOTE_CHILD === "1") status = { ...status, remoteSessionProtocol: 1, remoteControl: { version: 2, actions: [...allowedActions] } };
210
+ ownerTransport?.publish({ type: 'status', status: { ...status, remoteSessionProtocol: 1, remoteControl: { version: 2, actions: [...allowedActions] } } });
183
211
  if (remoteHosts) {
184
- void remoteHosts.update(PROVIDER_LINKS.some(provider => providerLinks[provider].connected) ? status : { ...status, access: { state: "unavailable" } }).catch(() => {});
185
- if (PROVIDER_LINKS.some(provider => providerLinks[provider].connected)) status = remoteHosts.project(status, { providerLinks });
212
+ void remoteHosts.update({ ...status, providerLinks }).catch(() => {});
213
+ status = remoteHosts.project(status, { providerLinks });
214
+ for (const candidate of status.sessionSupervision?.sessions || []) {
215
+ if (!candidate.hostId || candidate.hostId === 'local') continue;
216
+ const key = teamThreadKey(agent?.getTeamThreadsScope?.() || {}, candidate);
217
+ const sharing = teamThreads?.snapshot();
218
+ candidate.teamSharing = { threadKey: key, enabled: sharing?.own?.some(row => row.threadKey === key && row.enabled) === true, pendingStop: sharing?.pendingStops?.includes(key) === true };
219
+ }
220
+ }
221
+ if (preferenceScope && desktopAccessReady(status.access) && status.sessionSupervision?.sessions) {
222
+ const catalogScope = preferenceScope;
223
+ status = { ...status, sessionSupervision: { ...status.sessionSupervision, sessions: status.sessionSupervision.sessions.filter(row => controllerPreferences.get(catalogScope, row).deleted !== true).map(row => { const saved = controllerPreferences.get(catalogScope, row); return { ...row, controllerPreferences: saved, ...(saved.pinned === undefined ? {} : { listPinned: saved.pinned, listPinScopes: saved.pinScopes }), ...(saved.hidden === undefined ? {} : { localHidden: saved.hidden }) }; }) } };
224
+ const catalog = { observedAt: Date.now(), sessions: structuredClone(status.sessionSupervision.sessions), hosts: structuredClone(status.remoteHosts?.items || []), providerLinks: structuredClone(providerLinks) };
225
+ void controllerPreferences.change(catalogScope, state => { state.catalog = catalog; }).catch(() => {});
186
226
  }
187
227
  if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
188
228
  const { sampledAt, ...content } = status;
@@ -194,6 +234,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
194
234
  frames.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
195
235
  };
196
236
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
237
+ const controllerPreferences = createControllerStore(root);
238
+ operationJournal = createOperationJournal(join(root, 'remote-operations'));
197
239
  const diagnostics = createRuntimeDiagnostics(root, { name: "desktop-host" });
198
240
  const webOrigin = resolveWebOrigin(null, env);
199
241
  const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
@@ -231,12 +273,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
231
273
  const statisticsCache = new Map();
232
274
  let activeStatisticsRead = null;
233
275
  let statisticsSelection = { key: null, generation: 0 };
234
- const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
235
- if (env.SIMY_REMOTE_CHILD !== "1" && process.platform !== "win32") remoteHosts = (dependencies.createRemoteHostManager || createRemoteHostManager)({ root, onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); } });
276
+ const notificationJournal = createNotificationJournal(join(root, 'remote-events'));
277
+ const completionJournal = createNotificationJournal(join(root, 'source-completions'));
278
+ let retainedCompletions = [];
279
+ const stopNotifier = createStopNotifier({ hostId: 'local', emit: event => {
280
+ if (!closed && !relay) {
281
+ void notificationJournal.append({ ...event, scope: preferenceScope }).catch(() => {});
282
+ emit(event);
283
+ }
284
+ } });
285
+ if (env.SIMY_REMOTE_CHILD !== "1") remoteHosts = (dependencies.createRemoteHostManager || createRemoteHostManager)({ root, store: controllerPreferences, onNotify: event => { if (!closed && !relay) emit(event); }, onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); } });
286
+ const remoteMetadata = createRemoteMetadata({ root, webOrigin, onChange: () => { if (!closed && !relay) setImmediate(() => void publish().catch(() => {})); } });
236
287
  let teamThreads;
237
288
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
289
+ let preferenceScope = null, emailSync = null, emailSyncRetryAt = 0;
238
290
  let nativeDevelopmentReports = { state: 'loading', enrollments: [] };
239
291
  let nativeDevelopmentReportsScope = null;
292
+ let nativeDevelopmentReportsController = null;
240
293
  let nativeDevelopmentReportsRequest = null;
241
294
  let nativeDevelopmentReportsGeneration = 0;
242
295
  let nativeDevelopmentReportsRetryAt = 0;
@@ -255,6 +308,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
255
308
  // credential/organization revision itself did not change.
256
309
  nativeDevelopmentReports = { state: 'loading', enrollments: [] };
257
310
  nativeDevelopmentReportsScope = null;
311
+ nativeDevelopmentReportsController = null;
258
312
  nativeDevelopmentReportsGeneration++;
259
313
  nativeDevelopmentReportsRetryAt = 0;
260
314
  nativeCompletionInFlight.clear();
@@ -310,30 +364,61 @@ export async function startDesktopHost({ input = process.stdin, output = process
310
364
  if (failure) throw failure.reason;
311
365
  };
312
366
  const localCliApiSession = () => dependencies.getLocalCliApiSession?.() || agent?.getLocalCliApiSession?.() || null;
367
+ const reportDeviceSession = () => {
368
+ const session = localCliApiSession();
369
+ return session ? { ...session } : null;
370
+ };
371
+ const sameReportController = (left, right) => Boolean(left?.deviceSession && right?.deviceSession &&
372
+ left.scope === right.scope && left.controllerScope === right.controllerScope &&
373
+ left.deviceSession.token === right.deviceSession.token && left.deviceSession.api_base_url === right.deviceSession.api_base_url);
374
+ const requireReportController = async (context, { ready = true } = {}) => {
375
+ const status = await agent.getDesktopStatus();
376
+ const currentDevice = localCliApiSession();
377
+ const latestAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : status?.access;
378
+ if (!context.deviceSession || !currentDevice || currentDevice.token !== context.deviceSession.token ||
379
+ currentDevice.api_base_url !== context.deviceSession.api_base_url ||
380
+ accessScope(status?.access) !== context.scope ||
381
+ accessScope(latestAccess) !== context.scope ||
382
+ !controllerScope(status) || (context.controllerScope !== undefined && controllerScope(status) !== context.controllerScope)) {
383
+ throw Object.assign(new Error('Desktop account or organization changed.'), { code: 'desktop_access_required' });
384
+ }
385
+ if (ready) { requireDesktopAccess(status.access); requireDesktopAccess(latestAccess); }
386
+ return status;
387
+ };
313
388
  const nativeReportsApi = (fixedSession = null) => (dependencies.createNativeDevelopmentReportsApi || createNativeDevelopmentReportsApi)({
314
389
  webOrigin,
315
390
  readSession: async () => fixedSession || localCliApiSession(),
316
391
  fetchImpl: dependencies.fetch || globalThis.fetch,
317
392
  });
318
- const refreshNativeDevelopmentReports = async ({ force = false } = {}) => {
393
+ const refreshNativeDevelopmentReports = async ({ force = false, controller } = {}) => {
319
394
  if (!desktopAccessReady(currentAccess)) return nativeDevelopmentReports;
395
+ if (controller.scope !== accessScope(currentAccess) || controller.controllerScope !== preferenceScope) return nativeDevelopmentReports;
320
396
  if (nativeDevelopmentReportsMutation) return nativeDevelopmentReports;
321
397
  const requestedScope = accessScope(currentAccess);
322
- if (!force && nativeDevelopmentReportsScope === requestedScope && nativeDevelopmentReports.state === 'ready') return nativeDevelopmentReports;
323
- if (!force && Date.now() < nativeDevelopmentReportsRetryAt) return nativeDevelopmentReports;
324
- if (nativeDevelopmentReportsRequest?.scope === requestedScope) return nativeDevelopmentReportsRequest.promise;
325
- nativeDevelopmentReports = { state: 'loading', enrollments: nativeDevelopmentReportsScope === requestedScope ? nativeDevelopmentReports.enrollments : [] };
398
+ const sameController = sameReportController(controller, nativeDevelopmentReportsController);
399
+ if (!force && sameController && nativeDevelopmentReportsScope === requestedScope && nativeDevelopmentReports.state === 'ready') return nativeDevelopmentReports;
400
+ if (!force && sameController && Date.now() < nativeDevelopmentReportsRetryAt) return nativeDevelopmentReports;
401
+ if (sameReportController(nativeDevelopmentReportsRequest?.controller, controller)) return nativeDevelopmentReportsRequest.promise;
402
+ nativeDevelopmentReports = { state: 'loading', enrollments: sameController ? nativeDevelopmentReports.enrollments : [] };
326
403
  nativeDevelopmentReportsScope = requestedScope;
404
+ nativeDevelopmentReportsController = controller;
327
405
  const generation = nativeDevelopmentReportsGeneration;
328
- const job = { scope: requestedScope, generation, promise: null };
329
- job.promise = nativeReportsApi().list().then(enrollments => {
330
- if (accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
406
+ const job = { scope: requestedScope, controller, generation, promise: null };
407
+ job.promise = Promise.resolve().then(async () => {
408
+ await requireReportController(controller);
409
+ if (nativeDevelopmentReportsGeneration !== generation || accessScope(currentAccess) !== requestedScope) return null;
410
+ const enrollments = await nativeReportsApi(controller.deviceSession).list();
411
+ await requireReportController(controller);
412
+ return enrollments;
413
+ }).then(enrollments => {
414
+ if (!enrollments) return nativeDevelopmentReports;
415
+ if (sameReportController(controller, nativeDevelopmentReportsController) && accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
331
416
  nativeDevelopmentReports = { state: 'ready', enrollments };
332
417
  nativeDevelopmentReportsRetryAt = 0;
333
418
  }
334
419
  return nativeDevelopmentReports;
335
420
  }).catch(() => {
336
- if (accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
421
+ if (sameReportController(controller, nativeDevelopmentReportsController) && accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
337
422
  nativeDevelopmentReports = { state: 'unavailable', enrollments: [] };
338
423
  nativeDevelopmentReportsRetryAt = Date.now() + 30_000;
339
424
  }
@@ -345,26 +430,85 @@ export async function startDesktopHost({ input = process.stdin, output = process
345
430
  nativeDevelopmentReportsRequest = job;
346
431
  return job.promise;
347
432
  };
348
- const submitNativeDevelopmentReportCompletions = ({ records, scope, deviceSession }) => {
349
- if (!deviceSession?.token || !deviceSession?.api_base_url || nativeDevelopmentReports.state !== "ready" || nativeDevelopmentReportsScope !== scope) return;
433
+ const syncEmailPreferences = async controller => {
434
+ const { controllerScope: scope, deviceSession } = controller;
435
+ if (!scope || emailSync || nativeDevelopmentReportsMutation || Date.now() < emailSyncRetryAt || !deviceSession) return;
436
+ const generation = nativeDevelopmentReportsGeneration;
437
+ const stillCurrent = () => !closed && scope === preferenceScope && controller.scope === accessScope(currentAccess) && generation === nativeDevelopmentReportsGeneration;
438
+ emailSync = (async () => {
439
+ const state = await controllerPreferences.load(scope);
440
+ for (const [key, value] of Object.entries(state.conversations)) {
441
+ const pending = value.emailPreference;
442
+ if (pending?.state !== 'pending' || !stillCurrent()) continue;
443
+ try {
444
+ await requireReportController(controller);
445
+ if (!stillCurrent()) return;
446
+ const api = nativeReportsApi(deviceSession);
447
+ const [hostId, provider, session] = JSON.parse(key);
448
+ const matches = row => (row.host_id || 'local') === hostId && row.provider === provider && row.provider_session_id === session;
449
+ const prior = (await api.list()).find(matches);
450
+ await requireReportController(controller);
451
+ if (!stillCurrent() || controllerPreferences.get(scope, { hostId, provider, session }).emailPreference?.id !== pending.id) return;
452
+ if (!prior || prior.enabled !== pending.request.enabled || pending.request.enabled && prior.request_title !== pending.request.request_title) {
453
+ await api.save({ ...pending.request, expected_revision: prior?.revision || 0 });
454
+ }
455
+ await requireReportController(controller);
456
+ const enrollments = await api.list();
457
+ await requireReportController(controller);
458
+ const applied = enrollments.find(matches);
459
+ if (pending.request.enabled ? !applied?.enabled || applied.request_title !== pending.request.request_title : applied?.enabled) throw new Error('Preference sync remains pending.');
460
+ if (!stillCurrent()) return;
461
+ await controllerPreferences.change(scope, data => {
462
+ const current = data.conversations[key]?.emailPreference;
463
+ if (current?.id === pending.id) current.state = 'applied';
464
+ });
465
+ await requireReportController(controller);
466
+ if (!stillCurrent()) return;
467
+ nativeDevelopmentReports = { state: 'ready', enrollments }; nativeDevelopmentReportsScope = controller.scope;
468
+ nativeDevelopmentReportsController = controller;
469
+ } catch (error) {
470
+ if ([400,403].includes(error?.status)) await controllerPreferences.change(scope, data => { const current = data.conversations[key]?.emailPreference; if (current?.id === pending.id) current.state = 'conflict'; });
471
+ emailSyncRetryAt = Date.now() + 30_000; return;
472
+ }
473
+ }
474
+ })().finally(() => { emailSync = null; });
475
+ await emailSync;
476
+ };
477
+ const submitNativeDevelopmentReportCompletions = ({ completions, controller }) => {
478
+ const { scope, deviceSession } = controller;
479
+ if (!deviceSession?.token || !deviceSession?.api_base_url || !sameReportController(controller, nativeDevelopmentReportsController) || nativeDevelopmentReports.state !== "ready" || nativeDevelopmentReportsScope !== scope) return;
350
480
  const generation = nativeDevelopmentReportsGeneration;
351
481
  for (const enrollment of nativeDevelopmentReports.enrollments) {
352
- if (enrollment.provider !== "codex" || enrollment.enabled !== true) continue;
353
- const record = records.find(item => item.provider === enrollment.provider && item.session?.toLowerCase() === enrollment.provider_session_id?.toLowerCase());
354
- const completion = projectNativeDevelopmentReportCompletion({ enrollment, record });
355
- if (!completion) continue;
356
- const key = [scope, completion.provider, completion.provider_session_id, completion.task_id, completion.enrollment_revision, completion.managed_revision, completion.acceptance_digest].join(":");
482
+ if (enrollment.enabled !== true) continue;
483
+ const desired = controllerPreferences.get(controller.controllerScope, { hostId: enrollment.host_id || 'local', provider: enrollment.provider, session: enrollment.provider_session_id }).emailPreference;
484
+ if (desired?.request.enabled === false) continue;
485
+ const hostId = enrollment.host_id || 'local';
486
+ const observations = hostId === 'local' ? completions : remoteHosts?.completions?.() || [];
487
+ for (const observed of observations.filter(item => (item.host_id || 'local') === hostId && item.provider === enrollment.provider && item.provider_session_id === enrollment.provider_session_id)) {
488
+ const completion = { ...observed, host_id: hostId, enrollment_revision: enrollment.revision };
489
+ const key = [scope, completion.provider, completion.provider_session_id, completion.host_id, completion.task_id, completion.enrollment_revision, completion.managed_revision, completion.acceptance_digest].join(":");
357
490
  // A terminal response is tied to the consent identity, rather than a
358
491
  // particular proof revision: a new consent revision is required before
359
492
  // the desktop may offer that server a different attestation.
360
- const rejectionKey = [scope, completion.provider, completion.provider_session_id, completion.task_id, completion.enrollment_revision].join(":");
493
+ const rejectionKey = [scope, completion.provider, completion.provider_session_id, completion.host_id, completion.task_id, completion.enrollment_revision].join(":");
361
494
  if (nativeCompletionSuccess.has(key) || nativeCompletionRejected.has(rejectionKey) || nativeCompletionInFlight.has(key) || Date.now() < (nativeCompletionRetryAt.get(key) || 0)) continue;
362
495
  const job = Promise.resolve().then(async () => {
496
+ await requireReportController(controller);
363
497
  if (closed || relay || nativeDevelopmentReportsGeneration !== generation || accessScope(currentAccess) !== scope) return null;
364
- const receipt = await sendNativeDevelopmentReportCompletion({ api: nativeReportsApi(deviceSession), enrollment, record });
498
+ const receipt = await nativeReportsApi(deviceSession).submitCompletion(completion);
499
+ await requireReportController(controller);
365
500
  if (receipt && nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
366
- nativeCompletionSuccess.add(key);
367
- nativeCompletionRetryAt.delete(key);
501
+ if (receipt.status === 'pending') nativeCompletionRetryAt.set(key, Date.now() + 30_000);
502
+ else { nativeCompletionSuccess.add(key); nativeCompletionRetryAt.delete(key); }
503
+ const receiptScope = controller.controllerScope;
504
+ await controllerPreferences.change(receiptScope, state => {
505
+ const identity = JSON.stringify([hostId, completion.provider, completion.provider_session_id]);
506
+ const row = state.conversations[identity] ||= {};
507
+ row.emailDeliveries ||= {};
508
+ row.emailDeliveries[completion.task_id] = { ...receipt, checkedAt: Date.now(), provider: completion.provider, provider_session_id: completion.provider_session_id, host_id: hostId };
509
+ });
510
+ await requireReportController(controller);
511
+ if (!closed) void publish().catch(() => {});
368
512
  }
369
513
  return receipt;
370
514
  }).catch(error => {
@@ -377,6 +521,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
377
521
  return null;
378
522
  }).finally(() => nativeCompletionInFlight.delete(key));
379
523
  nativeCompletionInFlight.set(key, job);
524
+ }
380
525
  }
381
526
  };
382
527
  let organizationAnalysesSuspended = false;
@@ -486,6 +631,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
486
631
  }
487
632
  return sessionStatus;
488
633
  };
634
+ const claudeDesktopNative = dependencies.claudeDesktopNative || createClaudeDesktopNativeAdapter();
635
+ const archiveClaudeDesktop = createClaudeDesktopArchiveController({ native: claudeDesktopNative, ...(dependencies.readClaudeDesktopTarget ? { readTarget: dependencies.readClaudeDesktopTarget } : {}) });
489
636
  const selectedSession = async (command, { reuseDiscovery = false, preferObserved = false } = {}) => {
490
637
  const scope = await authorizeWork();
491
638
  const link = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
@@ -676,7 +823,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
676
823
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
677
824
  status.access = sampledAccess;
678
825
  status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
826
+ preferenceScope = controllerScope(status);
827
+ if (preferenceScope) await controllerPreferences.load(preferenceScope);
679
828
  const requestedLinks = await decorateProviderSettings(status);
829
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
830
+ const latestAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : await accessSnapshot();
831
+ if (accessScope(latestAccess) !== accessScope(status.access) || latestAccess?.state !== status.access?.state) {
832
+ publishAgain = true;
833
+ return;
834
+ }
835
+ // Select cached record evidence after all asynchronous authorization:
836
+ // a full projection may have advanced it while those reads were waiting.
680
837
  const sameScope = desktopAccessReady(currentAccess) &&
681
838
  accessScope(lastStatus?.access) === accessScope(currentAccess);
682
839
  status.deliveryHooks = sameScope && lastStatus?.deliveryHooks
@@ -685,12 +842,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
685
842
  ? structuredClone(lastStatus.sessionSupervision) : { sessions: [], providers: {}, refreshedAt: null, initialLoading: desktopAccessReady(currentAccess) };
686
843
  // Complete fast frames must retain the current saved preference so OFF
687
844
  // remains available while unrelated conversation enrichment is waiting.
688
- status.nativeDevelopmentReports = sameScope && nativeDevelopmentReportsScope === accessScope(currentAccess)
845
+ status.nativeDevelopmentReports = sameScope && sameReportController(nativeDevelopmentReportsController, {
846
+ scope: accessScope(status.access), controllerScope: controllerScope(status), deviceSession: reportDeviceSession(),
847
+ }) && nativeDevelopmentReportsScope === accessScope(currentAccess)
689
848
  ? structuredClone(nativeDevelopmentReports) : { state: 'loading', enrollments: [] };
690
849
  // Cached enrichment must follow the current connection preference even
691
850
  // while a full projection is waiting on records or remote metadata.
692
851
  status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
693
- status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []).map(candidate => expireSessionWorkState(candidate)).sort(compareSessionWork);
852
+ status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []).map(candidate => expireConversationPresentation(expireSessionWorkState(candidate))).sort(compareSessionWork);
694
853
  status.sessionSupervision.providers ||= {};
695
854
  for (const provider of PROVIDER_LINKS) {
696
855
  if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
@@ -705,12 +864,6 @@ export async function startDesktopHost({ input = process.stdin, output = process
705
864
  status.sqmChecks = { activeCount: 0, items: [] };
706
865
  status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
707
866
  }
708
- if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
709
- const latestAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : await accessSnapshot();
710
- if (accessScope(latestAccess) !== accessScope(status.access) || latestAccess?.state !== status.access?.state) {
711
- publishAgain = true;
712
- return;
713
- }
714
867
  if (!closed && !relay) { connectionRevision += 1; latestConnectionStatus = structuredClone(status); emitStatus(status); }
715
868
  }).finally(() => { connectionPublishing = null; });
716
869
  return connectionPublishing;
@@ -719,6 +872,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
719
872
  if (closed || relay || !agent) return Promise.resolve();
720
873
  if (publishing) { publishAgain = true; return publishConnectionStatus(); }
721
874
  const requestedConnectionRevision = connectionRevision;
875
+ const deviceSession = reportDeviceSession();
722
876
  publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
723
877
  if (closed || relay) return;
724
878
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
@@ -726,6 +880,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
726
880
  status.access = sampledAccess;
727
881
  status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
728
882
  const requestedScope = accessScope(currentAccess);
883
+ preferenceScope = controllerScope(status);
884
+ const reportController = { scope: requestedScope, controllerScope: preferenceScope, deviceSession };
885
+ if (preferenceScope) await controllerPreferences.load(preferenceScope);
729
886
  const requestedLinks = await decorateProviderSettings(status);
730
887
  const allRecords = await recordReader.list();
731
888
  const records = linkedSessions(allRecords);
@@ -753,25 +910,52 @@ export async function startDesktopHost({ input = process.stdin, output = process
753
910
  // Listing only reads rows previously registered by this device/account/org;
754
911
  // it never passes the locally discovered catalog to Web.
755
912
  if (agent.getLocalCliApiSession || dependencies.getLocalCliApiSession) {
756
- void refreshNativeDevelopmentReports();
913
+ void refreshNativeDevelopmentReports({ controller: reportController });
757
914
  }
758
- status.nativeDevelopmentReports = structuredClone(nativeDevelopmentReports);
915
+ if (reportController.controllerScope) void syncEmailPreferences(reportController);
916
+ const preferenceState = reportController.controllerScope ? await controllerPreferences.load(reportController.controllerScope) : null;
917
+ 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 }] : []) };
759
919
  // This is an asynchronous local attestation. It neither changes a
760
920
  // Delivery Loop nor places receipt or device data in status frames.
921
+ let reportCompletions = [];
922
+ if (reportController.controllerScope) {
923
+ for (const completion of allRecords.map(projectControllerCompletion).filter(Boolean)) {
924
+ await completionJournal.append({ id: [completion.provider, completion.provider_session_id, completion.task_id].join(':'), scope: reportController.controllerScope, completion });
925
+ }
926
+ reportCompletions = (await completionJournal.snapshot(reportController.controllerScope)).map(event => event.completion);
927
+ }
928
+ retainedCompletions = reportCompletions;
761
929
  submitNativeDevelopmentReportCompletions({
762
- records: allRecords,
763
- scope: requestedScope,
764
- deviceSession: localCliApiSession(),
930
+ completions: reportCompletions,
931
+ controller: reportController,
765
932
  });
933
+ status.controllerCompletions = retainedCompletions;
934
+ status.controllerEventsVersion = 1;
935
+ status.controllerEvents = preferenceScope ? await notificationJournal.snapshot(preferenceScope) : [];
766
936
  const projection = await actionAssociations.beginProjection?.({ records });
767
937
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
938
+ // A full record projection owns this receipt. Connection-only heartbeats
939
+ // replay the cached receipt; they must never mint evidence of a fresh read.
940
+ const acceptanceReadbackId = randomUUID();
941
+ const readbackOrder = !status.deliveryHooks.error && acceptanceReadbackSequence < Number.MAX_SAFE_INTEGER
942
+ ? { readerId: acceptanceReaderId, sequence: ++acceptanceReadbackSequence } : null;
943
+ status.deliveryHooks.runs = (status.deliveryHooks.runs || []).map(run => {
944
+ if (!run.manualAcceptance || run.hostId && run.hostId !== 'local') return run;
945
+ const manualAcceptance = { ...run.manualAcceptance };
946
+ delete manualAcceptance.readbackId; delete manualAcceptance.readbackOrder;
947
+ return { ...run, manualAcceptance: { ...manualAcceptance,
948
+ ...(readbackOrder ? { readbackId: acceptanceReadbackId, readbackOrder } : {}) } };
949
+ });
768
950
  await actionAssociations.decorate(status.deliveryHooks, projection);
769
951
  const discovery = refreshSessions();
770
952
  if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions);
771
953
  const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
772
954
  await actionAssociations.decorateSessions?.(sessions, projection);
773
- const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
955
+ const stateRecordsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record]));
956
+ const stateRunsBySession = new Map([...stateRecordsBySession].map(([key, record]) => [key, record.run]));
774
957
  const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
958
+ const recordedSqmChecks = [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])];
775
959
  for (const session of sessions) {
776
960
  const key = `${session.provider}:${session.session}`;
777
961
  session.statistics = activeStatisticsRead?.key === key && !activeStatisticsRead.controller.signal.aborted
@@ -779,7 +963,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
779
963
  ? { ...statisticsCache.get(key), refreshing: true }
780
964
  : { state: 'loading', hours: [], activity: [] }
781
965
  : statisticsCache.get(key) || unavailableStatistics();
782
- session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
966
+ session.sqmActivity = conversationSqmChecks(session, recordedSqmChecks);
967
+ session.sqmResult = conversationSqmResult(session, recordedSqmChecks);
783
968
  session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
784
969
  try {
785
970
  const context = await actionAssociations.deadlineContext?.(session, projection);
@@ -797,8 +982,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
797
982
  }
798
983
  status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
799
984
  decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
800
- workState: sessionWorkState(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`) || stateRunsBySession.get(`${candidate.provider}:${candidate.session}`), { provider: discovery.providers?.[candidate.provider] }) }))).sort(compareSessionWork) };
801
- teamThreads?.tick(sessions);
985
+ 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
+ runsBySession.get(`${candidate.provider}:${candidate.session}`), { rawRecord: stateRecordsBySession.get(`${candidate.provider}:${candidate.session}`) }) })).sort(compareSessionWork) };
987
+ const sharingSessions = remoteHosts ? remoteHosts.project({ ...status, sessionSupervision: { ...status.sessionSupervision, sessions } }, { providerLinks }).sessionSupervision.sessions : sessions;
988
+ const remoteRows = sharingSessions.filter(row => row.hostId && row.hostId !== 'local');
989
+ await remoteMetadata.decorate(preferenceScope, remoteRows);
990
+ remoteHosts?.setMetadata?.(remoteRows);
991
+ teamThreads?.tick(sharingSessions);
802
992
  status.teamThreads = teamThreads?.snapshot();
803
993
  for (const session of status.sessionSupervision.sessions) {
804
994
  const key = teamThreadKey(agent.getTeamThreadsScope?.() || {}, session);
@@ -820,6 +1010,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
820
1010
  if (accessScope(latestConnectionStatus?.access) !== requestedScope || !desktopAccessReady(latestConnectionStatus?.access)) { publishAgain = true; return; }
821
1011
  status = { ...structuredClone(latestConnectionStatus), deliveryHooks: status.deliveryHooks, sessionSupervision: status.sessionSupervision };
822
1012
  }
1013
+ await remoteMetadata.syncPending(preferenceScope, async command => {
1014
+ await authorizeProvider(command.provider, requestedScope);
1015
+ return remoteHosts.control(command);
1016
+ }).catch(() => diagnostics.write('conversation context synchronization pending'));
823
1017
  status.teamThreads = teamThreads?.snapshot();
824
1018
  if (!closed && !relay) {
825
1019
  // Native stop admission waits for this authorized projection, so an old
@@ -844,7 +1038,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
844
1038
  const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing, connectionPublishing,
845
1039
  pluginInspection,
846
1040
  ...providerRepairJobs,
847
- remoteHosts?.close(), actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
1041
+ controllerPreferences.close(), operationJournal.close(), remoteHosts?.close(), remoteMetadata.close(), actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
848
1042
  managedSupervisor.close(), recordReader.close(), stopWorkflows(),
849
1043
  agent ? createAgentShutdown(agent)() : undefined]);
850
1044
  const failure = results.find(result => result.status === "rejected");
@@ -863,6 +1057,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
863
1057
  if (relay.exitCode !== null) { clearTimeout(limit); done(); }
864
1058
  });
865
1059
  } else await stopAgent();
1060
+ await ownerTransport?.close();
866
1061
  await lock?.release();
867
1062
  })();
868
1063
 
@@ -898,7 +1093,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
898
1093
  readScope: () => agent.getTeamThreadsScope?.() || {key: accessScope(currentAccess),allowed:false,authenticated:false},
899
1094
  request: (body,signal) => agent.requestTeamThreads(body,signal),
900
1095
  fenceStore: createTeamStatusFenceStore(root),
901
- readContext: dependencies.readTeamThreadContext,
1096
+ readContext: async (candidate, options) => {
1097
+ if (candidate.hostId && candidate.hostId !== 'local') {
1098
+ const result = await remoteHosts.forward({ id: randomUUID(), action: 'session_read_context', provider: candidate.provider, session: candidate.session, hostId: candidate.hostId }, await agent.getDesktopStatus());
1099
+ if (!result.ok || !Array.isArray(result.data?.messages)) throw new Error('Remote context unavailable.');
1100
+ return result.data;
1101
+ }
1102
+ return (dependencies.readTeamThreadContext || readContinuationContext)(candidate, options);
1103
+ },
902
1104
  onChange: () => { if(!closed) setImmediate(() => {void publish().catch(() => {});}); },
903
1105
  });
904
1106
  stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
@@ -1089,9 +1291,18 @@ export async function startDesktopHost({ input = process.stdin, output = process
1089
1291
  let command; try { command = JSON.parse(line); } catch { return; }
1090
1292
  if (typeof command.id !== "string" || command.id.length > 128) return;
1091
1293
  if (relay) {
1294
+ if (command.action === 'session_renew_claude_owner') {
1295
+ emit({ type: 'result', id: command.id, ok: false, message: 'Use the verified local conversation host for connection recovery.' }); return;
1296
+ }
1092
1297
  if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
1093
1298
  return;
1094
1299
  }
1300
+ // Bind email intent when received, before either the preference queue or
1301
+ // authorization/session reads can yield. Published identity only restricts
1302
+ // this binding; fresh controller checks below still authorize every write.
1303
+ const emailDispatchController = command.action === 'session_set_email_reports' ? {
1304
+ deviceSession: reportDeviceSession(), scope: accessScope(currentAccess), controllerScope: controllerScope(lastStatus),
1305
+ } : null;
1095
1306
  if(command.action==='cancel_detach_update') {
1096
1307
  try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(currentAccess)});coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
1097
1308
  catch {emit({type:'result',id:command.id,ok:false,message:'This update operation changed. Refresh its status.'});}
@@ -1116,7 +1327,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1116
1327
  const access=await accessSnapshot(); requireDesktopAccess(access);
1117
1328
  updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());
1118
1329
  if(action==='detach_update')await updater.maybeApply({detach:true});
1119
- else await coordinator.reconnect();
1330
+ else await reconnectWithHistoryRecovery({root,coordinator,eligible:()=>updateRuntime.historyRecoveryEligible()});
1120
1331
  emit({type:'result',id:command.id,ok:coordinator.snapshot().state==='connected' && (action!=='detach_update' || updater.snapshot().state==='updated')});
1121
1332
  await publish();return;
1122
1333
  }
@@ -1142,6 +1353,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
1142
1353
  finally { clearTimeout(deadline); exit(0); }
1143
1354
  return;
1144
1355
  }
1356
+ if (['task_stop','task_detail'].includes(action) && remoteHosts?.isRemoteTask(command.taskKey)) {
1357
+ const result = await remoteHosts.forwardTask(command, await agent.getDesktopStatus(), detail => emit(detail));
1358
+ emit(result); void publish().catch(() => {}); return;
1359
+ }
1145
1360
  if (action === "task_stop") {
1146
1361
  if (command.force !== true) throw new Error("Confirm before stopping this task.");
1147
1362
  await agent.stopDesktopTask(command.taskKey);
@@ -1161,6 +1376,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
1161
1376
  }
1162
1377
  const access = await accessSnapshot();
1163
1378
  const commandScope = accessScope(access);
1379
+ if (action === 'controller_pair') {
1380
+ if (typeof command.organizationId !== 'string' || !command.organizationId) throw new Error('Select the controller organization.');
1381
+ const url = await agent.requestDesktopLogin(command.organizationId);
1382
+ if (!url || new URL(url).origin !== webOrigin) throw new Error('Remote sign-in URL is unavailable.');
1383
+ emit({ type: 'result', id: command.id, ok: true, data: { authorizationUrl: url } });
1384
+ return;
1385
+ }
1164
1386
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
1165
1387
  const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) || action === 'auto_update_set_enabled' && command.enabled === false ||
1166
1388
  ['session_set_auto_continue', 'session_set_email_reports', 'team_thread_share'].includes(action) && command.enabled === false;
@@ -1176,15 +1398,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
1176
1398
  else if(action === 'team_thread_open') ok = await teamThreads.open({memberId:command.memberId,threadKey:command.threadKey});
1177
1399
  else {
1178
1400
  if (command.organizationId !== undefined && command.organizationId !== access.organizationId) throw new Error('Sharing organization changed. Confirm again.');
1179
- // Publication reads this host's provider context. Never reinterpret
1180
- // a remote or ambiguous identity as a same-ID local conversation.
1181
- if ((command.hostId && command.hostId !== 'local') || (!command.hostId && remoteHosts?.hasSession(command.session))) {
1182
- throw new Error('Team sharing requires an explicitly selected local conversation.');
1183
- }
1184
- const selected = sessionStatus.sessions.find(item => (!item.hostId || item.hostId === 'local') && item.provider === command.provider && item.session === command.session);
1401
+ if (!command.hostId && remoteHosts?.hasSession(command.session)) throw new Error('Select the conversation host.');
1402
+ const selected = command.hostId && command.hostId !== 'local' ? remoteHosts?.candidate(command)
1403
+ : sessionStatus.sessions.find(item => (!item.hostId || item.hostId === 'local') && item.provider === command.provider && item.session === command.session);
1185
1404
  if (command.enabled === false) {
1186
1405
  // Revocation uses authenticated own rows, never provider context.
1187
- const key = command.threadKey ?? teamThreadKey(agent.getTeamThreadsScope(), { provider: command.provider, session: command.session });
1406
+ const key = command.threadKey ?? teamThreadKey(agent.getTeamThreadsScope(), { hostId: command.hostId, provider: command.provider, session: command.session });
1188
1407
  ok = await teamThreads.stopSharing(key);
1189
1408
  } else {
1190
1409
  if(!selected || command.threadKey) throw new Error('Refresh and select your conversation again.');
@@ -1194,19 +1413,103 @@ export async function startDesktopHost({ input = process.stdin, output = process
1194
1413
  emit({type:'result',id:command.id,ok:ok===true}); void publish().catch(() => {}); return;
1195
1414
  }
1196
1415
 
1416
+ if (action === 'controller_provider_connect') {
1417
+ const source = await agent.getDesktopStatus();
1418
+ if (source.auth?.userId !== command.userId || source.access?.organizationId !== command.organizationId) throw new Error('Remote account changed.');
1419
+ await changeProviderLink(command.provider, true);
1420
+ emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
1421
+ }
1422
+ if (['remote_host_login','remote_host_provider_connect'].includes(action)) {
1423
+ if (!remoteHosts) throw new Error('Nested remote setup is unavailable.');
1424
+ const result = await remoteHosts.configure(command, await agent.getDesktopStatus());
1425
+ if (!result.ok) { emit(result); return; }
1426
+ if (action === 'remote_host_login') {
1427
+ const url = result.data?.authorizationUrl;
1428
+ if (!url || new URL(url).origin !== webOrigin) throw new Error('Remote sign-in origin is invalid.');
1429
+ const opened = await (dependencies.openLogin || openAuthorizationUrl)(url, { logger: silentLogger });
1430
+ if (!opened.opened) throw new Error('Could not open the sign-in page.');
1431
+ }
1432
+ emit({ type: 'result', id: command.id, ok: true }); void publish().catch(() => {}); return;
1433
+ }
1434
+ if (action === 'notification_set_preferences') {
1435
+ const value = command.preferences;
1436
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.entries(value).some(([kind, enabled]) => !['finished','failed','paused','needs_decision'].includes(kind) || typeof enabled !== 'boolean')) throw new Error('Invalid notification preferences.');
1437
+ await authorizeWork(commandScope);
1438
+ const scope = controllerScope(await agent.getDesktopStatus());
1439
+ await controllerPreferences.change(scope, state => { state.notificationPreferences = { ...state.notificationPreferences, ...value }; });
1440
+ emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
1441
+ }
1442
+ if (action === 'session_import_preferences') {
1443
+ const candidate = command.hostId && command.hostId !== 'local' ? remoteHosts?.candidate(command) : await selectedSession(command, { preferObserved: true });
1444
+ if (!candidate || candidate.teamRemote || ![command.pinned, command.deleted].every(value => value === undefined || typeof value === 'boolean')) throw new Error('Invalid legacy conversation preferences.');
1445
+ await authorizeWork(commandScope);
1446
+ const scope = controllerScope(await agent.getDesktopStatus());
1447
+ await controllerPreferences.change(scope, state => {
1448
+ const key = JSON.stringify([command.hostId || 'local', command.provider, command.session]);
1449
+ const row = state.conversations[key] ||= {};
1450
+ if (row.nativePreferencesMigrated) return;
1451
+ if (row.deleted === undefined && command.deleted === true) row.deleted = true;
1452
+ if (row.pinned === undefined && command.pinned !== undefined) { row.pinned = command.pinned; row.pinScopes = command.pinned ? ['legacy'] : []; }
1453
+ if (row.deleted) { row.pinned = false; row.pinScopes = []; }
1454
+ row.nativePreferencesMigrated = true;
1455
+ });
1456
+ emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
1457
+ }
1458
+ if (action === 'session_remove') {
1459
+ const candidate = command.hostId && command.hostId !== 'local' ? remoteHosts?.candidate(command) : await selectedSession(command, { preferObserved: true });
1460
+ if (!candidate || candidate.isRunning || candidate.autoContinue || candidate.teamRemote) throw new Error('Stop this conversation before removing it from the list.');
1461
+ await authorizeWork(commandScope);
1462
+ const scope = controllerScope(await agent.getDesktopStatus());
1463
+ await controllerPreferences.set(scope, { ...candidate, hostId: command.hostId || 'local' }, { deleted: true, pinned: false, pinScopes: [] });
1464
+ emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
1465
+ }
1466
+ if (command.requestId && !['refresh','session_statistics','session_read_context','session_renew_claude_owner'].includes(action)) {
1467
+ const receiptScope = controllerScope(await agent.getDesktopStatus());
1468
+ const reconcileReceived = !controllerPolicyActions.has(action) &&
1469
+ !(['session_archive', 'session_restore'].includes(action) && command.scope === 'local') &&
1470
+ remoteHosts?.supportsReplay?.(command) === true;
1471
+ const previous = await operationJournal.begin(receiptScope, command, { reconcileReceived });
1472
+ if (previous) { emit({ ...previous, type: 'result', id: command.id }); return; }
1473
+ pendingRemoteReceipts.set(command.id, { scope: receiptScope, command });
1474
+ }
1197
1475
  let commandProviderLink;
1198
1476
  if (command.provider && !["provider_repair", "refresh", "provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
1199
1477
  !(action === "session_set_auto_continue" && command.enabled === false)) {
1200
1478
  commandProviderLink = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
1201
1479
  }
1202
- if (command.hostId && command.hostId !== 'local') {
1480
+ if (action === 'session_renew_claude_owner') {
1481
+ requireDesktopAccess(access);
1482
+ if (command.hostId !== 'local' || command.provider !== 'claude')
1483
+ throw Object.assign(new Error('Connection recovery requires the verified local Claude host.'), { code: 'owner_renewal_local_only' });
1484
+ if (command.expectedProviderEpoch !== commandProviderLink.epoch)
1485
+ throw Object.assign(new Error('Provider connection changed; refresh before recovering it.'), { code: 'provider_link_changed' });
1486
+ await authorizeProvider('claude', commandScope, commandProviderLink?.epoch);
1487
+ const data = await (dependencies.renewClaudeSessionOwner || renewClaudeSessionOwner)({
1488
+ root: deliveryRoot, hostId: command.hostId, provider: command.provider, session: command.session,
1489
+ taskId: command.taskId, expectedRunId: command.expectedRunId, expectedRevision: command.expectedRevision,
1490
+ expectedGateRevision: command.expectedGateRevision, expectedGeneration: command.expectedGeneration,
1491
+ expectedProviderEpoch: command.expectedProviderEpoch, requestId: command.ownerRenewalRequestId,
1492
+ ownerRenewalId: command.ownerRenewalId, candidateId: command.candidateId,
1493
+ }, { authorize: () => authorizeProvider('claude', commandScope, commandProviderLink.epoch) });
1494
+ emit({ type: 'result', id: command.id, ok: true, data }); void publish().catch(() => {}); return;
1495
+ }
1496
+ if (command.hostId && command.hostId !== 'local' && action !== 'session_set_email_reports') {
1203
1497
  if (!remoteHosts || !PROVIDER_LINKS.includes(command.provider)) throw Object.assign(new Error('Remote host is unavailable.'), { code: 'remote_host_unavailable' });
1204
1498
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1205
1499
  const status = await agent.getDesktopStatus();
1206
1500
  // Status collection can yield while provider consent is revoked.
1207
1501
  // Reject before writing SSH bytes, not only after its result arrives.
1208
1502
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1209
- const result = await remoteHosts.forward(command, status);
1503
+ if (['session_action_refresh','session_action_select','session_action_unlink','session_deadline_decide'].includes(action)) {
1504
+ const context = await remoteMetadata.command(controllerScope(status), command);
1505
+ if (context && action !== 'session_action_refresh') await remoteMetadata.syncPending(controllerScope(status), async sync => {
1506
+ await authorizeProvider(sync.provider, commandScope);
1507
+ return remoteHosts.control(sync);
1508
+ });
1509
+ emit({ type: 'result', id: command.id, ok: true, data: { executionSync: context && action !== 'session_action_refresh' ? 'pending' : 'unchanged' } }); void publish().catch(() => {}); return;
1510
+ }
1511
+ const localPreference = action === 'session_set_pinned' || ['session_archive','session_restore'].includes(action) && command.scope === 'local';
1512
+ const result = localPreference ? await remoteHosts.setPreference(command) : controllerPolicyActions.has(action) ? await remoteHosts.control(command) : await remoteHosts.forward(command, status);
1210
1513
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1211
1514
  emit(result); void publish().catch(() => {}); return;
1212
1515
  }
@@ -1268,6 +1571,35 @@ export async function startDesktopHost({ input = process.stdin, output = process
1268
1571
  emit({ type: "result", id: command.id, ok: true });
1269
1572
  return;
1270
1573
  }
1574
+ if (action === 'controller_events') {
1575
+ await authorizeWork(commandScope);
1576
+ const events = await notificationJournal.snapshot(controllerScope(await agent.getDesktopStatus()), command.after ?? 0);
1577
+ await authorizeWork(commandScope);
1578
+ emit({ type: 'result', id: command.id, ok: true, data: { events } });
1579
+ return;
1580
+ }
1581
+ if (action === 'session_get_status') {
1582
+ await selectedSession(command);
1583
+ await publish();
1584
+ await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1585
+ const row = lastStatus?.sessionSupervision?.sessions?.find(item =>
1586
+ 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.');
1588
+ // The same app projection carries observation time and next action;
1589
+ // saved consent and an active record cannot prove a provider send.
1590
+ const { provider, session, title, autoContinue, autoContinueStatus, autoContinueReason,
1591
+ binding, workState, dispatchReadiness, conversationPresentation } = row;
1592
+ emit({ type: 'result', id: command.id, ok: true, data: { provider, session, title,
1593
+ autoContinue, autoContinueStatus, autoContinueReason, binding, workState, dispatchReadiness, conversationPresentation } });
1594
+ return;
1595
+ }
1596
+ if (action === 'session_read_context') {
1597
+ const candidate = await selectedSession(command, { preferObserved: true });
1598
+ const context = await (dependencies.readTeamThreadContext || readContinuationContext)(candidate, {});
1599
+ await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1600
+ emit({ type: 'result', id: command.id, ok: true, data: { messages: context.messages, incomplete: context.incomplete, truncated: context.truncated } });
1601
+ return;
1602
+ }
1271
1603
  if (action === "session_statistics") {
1272
1604
  await authorizeWork(commandScope);
1273
1605
  // Authorization can wait for scope cleanup. Only the latest selection
@@ -1452,9 +1784,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
1452
1784
  } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_continuation_policy"].includes(action)) {
1453
1785
  await (action === "delivery_resume" ? dependencies.resumeSession || resumeSession : controlDelivery)({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
1454
1786
  taskId: command.taskId, expectedRunId: command.expectedRunId, expectedRevision: command.expectedRevision, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
1787
+ } else if (action === 'delivery_acceptance_decide') {
1788
+ const controls = manualAcceptanceCommand(command);
1789
+ await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1790
+ const authorize = () => authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1791
+ const result = await (dependencies.decideManualAcceptance || decideManualAcceptance)({ root: deliveryRoot, ...controls, authorize });
1792
+ await authorize();
1793
+ const continuation = manualAcceptanceContinuation(result?.continuation);
1794
+ if (!continuation) throw new Error('Manual acceptance receipt is unavailable; refresh before retrying.');
1795
+ emit({ type: 'result', id: command.id, ok: true, continuation });
1796
+ void publish().catch(() => {}); return;
1455
1797
  } else if (action === "delivery_verify") {
1456
1798
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
1457
1799
  taskId: command.taskId, criterionId: command.criterionId });
1800
+ } else if (action === 'session_sync_context') {
1801
+ const target = await selectedSession(command, { preferObserved: true });
1802
+ await applyControllerExecutionContext({ root: deliveryRoot, provider: command.provider, session: command.session, cwd: target.cwd, context: command.context,
1803
+ authorize: () => authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch) });
1458
1804
  } else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
1459
1805
  const candidate = await selectedSession(command);
1460
1806
  await authorizeWork(commandScope);
@@ -1474,7 +1820,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1474
1820
  }
1475
1821
  if (command.enabled) await authorizeWork(commandScope);
1476
1822
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
1477
- provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, admission, expectedProviderEpoch: providerEpoch });
1823
+ provider: command.provider, session: command.session, enabled: command.enabled, initialContinuationPolicy: command.continuationPolicy, admission, expectedProviderEpoch: providerEpoch });
1478
1824
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
1479
1825
  // Acknowledge the durable preference before discovery or analysis.
1480
1826
  emit({ type: "result", id: command.id, ok: true });
@@ -1485,12 +1831,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
1485
1831
  return;
1486
1832
  } else if (action === "session_set_email_reports") {
1487
1833
  if (typeof command.enabled !== 'boolean') throw new Error('Choose whether development report emails are enabled.');
1834
+ // Freeze credentials before session/identity/persistence awaits. A
1835
+ // controller change must never move this consent to another device.
1836
+ const deviceSession = emailDispatchController.deviceSession;
1837
+ if (!deviceSession) throw new Error('SIMY desktop sign-in is required.');
1838
+ if (!emailDispatchController.controllerScope) throw Object.assign(new Error('Refresh the desktop account before changing completion emails.'), { code: 'desktop_access_required' });
1839
+ const requireEmailController = async () => {
1840
+ const status = await requireReportController(emailDispatchController, { ready: command.enabled });
1841
+ if (command.enabled) requireWorkAdmission();
1842
+ return status;
1843
+ };
1844
+ await requireEmailController();
1488
1845
  if (command.enabled) {
1489
- if (command.provider !== 'codex') throw new Error('Development report email is not available for this provider yet.');
1846
+ if (!PROVIDER_LINKS.includes(command.provider)) throw new Error('Unsupported provider.');
1490
1847
  // Initial consent binds only to a currently observed local session.
1491
1848
  // The title remains caller-provided; the host never reads transcript
1492
1849
  // content to populate it.
1493
- await selectedSession(command, { preferObserved: true });
1850
+ if (command.hostId && command.hostId !== 'local') {
1851
+ if (!remoteHosts?.candidate(command)) throw new Error('Select a known remote conversation.');
1852
+ } else await selectedSession(command, { preferObserved: true });
1494
1853
  }
1495
1854
  if (command.enabled) await authorizeWork(commandScope);
1496
1855
  // Validate before the recovery path: an invalid local command is not
@@ -1498,18 +1857,24 @@ export async function startDesktopHost({ input = process.stdin, output = process
1498
1857
  const request = normalizeNativeDevelopmentReportSave({
1499
1858
  provider: command.provider,
1500
1859
  provider_session_id: command.session,
1860
+ host_id: command.hostId || 'local',
1501
1861
  request_title: command.requestTitle,
1502
1862
  enabled: command.enabled,
1503
1863
  expected_revision: command.expectedRevision,
1504
1864
  });
1505
- const deviceSession = localCliApiSession();
1506
- if (!deviceSession) throw new Error('SIMY desktop sign-in is required.');
1865
+ await requireEmailController();
1866
+ const savedPreferenceScope = emailDispatchController.controllerScope;
1867
+ const reportController = emailDispatchController;
1868
+ const preferenceId = randomUUID();
1869
+ const target = { hostId: command.hostId || 'local', provider: command.provider, session: command.session };
1870
+ await controllerPreferences.set(savedPreferenceScope, target, { emailPreference: { id: preferenceId, request, state: 'pending' } });
1871
+ await requireEmailController();
1507
1872
  // Keep the credential tuple fixed across PUT and its readback. A
1508
1873
  // changed account/device scope must fail instead of confirming one
1509
1874
  // user’s write with another user’s registry.
1510
1875
  const reportsApi = nativeReportsApi(deviceSession);
1511
1876
  const sameIdentity = enrollment => enrollment.provider === request.provider &&
1512
- enrollment.provider_session_id === String(request.provider_session_id || '').toLowerCase();
1877
+ enrollment.provider_session_id === String(request.provider_session_id || '').toLowerCase() && (enrollment.host_id || 'local') === (request.host_id || 'local');
1513
1878
  const matchesSaved = enrollment => sameIdentity(enrollment) && enrollment.enabled === request.enabled &&
1514
1879
  (!request.enabled || enrollment.request_title === String(request.request_title || '').trim());
1515
1880
  const confirmed = enrollments => request.enabled
@@ -1530,6 +1895,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1530
1895
  // The server may have committed before a connection failure. Read
1531
1896
  // back the device-scoped registry before reporting an error.
1532
1897
  const recovered = await reportsApi.list().catch(() => null);
1898
+ await requireEmailController();
1533
1899
  if (!recovered) {
1534
1900
  invalidateMutation();
1535
1901
  throw error;
@@ -1538,6 +1904,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1538
1904
  // the next explicit choice starts from the server's revision.
1539
1905
  if (nativeDevelopmentReportsGeneration === mutationGeneration && accessScope(currentAccess) === commandScope) {
1540
1906
  nativeDevelopmentReports = { state: 'ready', enrollments: recovered };
1907
+ nativeDevelopmentReportsController = reportController;
1541
1908
  nativeDevelopmentReportsRetryAt = 0;
1542
1909
  recoveredCurrent = true;
1543
1910
  }
@@ -1546,12 +1913,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
1546
1913
  const enrollments = await reportsApi.list();
1547
1914
  if (!confirmed(enrollments)) throw new Error('Development report email settings could not be confirmed.');
1548
1915
  if (command.enabled) await authorizeWork(commandScope);
1549
- const currentDeviceSession = localCliApiSession();
1550
- if (!currentDeviceSession || currentDeviceSession.token !== deviceSession.token || currentDeviceSession.api_base_url !== deviceSession.api_base_url) {
1551
- throw Object.assign(new Error('Desktop account or organization changed.'), { code: 'desktop_access_required' });
1552
- }
1916
+ await requireEmailController();
1553
1917
  if (nativeDevelopmentReportsGeneration !== mutationGeneration) throw new Error('Development report email settings changed; refresh and try again.');
1918
+ await controllerPreferences.set(savedPreferenceScope, target, { emailPreference: { id: preferenceId, request, state: 'applied' } });
1919
+ await requireEmailController();
1554
1920
  nativeDevelopmentReportsScope = accessScope(currentAccess);
1921
+ nativeDevelopmentReportsController = reportController;
1555
1922
  nativeDevelopmentReports = { state: 'ready', enrollments };
1556
1923
  nativeDevelopmentReportsRetryAt = 0;
1557
1924
  // Fence a GET that was in flight before this completed mutation.
@@ -1564,6 +1931,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1564
1931
  // prior ready revision cached forever. Preserve a 409 readback,
1565
1932
  // which is itself the current server state.
1566
1933
  if (!recoveredCurrent) invalidateMutation();
1934
+ const currentScope = controllerScope(await agent.getDesktopStatus());
1935
+ if (currentScope === savedPreferenceScope && ![400,401,403].includes(error?.status) && error?.code !== 'desktop_access_required') {
1936
+ emit({ type: 'result', id: command.id, ok: true, data: { syncState: 'pending' } });
1937
+ return;
1938
+ }
1567
1939
  throw error;
1568
1940
  } finally {
1569
1941
  nativeDevelopmentReportsMutation = false;
@@ -1587,7 +1959,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1587
1959
  await authorizeWork(commandScope);
1588
1960
  const pinScope = command.pinScope ?? 'legacy';
1589
1961
  if (typeof pinScope !== 'string' || !pinScope || pinScope.length > 512) throw new Error('Invalid pin scope.');
1590
- await updateRecord(deliveryRoot, command.provider, command.session, previous => {
1962
+ const updated = await updateRecord(deliveryRoot, command.provider, command.session, previous => {
1591
1963
  const scopes = new Set(Array.isArray(previous?.listPinScopes) ? previous.listPinScopes : previous?.listPinned ? ['legacy'] : []);
1592
1964
  if (command.enabled) scopes.add(pinScope); else scopes.delete(pinScope);
1593
1965
  return {
@@ -1596,17 +1968,63 @@ export async function startDesktopHost({ input = process.stdin, output = process
1596
1968
  ...(command.enabled ? { listMetadata: { title: candidate.title || '', createdAt: candidate.createdAt ?? null, lastActivityAt: candidate.lastActivityAt ?? null } } : {}),
1597
1969
  };
1598
1970
  });
1971
+ const scope = controllerScope(await agent.getDesktopStatus()), target = { ...candidate, hostId: 'local' };
1972
+ const scopes = new Set(controllerPreferences.get(scope, target).pinScopes || []);
1973
+ if (command.enabled) scopes.add(pinScope); else scopes.delete(pinScope);
1974
+ await controllerPreferences.set(scope, target, { pinned: scopes.size > 0, pinScopes: [...scopes], metadata: updated.listMetadata });
1599
1975
  refreshSessions(true);
1976
+ } else if (action === 'session_provider_setup') {
1977
+ if (command.provider !== 'claude' || !['inspect', 'setup'].includes(command.operation)) throw new Error('Choose Claude Desktop setup or inspection.');
1978
+ await authorizeProvider('claude', commandScope, commandProviderLink?.epoch);
1979
+ const data = await claudeDesktopNative[command.operation]();
1980
+ emit({ type: 'result', id: command.id, ok: true, data });
1981
+ return;
1600
1982
  } else if (action === "session_archive" || action === "session_restore") {
1601
1983
  const candidate = await selectedSession(command, { preferObserved: true });
1602
1984
  await authorizeWork(commandScope);
1603
1985
  if (!['local', 'provider'].includes(command.scope)) throw new Error('Choose local or provider archive explicitly.');
1604
1986
  const archived = action === 'session_archive';
1605
1987
  if (command.scope === 'provider') {
1606
- if (command.provider !== 'codex') throw new Error('Archive or restore this conversation in Claude, then refresh SIMY.');
1988
+ if (command.provider === 'claude') {
1989
+ const verifySelection = async () => {
1990
+ const fresh = await selectedSession(command);
1991
+ await authorizeProvider('claude', commandScope, selectedLinkEpochs.get(candidate));
1992
+ if (fresh.cwd !== candidate.cwd) throw new Error('The conversation workspace changed. Refresh and retry.');
1993
+ const record = await readRecord(deliveryRoot, 'claude', command.session);
1994
+ if (fresh.isRunning || fresh.autoContinue || record?.autoContinue || record?.run && !TERMINAL.has(record.run.state))
1995
+ throw Object.assign(new Error('Stop this conversation and turn off automatic continuation before organizing it.'), { code: 'claude_desktop_running' });
1996
+ };
1997
+ await verifySelection();
1998
+ const operation = new AbortController();
1999
+ let checking = false;
2000
+ const verifyAuthority = async () => {
2001
+ if (closed) throw new Error('SIMY is shutting down.');
2002
+ await authorizeProvider('claude', commandScope, selectedLinkEpochs.get(candidate));
2003
+ const current = await readRecord(deliveryRoot, 'claude', command.session);
2004
+ if (current?.autoContinue || current?.run && !TERMINAL.has(current.run.state))
2005
+ throw Object.assign(new Error('The conversation became active during native control.'), { code: 'claude_desktop_running' });
2006
+ };
2007
+ const watchdog = setInterval(() => {
2008
+ if (checking) return; checking = true;
2009
+ void verifyAuthority().catch(error => operation.abort(error)).finally(() => { checking = false; });
2010
+ }, 100);
2011
+ let data;
2012
+ try {
2013
+ data = await archiveClaudeDesktop(candidate, archived, { signal: operation.signal, beforeDispatch: verifySelection });
2014
+ await verifyAuthority();
2015
+ } finally { clearInterval(watchdog); }
2016
+
2017
+ await recordProviderArchive(deliveryRoot, 'claude', command.session, archived, Date.now());
2018
+ refreshSessions(true);
2019
+ emit({ type: 'result', id: command.id, ok: true, data });
2020
+ void publish().catch(() => {});
2021
+ return;
2022
+ }
2023
+ if (command.provider !== 'codex') throw new Error('Unsupported conversation provider.');
1607
2024
  const client = await (dependencies.openCodexAppServer || openCodexAppServer)();
1608
2025
  try { await client.request(archived ? 'thread/archive' : 'thread/unarchive', { threadId: command.session });
1609
- if (archived) await recordProviderArchive(deliveryRoot, command.provider, command.session, true, Date.now());
2026
+ await verifyCodexArchiveState(client, command.session, archived);
2027
+ await recordProviderArchive(deliveryRoot, command.provider, command.session, archived, Date.now());
1610
2028
  }
1611
2029
  finally { await client.close(); }
1612
2030
  } else {
@@ -1728,6 +2146,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
1728
2146
  }
1729
2147
  });
1730
2148
  commands.on("close", () => { if (!closed) void close().then(() => exit(0)); });
2149
+ if (lock && !dependencies.startAgent) {
2150
+ ownerTransport = await createOwnerTransport({ root, nonce: lock.nonce, allowedActions, allowControllerArchive: env.SIMY_REMOTE_CHILD !== '1', dispatch: command => commands.emit('line', JSON.stringify(command)) });
2151
+ await publish();
2152
+ }
1731
2153
  return { close, publish, agent };
1732
2154
  }
1733
2155