@awak-app/simy-cli 0.7.28 → 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.
- package/README.md +88 -3
- package/package.json +4 -4
- package/src/agent.js +1 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +570 -65
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
- package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
- package/src/delivery-hooks/claude-workflow.md +3 -3
- package/src/delivery-hooks/command.js +12 -0
- package/src/delivery-hooks/desktop.js +31 -9
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-access.js +4 -1
- package/src/desktop-executor.js +6 -0
- package/src/desktop-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/budget.js +24 -0
- package/src/orchestrator/charter-repair.js +1 -1
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/independent-audit.js +13 -6
- package/src/orchestrator/instruction.js +1 -1
- package/src/orchestrator/loop.js +34 -4
- package/src/provider-stream.js +9 -2
- package/src/runner.js +44 -21
- package/src/session-supervision/action-deadline.js +18 -1
- package/src/session-supervision/autonomous-upgrade.js +4 -2
- package/src/session-supervision/claude-desktop-archive.js +57 -0
- package/src/session-supervision/claude-desktop-native.js +50 -0
- package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
- package/src/session-supervision/claude-desktop-target.js +87 -0
- package/src/session-supervision/claude-outcome-review.js +132 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-review-definition.js +5 -0
- package/src/session-supervision/claude-sessions.js +210 -40
- package/src/session-supervision/codex-app-server.js +189 -36
- package/src/session-supervision/continuation-analysis.js +7 -7
- package/src/session-supervision/continuation-command.js +45 -0
- package/src/session-supervision/controller-store.js +48 -0
- package/src/session-supervision/conversation-presentation.js +241 -0
- package/src/session-supervision/decision-presentation.js +96 -35
- package/src/session-supervision/execution-context.js +39 -0
- package/src/session-supervision/index.js +38 -9
- package/src/session-supervision/manual-acceptance-command.js +30 -0
- package/src/session-supervision/notification-authority.js +9 -0
- package/src/session-supervision/notification-journal.js +40 -0
- package/src/session-supervision/operation-journal.js +42 -0
- package/src/session-supervision/owner-transport.js +146 -0
- package/src/session-supervision/purpose-recovery.js +10 -2
- package/src/session-supervision/purpose-verification.js +103 -9
- package/src/session-supervision/remote-control.js +1 -1
- package/src/session-supervision/remote-entry.js +2 -0
- package/src/session-supervision/remote-errors.js +15 -0
- package/src/session-supervision/remote-hosts.js +344 -36
- package/src/session-supervision/remote-metadata.js +103 -0
- package/src/session-supervision/remote-session-list.js +2 -4
- package/src/session-supervision/session-archive-command.js +45 -0
- package/src/session-supervision/session-list.js +40 -4
- package/src/session-supervision/sqm-presentation.js +46 -3
- package/src/session-supervision/stop-notifications.js +42 -11
- package/src/session-supervision/work-state.js +106 -6
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/claude-plugin.js +7 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +1 -1
- package/src/update-detachment/coordinator.js +7 -1
- package/src/update-detachment/history-recovery.js +18 -0
- package/src/update-detachment/ledger-adapter.js +1 -0
- package/src/update-detachment/retained-paused.js +290 -0
- package/src/update-detachment/runtime.js +78 -15
package/src/desktop-host.js
CHANGED
|
@@ -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
|
|
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(
|
|
185
|
-
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
323
|
-
if (!force &&
|
|
324
|
-
if (
|
|
325
|
-
|
|
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 =
|
|
330
|
-
|
|
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
|
|
349
|
-
|
|
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.
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
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
|
|
498
|
+
const receipt = await nativeReportsApi(deviceSession).submitCompletion(completion);
|
|
499
|
+
await requireReportController(controller);
|
|
365
500
|
if (receipt && nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
|
|
366
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
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
|
|
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,
|
|
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] }) }))).
|
|
801
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|