@awak-app/simy-cli 0.6.16 → 0.6.18
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 +99 -6
- package/package.json +1 -1
- package/src/agent.js +123 -28
- package/src/auto-update.js +15 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
- package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
- package/src/codex-plugins/simy/hooks/hooks.json +2 -2
- package/src/codex-work-pattern-sync.js +37 -9
- package/src/delivery-hooks/command.js +9 -2
- package/src/delivery-hooks/desktop.js +10 -12
- package/src/delivery-hooks/evidence.js +50 -0
- package/src/delivery-hooks/supervisor.js +8 -2
- package/src/desktop-access.js +4 -3
- package/src/desktop-host.js +669 -73
- package/src/doctor.js +2 -1
- package/src/host-exit-diagnostics.js +28 -0
- package/src/index.js +1 -0
- package/src/native-development-report-completion.js +143 -0
- package/src/native-development-reports.js +137 -0
- package/src/orchestrator/checklist-continuation.js +5 -0
- package/src/orchestrator/delivery/WORKFLOW.md +4 -0
- package/src/orchestrator/delivery/references/state-model.md +187 -0
- package/src/orchestrator/loop.js +119 -34
- package/src/provider-links.js +74 -0
- package/src/runner.js +31 -2
- package/src/session-supervision/automatic-continuation.js +31 -2
- package/src/session-supervision/claude-sessions.js +25 -15
- package/src/session-supervision/codex-app-server.js +27 -4
- package/src/session-supervision/index.js +163 -42
- package/src/session-supervision/statistics.js +76 -15
- package/src/sqm/command.js +58 -7
- package/src/sqm/evidence-client.js +11 -1
- package/src/sqm/failure-circuit.js +28 -6
- package/src/sqm/history-store.js +49 -18
- package/src/sqm/provider-diagnostics.js +1 -0
- package/src/sqm/review-worker.js +70 -21
- package/src/sqm/workflow-update.js +12 -3
- package/src/windows-bootstrap.js +2 -1
- package/src/windows-desktop-updater.js +6 -1
- package/src/windows-inbound.js +9 -0
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
1
3
|
import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
|
|
2
4
|
import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
|
|
3
5
|
import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-plugin-status.js';
|
|
@@ -8,11 +10,11 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
|
|
|
8
10
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
9
11
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
10
12
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
11
|
-
import { controlDelivery, verifyDelivery, createRecordReader, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
13
|
+
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
12
14
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
13
15
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
14
16
|
import { createStopNotifier } from "./session-supervision/stop-notifications.js";
|
|
15
|
-
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
17
|
+
import { AutoContinueUnavailableError, answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
16
18
|
import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
|
|
17
19
|
import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
18
20
|
import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
|
|
@@ -35,6 +37,8 @@ import { openAuthorizationUrl } from "./browser.js";
|
|
|
35
37
|
import { inspectBackendExecutable } from "./backend-executable.js";
|
|
36
38
|
import { inspectSqmAgentSkill } from "./sqm/agent-skill.js";
|
|
37
39
|
import { startWorkflowUpdates, syncInstalledWorkflows } from "./sqm/workflow-update.js";
|
|
40
|
+
import { createNativeDevelopmentReportsApi, normalizeNativeDevelopmentReportSave } from "./native-development-reports.js";
|
|
41
|
+
import { projectNativeDevelopmentReportCompletion, sendNativeDevelopmentReportCompletion } from "./native-development-report-completion.js";
|
|
38
42
|
import {
|
|
39
43
|
acquireDesktopProcessLock,
|
|
40
44
|
assertNoLegacyCliDaemon,
|
|
@@ -48,9 +52,37 @@ const silentLogger = { log() {}, error() {} };
|
|
|
48
52
|
// exceed 128 KiB, including after a signed runtime handoff.
|
|
49
53
|
const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
|
|
50
54
|
|
|
55
|
+
async function withReadCancellation(signal, work) {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
let cancelled;
|
|
58
|
+
const cancellation = new Promise((_, reject) => {
|
|
59
|
+
cancelled = () => reject(Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' }));
|
|
60
|
+
signal.addEventListener('abort', cancelled, { once: true });
|
|
61
|
+
});
|
|
62
|
+
try { return await Promise.race([Promise.resolve().then(work), cancellation]); }
|
|
63
|
+
finally { signal.removeEventListener('abort', cancelled); }
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
function desktopActionError(action, error) {
|
|
67
|
+
if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
|
|
68
|
+
if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
|
|
69
|
+
if (["provider_connect", "provider_disconnect"].includes(action)) return "Could not finish changing the SIMY connection. Refresh its status and try again.";
|
|
70
|
+
if (action === "session_set_auto_continue") {
|
|
71
|
+
const messages = {
|
|
72
|
+
session_workspace_changed: "The conversation workspace changed. Refresh and select it again.",
|
|
73
|
+
session_metadata_unavailable: "Conversation details are unavailable. Refresh this provider and select the conversation again.",
|
|
74
|
+
session_model_unavailable: "This provider did not expose the conversation model; automatic continuation is unavailable.",
|
|
75
|
+
existing_loop: "This conversation already has a Delivery Loop. Open its existing supervision instead.",
|
|
76
|
+
task_settled: "This task has finished. Start a new conversation before enabling automatic continuation.",
|
|
77
|
+
};
|
|
78
|
+
if (messages[error?.code]) return messages[error.code];
|
|
79
|
+
}
|
|
52
80
|
if (action === 'authorization_open' || error?.code === 'desktop_browser_unavailable') return "Could not open your browser. Open the verification link again, or check your default browser.";
|
|
53
81
|
if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
|
|
82
|
+
if (action === "codex_hooks" || action === "claude_hooks") return "Could not open the setup guide. Check your default browser, or use /hooks inside the provider.";
|
|
83
|
+
if (action === "install_plugins") return error?.code === "plugin_setup_busy"
|
|
84
|
+
? "Another SIMY process is preparing plugins. Wait, then refresh to check the files."
|
|
85
|
+
: "Plugin setup could not finish. Refresh its status or retry setup.";
|
|
54
86
|
if (action === "session_deadline_decide") return error?.code === "conflict"
|
|
55
87
|
? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
|
|
56
88
|
if (typeof action === "string" && action.startsWith("session_")) {
|
|
@@ -65,6 +97,10 @@ function desktopActionError(action, error) {
|
|
|
65
97
|
return "Could not save SQM settings. Check your SIMY connection and try again.";
|
|
66
98
|
}
|
|
67
99
|
if (action === 'update') return 'Update could not finish. Restart SIMY and retry; stopped tasks stay stopped.';
|
|
100
|
+
if (/^(codex|claude)_login$/.test(action || "")) {
|
|
101
|
+
if (error?.code === "provider_not_installed") return "Provider CLI is not installed. Install it, then refresh connections.";
|
|
102
|
+
return "Could not open the sign-in terminal. Check your Terminal / PowerShell app, then try again.";
|
|
103
|
+
}
|
|
68
104
|
return "Action could not finish. Refresh the status and try again.";
|
|
69
105
|
}
|
|
70
106
|
|
|
@@ -74,8 +110,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
74
110
|
// A heartbeat remains a complete native frame. Only its observation time is
|
|
75
111
|
// volatile; reuse the encoded body after comparing freshly authorized data.
|
|
76
112
|
// Detach the comparison value because registries may mutate nested objects.
|
|
77
|
-
let encodedStatus = null, lastStatus = null;
|
|
113
|
+
let encodedStatus = null, lastStatus = null, updateProgress = null;
|
|
78
114
|
const emitStatus = status => {
|
|
115
|
+
if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
|
|
79
116
|
const { sampledAt, ...content } = status;
|
|
80
117
|
if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
|
|
81
118
|
encodedStatus = JSON.stringify(content).slice(1, -1);
|
|
@@ -95,18 +132,51 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
95
132
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
96
133
|
const deliveryRoot = join(root, "delivery-hooks");
|
|
97
134
|
const recordReader = createRecordReader(deliveryRoot);
|
|
135
|
+
let providerLinks = await readProviderLinks(root);
|
|
136
|
+
const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
|
|
137
|
+
const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
|
|
138
|
+
const analysisSessions = sessions => linkedSessions(sessions).filter(item => !providerLinks[item.provider].requiresConsent ||
|
|
139
|
+
item.autoContinue === true || item.tracked === true || item.monitor === true || explicitAnalyses.has(`${item.provider}:${item.session}`));
|
|
140
|
+
const authorizeProvider = async (provider, scope, expectedEpoch) => {
|
|
141
|
+
await authorizeWork(scope);
|
|
142
|
+
return requireProviderLink({ root: deliveryRoot, provider, expectedEpoch });
|
|
143
|
+
};
|
|
98
144
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
|
|
99
|
-
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
145
|
+
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: async () => linkedSessions(await recordReader.list()), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
100
146
|
let actionAssociations = makeActionAssociations();
|
|
101
147
|
const statisticsCache = new Map();
|
|
148
|
+
let activeStatisticsRead = null;
|
|
149
|
+
let statisticsSelection = { key: null, generation: 0 };
|
|
102
150
|
const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
|
|
103
151
|
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
152
|
+
let nativeDevelopmentReports = { state: 'loading', enrollments: [] };
|
|
153
|
+
let nativeDevelopmentReportsScope = null;
|
|
154
|
+
let nativeDevelopmentReportsRequest = null;
|
|
155
|
+
let nativeDevelopmentReportsGeneration = 0;
|
|
156
|
+
let nativeDevelopmentReportsRetryAt = 0;
|
|
157
|
+
let nativeDevelopmentReportsMutation = false;
|
|
158
|
+
const nativeCompletionSuccess = new Set();
|
|
159
|
+
const nativeCompletionRejected = new Set();
|
|
160
|
+
const nativeCompletionInFlight = new Map();
|
|
161
|
+
const nativeCompletionRetryAt = new Map();
|
|
104
162
|
const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
|
|
105
163
|
const setAccessSnapshot = async access => {
|
|
106
164
|
const previousScope = accessScope(currentAccess);
|
|
165
|
+
const previouslyReady = desktopAccessReady(currentAccess);
|
|
107
166
|
currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
|
|
167
|
+
if (previousScope !== accessScope(currentAccess) || previouslyReady && !desktopAccessReady(currentAccess)) {
|
|
168
|
+
// A privilege loss also invalidates consent reads, even when the
|
|
169
|
+
// credential/organization revision itself did not change.
|
|
170
|
+
nativeDevelopmentReports = { state: 'loading', enrollments: [] };
|
|
171
|
+
nativeDevelopmentReportsScope = null;
|
|
172
|
+
nativeDevelopmentReportsGeneration++;
|
|
173
|
+
nativeDevelopmentReportsRetryAt = 0;
|
|
174
|
+
nativeCompletionInFlight.clear();
|
|
175
|
+
}
|
|
108
176
|
if (previousScope !== accessScope(currentAccess)) {
|
|
109
177
|
statisticsCache.clear();
|
|
178
|
+
activeStatisticsRead?.controller.abort();
|
|
179
|
+
explicitAnalyses.clear();
|
|
110
180
|
discoveryController?.abort();
|
|
111
181
|
sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
|
|
112
182
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
@@ -115,9 +185,114 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
115
185
|
}
|
|
116
186
|
return currentAccess;
|
|
117
187
|
};
|
|
118
|
-
const
|
|
119
|
-
|
|
188
|
+
const sameAccess = (left, right) => accessScope(left) === accessScope(right) && left?.state === right?.state;
|
|
189
|
+
const readAccess = async () => agent?.getDesktopAccess
|
|
190
|
+
? agent.getDesktopAccess() : (await agent?.getDesktopStatus?.())?.access;
|
|
191
|
+
const accessSnapshot = async () => {
|
|
192
|
+
await setAccessSnapshot(agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess());
|
|
193
|
+
// Cleanup can yield while the account changes. Never return that obsolete
|
|
194
|
+
// access, or reapply it after another caller installed the new scope.
|
|
195
|
+
if (agent?.getDesktopAccess && !sameAccess(currentAccess, agent.getDesktopAccess())) return accessSnapshot();
|
|
196
|
+
return currentAccess;
|
|
197
|
+
};
|
|
198
|
+
const acceptStatusAccess = async sampledAccess => {
|
|
199
|
+
// Validate before setAccessSnapshot: it clears the catalog/statistics and
|
|
200
|
+
// revokes continuations, so rejecting only the emitted frame is too late.
|
|
201
|
+
const before = agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess();
|
|
202
|
+
if (!sameAccess(sampledAccess, before)) return false;
|
|
203
|
+
await setAccessSnapshot(sampledAccess);
|
|
204
|
+
const latestAccess = agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess();
|
|
205
|
+
if (!sameAccess(sampledAccess, latestAccess)) {
|
|
206
|
+
await accessSnapshot();
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return sameAccess(currentAccess, sampledAccess);
|
|
210
|
+
};
|
|
211
|
+
let stoppingForUpdate = false, updateOperation = null, updateOperationKind = null, updateDrain = null, releaseUpdateHold = null;
|
|
120
212
|
let unfinishedDesktopWork = true;
|
|
213
|
+
const runningUpdateDeliveryTask = record => record.run && ['active', 'auto_resuming', 'recovery_required'].includes(record.run.state);
|
|
214
|
+
const stopUpdateDeliveryTasks = async () => {
|
|
215
|
+
const records = await recordReader.list();
|
|
216
|
+
const results = await Promise.allSettled(records.map(async record => {
|
|
217
|
+
if (record.autoContinue === true) await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: deliveryRoot, provider: record.provider, session: record.session, enabled: false });
|
|
218
|
+
if (record.run && !TERMINAL.has(record.run.state)) await controlDelivery({
|
|
219
|
+
root: deliveryRoot, provider: record.provider, session: record.session,
|
|
220
|
+
taskId: record.run.managed?.taskId, action: 'stop',
|
|
221
|
+
});
|
|
222
|
+
}));
|
|
223
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
224
|
+
if (failure) throw failure.reason;
|
|
225
|
+
};
|
|
226
|
+
const localCliApiSession = () => dependencies.getLocalCliApiSession?.() || agent?.getLocalCliApiSession?.() || null;
|
|
227
|
+
const nativeReportsApi = (fixedSession = null) => (dependencies.createNativeDevelopmentReportsApi || createNativeDevelopmentReportsApi)({
|
|
228
|
+
webOrigin,
|
|
229
|
+
readSession: async () => fixedSession || localCliApiSession(),
|
|
230
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
231
|
+
});
|
|
232
|
+
const refreshNativeDevelopmentReports = async ({ force = false } = {}) => {
|
|
233
|
+
if (!desktopAccessReady(currentAccess)) return nativeDevelopmentReports;
|
|
234
|
+
if (nativeDevelopmentReportsMutation) return nativeDevelopmentReports;
|
|
235
|
+
const requestedScope = accessScope(currentAccess);
|
|
236
|
+
if (!force && nativeDevelopmentReportsScope === requestedScope && nativeDevelopmentReports.state === 'ready') return nativeDevelopmentReports;
|
|
237
|
+
if (!force && Date.now() < nativeDevelopmentReportsRetryAt) return nativeDevelopmentReports;
|
|
238
|
+
if (nativeDevelopmentReportsRequest?.scope === requestedScope) return nativeDevelopmentReportsRequest.promise;
|
|
239
|
+
nativeDevelopmentReports = { state: 'loading', enrollments: nativeDevelopmentReportsScope === requestedScope ? nativeDevelopmentReports.enrollments : [] };
|
|
240
|
+
nativeDevelopmentReportsScope = requestedScope;
|
|
241
|
+
const generation = nativeDevelopmentReportsGeneration;
|
|
242
|
+
const job = { scope: requestedScope, generation, promise: null };
|
|
243
|
+
job.promise = nativeReportsApi().list().then(enrollments => {
|
|
244
|
+
if (accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
|
|
245
|
+
nativeDevelopmentReports = { state: 'ready', enrollments };
|
|
246
|
+
nativeDevelopmentReportsRetryAt = 0;
|
|
247
|
+
}
|
|
248
|
+
return nativeDevelopmentReports;
|
|
249
|
+
}).catch(() => {
|
|
250
|
+
if (accessScope(currentAccess) === requestedScope && nativeDevelopmentReportsGeneration === generation) {
|
|
251
|
+
nativeDevelopmentReports = { state: 'unavailable', enrollments: [] };
|
|
252
|
+
nativeDevelopmentReportsRetryAt = Date.now() + 30_000;
|
|
253
|
+
}
|
|
254
|
+
return nativeDevelopmentReports;
|
|
255
|
+
}).finally(() => {
|
|
256
|
+
if (nativeDevelopmentReportsRequest === job) nativeDevelopmentReportsRequest = null;
|
|
257
|
+
if (!closed && !relay) void publish().catch(() => {});
|
|
258
|
+
});
|
|
259
|
+
nativeDevelopmentReportsRequest = job;
|
|
260
|
+
return job.promise;
|
|
261
|
+
};
|
|
262
|
+
const submitNativeDevelopmentReportCompletions = ({ records, scope, deviceSession }) => {
|
|
263
|
+
if (!deviceSession?.token || !deviceSession?.api_base_url || nativeDevelopmentReports.state !== "ready" || nativeDevelopmentReportsScope !== scope) return;
|
|
264
|
+
const generation = nativeDevelopmentReportsGeneration;
|
|
265
|
+
for (const enrollment of nativeDevelopmentReports.enrollments) {
|
|
266
|
+
if (enrollment.provider !== "codex" || enrollment.enabled !== true) continue;
|
|
267
|
+
const record = records.find(item => item.provider === enrollment.provider && item.session?.toLowerCase() === enrollment.provider_session_id?.toLowerCase());
|
|
268
|
+
const completion = projectNativeDevelopmentReportCompletion({ enrollment, record });
|
|
269
|
+
if (!completion) continue;
|
|
270
|
+
const key = [scope, completion.provider, completion.provider_session_id, completion.task_id, completion.enrollment_revision, completion.managed_revision, completion.acceptance_digest].join(":");
|
|
271
|
+
// A terminal response is tied to the consent identity, rather than a
|
|
272
|
+
// particular proof revision: a new consent revision is required before
|
|
273
|
+
// the desktop may offer that server a different attestation.
|
|
274
|
+
const rejectionKey = [scope, completion.provider, completion.provider_session_id, completion.task_id, completion.enrollment_revision].join(":");
|
|
275
|
+
if (nativeCompletionSuccess.has(key) || nativeCompletionRejected.has(rejectionKey) || nativeCompletionInFlight.has(key) || Date.now() < (nativeCompletionRetryAt.get(key) || 0)) continue;
|
|
276
|
+
const job = Promise.resolve().then(async () => {
|
|
277
|
+
if (closed || relay || nativeDevelopmentReportsGeneration !== generation || accessScope(currentAccess) !== scope) return null;
|
|
278
|
+
const receipt = await sendNativeDevelopmentReportCompletion({ api: nativeReportsApi(deviceSession), enrollment, record });
|
|
279
|
+
if (receipt && nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
|
|
280
|
+
nativeCompletionSuccess.add(key);
|
|
281
|
+
nativeCompletionRetryAt.delete(key);
|
|
282
|
+
}
|
|
283
|
+
return receipt;
|
|
284
|
+
}).catch(error => {
|
|
285
|
+
if (nativeDevelopmentReportsGeneration === generation && accessScope(currentAccess) === scope) {
|
|
286
|
+
// These are immutable authorization/contract rejections. A new
|
|
287
|
+
// enrollment revision or scope produces a fresh rejection identity.
|
|
288
|
+
if ([400, 403, 409].includes(error?.status)) nativeCompletionRejected.add(rejectionKey);
|
|
289
|
+
else nativeCompletionRetryAt.set(key, Date.now() + 30_000);
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}).finally(() => nativeCompletionInFlight.delete(key));
|
|
293
|
+
nativeCompletionInFlight.set(key, job);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
121
296
|
let organizationAnalysesSuspended = false;
|
|
122
297
|
let organizationAnalysisDrain = Promise.resolve();
|
|
123
298
|
let organizationAnalysisEpoch = 0;
|
|
@@ -131,15 +306,19 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
131
306
|
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
132
307
|
return scope;
|
|
133
308
|
};
|
|
134
|
-
const selectedScopes = new WeakMap();
|
|
309
|
+
const selectedScopes = new WeakMap(), selectedLinkEpochs = new WeakMap();
|
|
135
310
|
const guardedEnroll = async options => {
|
|
136
|
-
await
|
|
311
|
+
await authorizeProvider(options.provider);
|
|
137
312
|
return (dependencies.continueSession || continueSession)(options);
|
|
138
313
|
};
|
|
139
314
|
|
|
140
315
|
const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
|
|
141
316
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
142
|
-
authorizeDispatch:
|
|
317
|
+
authorizeDispatch: async record => {
|
|
318
|
+
const scope = await authorizeWork();
|
|
319
|
+
const link = await requireProviderLink({ root: deliveryRoot, provider: record.provider });
|
|
320
|
+
return JSON.stringify([scope, link.epoch]);
|
|
321
|
+
}, readRecords: () => recordReader.list(),
|
|
143
322
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
144
323
|
let shuttingDown = null;
|
|
145
324
|
let commands;
|
|
@@ -155,6 +334,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
155
334
|
let confirmedUpdateStop;
|
|
156
335
|
const stopWorkForUpdate = () => confirmedUpdateStop ||= (async () => {
|
|
157
336
|
stoppingForUpdate = true; discoveryStopped = true;
|
|
337
|
+
activeStatisticsRead?.controller.abort();
|
|
158
338
|
void stopDiscovery();
|
|
159
339
|
await stopDesktopWorkForUpdate({
|
|
160
340
|
stopWork: () => Promise.all([
|
|
@@ -183,7 +363,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
183
363
|
if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
|
|
184
364
|
const requestedScope = accessScope(currentAccess);
|
|
185
365
|
const controller = discoveryController = new AbortController();
|
|
186
|
-
sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal }); })
|
|
366
|
+
sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
|
|
187
367
|
.then(async value => {
|
|
188
368
|
if (closed || relay || controller.signal.aborted) return;
|
|
189
369
|
const latestAccess = await accessSnapshot();
|
|
@@ -193,7 +373,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
193
373
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
194
374
|
return;
|
|
195
375
|
}
|
|
196
|
-
|
|
376
|
+
const sessions = linkedSessions(value.sessions);
|
|
377
|
+
await analysisManager.update(analysisSessions(sessions));
|
|
378
|
+
if (controller.signal.aborted) return;
|
|
379
|
+
sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
|
|
197
380
|
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
198
381
|
.finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
199
382
|
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
@@ -202,6 +385,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
202
385
|
};
|
|
203
386
|
const selectedSession = async (command, { reuseDiscovery = false, preferObserved = false } = {}) => {
|
|
204
387
|
const scope = await authorizeWork();
|
|
388
|
+
const link = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
|
|
205
389
|
// Preference changes may use the observation already displayed by the UI.
|
|
206
390
|
// Execution still takes the fresh-discovery path and rechecks opt-in epochs.
|
|
207
391
|
const matches = item => item.provider === command.provider && item.session === command.session;
|
|
@@ -216,21 +400,72 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
216
400
|
candidate = sessionStatus.sessions.find(matches);
|
|
217
401
|
}
|
|
218
402
|
if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
|
|
219
|
-
await
|
|
403
|
+
await authorizeProvider(command.provider, scope, link.epoch);
|
|
220
404
|
const selected = { ...candidate };
|
|
221
405
|
selectedScopes.set(selected, scope);
|
|
406
|
+
selectedLinkEpochs.set(selected, link.epoch);
|
|
222
407
|
return selected;
|
|
223
408
|
};
|
|
409
|
+
const revokeProviderConsent = async provider => {
|
|
410
|
+
const records = (await recordReader.list()).filter(record => record.provider === provider);
|
|
411
|
+
for (const record of records) {
|
|
412
|
+
automaticContinuation?.revoke(provider, record.session);
|
|
413
|
+
await updateRecord(deliveryRoot, provider, record.session, previous => {
|
|
414
|
+
if (!previous) return previous;
|
|
415
|
+
const next = { ...previous, autoContinue: false, autoContinueEpoch: randomUUID(), tracked: false, monitor: false };
|
|
416
|
+
if (previous.run && !TERMINAL.has(previous.run.state)) {
|
|
417
|
+
next.run = { ...previous.run, state: "paused", reason: "Provider disconnected from SIMY.",
|
|
418
|
+
revision: (previous.run.revision || 0) + 1, updatedAt: Date.now(),
|
|
419
|
+
...(previous.run.managed ? { managed: { ...previous.run.managed, autoRecovery: false } } : {}) };
|
|
420
|
+
}
|
|
421
|
+
return next;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const changeProviderLink = async (provider, connected) => {
|
|
426
|
+
if (!PROVIDER_LINKS.includes(provider)) throw new Error("Unsupported SIMY provider connection.");
|
|
427
|
+
if (providerLinkBusy.has(provider)) throw new Error("SIMY connection is already changing.");
|
|
428
|
+
providerLinkBusy.add(provider);
|
|
429
|
+
providerLinkFeedback[provider] = { state: connected ? "connecting" : "disconnecting" };
|
|
430
|
+
try {
|
|
431
|
+
providerLinks = await readProviderLinks(root);
|
|
432
|
+
if (!connected) providerLinks = await writeProviderLink(root, provider, false);
|
|
433
|
+
if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
|
|
434
|
+
discoveryController?.abort();
|
|
435
|
+
sessionRefreshAgain = false;
|
|
436
|
+
for (const item of sessionStatus.sessions.filter(item => item.provider === provider)) automaticContinuation?.revoke(provider, item.session);
|
|
437
|
+
for (const key of explicitAnalyses) if (key.startsWith(`${provider}:`)) explicitAnalyses.delete(key);
|
|
438
|
+
statisticsCache.clear();
|
|
439
|
+
if (!connected) sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessionStatus.sessions) };
|
|
440
|
+
await analysisManager.update(analysisSessions(sessionStatus.sessions));
|
|
441
|
+
await publish();
|
|
442
|
+
// A crash after saving disconnected must not restore old session consent
|
|
443
|
+
// when the user reconnects. Revoke before persisting connected again.
|
|
444
|
+
if (!connected || !providerLinks[provider].connected) await revokeProviderConsent(provider);
|
|
445
|
+
if (connected && !providerLinks[provider].connected) {
|
|
446
|
+
await authorizeWork();
|
|
447
|
+
providerLinks = await writeProviderLink(root, provider, true);
|
|
448
|
+
}
|
|
449
|
+
delete providerLinkFeedback[provider];
|
|
450
|
+
try { await agent.refreshProviderStatus?.(); }
|
|
451
|
+
catch { providerLinkFeedback[provider] = { message: "SIMY connection saved. Provider status could not be verified; refresh connections to check it." }; }
|
|
452
|
+
refreshSessions(true);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
providerLinkFeedback[provider] = { state: "error", message: desktopActionError(connected ? "provider_connect" : "provider_disconnect", error) };
|
|
455
|
+
throw error;
|
|
456
|
+
} finally { providerLinkBusy.delete(provider); await publish(); }
|
|
457
|
+
};
|
|
224
458
|
const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
|
|
225
459
|
getManager: () => analysisManager, selectCandidate: selectedSession,
|
|
226
460
|
readSetting: dependencies.readSessionAutoContinue,
|
|
227
461
|
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
228
|
-
enroll: guardedEnroll, beforeEnroll: candidate =>
|
|
462
|
+
enroll: guardedEnroll, beforeEnroll: candidate => authorizeProvider(candidate.provider, selectedScopes.get(candidate), selectedLinkEpochs.get(candidate)),
|
|
229
463
|
onChange: () => { if (!closed && !relay) refreshSessions(true); },
|
|
230
464
|
});
|
|
231
465
|
const suspendOrganizationAnalyses = () => {
|
|
232
466
|
organizationAnalysisEpoch++;
|
|
233
467
|
organizationAnalysesSuspended = true;
|
|
468
|
+
activeStatisticsRead?.controller.abort();
|
|
234
469
|
discoveryStopped = true;
|
|
235
470
|
organizationAnalysisDrain = Promise.all([
|
|
236
471
|
stopDiscovery({ drain: true }), automaticContinuation?.close(), analysisManager?.suspend?.(),
|
|
@@ -252,6 +487,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
252
487
|
refreshSessions(true);
|
|
253
488
|
};
|
|
254
489
|
let pluginSetup = { state: "unknown" };
|
|
490
|
+
const providerAuth = {};
|
|
491
|
+
const providerAuthBusy = new Set();
|
|
492
|
+
let pluginInstallationActive = false, pluginSetupEpoch = 0;
|
|
255
493
|
let providerPlugins = { codex: { state: 'checking' }, claude: { state: 'checking' } };
|
|
256
494
|
let pluginInspection = null, lastPluginInspectionAt = -Infinity;
|
|
257
495
|
let pluginInspectionAbort = new AbortController();
|
|
@@ -267,20 +505,40 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
267
505
|
return pluginInspection;
|
|
268
506
|
};
|
|
269
507
|
const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
|
|
270
|
-
const refreshPlugins = async () => {
|
|
508
|
+
const refreshPlugins = async ({ settleInstall = false } = {}) => {
|
|
509
|
+
const epoch = pluginSetupEpoch;
|
|
510
|
+
const canApply = () => epoch === pluginSetupEpoch && (!pluginInstallationActive || settleInstall);
|
|
271
511
|
try {
|
|
272
512
|
const status = await inspectPlugins();
|
|
273
|
-
pluginSetup = { state: status.installed ? "ready" : "missing", refreshPending: status.installed && pluginSetup.refreshPending === true };
|
|
274
|
-
} catch { pluginSetup = { state: "error" }; }
|
|
513
|
+
if (canApply()) pluginSetup = { state: status.installed ? "ready" : "missing", refreshPending: status.installed && pluginSetup.refreshPending === true };
|
|
514
|
+
} catch { if (canApply()) pluginSetup = { state: "error" }; }
|
|
275
515
|
void refreshProviderPlugins();
|
|
276
516
|
};
|
|
517
|
+
const decorateProviderSettings = async status => {
|
|
518
|
+
providerLinks = await readProviderLinks(root);
|
|
519
|
+
sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessionStatus.sessions) };
|
|
520
|
+
status.providerLinks = Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
|
|
521
|
+
...providerLinks[provider], state: providerLinks[provider].connected ? "connected" : "disconnected",
|
|
522
|
+
...(providerLinks[provider].settingsError ? { state: "error", message: "SIMY connection settings could not be read. Restore the settings before reconnecting." } : {}),
|
|
523
|
+
...providerLinkFeedback[provider], busy: providerLinkBusy.has(provider),
|
|
524
|
+
}]));
|
|
525
|
+
status.pluginSetup = { ...pluginSetup };
|
|
526
|
+
status.providerPlugins = structuredClone(providerPlugins);
|
|
527
|
+
for (const provider of ["codex", "claude"]) {
|
|
528
|
+
if (providerAuth[provider]?.state === "awaiting_sign_in" && !providerAuthBusy.has(provider) && status.providers?.[provider]?.state === "ready") delete providerAuth[provider];
|
|
529
|
+
}
|
|
530
|
+
status.providerAuth = Object.fromEntries(Object.entries(providerAuth).map(([provider, value]) => [provider, { ...value, busy: providerAuthBusy.has(provider) }]));
|
|
531
|
+
return JSON.stringify(providerLinks);
|
|
532
|
+
};
|
|
277
533
|
const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
|
|
278
534
|
diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
|
|
279
535
|
const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
|
|
280
536
|
root: join(root, 'session-analysis'), analyze: async (...args) => {
|
|
281
537
|
const scope = await authorizeWork();
|
|
282
538
|
const [candidate, options] = args;
|
|
283
|
-
|
|
539
|
+
const link = await requireProviderLink({ root: deliveryRoot, provider: candidate.provider });
|
|
540
|
+
return (dependencies.analyzeContinuation || analyzeContinuation)(candidate, { ...options,
|
|
541
|
+
beforeProviderDispatch: () => authorizeProvider(candidate.provider, scope, link.epoch) });
|
|
284
542
|
},
|
|
285
543
|
onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
|
|
286
544
|
});
|
|
@@ -296,21 +554,76 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
296
554
|
return scheduledPublish.promise;
|
|
297
555
|
};
|
|
298
556
|
let lastDeadlineFailureAt = -Infinity;
|
|
557
|
+
let connectionPublishing = null;
|
|
558
|
+
let connectionRevision = 0;
|
|
559
|
+
let latestConnectionStatus = null;
|
|
560
|
+
// Optional conversation enrichment can wait on local records or remote
|
|
561
|
+
// metadata. Keep sampling the real connection while that projection waits;
|
|
562
|
+
// replaying an old frame alone would hide sign-out or organization changes.
|
|
563
|
+
const publishConnectionStatus = () => {
|
|
564
|
+
if (connectionPublishing) return connectionPublishing;
|
|
565
|
+
connectionPublishing = Promise.resolve().then(() => agent.getDesktopStatus()).then(async status => {
|
|
566
|
+
if (closed || relay) return;
|
|
567
|
+
const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
|
|
568
|
+
if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
|
|
569
|
+
status.access = sampledAccess;
|
|
570
|
+
const requestedLinks = await decorateProviderSettings(status);
|
|
571
|
+
const sameScope = desktopAccessReady(currentAccess) &&
|
|
572
|
+
accessScope(lastStatus?.access) === accessScope(currentAccess);
|
|
573
|
+
status.deliveryHooks = sameScope && lastStatus?.deliveryHooks
|
|
574
|
+
? structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
|
|
575
|
+
status.sessionSupervision = sameScope && lastStatus?.sessionSupervision
|
|
576
|
+
? structuredClone(lastStatus.sessionSupervision) : { sessions: [], providers: {}, refreshedAt: null, initialLoading: desktopAccessReady(currentAccess) };
|
|
577
|
+
// Complete fast frames must retain the current saved preference so OFF
|
|
578
|
+
// remains available while unrelated conversation enrichment is waiting.
|
|
579
|
+
status.nativeDevelopmentReports = sameScope && nativeDevelopmentReportsScope === accessScope(currentAccess)
|
|
580
|
+
? structuredClone(nativeDevelopmentReports) : { state: 'loading', enrollments: [] };
|
|
581
|
+
// Cached enrichment must follow the current connection preference even
|
|
582
|
+
// while a full projection is waiting on records or remote metadata.
|
|
583
|
+
status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
|
|
584
|
+
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []);
|
|
585
|
+
status.sessionSupervision.providers ||= {};
|
|
586
|
+
for (const provider of PROVIDER_LINKS) {
|
|
587
|
+
if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
|
|
588
|
+
}
|
|
589
|
+
const update = status.update || updater.snapshot?.() || {};
|
|
590
|
+
status.update = { ...update, requires_task_stop: update.requires_task_stop === true || unfinishedDesktopWork ||
|
|
591
|
+
(status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 };
|
|
592
|
+
status.updateActiveTaskCount = Math.max(lastStatus?.updateActiveTaskCount || 0,
|
|
593
|
+
(status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0));
|
|
594
|
+
if (!desktopAccessReady(currentAccess)) {
|
|
595
|
+
status.tasks = { activeCount: status.tasks?.activeCount || 0, items: [] };
|
|
596
|
+
status.sqmChecks = { activeCount: 0, items: [] };
|
|
597
|
+
status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
|
|
598
|
+
}
|
|
599
|
+
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
|
|
600
|
+
const latestAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : await accessSnapshot();
|
|
601
|
+
if (accessScope(latestAccess) !== accessScope(status.access) || latestAccess?.state !== status.access?.state) {
|
|
602
|
+
publishAgain = true;
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (!closed && !relay) { connectionRevision += 1; latestConnectionStatus = structuredClone(status); emitStatus(status); }
|
|
606
|
+
}).finally(() => { connectionPublishing = null; });
|
|
607
|
+
return connectionPublishing;
|
|
608
|
+
};
|
|
299
609
|
const projectStatus = () => {
|
|
300
610
|
if (closed || relay || !agent) return Promise.resolve();
|
|
301
|
-
if (publishing) { publishAgain = true; return
|
|
611
|
+
if (publishing) { publishAgain = true; return publishConnectionStatus(); }
|
|
612
|
+
const requestedConnectionRevision = connectionRevision;
|
|
302
613
|
publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
|
|
303
614
|
if (closed || relay) return;
|
|
304
|
-
|
|
305
|
-
|
|
615
|
+
const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
|
|
616
|
+
if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
|
|
617
|
+
status.access = sampledAccess;
|
|
306
618
|
const requestedScope = accessScope(currentAccess);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const records =
|
|
619
|
+
const requestedLinks = await decorateProviderSettings(status);
|
|
620
|
+
const allRecords = await recordReader.list();
|
|
621
|
+
const records = linkedSessions(allRecords);
|
|
310
622
|
const update = status.update || updater.snapshot?.() || {};
|
|
311
|
-
if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(
|
|
623
|
+
if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(allRecords);
|
|
312
624
|
status.update = { ...update, requires_task_stop: update.requires_task_stop === true ||
|
|
313
|
-
(status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || hasUnfinishedSessions(
|
|
625
|
+
(status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || hasUnfinishedSessions(allRecords) };
|
|
626
|
+
status.updateActiveTaskCount = (status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) + allRecords.filter(runningUpdateDeliveryTask).length;
|
|
314
627
|
if (!desktopAccessReady(currentAccess)) {
|
|
315
628
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
316
629
|
if (currentAccess.state !== 'checking') await analysisManager.update([]);
|
|
@@ -319,10 +632,26 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
319
632
|
status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
|
|
320
633
|
status.deliveryHooks = { runs: [], providers: {} };
|
|
321
634
|
status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
|
|
635
|
+
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
|
|
636
|
+
if (connectionRevision !== requestedConnectionRevision || !sameAccess(sampledAccess, agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess())) { publishAgain = true; return; }
|
|
637
|
+
status.nativeDevelopmentReports = { state: 'loading', enrollments: [] };
|
|
322
638
|
if (!closed && !relay) emitStatus(status);
|
|
323
639
|
return;
|
|
324
640
|
}
|
|
325
641
|
await analysisManager.updateProviderStatuses?.(status.providers);
|
|
642
|
+
// Listing only reads rows previously registered by this device/account/org;
|
|
643
|
+
// it never passes the locally discovered catalog to Web.
|
|
644
|
+
if (agent.getLocalCliApiSession || dependencies.getLocalCliApiSession) {
|
|
645
|
+
void refreshNativeDevelopmentReports();
|
|
646
|
+
}
|
|
647
|
+
status.nativeDevelopmentReports = structuredClone(nativeDevelopmentReports);
|
|
648
|
+
// This is an asynchronous local attestation. It neither changes a
|
|
649
|
+
// Delivery Loop nor places receipt or device data in status frames.
|
|
650
|
+
submitNativeDevelopmentReportCompletions({
|
|
651
|
+
records: allRecords,
|
|
652
|
+
scope: requestedScope,
|
|
653
|
+
deviceSession: localCliApiSession(),
|
|
654
|
+
});
|
|
326
655
|
stopNotifier.observe(records);
|
|
327
656
|
const projection = await actionAssociations.beginProjection?.({ records });
|
|
328
657
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
@@ -333,7 +662,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
333
662
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
334
663
|
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
335
664
|
for (const session of sessions) {
|
|
336
|
-
|
|
665
|
+
const key = `${session.provider}:${session.session}`;
|
|
666
|
+
session.statistics = activeStatisticsRead?.key === key && !activeStatisticsRead.controller.signal.aborted
|
|
667
|
+
? { state: 'loading', hours: [], activity: [] }
|
|
668
|
+
: statisticsCache.get(key) || unavailableStatistics();
|
|
337
669
|
session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
|
|
338
670
|
session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
|
|
339
671
|
try {
|
|
@@ -350,10 +682,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
350
682
|
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
351
683
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
352
684
|
}
|
|
353
|
-
status.sessionSupervision = { ...discovery, sessions };
|
|
685
|
+
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions };
|
|
354
686
|
const latestAccess = await accessSnapshot();
|
|
355
|
-
if (
|
|
687
|
+
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
|
|
688
|
+
!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
356
689
|
actionAssociations.validateProjection && !await actionAssociations.validateProjection(projection)) { publishAgain = true; return; }
|
|
690
|
+
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
|
|
691
|
+
// Validation may wait for synchronization. Recheck access after that
|
|
692
|
+
// await so a sign-out or scope change cannot publish the old projection.
|
|
693
|
+
const validatedAccess = agent?.getDesktopAccess ? agent.getDesktopAccess() : await readAccess();
|
|
694
|
+
if (!sameAccess(sampledAccess, validatedAccess)) { publishAgain = true; return; }
|
|
695
|
+
if (connectionRevision !== requestedConnectionRevision) {
|
|
696
|
+
// Keep successfully enriched metadata, but never roll fresh connection
|
|
697
|
+
// observations back to the status captured before that work began.
|
|
698
|
+
if (accessScope(latestConnectionStatus?.access) !== requestedScope || !desktopAccessReady(latestConnectionStatus?.access)) { publishAgain = true; return; }
|
|
699
|
+
status = { ...structuredClone(latestConnectionStatus), deliveryHooks: status.deliveryHooks, sessionSupervision: status.sessionSupervision };
|
|
700
|
+
}
|
|
357
701
|
if (!closed && !relay) emitStatus(status);
|
|
358
702
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
359
703
|
return publishing;
|
|
@@ -361,12 +705,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
361
705
|
const stopAgent = async () => {
|
|
362
706
|
clearInterval(timer);
|
|
363
707
|
pluginInspectionAbort.abort();
|
|
708
|
+
activeStatisticsRead?.controller.abort();
|
|
364
709
|
discoveryStopped = true;
|
|
365
710
|
encodedStatus = null; lastStatus = null;
|
|
366
711
|
if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
|
|
367
712
|
// Start every cancellation before draining discovery/network work. A hung
|
|
368
713
|
// reader must never postpone cancellation of an owned Provider process.
|
|
369
|
-
const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing,
|
|
714
|
+
const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing, connectionPublishing,
|
|
370
715
|
pluginInspection,
|
|
371
716
|
actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
|
|
372
717
|
managedSupervisor.close(), recordReader.close(), stopWorkflows(),
|
|
@@ -505,6 +850,73 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
505
850
|
}, 3_000);
|
|
506
851
|
} catch (error) { await close(); throw error; }
|
|
507
852
|
|
|
853
|
+
const drainForUpdate = async ({ releaseHold = true } = {}) => {
|
|
854
|
+
// Acquire the hold before suspending dispatch: an automatic update may
|
|
855
|
+
// already have started since the last native status frame.
|
|
856
|
+
releaseUpdateHold ||= updater.holdApply?.() || (() => {});
|
|
857
|
+
stoppingForUpdate = true; discoveryStopped = true;
|
|
858
|
+
activeStatisticsRead?.controller.abort();
|
|
859
|
+
updateProgress = { state: 'stopping_tasks', available: true, message: 'Stopping running tasks before updating…' };
|
|
860
|
+
void publish().catch(() => {});
|
|
861
|
+
// Start every cancellation immediately. A blocked stop/detail command or
|
|
862
|
+
// discovery request must not postpone cancellation in another worker.
|
|
863
|
+
updateDrain ||= (async () => {
|
|
864
|
+
const results = await Promise.allSettled([stopDiscovery(), sessionRefresh,
|
|
865
|
+
stopUpdateDeliveryTasks(), agent.stopDesktopTasks(), managedSupervisor.close(),
|
|
866
|
+
automaticContinuation?.close(), analysisManager?.close()]);
|
|
867
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
868
|
+
if (failure) throw failure.reason;
|
|
869
|
+
const status = await agent.getDesktopStatus();
|
|
870
|
+
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
|
|
871
|
+
(await recordReader.list()).some(record => record.autoContinue === true || record.run && !TERMINAL.has(record.run.state))) throw new Error('Tasks are still running.');
|
|
872
|
+
})();
|
|
873
|
+
const draining = updateDrain;
|
|
874
|
+
void draining.finally(() => { if (updateDrain === draining) updateDrain = null; }).catch(() => {});
|
|
875
|
+
let timeout, timedOut = false;
|
|
876
|
+
try {
|
|
877
|
+
await Promise.race([updateDrain, new Promise((_, reject) => {
|
|
878
|
+
timeout = setTimeout(() => { timedOut = true; reject(new Error('Task shutdown timed out.')); }, dependencies.updateDrainTimeoutMs ?? dependencies.updateStopTimeoutMs ?? 20_000);
|
|
879
|
+
})]);
|
|
880
|
+
} catch {
|
|
881
|
+
if (timedOut) await (dependencies.stopOwnedHost || stopOwnedDesktopHost)(process.pid, { parentPid: process.ppid, includeRoot: false });
|
|
882
|
+
// Keep the hold after a timeout: a late lease release must never start
|
|
883
|
+
// installation after the native UI has reported that shutdown failed.
|
|
884
|
+
updateProgress = { state: 'update_stop_failed', available: true,
|
|
885
|
+
message: 'Tasks could not stop. The update has not started. Retry, or reopen SIMY.' };
|
|
886
|
+
void publish().catch(() => {});
|
|
887
|
+
throw new Error(updateProgress.message);
|
|
888
|
+
} finally { clearTimeout(timeout); }
|
|
889
|
+
unfinishedDesktopWork = false;
|
|
890
|
+
if (releaseHold) { releaseUpdateHold(); releaseUpdateHold = null; }
|
|
891
|
+
};
|
|
892
|
+
const runUpdate = async force => {
|
|
893
|
+
try {
|
|
894
|
+
if ((force || stoppingForUpdate) && updater.applyConfirmed) {
|
|
895
|
+
await updater.applyConfirmed(async () => { await drainForUpdate(); updateProgress = null; });
|
|
896
|
+
if (updateProgress?.state === 'update_stop_failed') throw new Error(updateProgress.message);
|
|
897
|
+
} else {
|
|
898
|
+
if (force || stoppingForUpdate) await drainForUpdate();
|
|
899
|
+
updateProgress = null;
|
|
900
|
+
if (updater.snapshot().state === 'waiting_for_idle') await updater.maybeApply();
|
|
901
|
+
else await updater.performAction();
|
|
902
|
+
}
|
|
903
|
+
if (stoppingForUpdate && ['failed', 'error', 'waiting_for_idle'].includes(updater.snapshot().state)) throw new Error('Update did not finish.');
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (!stoppingForUpdate || updateProgress?.state === 'update_stop_failed') throw error;
|
|
906
|
+
updateProgress = { state: 'update_failed', available: true,
|
|
907
|
+
message: 'The update could not finish. Tasks remain stopped. Retry, or reopen SIMY.' };
|
|
908
|
+
throw new Error(updateProgress.message);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
const executeUpdate = (prepareRestart = false) => {
|
|
912
|
+
const kind = prepareRestart ? 'restart' : 'install';
|
|
913
|
+
if (updateOperation && updateOperationKind !== kind) throw new Error('Another update operation is in progress. Retry after it finishes.');
|
|
914
|
+
if (updateOperation) return updateOperation;
|
|
915
|
+
updateOperationKind = kind;
|
|
916
|
+
return updateOperation = (prepareRestart ? drainForUpdate({ releaseHold: false }) : runUpdate(true))
|
|
917
|
+
.finally(() => { updateOperation = null; updateOperationKind = null; });
|
|
918
|
+
};
|
|
919
|
+
|
|
508
920
|
let queue = Promise.resolve();
|
|
509
921
|
// Authorization must not wait behind remote refresh/detail/provider work.
|
|
510
922
|
// Keep these commands ordered with each other; the agent still owns all
|
|
@@ -528,6 +940,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
528
940
|
if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
|
|
529
941
|
return;
|
|
530
942
|
}
|
|
943
|
+
// Read-only activity must never hold the ordered control queue. Share an
|
|
944
|
+
// identical pending selection and cancel superseded selections immediately.
|
|
945
|
+
let statisticsRequest, requestedStatisticsSelection;
|
|
946
|
+
if (command.action === 'session_statistics') {
|
|
947
|
+
const key = `${command.provider}:${command.session}`;
|
|
948
|
+
if (statisticsSelection.key !== key) {
|
|
949
|
+
statisticsSelection = { key, generation: statisticsSelection.generation + 1 };
|
|
950
|
+
activeStatisticsRead?.controller.abort();
|
|
951
|
+
}
|
|
952
|
+
requestedStatisticsSelection = statisticsSelection;
|
|
953
|
+
}
|
|
531
954
|
const execute = async () => {
|
|
532
955
|
if (closed) return;
|
|
533
956
|
try {
|
|
@@ -559,23 +982,67 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
559
982
|
emit({ type: "result", id: command.id, ok: true });
|
|
560
983
|
void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); return;
|
|
561
984
|
}
|
|
985
|
+
if (action === 'prepare_update_restart' || action === 'update' && (command.force === true || stoppingForUpdate)) {
|
|
986
|
+
if (action === 'prepare_update_restart' && command.force !== true) {
|
|
987
|
+
const status = await agent.getDesktopStatus();
|
|
988
|
+
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
|
|
989
|
+
hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping tasks to restart.');
|
|
990
|
+
}
|
|
991
|
+
await executeUpdate(action === 'prepare_update_restart');
|
|
992
|
+
emit({ type: 'result', id: command.id, ok: true });
|
|
993
|
+
void publish().catch(() => {}); return;
|
|
994
|
+
}
|
|
562
995
|
const access = await accessSnapshot();
|
|
563
996
|
const commandScope = accessScope(access);
|
|
564
997
|
if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
|
|
565
998
|
if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
|
|
566
999
|
|
|
1000
|
+
let commandProviderLink;
|
|
1001
|
+
if (command.provider && !["provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
|
|
1002
|
+
!(action === "session_set_auto_continue" && command.enabled === false)) {
|
|
1003
|
+
commandProviderLink = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
|
|
1004
|
+
}
|
|
1005
|
+
if (action === "provider_connect" || action === "provider_disconnect") {
|
|
1006
|
+
await changeProviderLink(command.provider, action === "provider_connect");
|
|
1007
|
+
emit({ type: "result", id: command.id, ok: true });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
567
1010
|
if (action === "session_statistics") {
|
|
568
|
-
const candidate = await selectedSession(command, { preferObserved: true });
|
|
569
|
-
let statistics;
|
|
570
|
-
if (dependencies.readSessionStatistics) statistics = await dependencies.readSessionStatistics(candidate);
|
|
571
|
-
else if (candidate.provider === 'claude') statistics = await readClaudeStatistics(candidate);
|
|
572
|
-
else {
|
|
573
|
-
const client = await openCodexAppServer();
|
|
574
|
-
try { statistics = await readCodexStatistics(client, candidate); } finally { await client.close(); }
|
|
575
|
-
}
|
|
576
1011
|
await authorizeWork(commandScope);
|
|
577
|
-
|
|
578
|
-
|
|
1012
|
+
// Authorization can wait for scope cleanup. Only the latest selection
|
|
1013
|
+
// generation may register a reader; an old A cannot revive after A-B-A.
|
|
1014
|
+
if (requestedStatisticsSelection.generation !== statisticsSelection.generation) {
|
|
1015
|
+
throw Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' });
|
|
1016
|
+
}
|
|
1017
|
+
if (activeStatisticsRead?.key !== requestedStatisticsSelection.key ||
|
|
1018
|
+
activeStatisticsRead.scope !== commandScope || activeStatisticsRead.controller.signal.aborted) {
|
|
1019
|
+
activeStatisticsRead?.controller.abort();
|
|
1020
|
+
activeStatisticsRead = { ...requestedStatisticsSelection, scope: commandScope, controller: new AbortController(), promise: null };
|
|
1021
|
+
}
|
|
1022
|
+
statisticsRequest = activeStatisticsRead;
|
|
1023
|
+
const request = statisticsRequest, signal = request.controller.signal;
|
|
1024
|
+
request.promise ||= withReadCancellation(signal, async () => {
|
|
1025
|
+
const candidate = await selectedSession(command, { preferObserved: true });
|
|
1026
|
+
signal.throwIfAborted();
|
|
1027
|
+
void publish().catch(() => {});
|
|
1028
|
+
let statistics;
|
|
1029
|
+
if (dependencies.readSessionStatistics) statistics = await dependencies.readSessionStatistics(candidate, { signal });
|
|
1030
|
+
else if (candidate.provider === 'claude') statistics = await readClaudeStatistics(candidate, { signal });
|
|
1031
|
+
else {
|
|
1032
|
+
const client = await openCodexAppServer({ signal, cancelOnAbort: true });
|
|
1033
|
+
try { statistics = await readCodexStatistics(client, candidate, { signal }); } finally { await client.close(); }
|
|
1034
|
+
}
|
|
1035
|
+
signal.throwIfAborted();
|
|
1036
|
+
await authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate));
|
|
1037
|
+
signal.throwIfAborted();
|
|
1038
|
+
if (activeStatisticsRead !== request) throw Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' });
|
|
1039
|
+
statisticsCache.clear(); // Only the selected conversation needs chart data.
|
|
1040
|
+
statisticsCache.set(request.key, statistics);
|
|
1041
|
+
}).finally(() => {
|
|
1042
|
+
if (activeStatisticsRead === request) activeStatisticsRead = null;
|
|
1043
|
+
if (!closed) void publish().catch(() => {});
|
|
1044
|
+
});
|
|
1045
|
+
await request.promise;
|
|
579
1046
|
emit({ type: "result", id: command.id, ok: true });
|
|
580
1047
|
void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); return;
|
|
581
1048
|
}
|
|
@@ -633,35 +1100,57 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
633
1100
|
}
|
|
634
1101
|
} else if (action === "check_update") await updater.checkNow();
|
|
635
1102
|
else if (action === "update") {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1103
|
+
const status = await agent.getDesktopStatus();
|
|
1104
|
+
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || updater.snapshot().requires_task_stop === true || hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping unfinished tasks.');
|
|
1105
|
+
await runUpdate(false);
|
|
1106
|
+
} else if (/^(codex|claude)_login$/.test(action)) {
|
|
1107
|
+
const [provider] = action.split("_");
|
|
1108
|
+
if (providerAuthBusy.has(provider)) throw new Error("Provider authentication is already running.");
|
|
1109
|
+
providerAuthBusy.add(provider);
|
|
1110
|
+
providerAuth[provider] = { state: "opening" };
|
|
1111
|
+
try {
|
|
1112
|
+
await publish();
|
|
1113
|
+
const custom = dependencies[`${provider}Login`];
|
|
1114
|
+
if (custom) await custom(root);
|
|
1115
|
+
else await openProviderLogin(provider, root);
|
|
1116
|
+
providerAuth[provider] = { state: "awaiting_sign_in" };
|
|
1117
|
+
await publish();
|
|
1118
|
+
// Terminal launch is not authentication success. The existing CLI
|
|
1119
|
+
// probes remain the authority for readiness after the handoff.
|
|
1120
|
+
try { await agent.refreshProviderStatus?.(); }
|
|
1121
|
+
catch {
|
|
1122
|
+
// The Terminal launch already finished. A failed verification
|
|
1123
|
+
// probe must not turn a real Terminal handoff into failure.
|
|
1124
|
+
providerAuth[provider] = { ...providerAuth[provider], statusRefreshFailed: true,
|
|
1125
|
+
message: "Provider sign-in status could not be verified. Refresh connections to check it." };
|
|
1126
|
+
}
|
|
1127
|
+
refreshSessions(true);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
providerAuth[provider] = { state: "error", message: desktopActionError(action, error) };
|
|
1130
|
+
await publish();
|
|
1131
|
+
throw error;
|
|
1132
|
+
} finally { providerAuthBusy.delete(provider); await publish(); }
|
|
651
1133
|
}
|
|
652
1134
|
else if (action === "install_plugins") {
|
|
1135
|
+
pluginInstallationActive = true; pluginSetupEpoch++;
|
|
653
1136
|
pluginSetup = { state: "installing" };
|
|
654
|
-
await publish();
|
|
655
1137
|
try {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1138
|
+
await publish();
|
|
1139
|
+
const result = await (dependencies.installPlugins || syncInstalledWorkflows)({ installMissing: true, lockWaitMs: 30_000 });
|
|
1140
|
+
if (result.outcome === "busy") throw Object.assign(new Error("Plugin installation is busy."), { code: "plugin_setup_busy" });
|
|
1141
|
+
await refreshPlugins({ settleInstall: true });
|
|
659
1142
|
if (pluginSetup.state !== "ready") throw new Error("Plugin files could not be verified.");
|
|
660
1143
|
pluginSetup.refreshPending = result.refreshPending === true;
|
|
661
|
-
} catch {
|
|
662
|
-
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
// A cache refresh or lock conflict does not undo prepared files.
|
|
1146
|
+
// Keep the result aligned with the source verification shown to users.
|
|
1147
|
+
await refreshPlugins({ settleInstall: true });
|
|
1148
|
+
if (pluginSetup.state === "ready") pluginSetup.refreshPending = true;
|
|
1149
|
+
else pluginSetup = { state: error?.code === "plugin_setup_busy" ? "waiting" : "error" };
|
|
663
1150
|
await publish();
|
|
664
|
-
|
|
1151
|
+
if (pluginSetup.state !== "ready") throw error;
|
|
1152
|
+
} finally {
|
|
1153
|
+
pluginInstallationActive = false; pluginSetupEpoch++;
|
|
665
1154
|
}
|
|
666
1155
|
}
|
|
667
1156
|
else if (action === "codex_hooks" || action === "claude_hooks") {
|
|
@@ -679,13 +1168,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
679
1168
|
await actionAssociations.correct(command, analysisManager.project(candidate));
|
|
680
1169
|
} else if (action === "session_set_auto_continue") {
|
|
681
1170
|
if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
|
|
1171
|
+
let admission, providerEpoch;
|
|
682
1172
|
if (command.enabled) {
|
|
683
1173
|
const candidate = await selectedSession(command, { preferObserved: true });
|
|
684
|
-
|
|
1174
|
+
const canEnable = candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue;
|
|
1175
|
+
if (canEnable !== true) {
|
|
1176
|
+
throw new AutoContinueUnavailableError({ reason: candidate.autoContinueAvailability?.reason || candidate.reason,
|
|
1177
|
+
reasonCode: candidate.autoContinueAvailability?.reasonCode || candidate.continuationReasonCode });
|
|
1178
|
+
}
|
|
1179
|
+
admission = candidate.continuationAdmission;
|
|
1180
|
+
providerEpoch = selectedLinkEpochs.get(candidate);
|
|
685
1181
|
}
|
|
686
1182
|
if (command.enabled) await authorizeWork(commandScope);
|
|
687
1183
|
await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
|
|
688
|
-
provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy });
|
|
1184
|
+
provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, admission, expectedProviderEpoch: providerEpoch });
|
|
689
1185
|
if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
|
|
690
1186
|
// Acknowledge the durable preference before discovery or analysis.
|
|
691
1187
|
emit({ type: "result", id: command.id, ok: true });
|
|
@@ -694,6 +1190,92 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
694
1190
|
// forced request could invalidate a concurrent manual recommendation.
|
|
695
1191
|
void publish().catch(() => {});
|
|
696
1192
|
return;
|
|
1193
|
+
} else if (action === "session_set_email_reports") {
|
|
1194
|
+
if (typeof command.enabled !== 'boolean') throw new Error('Choose whether development report emails are enabled.');
|
|
1195
|
+
if (command.enabled) {
|
|
1196
|
+
if (command.provider !== 'codex') throw new Error('Development report email is not available for this provider yet.');
|
|
1197
|
+
// Initial consent binds only to a currently observed local session.
|
|
1198
|
+
// The title remains caller-provided; the host never reads transcript
|
|
1199
|
+
// content to populate it.
|
|
1200
|
+
await selectedSession(command, { preferObserved: true });
|
|
1201
|
+
}
|
|
1202
|
+
if (command.enabled) await authorizeWork(commandScope);
|
|
1203
|
+
// Validate before the recovery path: an invalid local command is not
|
|
1204
|
+
// an ambiguous remote write and must never be accepted from a list.
|
|
1205
|
+
const request = normalizeNativeDevelopmentReportSave({
|
|
1206
|
+
provider: command.provider,
|
|
1207
|
+
provider_session_id: command.session,
|
|
1208
|
+
request_title: command.requestTitle,
|
|
1209
|
+
enabled: command.enabled,
|
|
1210
|
+
expected_revision: command.expectedRevision,
|
|
1211
|
+
});
|
|
1212
|
+
const deviceSession = localCliApiSession();
|
|
1213
|
+
if (!deviceSession) throw new Error('SIMY desktop sign-in is required.');
|
|
1214
|
+
// Keep the credential tuple fixed across PUT and its readback. A
|
|
1215
|
+
// changed account/device scope must fail instead of confirming one
|
|
1216
|
+
// user’s write with another user’s registry.
|
|
1217
|
+
const reportsApi = nativeReportsApi(deviceSession);
|
|
1218
|
+
const sameIdentity = enrollment => enrollment.provider === request.provider &&
|
|
1219
|
+
enrollment.provider_session_id === String(request.provider_session_id || '').toLowerCase();
|
|
1220
|
+
const matchesSaved = enrollment => sameIdentity(enrollment) && enrollment.enabled === request.enabled &&
|
|
1221
|
+
(!request.enabled || enrollment.request_title === String(request.request_title || '').trim());
|
|
1222
|
+
const confirmed = enrollments => request.enabled
|
|
1223
|
+
? enrollments.some(matchesSaved)
|
|
1224
|
+
: !enrollments.some(sameIdentity) || enrollments.some(matchesSaved);
|
|
1225
|
+
const mutationGeneration = ++nativeDevelopmentReportsGeneration;
|
|
1226
|
+
nativeDevelopmentReportsMutation = true;
|
|
1227
|
+
let recoveredCurrent = false;
|
|
1228
|
+
const invalidateMutation = () => {
|
|
1229
|
+
if (nativeDevelopmentReportsGeneration !== mutationGeneration || accessScope(currentAccess) !== commandScope) return;
|
|
1230
|
+
nativeDevelopmentReports = { state: 'unavailable', enrollments: [] };
|
|
1231
|
+
nativeDevelopmentReportsRetryAt = Date.now() + 30_000;
|
|
1232
|
+
};
|
|
1233
|
+
try {
|
|
1234
|
+
try {
|
|
1235
|
+
await reportsApi.save(request);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
// The server may have committed before a connection failure. Read
|
|
1238
|
+
// back the device-scoped registry before reporting an error.
|
|
1239
|
+
const recovered = await reportsApi.list().catch(() => null);
|
|
1240
|
+
if (!recovered) {
|
|
1241
|
+
invalidateMutation();
|
|
1242
|
+
throw error;
|
|
1243
|
+
}
|
|
1244
|
+
// A conflict contains useful current revision state. Publish it so
|
|
1245
|
+
// the next explicit choice starts from the server's revision.
|
|
1246
|
+
if (nativeDevelopmentReportsGeneration === mutationGeneration && accessScope(currentAccess) === commandScope) {
|
|
1247
|
+
nativeDevelopmentReports = { state: 'ready', enrollments: recovered };
|
|
1248
|
+
nativeDevelopmentReportsRetryAt = 0;
|
|
1249
|
+
recoveredCurrent = true;
|
|
1250
|
+
}
|
|
1251
|
+
if (!confirmed(recovered)) throw error;
|
|
1252
|
+
}
|
|
1253
|
+
const enrollments = await reportsApi.list();
|
|
1254
|
+
if (!confirmed(enrollments)) throw new Error('Development report email settings could not be confirmed.');
|
|
1255
|
+
if (command.enabled) await authorizeWork(commandScope);
|
|
1256
|
+
const currentDeviceSession = localCliApiSession();
|
|
1257
|
+
if (!currentDeviceSession || currentDeviceSession.token !== deviceSession.token || currentDeviceSession.api_base_url !== deviceSession.api_base_url) {
|
|
1258
|
+
throw Object.assign(new Error('Desktop account or organization changed.'), { code: 'desktop_access_required' });
|
|
1259
|
+
}
|
|
1260
|
+
if (nativeDevelopmentReportsGeneration !== mutationGeneration) throw new Error('Development report email settings changed; refresh and try again.');
|
|
1261
|
+
nativeDevelopmentReportsScope = accessScope(currentAccess);
|
|
1262
|
+
nativeDevelopmentReports = { state: 'ready', enrollments };
|
|
1263
|
+
nativeDevelopmentReportsRetryAt = 0;
|
|
1264
|
+
// Fence a GET that was in flight before this completed mutation.
|
|
1265
|
+
nativeDevelopmentReportsGeneration++;
|
|
1266
|
+
emit({ type: "result", id: command.id, ok: true });
|
|
1267
|
+
void publish().catch(() => {});
|
|
1268
|
+
return;
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
// A completed PUT without a confirmable readback must not leave a
|
|
1271
|
+
// prior ready revision cached forever. Preserve a 409 readback,
|
|
1272
|
+
// which is itself the current server state.
|
|
1273
|
+
if (!recoveredCurrent) invalidateMutation();
|
|
1274
|
+
throw error;
|
|
1275
|
+
} finally {
|
|
1276
|
+
nativeDevelopmentReportsMutation = false;
|
|
1277
|
+
if (!closed && !relay) void publish().catch(() => {});
|
|
1278
|
+
}
|
|
697
1279
|
} else if (action === "session_deadline_decide") {
|
|
698
1280
|
if (command.cancel === true) { emit({ type: "result", id: command.id, ok: true }); return; }
|
|
699
1281
|
const candidate = await selectedSession(command);
|
|
@@ -711,19 +1293,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
711
1293
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
712
1294
|
await authorizeWork(commandScope);
|
|
713
1295
|
await (dependencies.trackSession || trackSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
714
|
-
session: command.session, tracked: action === "session_track" });
|
|
1296
|
+
session: command.session, tracked: action === "session_track", expectedProviderEpoch: selectedLinkEpochs.get(candidate) });
|
|
715
1297
|
await refreshSessions(true);
|
|
716
1298
|
} else if (action === "session_monitor" || action === "session_unmonitor") {
|
|
717
1299
|
await (dependencies.monitorSession || monitorSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
718
|
-
session: command.session, monitor: action === "session_monitor" });
|
|
1300
|
+
session: command.session, monitor: action === "session_monitor", expectedProviderEpoch: commandProviderLink?.epoch });
|
|
719
1301
|
await refreshSessions(true);
|
|
720
1302
|
} else if (action === "session_enroll") {
|
|
721
1303
|
await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
722
1304
|
session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
|
|
723
|
-
autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy });
|
|
1305
|
+
autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy, expectedProviderEpoch: commandProviderLink?.epoch });
|
|
724
1306
|
await refreshSessions(true);
|
|
725
1307
|
} else if (action === "session_analyze") {
|
|
726
1308
|
const candidate = await selectedSession(command, { reuseDiscovery: true });
|
|
1309
|
+
explicitAnalyses.add(`${candidate.provider}:${candidate.session}`);
|
|
727
1310
|
await analysisManager.request(candidate, { requestId: command.analysisRequestId || command.id });
|
|
728
1311
|
if (candidate.autoContinue === true && candidate.capabilities?.canContinue === true) automaticContinuation.retry(candidate.provider, candidate.session);
|
|
729
1312
|
} else if (action === "session_continue") {
|
|
@@ -731,7 +1314,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
731
1314
|
const manager = analysisManager;
|
|
732
1315
|
await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
|
|
733
1316
|
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
734
|
-
enroll: async options => { await
|
|
1317
|
+
enroll: async options => { await authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate)); return guardedEnroll(options); }, beforeEnroll: () => authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate)),
|
|
735
1318
|
settings: { autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures,
|
|
736
1319
|
maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy } });
|
|
737
1320
|
await refreshSessions(true);
|
|
@@ -744,18 +1327,30 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
744
1327
|
emit({ type: "result", id: command.id, ok: true });
|
|
745
1328
|
await publish();
|
|
746
1329
|
} catch (error) {
|
|
747
|
-
emit({ type: "result", id: command.id, ok: false,
|
|
1330
|
+
emit({ type: "result", id: command.id, ok: false, ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
|
|
1331
|
+
} finally {
|
|
1332
|
+
// Access rejection can happen before the shared reader is constructed.
|
|
1333
|
+
if (statisticsRequest && !statisticsRequest.promise && activeStatisticsRead === statisticsRequest) {
|
|
1334
|
+
activeStatisticsRead = null;
|
|
1335
|
+
if (!closed) void publish().catch(() => {});
|
|
1336
|
+
}
|
|
748
1337
|
}
|
|
749
1338
|
};
|
|
750
|
-
if (command.action === "quit" || command.action === "update" && command.force === true) {
|
|
1339
|
+
if (command.action === "quit" || command.action === "prepare_update_restart" || command.action === "update" && (command.force === true || stoppingForUpdate)) {
|
|
751
1340
|
void execute();
|
|
752
1341
|
} else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
|
|
753
1342
|
authorizationQueue = authorizationQueue.then(execute).catch(() => {});
|
|
1343
|
+
} else if (/^(codex|claude)_login$/.test(command.action || "") || ["provider_connect", "provider_disconnect"].includes(command.action)) {
|
|
1344
|
+
// Opening an auth terminal must not wait for an unrelated provider turn.
|
|
1345
|
+
// Per-provider busy guards prevent overlapping connection changes.
|
|
1346
|
+
void execute();
|
|
754
1347
|
} else if (command.action === "my_ai_session") {
|
|
755
1348
|
myAiSessionQueue = myAiSessionQueue.then(execute).catch(() => {});
|
|
756
|
-
} else if (command.action
|
|
1349
|
+
} else if (["session_set_auto_continue", "session_set_email_reports"].includes(command.action)) {
|
|
757
1350
|
preferenceQueue = preferenceQueue.then(execute).catch(() => {});
|
|
758
|
-
} else if (
|
|
1351
|
+
} else if (command.action === 'session_statistics') {
|
|
1352
|
+
void execute();
|
|
1353
|
+
} else if (["task_stop", "delivery_stop"].includes(command.action)) {
|
|
759
1354
|
controlQueue = controlQueue.then(execute).catch(() => {});
|
|
760
1355
|
} else {
|
|
761
1356
|
queue = queue.then(async () => {
|
|
@@ -776,6 +1371,7 @@ function associationActionError(error) {
|
|
|
776
1371
|
}
|
|
777
1372
|
|
|
778
1373
|
function sessionActionError(error) {
|
|
1374
|
+
if (error instanceof AutoContinueUnavailableError) return error.message.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 600);
|
|
779
1375
|
const message = String(error?.message || "");
|
|
780
1376
|
if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
|
|
781
1377
|
if (/model.*unavailable|did not expose.*model/i.test(message)) return "This provider did not expose the conversation model; automatic continuation is unavailable.";
|
|
@@ -815,7 +1411,7 @@ export async function openProviderLogin(provider, root, {
|
|
|
815
1411
|
} = {}) {
|
|
816
1412
|
if (provider !== "codex" && provider !== "claude") throw new Error("Unsupported provider.");
|
|
817
1413
|
const executable = await inspectExecutable(provider);
|
|
818
|
-
if (!executable.available || !executable.executable) throw new Error("Provider is not installed.");
|
|
1414
|
+
if (!executable.available || !executable.executable) throw Object.assign(new Error("Provider is not installed."), { code: "provider_not_installed" });
|
|
819
1415
|
const label = provider === "claude" ? "Claude Code" : "Codex";
|
|
820
1416
|
const args = provider === "claude" ? ["auth", "login"] : ["login"];
|
|
821
1417
|
if (platform === "win32") {
|
|
@@ -828,7 +1424,7 @@ export async function openProviderLogin(provider, root, {
|
|
|
828
1424
|
const directory = await mkdtemp(join(root, `${provider}-login-`));
|
|
829
1425
|
const script = join(directory, `Sign in to ${label}.command`);
|
|
830
1426
|
const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
831
|
-
await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
|
|
1427
|
+
await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nif [ "$result" -ne 0 ]; then\n echo "Sign-in failed. Check the error above, then press Return to close."\n read -r _\nfi\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
|
|
832
1428
|
await chmod(script, 0o700);
|
|
833
1429
|
try { await execFileImpl("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
|
|
834
1430
|
catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
|