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