@awak-app/simy-cli 0.5.47 → 0.5.49
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/package.json +1 -1
- package/src/agent.js +110 -20
- package/src/cli-contract.js +1 -1
- package/src/delivery-hooks/supervisor.js +31 -6
- package/src/desktop-access.js +65 -0
- package/src/desktop-host.js +118 -15
- package/src/session-supervision/association-store.js +11 -4
- package/src/session-supervision/automatic-continuation.js +3 -3
- package/src/session-supervision/codex-app-server.js +5 -2
- package/src/session-supervision/continuation-analysis.js +35 -9
- package/src/sqm/review-worker.js +26 -2
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
|
|
1
2
|
import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
5
|
import { createReadStream } from "node:fs";
|
|
5
6
|
import { dirname, join, resolve } from "node:path";
|
|
6
7
|
|
|
@@ -142,6 +143,7 @@ import { inspectSqmAgentSkill, installSqmAgentSkill } from "./sqm/agent-skill.js
|
|
|
142
143
|
import {
|
|
143
144
|
createSqmLocalReviewApi,
|
|
144
145
|
createSqmLocalReviewWorker,
|
|
146
|
+
runStructuredProviderReview,
|
|
145
147
|
} from "./sqm/review-worker.js";
|
|
146
148
|
import { createCodexWorkPatternSync, workPatternHistoryHomes } from "./codex-work-pattern-sync.js";
|
|
147
149
|
import {
|
|
@@ -159,6 +161,7 @@ export async function startAgent({
|
|
|
159
161
|
sessionRoot,
|
|
160
162
|
updateManager = null,
|
|
161
163
|
deferStartupSynchronization = false,
|
|
164
|
+
enforceDesktopAccess = false,
|
|
162
165
|
onDesktopStatusChange = null,
|
|
163
166
|
dependencies = {},
|
|
164
167
|
quiet = false,
|
|
@@ -228,14 +231,13 @@ export async function startAgent({
|
|
|
228
231
|
availableCapabilities = withCliContract({
|
|
229
232
|
...contractSourceCapabilities,
|
|
230
233
|
...(features ? { features: { ...features, ...(Object.hasOwn(features, "sqm_local_review")
|
|
231
|
-
? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization } : {}) } } : {}),
|
|
234
|
+
? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization && desktopWorkAllowed() } : {}) } } : {}),
|
|
232
235
|
...(providerStatuses ? { provider_statuses: providerStatuses } : {}),
|
|
233
236
|
}, installMode, {
|
|
234
237
|
authenticated: isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
|
|
235
238
|
});
|
|
236
239
|
return availableCapabilities;
|
|
237
240
|
};
|
|
238
|
-
refreshAvailableCapabilities();
|
|
239
241
|
const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
|
|
240
242
|
const reclaimDevice = dependencies.reclaimDevice || reclaimLocalDevice;
|
|
241
243
|
const runOptions = dependencies.runOptions || {};
|
|
@@ -257,6 +259,33 @@ export async function startAgent({
|
|
|
257
259
|
if (shuttingDown) return;
|
|
258
260
|
try { onDesktopStatusChange?.(); } catch { /* Desktop status polling remains available. */ }
|
|
259
261
|
};
|
|
262
|
+
const desktopAccess = createDesktopAccessPolicy({
|
|
263
|
+
readSession: () => session,
|
|
264
|
+
isValid: value => isSessionValid(value, Date.now(), apiOrigin),
|
|
265
|
+
isSwitching: () => Boolean(pendingOrganization),
|
|
266
|
+
onChange: notifyDesktopStatusChange,
|
|
267
|
+
fetchAccess: dependencies.fetchDesktopAccess || (async selected => {
|
|
268
|
+
const response = await (dependencies.fetch || globalThis.fetch)(webApiUrl('desktop-access', {
|
|
269
|
+
webOrigin: apiOrigin, apiBaseUrl: selected.api_base_url,
|
|
270
|
+
}), { headers: webApiHeaders({ token: selected.token }), signal: AbortSignal.timeout(10_000), cache: 'no-store' });
|
|
271
|
+
if (!response.ok) throw Object.assign(new Error('Desktop access could not be verified.'), { status: response.status });
|
|
272
|
+
return response.json();
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
const desktopScopeRevision = () => createHash('sha256').update(JSON.stringify([
|
|
276
|
+
session?.account_id ?? null, session?.organization_id ?? null, session?.device_id ?? null, session?.token ?? null,
|
|
277
|
+
pendingOrganization?.id ?? null, isSessionValid(session, Date.now(), apiOrigin),
|
|
278
|
+
])).digest('hex');
|
|
279
|
+
const desktopWorkAllowed = () => !enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot());
|
|
280
|
+
const authorizeDesktopWork = async expectedScope => {
|
|
281
|
+
if (enforceDesktopAccess) {
|
|
282
|
+
requireDesktopAccess(await desktopAccess.refresh());
|
|
283
|
+
const scope = desktopScopeRevision();
|
|
284
|
+
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
285
|
+
return scope;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
refreshAvailableCapabilities();
|
|
260
289
|
let durableLocalTaskWorker = null;
|
|
261
290
|
let sqmLocalReviewWorker = null;
|
|
262
291
|
let codexWorkPatternSync = null;
|
|
@@ -264,6 +293,7 @@ export async function startAgent({
|
|
|
264
293
|
|
|
265
294
|
let codexWorkPatternSkipReason = null;
|
|
266
295
|
const pollCodexWorkPatterns = () => {
|
|
296
|
+
if (!desktopWorkAllowed()) return Promise.resolve();
|
|
267
297
|
if (shuttingDown) return;
|
|
268
298
|
const skipReason = codexWorkPatternSkipReasonFor({
|
|
269
299
|
daemon,
|
|
@@ -385,8 +415,10 @@ export async function startAgent({
|
|
|
385
415
|
};
|
|
386
416
|
|
|
387
417
|
const requireRunProvider = async (run) => {
|
|
418
|
+
const scope = await authorizeDesktopWork();
|
|
388
419
|
const backend = run.request.backend;
|
|
389
420
|
const inspection = await inspectReadyBackend(backend, { force: true });
|
|
421
|
+
await authorizeDesktopWork(scope);
|
|
390
422
|
if (!inspection.compatible) {
|
|
391
423
|
throw Object.assign(new Error(backendPreflightSummary(backend, inspection)), {
|
|
392
424
|
code: `desktop_executor_${inspection.status}`,
|
|
@@ -412,7 +444,8 @@ export async function startAgent({
|
|
|
412
444
|
if (!Array.isArray(payload.organizations)) throw new Error("Invalid organization list.");
|
|
413
445
|
if (session !== requestedSession) return;
|
|
414
446
|
organizationItems = payload.organizations.filter(org => typeof org.id === "string" && typeof org.name === "string")
|
|
415
|
-
.slice(0, 100).map(org => ({ id: org.id, name: org.name.slice(0, 120)
|
|
447
|
+
.slice(0, 100).map(org => ({ id: org.id, name: org.name.slice(0, 120),
|
|
448
|
+
...(typeof org.slug === 'string' && org.slug && org.slug.length <= 160 && !/[\/\\?#\x00-\x1f]/.test(org.slug) ? { slug: org.slug } : {}) }));
|
|
416
449
|
organizationState = "ready";
|
|
417
450
|
} catch {
|
|
418
451
|
if (session === requestedSession) organizationState = "unavailable";
|
|
@@ -421,16 +454,23 @@ export async function startAgent({
|
|
|
421
454
|
return organizationRefresh;
|
|
422
455
|
};
|
|
423
456
|
const requestDesktopLogin = (organizationId = null) => {
|
|
457
|
+
let organizationSlug = null;
|
|
424
458
|
if (organizationId) {
|
|
425
459
|
if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
|
|
426
460
|
if (organizationState !== "ready" || !organizationItems.some(org => org.id === organizationId)) throw new Error("Refresh the organization list before selecting an organization.");
|
|
461
|
+
organizationSlug = organizationItems.find(org => org.id === organizationId)?.slug;
|
|
462
|
+
if (!organizationSlug) throw new Error('Refresh organizations before switching; its verified slug is unavailable.');
|
|
427
463
|
if (organizationBusy()) throw Object.assign(new Error("Wait for running tasks to finish before switching organizations."), { code: "desktop_organization_busy" });
|
|
428
464
|
authNonce = randomBytes(16).toString("base64url");
|
|
429
465
|
pendingOrganization = { id: organizationId, accountId: session.account_id };
|
|
430
466
|
loginUrl = null;
|
|
431
467
|
refreshAvailableCapabilities();
|
|
432
468
|
notifyDesktopStatusChange();
|
|
433
|
-
} else if (isSessionValid(session, Date.now(), apiOrigin))
|
|
469
|
+
} else if (isSessionValid(session, Date.now(), apiOrigin)) {
|
|
470
|
+
if (!enforceDesktopAccess || desktopAccess.snapshot().state !== 'signed_out') return null;
|
|
471
|
+
authNonce = randomBytes(16).toString("base64url");
|
|
472
|
+
loginUrl = null;
|
|
473
|
+
}
|
|
434
474
|
if (!loginUrl) {
|
|
435
475
|
loginUrl = new URL("/local-cli/connect", apiOrigin);
|
|
436
476
|
loginUrl.searchParams.set("port", String(port));
|
|
@@ -438,7 +478,10 @@ export async function startAgent({
|
|
|
438
478
|
loginUrl.searchParams.set("cli_version", CLI_VERSION);
|
|
439
479
|
loginUrl.searchParams.set("api_contract_version", String(CLI_API_CONTRACT_VERSION));
|
|
440
480
|
if (installMode) loginUrl.searchParams.set("install_mode", installMode);
|
|
441
|
-
if (organizationId)
|
|
481
|
+
if (organizationId) {
|
|
482
|
+
loginUrl.searchParams.set('org_id', organizationId);
|
|
483
|
+
loginUrl.searchParams.set('org_slug', organizationSlug);
|
|
484
|
+
}
|
|
442
485
|
}
|
|
443
486
|
return loginUrl.toString();
|
|
444
487
|
};
|
|
@@ -452,6 +495,7 @@ export async function startAgent({
|
|
|
452
495
|
};
|
|
453
496
|
|
|
454
497
|
const restoreRemoteRuns = async () => {
|
|
498
|
+
if (!desktopWorkAllowed()) return;
|
|
455
499
|
if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
|
|
456
500
|
const snapshots = dependencies.listRemoteRuns
|
|
457
501
|
? await dependencies.listRemoteRuns({
|
|
@@ -518,6 +562,7 @@ export async function startAgent({
|
|
|
518
562
|
};
|
|
519
563
|
|
|
520
564
|
const restoreLocalCodingRuns = async () => {
|
|
565
|
+
if (!desktopWorkAllowed()) return;
|
|
521
566
|
const { snapshots, errors } = await loadCodingRunSnapshots(codingRunStateRoot);
|
|
522
567
|
for (const error of errors) {
|
|
523
568
|
reportStartupConnectionError(new Error(`Coding run ${error.id}: ${error.message}`), quiet);
|
|
@@ -548,6 +593,7 @@ export async function startAgent({
|
|
|
548
593
|
};
|
|
549
594
|
|
|
550
595
|
const monitorCodingRuns = () => {
|
|
596
|
+
if (!desktopWorkAllowed()) return Promise.resolve();
|
|
551
597
|
if (!daemon || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve();
|
|
552
598
|
if (codingRunMonitorInFlight) return codingRunMonitorInFlight;
|
|
553
599
|
codingRunMonitorInFlight = (async () => {
|
|
@@ -564,6 +610,7 @@ export async function startAgent({
|
|
|
564
610
|
};
|
|
565
611
|
|
|
566
612
|
const startCodingRunMonitor = () => {
|
|
613
|
+
if (!desktopWorkAllowed()) return;
|
|
567
614
|
if (!daemon || codingRunMonitorTimer) return;
|
|
568
615
|
codingRunMonitorTimer = setInterval(() => {
|
|
569
616
|
void monitorCodingRuns().catch((error) => reportStartupConnectionError(error, quiet));
|
|
@@ -579,6 +626,7 @@ export async function startAgent({
|
|
|
579
626
|
let deviceHeartbeatSucceeded = false;
|
|
580
627
|
const advertisedProviders = JSON.stringify(providerStatuses);
|
|
581
628
|
heartbeatInFlight = Promise.resolve()
|
|
629
|
+
.then(async () => { if (enforceDesktopAccess) await desktopAccess.refresh(true); })
|
|
582
630
|
.then(() =>
|
|
583
631
|
heartbeatDevice({
|
|
584
632
|
apiOrigin,
|
|
@@ -604,12 +652,12 @@ export async function startAgent({
|
|
|
604
652
|
}
|
|
605
653
|
pollCodexWorkPatterns();
|
|
606
654
|
};
|
|
607
|
-
if (deferStartupSynchronization) {
|
|
655
|
+
if (desktopWorkAllowed() && deferStartupSynchronization) {
|
|
608
656
|
void publishVerifiedProviders().catch((error) => reportHeartbeatError(error, quiet));
|
|
609
|
-
} else {
|
|
657
|
+
} else if (desktopWorkAllowed()) {
|
|
610
658
|
await publishVerifiedProviders();
|
|
611
659
|
}
|
|
612
|
-
if (shuttingDown || pendingOrganization) return false;
|
|
660
|
+
if (shuttingDown || pendingOrganization || !desktopWorkAllowed()) return false;
|
|
613
661
|
const sqmResult = sqmEnabled && sqmLocalReviewWorker
|
|
614
662
|
? await sqmLocalReviewWorker.poll()
|
|
615
663
|
: null;
|
|
@@ -661,9 +709,11 @@ export async function startAgent({
|
|
|
661
709
|
};
|
|
662
710
|
|
|
663
711
|
const scanRepositories = async (root) => {
|
|
712
|
+
const scope = await authorizeDesktopWork();
|
|
664
713
|
const scan = dependencies.scanRepositories
|
|
665
714
|
? await dependencies.scanRepositories(root)
|
|
666
715
|
: await scanGitRepositories(root);
|
|
716
|
+
await authorizeDesktopWork(scope);
|
|
667
717
|
repositoryInventory = mergeRepositoryInventory(repositoryInventory, scan.repositories);
|
|
668
718
|
authorizedRepositoryRoots = [
|
|
669
719
|
...new Set([...authorizedRepositoryRoots, scan.root].filter(Boolean)),
|
|
@@ -731,12 +781,14 @@ export async function startAgent({
|
|
|
731
781
|
workspaceRoot: dependencies.localTaskWorkspaceRoot,
|
|
732
782
|
resolveRepository: ensureRepositoryIndexed,
|
|
733
783
|
runLocalTask: async (task, options) => {
|
|
784
|
+
const scope = await authorizeDesktopWork();
|
|
734
785
|
const inspection = await inspectReadyBackend(task.backend);
|
|
735
786
|
if (!inspection.compatible) {
|
|
736
787
|
throw Object.assign(new Error(backendPreflightSummary(task.backend, inspection)), {
|
|
737
788
|
code: `desktop_executor_${inspection.status}`,
|
|
738
789
|
});
|
|
739
790
|
}
|
|
791
|
+
await authorizeDesktopWork(scope);
|
|
740
792
|
return (dependencies.runLocalTask || startLocalTask)(task, options);
|
|
741
793
|
},
|
|
742
794
|
attachmentStore: localTaskAttachmentStore,
|
|
@@ -762,9 +814,13 @@ export async function startAgent({
|
|
|
762
814
|
sqmLocalReviewWorker = createWorker({
|
|
763
815
|
api: sqmApi,
|
|
764
816
|
beginWork: () => acquireUpdateWork(updateManager),
|
|
765
|
-
runReview:
|
|
817
|
+
runReview: async (request, provider) => {
|
|
818
|
+
const scope = await authorizeDesktopWork();
|
|
819
|
+
const beforeDispatch = () => authorizeDesktopWork(scope);
|
|
820
|
+
return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch });
|
|
821
|
+
},
|
|
766
822
|
onChange: notifyDesktopStatusChange,
|
|
767
|
-
getProvider: () => availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
|
|
823
|
+
getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
|
|
768
824
|
(provider) => availableCapabilities?.backends?.[provider] === true &&
|
|
769
825
|
providerStatuses[provider]?.state === "ready",
|
|
770
826
|
) || null,
|
|
@@ -773,6 +829,7 @@ export async function startAgent({
|
|
|
773
829
|
}
|
|
774
830
|
|
|
775
831
|
const selectRepository = async (run, repository) => {
|
|
832
|
+
await authorizeDesktopWork();
|
|
776
833
|
if (run) ensureAcceptingNewWork(updateManager);
|
|
777
834
|
const selected = repository?.local_path
|
|
778
835
|
? repositoryInventory.find(
|
|
@@ -812,6 +869,14 @@ export async function startAgent({
|
|
|
812
869
|
|
|
813
870
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
814
871
|
const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
|
|
872
|
+
const desktopRecoveryRoute = ['/v1/health', '/v1/capabilities', '/v1/auth/complete'].includes(url.pathname) ||
|
|
873
|
+
req.method === 'POST' && (/^\/v1\/local-tasks\/[^/]+\/control$/.test(url.pathname) || /^\/v1\/agentic-loop\/[^/]+\/control$/.test(agenticLoopPath));
|
|
874
|
+
let requestAccessScope;
|
|
875
|
+
if (enforceDesktopAccess && !desktopRecoveryRoute) {
|
|
876
|
+
try { requestAccessScope = await authorizeDesktopWork(); }
|
|
877
|
+
catch { json(res, 403, { error: 'Desktop access is required.', code: 'desktop_access_required', access: desktopAccess.snapshot() }); return; }
|
|
878
|
+
}
|
|
879
|
+
|
|
815
880
|
const startsLocalExecution =
|
|
816
881
|
url.pathname === "/v1/local-tasks/start" ||
|
|
817
882
|
url.pathname === "/v1/direct-execution/start";
|
|
@@ -1168,7 +1233,7 @@ export async function startAgent({
|
|
|
1168
1233
|
}
|
|
1169
1234
|
const localTaskControlMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/control$/);
|
|
1170
1235
|
if (req.method === "POST" && localTaskControlMatch) {
|
|
1171
|
-
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
1236
|
+
if (!enforceDesktopAccess && !isSessionValid(session, Date.now(), apiOrigin)) {
|
|
1172
1237
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
1173
1238
|
return;
|
|
1174
1239
|
}
|
|
@@ -1485,6 +1550,7 @@ export async function startAgent({
|
|
|
1485
1550
|
const runLocalTask =
|
|
1486
1551
|
dependencies.runLocalTask ||
|
|
1487
1552
|
((localTask) => startLocalTask(localTask, { session, apiOrigin }));
|
|
1553
|
+
await authorizeDesktopWork(requestAccessScope);
|
|
1488
1554
|
task.operation_promise = Promise.resolve(runLocalTask(task))
|
|
1489
1555
|
.catch(async (error) => {
|
|
1490
1556
|
task.status = "failed";
|
|
@@ -1600,6 +1666,7 @@ export async function startAgent({
|
|
|
1600
1666
|
});
|
|
1601
1667
|
return;
|
|
1602
1668
|
}
|
|
1669
|
+
await authorizeDesktopWork(requestAccessScope);
|
|
1603
1670
|
const task = createDirectTask({
|
|
1604
1671
|
instruction,
|
|
1605
1672
|
backend: body.backend,
|
|
@@ -1795,6 +1862,7 @@ export async function startAgent({
|
|
|
1795
1862
|
await cleanupRunAttachments(stagedAttachments);
|
|
1796
1863
|
throw error;
|
|
1797
1864
|
}
|
|
1865
|
+
await authorizeDesktopWork(requestAccessScope);
|
|
1798
1866
|
void startLocalCodingRun(run, dependencies.runOptions);
|
|
1799
1867
|
json(res, 202, { ok: true, run_id: run.id });
|
|
1800
1868
|
return;
|
|
@@ -1802,7 +1870,7 @@ export async function startAgent({
|
|
|
1802
1870
|
|
|
1803
1871
|
const controlMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/control$/);
|
|
1804
1872
|
if (req.method === "POST" && controlMatch) {
|
|
1805
|
-
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
1873
|
+
if (!enforceDesktopAccess && !isSessionValid(session, Date.now(), apiOrigin)) {
|
|
1806
1874
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
1807
1875
|
return;
|
|
1808
1876
|
}
|
|
@@ -1821,6 +1889,8 @@ export async function startAgent({
|
|
|
1821
1889
|
} else if (body.action === "pause") {
|
|
1822
1890
|
pauseLocalCodingRun(run);
|
|
1823
1891
|
} else if (body.action === "resume") {
|
|
1892
|
+
try { await authorizeDesktopWork(); }
|
|
1893
|
+
catch { json(res, 403, { error: "Desktop access is required.", code: "desktop_access_required" }); return; }
|
|
1824
1894
|
if (!acceptsNewWork(updateManager)) {
|
|
1825
1895
|
json(res, 503, updateInProgressResponse(updateManager));
|
|
1826
1896
|
return;
|
|
@@ -2074,6 +2144,7 @@ export async function startAgent({
|
|
|
2074
2144
|
|
|
2075
2145
|
const synchronizeStartup = async () => {
|
|
2076
2146
|
try {
|
|
2147
|
+
if (enforceDesktopAccess) { await desktopAccess.refresh(); if (!desktopWorkAllowed()) { await heartbeat(); return; } }
|
|
2077
2148
|
await refreshAuthorizedRepositoryInventory();
|
|
2078
2149
|
if (shuttingDown) return;
|
|
2079
2150
|
await heartbeat();
|
|
@@ -2088,7 +2159,7 @@ export async function startAgent({
|
|
|
2088
2159
|
if (deferStartupSynchronization) {
|
|
2089
2160
|
// The desktop can show restored tasks and account state while network and
|
|
2090
2161
|
// provider checks run. Unknown remains unknown until each probe succeeds.
|
|
2091
|
-
void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
|
|
2162
|
+
if (desktopWorkAllowed()) void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
|
|
2092
2163
|
if (isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2093
2164
|
startHeartbeatTimer();
|
|
2094
2165
|
void synchronizeStartup();
|
|
@@ -2129,7 +2200,8 @@ export async function startAgent({
|
|
|
2129
2200
|
const getDesktopStatus = async () => {
|
|
2130
2201
|
const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
|
|
2131
2202
|
if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
|
|
2132
|
-
if (
|
|
2203
|
+
if (enforceDesktopAccess) void desktopAccess.refresh().catch(() => {});
|
|
2204
|
+
if (deferStartupSynchronization && !shuttingDown && desktopWorkAllowed()) {
|
|
2133
2205
|
// Cached checks return immediately; expired checks refresh in the
|
|
2134
2206
|
// background without making the desktop frame wait for subprocesses.
|
|
2135
2207
|
void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
|
|
@@ -2143,11 +2215,12 @@ export async function startAgent({
|
|
|
2143
2215
|
version: CLI_VERSION,
|
|
2144
2216
|
webOrigin: apiOrigin,
|
|
2145
2217
|
auth,
|
|
2218
|
+
...(enforceDesktopAccess ? { access: { ...desktopAccess.snapshot(), revision: desktopScopeRevision() } } : {}),
|
|
2146
2219
|
connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
|
|
2147
2220
|
tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
|
|
2148
2221
|
organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
|
|
2149
2222
|
switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
|
|
2150
|
-
sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization,
|
|
2223
|
+
sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
|
|
2151
2224
|
activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
|
|
2152
2225
|
issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
|
|
2153
2226
|
providerReady: [codex, claude].some(provider => provider?.state === "ready") },
|
|
@@ -2173,9 +2246,20 @@ export async function startAgent({
|
|
|
2173
2246
|
return requestDesktopLogin();
|
|
2174
2247
|
},
|
|
2175
2248
|
requestDesktopLogin,
|
|
2249
|
+
async refreshDesktopAccess() {
|
|
2250
|
+
const previous = desktopAccess.snapshot().state;
|
|
2251
|
+
const result = await desktopAccess.refresh(true);
|
|
2252
|
+
if (enforceDesktopAccess && result.state === 'ready' && previous !== 'ready' && !shuttingDown) {
|
|
2253
|
+
void synchronizeAuthorizedSession().catch(error => reportStartupConnectionError(error, quiet));
|
|
2254
|
+
}
|
|
2255
|
+
return { ...result, revision: desktopScopeRevision() };
|
|
2256
|
+
},
|
|
2257
|
+
getDesktopAccess: () => ({ ...desktopAccess.snapshot(), revision: desktopScopeRevision() }),
|
|
2258
|
+
requireDesktopAccess: authorizeDesktopWork,
|
|
2176
2259
|
refreshOrganizations,
|
|
2177
2260
|
cancelOrganizationSelection() { authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
|
|
2178
2261
|
async setSqmEnabled(enabled) {
|
|
2262
|
+
if (enabled) await authorizeDesktopWork();
|
|
2179
2263
|
if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
|
|
2180
2264
|
const settingsSession = session;
|
|
2181
2265
|
await writeSqmEnabled(sessionRoot, apiOrigin, settingsSession, enabled);
|
|
@@ -2206,6 +2290,7 @@ export async function startAgent({
|
|
|
2206
2290
|
scanRepositories,
|
|
2207
2291
|
selectRepository,
|
|
2208
2292
|
create: (input) => withUpdateWork(updateManager, async () => {
|
|
2293
|
+
const scope = await authorizeDesktopWork();
|
|
2209
2294
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2210
2295
|
throw new Error("Sign in to SIMY before starting a coding task.");
|
|
2211
2296
|
}
|
|
@@ -2252,6 +2337,7 @@ export async function startAgent({
|
|
|
2252
2337
|
const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot });
|
|
2253
2338
|
await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
|
|
2254
2339
|
registry.create(run);
|
|
2340
|
+
await authorizeDesktopWork(scope);
|
|
2255
2341
|
void startLocalCodingRun(run, runOptions);
|
|
2256
2342
|
return run;
|
|
2257
2343
|
}),
|
|
@@ -2265,7 +2351,10 @@ export async function startAgent({
|
|
|
2265
2351
|
await requireRunProvider(run);
|
|
2266
2352
|
return applyLocalHumanDecision(run, decision, runOptions);
|
|
2267
2353
|
}),
|
|
2268
|
-
queueGuidance:
|
|
2354
|
+
queueGuidance: (run, guidance) => {
|
|
2355
|
+
if (enforceDesktopAccess) requireDesktopAccess(desktopAccess.snapshot());
|
|
2356
|
+
return queueLocalCodingGuidance(run, guidance);
|
|
2357
|
+
},
|
|
2269
2358
|
pause: pauseLocalCodingRun,
|
|
2270
2359
|
resume: (run) => withUpdateWork(updateManager, async () => {
|
|
2271
2360
|
await requireRunProvider(run);
|
|
@@ -2273,9 +2362,10 @@ export async function startAgent({
|
|
|
2273
2362
|
}),
|
|
2274
2363
|
stop: stopLocalCodingRun,
|
|
2275
2364
|
recheck: (run) =>
|
|
2276
|
-
withUpdateWork(updateManager, () =>
|
|
2277
|
-
|
|
2278
|
-
|
|
2365
|
+
withUpdateWork(updateManager, async () => {
|
|
2366
|
+
await authorizeDesktopWork();
|
|
2367
|
+
return recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence });
|
|
2368
|
+
}),
|
|
2279
2369
|
},
|
|
2280
2370
|
};
|
|
2281
2371
|
}
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.49";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -54,7 +54,7 @@ export function parseAgentCheckpoint(output, expectedSession) {
|
|
|
54
54
|
|
|
55
55
|
// The provider CLI is a new process. It is used only for an exact Codex UUID
|
|
56
56
|
// enrolled for this recovery channel after a corroborated process exit.
|
|
57
|
-
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {} } = {}) {
|
|
57
|
+
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {} } = {}) {
|
|
58
58
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
59
59
|
const binary = await executable('codex');
|
|
60
60
|
if (!binary) throw new Error('Codex executable unavailable.');
|
|
@@ -70,6 +70,9 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
70
70
|
const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
|
|
71
71
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
72
72
|
if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
73
|
+
await beforeDispatch();
|
|
74
|
+
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
75
|
+
if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
73
76
|
return new Promise((resolve, reject) => {
|
|
74
77
|
const child = spawn(binary, args, { cwd: record.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
|
|
75
78
|
env: { ...process.env, SIMY_HOME: dirname(root) } });
|
|
@@ -116,7 +119,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
116
119
|
});
|
|
117
120
|
}
|
|
118
121
|
|
|
119
|
-
export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim } = {}) {
|
|
122
|
+
export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch } = {}) {
|
|
120
123
|
const tickWallStart = Date.now();
|
|
121
124
|
const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
|
|
122
125
|
if (signal?.aborted) return;
|
|
@@ -152,6 +155,16 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
152
155
|
if (run.managed.executionUncertain && run.managed.recoveryDiagnosisDecisionId !== run.managed.decision?.id) continue;
|
|
153
156
|
if (['pending', 'transferred', 'pending_received'].includes(run.managed.handoff?.status)) continue;
|
|
154
157
|
if (run.state !== 'recovery_required' || !causeAllowed || !run.managed.autoRecovery || run.managed.pendingApproval || run.managed.autonomyPolicy && run.managed.decision?.outcome === 'wait' && !approvedWait || appServer && run.managed.nextAttemptAt > claimNow) continue;
|
|
158
|
+
let authorizationScope;
|
|
159
|
+
const authorizeClaim = async () => {
|
|
160
|
+
if (!authorizeDispatch) return;
|
|
161
|
+
const latest = await authorizeDispatch();
|
|
162
|
+
if (authorizationScope !== undefined && latest !== authorizationScope) throw Object.assign(new Error("Desktop authorization scope changed."), { code: "desktop_access_required" });
|
|
163
|
+
authorizationScope = latest;
|
|
164
|
+
};
|
|
165
|
+
if (authorizeDispatch) {
|
|
166
|
+
try { await authorizeClaim(); } catch { continue; }
|
|
167
|
+
}
|
|
155
168
|
if (beforeClaim) {
|
|
156
169
|
try {
|
|
157
170
|
const prepared = await beforeClaim(record);
|
|
@@ -237,6 +250,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
237
250
|
else if (['local_edit', 'local_test'].includes(claimed.run.managed.decision?.action.kind) &&
|
|
238
251
|
(await listRecords(root)).some(other => other.cwd === claimed.cwd && other.run?.managed?.executionUncertain)) result = { code: -3, busy: true };
|
|
239
252
|
if (!result) {
|
|
253
|
+
await authorizeClaim();
|
|
240
254
|
const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
|
|
241
255
|
result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
|
|
242
256
|
onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
|
|
@@ -268,11 +282,14 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
268
282
|
}
|
|
269
283
|
return current;
|
|
270
284
|
});
|
|
285
|
+
await authorizeClaim();
|
|
271
286
|
} });
|
|
272
287
|
}
|
|
273
288
|
}
|
|
274
|
-
} catch (error) { result =
|
|
275
|
-
|
|
289
|
+
} catch (error) { result = error?.code === 'desktop_access_required'
|
|
290
|
+
? { code: -3, busy: true, accessDenied: true }
|
|
291
|
+
: { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
|
|
292
|
+
errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
|
|
276
293
|
finally { clearTimeout(timeout); clearInterval(watch); signal?.removeEventListener('abort', abort); }
|
|
277
294
|
if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
|
|
278
295
|
if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
|
|
@@ -319,7 +336,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
319
336
|
checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
|
|
320
337
|
errorCode: result.code === 0 || result.busy || result.cancelled ? null : safeProviderCode(result.errorCode),
|
|
321
338
|
restorationUnverified: result.restorationUnverified === true,
|
|
322
|
-
reason: result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
|
|
339
|
+
reason: result.accessDenied ? 'Desktop organization access is required before another provider turn.' : result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
|
|
323
340
|
nextStep: live.nextStep });
|
|
324
341
|
live.managed.attempts = live.managed.attempts.slice(-20);
|
|
325
342
|
if (result.restorationUnverified) {
|
|
@@ -340,8 +357,16 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
340
357
|
live.state = 'recovery_required'; live.managed.autoRecovery = false;
|
|
341
358
|
live.reason = 'Provider usage was unavailable; token-budgeted continuation cannot safely start another turn.';
|
|
342
359
|
} else if (result.busy) {
|
|
360
|
+
if (result.accessDenied && dispatched) {
|
|
361
|
+
// The final guard rejected before the provider call. Keep its one-shot approval unused.
|
|
362
|
+
if (live.managed.approvedAction?.id === live.managed.decision?.id) delete live.managed.approvedAction.dispatchedAt;
|
|
363
|
+
if (live.managed.executionAttempt?.id === claimed.run.managed.executionAttempt?.id) {
|
|
364
|
+
delete live.managed.executionAttempt.dispatchedAt;
|
|
365
|
+
live.managed.executionAttempt.phase = 'claimed';
|
|
366
|
+
}
|
|
367
|
+
}
|
|
343
368
|
live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
|
|
344
|
-
live.reason = 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
|
|
369
|
+
live.reason = result.accessDenied ? 'Sign in to a SIMY organization with SQM and Developer access before continuing.' : 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
|
|
345
370
|
live.managed.nextAttemptAt = Date.now() + 15_000;
|
|
346
371
|
} else if (result.errorCode === 'provider_approval') {
|
|
347
372
|
live.state = 'blocked'; live.blocker = 'authorization'; live.managed.autoRecovery = false;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Desktop entitlement is a server-authenticated organization/member decision.
|
|
2
|
+
// Local provider readiness and the local SQM consent switch never grant access.
|
|
3
|
+
const SERVER_STATES = new Set(['ready', 'sqm_required', 'developer_required']);
|
|
4
|
+
export function validDesktopAccessPath(path, slug) {
|
|
5
|
+
if (typeof slug !== 'string' || !slug || ['.', '..'].includes(slug) || slug.length > 160 || /[\/\\?#\x00-\x1f]/.test(slug)) return false;
|
|
6
|
+
const expected = `/org/${encodeURIComponent(slug)}/settings/plan`;
|
|
7
|
+
return path === expected || path === `${expected}/`;
|
|
8
|
+
}
|
|
9
|
+
export const desktopAccessReady = value => value?.state === 'ready';
|
|
10
|
+
const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'organization_select', 'organization_cancel', 'organizations_refresh',
|
|
11
|
+
'check_update', 'update', 'quit', 'delivery_stop', 'delivery_pause', 'delivery_reject',
|
|
12
|
+
'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
|
|
13
|
+
export function desktopActionAllowed(access, command) {
|
|
14
|
+
return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
|
|
15
|
+
command?.action === 'session_set_auto_continue' && command.enabled === false;
|
|
16
|
+
}
|
|
17
|
+
export function requireDesktopAccess(access) {
|
|
18
|
+
if (!desktopAccessReady(access)) throw Object.assign(new Error('Sign in to an organization with SQM and Developer access before using SIMY.'), { code: 'desktop_access_required' });
|
|
19
|
+
}
|
|
20
|
+
export function createDesktopAccessPolicy({ readSession, isValid, fetchAccess, isSwitching = () => false, now = Date.now, ttlMs = 30_000, onChange = () => {} }) {
|
|
21
|
+
let verified = null, verifiedSession = null, verifiedAt = 0, pending = null;
|
|
22
|
+
const basic = () => {
|
|
23
|
+
const session = readSession();
|
|
24
|
+
if (!isValid(session)) return { state: 'signed_out', organizationId: null, organizationName: null };
|
|
25
|
+
const organizationId = typeof session.organization_id === 'string' && session.organization_id ? session.organization_id : null;
|
|
26
|
+
if (!organizationId) return { state: 'organization_required', organizationId: null, organizationName: null };
|
|
27
|
+
return { state: 'checking', organizationId, organizationName: null };
|
|
28
|
+
};
|
|
29
|
+
const snapshot = () => {
|
|
30
|
+
const base = basic();
|
|
31
|
+
if (base.state !== 'checking' || isSwitching()) return base;
|
|
32
|
+
if (readSession() !== verifiedSession || now() - verifiedAt >= ttlMs || !verified) return base;
|
|
33
|
+
return { ...verified };
|
|
34
|
+
};
|
|
35
|
+
const refresh = async (force = false) => {
|
|
36
|
+
const base = basic();
|
|
37
|
+
if (base.state !== 'checking' || isSwitching()) return base;
|
|
38
|
+
const session = readSession();
|
|
39
|
+
if (pending?.session === session) return pending.promise;
|
|
40
|
+
if (!force && readSession() === verifiedSession && now() - verifiedAt < ttlMs && verified) return snapshot();
|
|
41
|
+
onChange();
|
|
42
|
+
const job = { session, promise: null };
|
|
43
|
+
job.promise = (async () => {
|
|
44
|
+
let result;
|
|
45
|
+
try {
|
|
46
|
+
const payload = await fetchAccess(session);
|
|
47
|
+
if (!payload || payload.schemaVersion !== 1 || payload.organizationId !== session.organization_id ||
|
|
48
|
+
typeof payload.organizationName !== 'string' || !SERVER_STATES.has(payload.state) ||
|
|
49
|
+
!validDesktopAccessPath(payload.settingsPath, payload.organizationSlug) || !validDesktopAccessPath(payload.membersPath, payload.organizationSlug)) throw new Error('Invalid entitlement response');
|
|
50
|
+
result = { state: payload.state,
|
|
51
|
+
organizationId: payload.organizationId, organizationName: payload.organizationName.slice(0, 120), organizationSlug: payload.organizationSlug, settingsPath: payload.settingsPath, membersPath: payload.membersPath };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable' };
|
|
54
|
+
}
|
|
55
|
+
// Late responses never authorize a new account or a switched organization.
|
|
56
|
+
if (readSession() === session && basic().state === 'checking') {
|
|
57
|
+
verified = result; verifiedSession = session; verifiedAt = now(); onChange();
|
|
58
|
+
}
|
|
59
|
+
return snapshot();
|
|
60
|
+
})().finally(() => { if (pending === job) pending = null; });
|
|
61
|
+
pending = job;
|
|
62
|
+
return job.promise;
|
|
63
|
+
};
|
|
64
|
+
return { snapshot, refresh };
|
|
65
|
+
}
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
1
2
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
2
3
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
3
4
|
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
@@ -5,7 +6,7 @@ import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
|
5
6
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
6
7
|
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
7
8
|
import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
|
|
8
|
-
import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
9
|
+
import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
9
10
|
import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
|
|
10
11
|
import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
|
|
11
12
|
import { spawn, execFile } from "node:child_process";
|
|
@@ -38,6 +39,9 @@ const silentLogger = { log() {}, error() {} };
|
|
|
38
39
|
const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
|
|
39
40
|
|
|
40
41
|
function desktopActionError(action, error) {
|
|
42
|
+
if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
|
|
43
|
+
if (action === "session_deadline_decide") return error?.code === "conflict"
|
|
44
|
+
? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
|
|
41
45
|
if (typeof action === "string" && action.startsWith("session_")) {
|
|
42
46
|
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
|
|
43
47
|
}
|
|
@@ -66,18 +70,55 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
66
70
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
|
|
67
71
|
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
68
72
|
let actionAssociations = makeActionAssociations();
|
|
73
|
+
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
74
|
+
const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
|
|
75
|
+
const setAccessSnapshot = async access => {
|
|
76
|
+
const previousScope = accessScope(currentAccess);
|
|
77
|
+
currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
|
|
78
|
+
if (previousScope !== accessScope(currentAccess)) {
|
|
79
|
+
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
80
|
+
sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
|
|
81
|
+
await analysisManager?.update([]);
|
|
82
|
+
}
|
|
83
|
+
return currentAccess;
|
|
84
|
+
};
|
|
85
|
+
const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
|
|
86
|
+
const authorizeWork = async expectedScope => {
|
|
87
|
+
if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
|
|
88
|
+
const access = await accessSnapshot();
|
|
89
|
+
requireDesktopAccess(access);
|
|
90
|
+
const scope = accessScope(access);
|
|
91
|
+
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
92
|
+
return scope;
|
|
93
|
+
};
|
|
94
|
+
const selectedScopes = new WeakMap();
|
|
95
|
+
const guardedEnroll = async options => {
|
|
96
|
+
await authorizeWork();
|
|
97
|
+
return (dependencies.continueSession || continueSession)(options);
|
|
98
|
+
};
|
|
99
|
+
|
|
69
100
|
const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
|
|
70
101
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
102
|
+
authorizeDispatch: authorizeWork,
|
|
71
103
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
72
104
|
let shuttingDown = null;
|
|
73
105
|
let commands;
|
|
74
106
|
let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
|
|
75
107
|
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
|
|
76
108
|
const refreshSessions = (force = false) => {
|
|
109
|
+
if (!desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
77
110
|
if (force && sessionRefresh) sessionRefreshAgain = true;
|
|
78
111
|
if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
|
|
112
|
+
const requestedScope = accessScope(currentAccess);
|
|
79
113
|
sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
|
|
80
|
-
.then(async value => {
|
|
114
|
+
.then(async value => {
|
|
115
|
+
const latestAccess = await accessSnapshot();
|
|
116
|
+
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
|
|
117
|
+
await analysisManager.update([]);
|
|
118
|
+
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
|
|
81
122
|
.catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
82
123
|
.finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
83
124
|
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
@@ -85,19 +126,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
85
126
|
return sessionStatus;
|
|
86
127
|
};
|
|
87
128
|
const selectedSession = async command => {
|
|
129
|
+
const scope = await authorizeWork();
|
|
88
130
|
refreshSessions(true);
|
|
89
131
|
await sessionRefresh;
|
|
90
132
|
// A forced refresh requested during discovery schedules one follow-up.
|
|
91
133
|
if (sessionRefresh) await sessionRefresh;
|
|
92
134
|
const candidate = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
|
|
93
135
|
if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
|
|
94
|
-
|
|
136
|
+
await authorizeWork(scope);
|
|
137
|
+
const selected = { ...candidate };
|
|
138
|
+
selectedScopes.set(selected, scope);
|
|
139
|
+
return selected;
|
|
95
140
|
};
|
|
96
141
|
const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
|
|
97
142
|
getManager: () => analysisManager, selectCandidate: selectedSession,
|
|
98
143
|
readSetting: dependencies.readSessionAutoContinue,
|
|
99
144
|
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
100
|
-
enroll:
|
|
145
|
+
enroll: guardedEnroll, beforeEnroll: candidate => authorizeWork(selectedScopes.get(candidate)),
|
|
101
146
|
onChange: () => { if (!closed && !relay) refreshSessions(true); },
|
|
102
147
|
});
|
|
103
148
|
let pluginSetup = { state: "unknown" };
|
|
@@ -110,14 +155,31 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
110
155
|
};
|
|
111
156
|
const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
|
|
112
157
|
const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
|
|
113
|
-
root: join(root, 'session-analysis'), analyze:
|
|
158
|
+
root: join(root, 'session-analysis'), analyze: async (...args) => {
|
|
159
|
+
const scope = await authorizeWork();
|
|
160
|
+
const [candidate, options] = args;
|
|
161
|
+
return (dependencies.analyzeContinuation || analyzeContinuation)(candidate, { ...options, beforeProviderDispatch: () => authorizeWork(scope) });
|
|
162
|
+
},
|
|
114
163
|
onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
|
|
115
164
|
});
|
|
116
165
|
const publish = () => {
|
|
117
166
|
if (closed || relay || !agent) return Promise.resolve();
|
|
118
167
|
if (publishing) { publishAgain = true; return publishing; }
|
|
119
168
|
publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
|
|
169
|
+
await setAccessSnapshot(status.access);
|
|
170
|
+
status.access = currentAccess;
|
|
171
|
+
const requestedScope = accessScope(currentAccess);
|
|
120
172
|
status.pluginSetup = { ...pluginSetup };
|
|
173
|
+
if (!desktopAccessReady(currentAccess)) {
|
|
174
|
+
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
175
|
+
if (currentAccess.state !== 'checking') await analysisManager.update([]);
|
|
176
|
+
status.tasks = { activeCount: status.tasks?.activeCount || 0, items: [] };
|
|
177
|
+
status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
|
|
178
|
+
status.deliveryHooks = { runs: [], providers: {} };
|
|
179
|
+
status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
|
|
180
|
+
if (!closed && !relay) emit({ type: 'status', status });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
121
183
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
122
184
|
await actionAssociations.decorate(status.deliveryHooks);
|
|
123
185
|
const discovery = refreshSessions();
|
|
@@ -132,6 +194,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
132
194
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
133
195
|
}
|
|
134
196
|
status.sessionSupervision = { ...discovery, sessions };
|
|
197
|
+
const latestAccess = await accessSnapshot();
|
|
198
|
+
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) { publishAgain = true; return; }
|
|
135
199
|
if (!closed && !relay) emit({ type: "status", status });
|
|
136
200
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
137
201
|
return publishing;
|
|
@@ -173,7 +237,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
173
237
|
});
|
|
174
238
|
agent = await (dependencies.startAgent || startAgent)({
|
|
175
239
|
daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
|
|
176
|
-
deferStartupSynchronization: true,
|
|
240
|
+
deferStartupSynchronization: true, enforceDesktopAccess: true,
|
|
177
241
|
onDesktopStatusChange: () => {
|
|
178
242
|
// Let an in-progress frame finish before publishing its new snapshot.
|
|
179
243
|
setImmediate(() => { void publish().catch(() => {}); });
|
|
@@ -251,13 +315,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
251
315
|
await publish();
|
|
252
316
|
void actionAssociations.tick().then(publish).catch(() => {});
|
|
253
317
|
timer = setInterval(() => {
|
|
254
|
-
|
|
255
|
-
|
|
318
|
+
if (desktopAccessReady(currentAccess)) {
|
|
319
|
+
void actionAssociations.tick().then(publish).catch(() => {});
|
|
320
|
+
void managedSupervisor.tick().then(publish).catch(() => {});
|
|
321
|
+
}
|
|
256
322
|
void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
|
|
257
323
|
}, 3_000);
|
|
258
324
|
} catch (error) { await close(); throw error; }
|
|
259
325
|
|
|
260
326
|
let queue = Promise.resolve();
|
|
327
|
+
// Authorization must not wait behind remote refresh/detail/provider work.
|
|
328
|
+
// Keep these commands ordered with each other; the agent still owns all
|
|
329
|
+
// membership, active-work and nonce checks.
|
|
330
|
+
let authorizationQueue = Promise.resolve();
|
|
331
|
+
let activeQueuedAction = null;
|
|
261
332
|
commands = createInterface({ input, crlfDelay: Infinity });
|
|
262
333
|
commands.on("line", (line) => {
|
|
263
334
|
if (closed) return;
|
|
@@ -268,28 +339,48 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
268
339
|
if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
|
|
269
340
|
return;
|
|
270
341
|
}
|
|
271
|
-
|
|
342
|
+
const execute = async () => {
|
|
343
|
+
if (closed) return;
|
|
272
344
|
try {
|
|
273
345
|
const action = command.action;
|
|
346
|
+
const access = await accessSnapshot();
|
|
347
|
+
const commandScope = accessScope(access);
|
|
348
|
+
if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
|
|
349
|
+
if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
|
|
350
|
+
|
|
274
351
|
if (action === "task_detail") {
|
|
275
352
|
const task = await agent.getDesktopTask?.(command.taskKey);
|
|
353
|
+
await authorizeWork(commandScope);
|
|
276
354
|
emit({ type: "task_detail", id: command.id, taskKey: command.taskKey, task: task ?? null });
|
|
277
355
|
emit({ type: "result", id: command.id, ok: Boolean(task) });
|
|
278
356
|
return;
|
|
279
357
|
}
|
|
280
|
-
if (action ===
|
|
281
|
-
|
|
282
|
-
|
|
358
|
+
if (action === 'desktop_access_refresh') {
|
|
359
|
+
await agent.refreshDesktopAccess?.();
|
|
360
|
+
} else if (action === 'desktop_access_settings' || action === 'desktop_access_members') {
|
|
361
|
+
const verified = await agent.refreshDesktopAccess?.();
|
|
362
|
+
const path = verified?.[action === 'desktop_access_settings' ? 'settingsPath' : 'membersPath'];
|
|
363
|
+
if (!validDesktopAccessPath(path, verified?.organizationSlug)) throw new Error('Organization settings are unavailable; refresh access first.');
|
|
364
|
+
const url = new URL(path, webOrigin);
|
|
365
|
+
if (url.origin !== webOrigin) throw new Error('Organization settings are unavailable.');
|
|
366
|
+
const result = await (dependencies.openLogin || openAuthorizationUrl)(url.toString(), { logger: silentLogger });
|
|
367
|
+
if (!result.opened) throw new Error('Could not open organization settings.');
|
|
368
|
+
} else if (action === "refresh" || action === "reconnect") {
|
|
369
|
+
if (desktopAccessReady(access) && agent.refreshProviderStatus) await agent.refreshProviderStatus();
|
|
370
|
+
else if (desktopAccessReady(access)) await agent.refreshCodexStatus?.();
|
|
283
371
|
await agent.refreshDesktopStatus();
|
|
284
372
|
await refreshPlugins();
|
|
285
373
|
refreshSessions(true);
|
|
286
|
-
void actionAssociations.tick().then(publish).catch(() => {});
|
|
374
|
+
if (desktopAccessReady(currentAccess)) void actionAssociations.tick().then(publish).catch(() => {});
|
|
287
375
|
}
|
|
288
376
|
else if (action === "organizations_refresh") await agent.refreshOrganizations();
|
|
289
377
|
else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
|
|
290
378
|
else if (action === "organization_cancel") agent.cancelOrganizationSelection();
|
|
291
379
|
else if (action === "login" || action === "organization_select") {
|
|
292
380
|
if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
|
|
381
|
+
if (action === "organization_select" && activeQueuedAction && !["refresh", "reconnect", "task_detail", "organizations_refresh", "check_update"].includes(activeQueuedAction)) {
|
|
382
|
+
throw Object.assign(new Error("Wait for the current action to finish before switching organizations."), { code: "desktop_organization_busy" });
|
|
383
|
+
}
|
|
293
384
|
const url = await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
|
|
294
385
|
if (url) {
|
|
295
386
|
if (new URL(url).origin !== webOrigin) throw new Error("Invalid login origin.");
|
|
@@ -332,6 +423,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
332
423
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
333
424
|
} else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
|
|
334
425
|
const candidate = await selectedSession(command);
|
|
426
|
+
await authorizeWork(commandScope);
|
|
335
427
|
await actionAssociations.correct(command, analysisManager.project(candidate));
|
|
336
428
|
} else if (action === "session_set_auto_continue") {
|
|
337
429
|
if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
|
|
@@ -339,6 +431,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
339
431
|
const candidate = await selectedSession(command);
|
|
340
432
|
if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
341
433
|
}
|
|
434
|
+
if (command.enabled) await authorizeWork(commandScope);
|
|
342
435
|
await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
|
|
343
436
|
provider: command.provider, session: command.session, enabled: command.enabled });
|
|
344
437
|
if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
|
|
@@ -356,11 +449,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
356
449
|
const context = await actionAssociations.deadlineContext?.(projected);
|
|
357
450
|
if (!context || context.taskId !== command.taskId) throw new Error('Refresh this conversation before deciding its deadline.');
|
|
358
451
|
await deadlines.reconcile(context);
|
|
452
|
+
await authorizeWork(commandScope);
|
|
359
453
|
const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
360
454
|
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
|
|
361
455
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
362
456
|
const candidate = await selectedSession(command);
|
|
363
457
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
458
|
+
await authorizeWork(commandScope);
|
|
364
459
|
await (dependencies.trackSession || trackSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
365
460
|
session: command.session, tracked: action === "session_track" });
|
|
366
461
|
await refreshSessions(true);
|
|
@@ -383,7 +478,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
383
478
|
const manager = analysisManager;
|
|
384
479
|
await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
|
|
385
480
|
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
386
|
-
enroll:
|
|
481
|
+
enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
|
|
387
482
|
settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
388
483
|
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
|
|
389
484
|
maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
|
|
@@ -404,7 +499,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
404
499
|
} catch (error) {
|
|
405
500
|
emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
|
|
406
501
|
}
|
|
407
|
-
}
|
|
502
|
+
};
|
|
503
|
+
if (["login", "organization_select", "organization_cancel"].includes(command.action)) {
|
|
504
|
+
authorizationQueue = authorizationQueue.then(execute).catch(() => {});
|
|
505
|
+
} else {
|
|
506
|
+
queue = queue.then(async () => {
|
|
507
|
+
activeQueuedAction = command.action;
|
|
508
|
+
try { await execute(); } finally { activeQueuedAction = null; }
|
|
509
|
+
}).catch(() => {});
|
|
510
|
+
}
|
|
408
511
|
});
|
|
409
512
|
commands.on("close", () => { if (!closed) void close().then(() => exit(0)); });
|
|
410
513
|
return { close, publish, agent };
|
|
@@ -49,7 +49,7 @@ export function createOrdinaryAssociationStore({
|
|
|
49
49
|
now = Date.now,
|
|
50
50
|
onChange = () => {},
|
|
51
51
|
}) {
|
|
52
|
-
const inFlight = new Map();
|
|
52
|
+
const inFlight = new Map(), syncTails = new Map();
|
|
53
53
|
const views = new Map(),
|
|
54
54
|
abort = new AbortController();
|
|
55
55
|
let serial = Promise.resolve(),
|
|
@@ -90,9 +90,16 @@ export function createOrdinaryAssociationStore({
|
|
|
90
90
|
},
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
function sync(candidate, force = false, known) {
|
|
94
|
+
// Forced user refreshes and background decoration share the same local
|
|
95
|
+
// binding writer; an in-flight check alone leaves a race after its await.
|
|
96
|
+
const key = `${candidate.provider}:${candidate.session}`;
|
|
97
|
+
const work = (syncTails.get(key) || Promise.resolve()).then(() => performSync(candidate, force, known));
|
|
98
|
+
const tail = work.catch(() => {}).finally(() => { if (syncTails.get(key) === tail) syncTails.delete(key); });
|
|
99
|
+
syncTails.set(key, tail);
|
|
100
|
+
return work;
|
|
101
|
+
}
|
|
102
|
+
async function performSync(candidate, force = false, known) {
|
|
96
103
|
if (closed) return;
|
|
97
104
|
const record = known || (await ensure(candidate)),
|
|
98
105
|
taskId = record.run?.managed?.taskId || record.associationTaskId;
|
|
@@ -13,7 +13,7 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
|
|
|
13
13
|
throw new Error('This conversation changed; wait for its updated analysis.');
|
|
14
14
|
}
|
|
15
15
|
manager.requireRecommendation(candidate);
|
|
16
|
-
await beforeEnroll();
|
|
16
|
+
await beforeEnroll(candidate);
|
|
17
17
|
if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
|
|
18
18
|
return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
|
|
19
19
|
}
|
|
@@ -22,7 +22,7 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
|
|
|
22
22
|
// context/opt-in changes; an existing (including terminal) run is never reset.
|
|
23
23
|
export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
24
24
|
readSetting = readSessionAutoContinue, readContext = readContinuationContext, enroll = continueSession,
|
|
25
|
-
onChange = () => {} }) {
|
|
25
|
+
beforeEnroll = async () => {}, onChange = () => {} }) {
|
|
26
26
|
const pending = new Map(), attempted = new Map(), errors = new Map();
|
|
27
27
|
let closed = false;
|
|
28
28
|
const tick = candidates => {
|
|
@@ -51,7 +51,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
51
51
|
const fresh = await selectCandidate(candidate);
|
|
52
52
|
if (!fresh.autoContinue || fresh.autoContinueEpoch !== candidate.autoContinueEpoch) throw new Error('Automatic continuation setting changed.');
|
|
53
53
|
await continueAnalyzedSession({ root, candidate: fresh, manager, signal: controller.signal,
|
|
54
|
-
readContext, enroll, beforeEnroll: guard,
|
|
54
|
+
readContext, enroll, beforeEnroll: async selected => { await guard(); await beforeEnroll(selected); },
|
|
55
55
|
settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
|
|
56
56
|
}).catch(error => {
|
|
57
57
|
if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
|
|
@@ -315,13 +315,16 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
|
|
|
315
315
|
for (const [index, turn] of settled.toReversed().entries()) {
|
|
316
316
|
if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
|
|
317
317
|
if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
|
|
318
|
-
if (!Array.isArray(turn?.items)) { incomplete = true; continue; }
|
|
318
|
+
if (!Array.isArray(turn?.items)) { if (turn === boundary) incomplete = true; continue; }
|
|
319
319
|
if (turn === boundary && !turn.items.length) incomplete = true;
|
|
320
320
|
for (const [itemIndex, item] of turn.items.entries()) {
|
|
321
321
|
const role = item?.type === 'userMessage' ? 'user' : item?.type === 'agentMessage' ? 'assistant' : null;
|
|
322
322
|
if (!role) continue;
|
|
323
323
|
const text = role === 'user' ? textContent(item.content) : item.text;
|
|
324
|
-
|
|
324
|
+
// An older interrupted/empty answer is an omission in the display
|
|
325
|
+
// history, not a missing latest exchange. Retain human corrections;
|
|
326
|
+
// the classifier must still establish a self-contained current goal.
|
|
327
|
+
if (typeof text !== 'string' || !text.trim()) { if (turn === boundary) incomplete = true; continue; }
|
|
325
328
|
messages.push({ role, turnRef: `${turn.id || index}:${item.id || itemIndex}`, text });
|
|
326
329
|
}
|
|
327
330
|
}
|
|
@@ -2,9 +2,10 @@ import { readCodexConversation } from './codex-app-server.js';
|
|
|
2
2
|
import { readClaudeConversation } from './claude-sessions.js';
|
|
3
3
|
import { boundConversation, boundProviderGoal, sanitizedText } from './conversation-context.js';
|
|
4
4
|
import { runStructuredProviderReview } from '../sqm/review-worker.js';
|
|
5
|
+
import { inspectProviderStatus } from '../desktop-status.js';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
|
|
7
|
-
export const CONTINUATION_ANALYSIS_VERSION =
|
|
8
|
+
export const CONTINUATION_ANALYSIS_VERSION = 3;
|
|
8
9
|
const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
|
|
9
10
|
const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
|
|
10
11
|
const string = maximum => ({ type: 'string', maxLength: maximum });
|
|
@@ -50,8 +51,22 @@ Older history may be omitted. Set assessment.contextSufficient=true only if the
|
|
|
50
51
|
Return blocker=null unless concrete unfinished original work is currently prevented by a specific unresolved approval, missing_information, external_dependency or execution_failure. State and needsUser/externalWait flags alone are not blockers. Running/status-only waiting, future reminders, voluntary pauses, budget limits, uncertainty, optional follow-ups and resolved historical failures must have blocker=null. A recoverable failure with a feasible next step is not a blocker. A blocker never changes continuation authority. Its reason must describe the actual obstacle concisely; basis must quote exact original conversation excerpts that establish the obstacle, checking all later supplied messages to establish that it is still current. Evidence may originate in an earlier turn when no later message resolves the obstacle. Goal metadata alone cannot establish a blocker. Later authorization, supplied information, resolution or completion clears older blockers. For execution_failure, require explicit evidence that the failure prevents further useful progress, not simply that a command failed.
|
|
51
52
|
Return concise reasons and exact short supporting excerpts, each tied to the role and turnRef provided. Excerpts must occur verbatim in the supplied message. Do not reproduce secrets, credentials or large private passages. Set nextAction=null except for continue. Populate assessment honestly; nextActionKind names the concrete action, not merely a plan to act.`;
|
|
52
53
|
|
|
53
|
-
function uncertain(reason) {
|
|
54
|
-
return { state: 'uncertain', reason, objective: '', remaining: [], nextAction: null, basis: [], blocker: null, analysisVersion: CONTINUATION_ANALYSIS_VERSION };
|
|
54
|
+
function uncertain(reason, reasonCode) {
|
|
55
|
+
return { state: 'uncertain', reason, ...(reasonCode ? { reasonCode } : {}), objective: '', remaining: [], nextAction: null, basis: [], blocker: null, analysisVersion: CONTINUATION_ANALYSIS_VERSION };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function unavailableAnalysis(candidate, error, inspectProvider, signal) {
|
|
59
|
+
if (error?.code === 'analysis_cancelled') return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
60
|
+
if (error?.code === 'analysis_timeout') return uncertain('The local AI app did not finish checking this turn in time. Retry when it is available.', 'timeout');
|
|
61
|
+
if (error?.code === `${candidate.provider}_local_unavailable`) return uncertain('The local AI app is unavailable. Open or update it, then retry.', 'unavailable');
|
|
62
|
+
let status;
|
|
63
|
+
try { status = await inspectProvider(candidate.provider); } catch { /* Unknown is never proof of sign-out. */ }
|
|
64
|
+
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
65
|
+
if (error?.code === 'provider_sign_in_required' || status?.state === 'signed_out') {
|
|
66
|
+
const label = candidate.provider === 'claude' ? 'Claude Code' : 'Codex';
|
|
67
|
+
return uncertain(`Sign in to ${label} to check this conversation and continue its unfinished work. After signing in, retry this check.`, 'provider_sign_in_required');
|
|
68
|
+
}
|
|
69
|
+
return uncertain('The local AI app could not check this turn. Check its connection, then retry.', 'analysis_unavailable');
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
function unreadableContext(error) {
|
|
@@ -143,7 +158,7 @@ export async function readContinuationContext(candidate, { signal, readContext,
|
|
|
143
158
|
|
|
144
159
|
export async function analyzeContinuation(candidate, {
|
|
145
160
|
signal, readContext, openCodexClient, claudeHome, previousAnalysis,
|
|
146
|
-
runProvider = runStructuredProviderReview, providerOptions = {},
|
|
161
|
+
runProvider = runStructuredProviderReview, providerOptions = {}, inspectProvider = inspectProviderStatus, beforeProviderDispatch = async () => {},
|
|
147
162
|
} = {}) {
|
|
148
163
|
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
|
|
149
164
|
if (!['codex', 'claude'].includes(candidate?.provider) || typeof candidate?.session !== 'string' || typeof candidate?.cwd !== 'string') {
|
|
@@ -172,18 +187,29 @@ export async function analyzeContinuation(candidate, {
|
|
|
172
187
|
if (previousAnalysis?.analysisVersion === CONTINUATION_ANALYSIS_VERSION && previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
|
|
173
188
|
return { ...snapshot, ...previousAnalysis };
|
|
174
189
|
}
|
|
175
|
-
if (context.incomplete || !context.messages.length) return uncertain('The latest
|
|
190
|
+
if (context.incomplete || !context.messages.length) return uncertain('The latest request or reply could not be read completely. Open the original conversation to review it, then retry.', 'context_incomplete');
|
|
191
|
+
let result;
|
|
176
192
|
try {
|
|
177
|
-
|
|
193
|
+
await beforeProviderDispatch();
|
|
194
|
+
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
195
|
+
result = await runProvider(candidate.provider, {
|
|
178
196
|
system: SYSTEM, prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
|
|
179
|
-
}, { ...providerOptions, signal, readOnlyAnalysis: true });
|
|
180
|
-
|
|
197
|
+
}, { ...providerOptions, signal, readOnlyAnalysis: true, beforeDispatch: beforeProviderDispatch });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error?.code === 'desktop_access_required') return uncertain('SIMY organization access is required before checking this conversation.', 'desktop_access_required');
|
|
200
|
+
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
201
|
+
// Only a known machine code or the provider's own login probe can
|
|
202
|
+
// establish sign-out. SIMY account login is unrelated to local analysis.
|
|
203
|
+
return unavailableAnalysis(candidate, error, inspectProvider, signal);
|
|
204
|
+
}
|
|
205
|
+
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
|
|
206
|
+
try {
|
|
181
207
|
const analysis = validateContinuationAnalysis(result?.output, context);
|
|
182
208
|
const model = typeof result?.model === 'string' && /^[a-zA-Z0-9._/-]{1,120}$/.test(result.model) ? result.model : null;
|
|
183
209
|
return { ...snapshot, ...analysis, ...(model ? { model } : {}) };
|
|
184
210
|
} catch {
|
|
185
211
|
// Provider stderr/errors may contain transcript text or credentials. Never
|
|
186
212
|
// expose them to the desktop, cache, diagnostics or control plane.
|
|
187
|
-
return uncertain('
|
|
213
|
+
return uncertain('The AI app returned a check that could not be verified. Retry this check or review the original conversation.', 'analysis_invalid_result');
|
|
188
214
|
}
|
|
189
215
|
}
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -208,6 +208,7 @@ export async function runStructuredProviderReview(
|
|
|
208
208
|
signal,
|
|
209
209
|
readOnlyAnalysis = false,
|
|
210
210
|
terminationGraceMs = 1_000,
|
|
211
|
+
beforeDispatch = async () => {},
|
|
211
212
|
} = {},
|
|
212
213
|
) {
|
|
213
214
|
assertProvider(provider);
|
|
@@ -230,6 +231,8 @@ export async function runStructuredProviderReview(
|
|
|
230
231
|
"Return only JSON that strictly matches the provided output schema.",
|
|
231
232
|
request.prompt,
|
|
232
233
|
].join("\n\n");
|
|
234
|
+
await beforeDispatch();
|
|
235
|
+
if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
|
|
233
236
|
const stdout = await spawnReview(
|
|
234
237
|
inspection.executable,
|
|
235
238
|
provider === "claude" ? [
|
|
@@ -260,6 +263,9 @@ export async function runStructuredProviderReview(
|
|
|
260
263
|
);
|
|
261
264
|
const envelope = provider === "claude" ? JSON.parse(stdout) : null;
|
|
262
265
|
if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
|
|
266
|
+
if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
|
|
267
|
+
throw new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' });
|
|
268
|
+
}
|
|
263
269
|
throw new Error("Claude Code did not complete a structured review");
|
|
264
270
|
}
|
|
265
271
|
const output = provider === "claude"
|
|
@@ -273,7 +279,7 @@ export async function runStructuredProviderReview(
|
|
|
273
279
|
model: `${provider}-local/${inspection.installed_version || "unknown"}`,
|
|
274
280
|
};
|
|
275
281
|
} catch (error) {
|
|
276
|
-
if (error instanceof SqmLocalReviewError) throw error;
|
|
282
|
+
if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
|
|
277
283
|
throw new SqmLocalReviewError(`Local ${label} review failed: ${errorMessage(error)}`, {
|
|
278
284
|
code: `${provider}_local_review_failed`,
|
|
279
285
|
});
|
|
@@ -322,7 +328,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
322
328
|
};
|
|
323
329
|
const cancelled = () => halt(new Error('Local analysis was cancelled'));
|
|
324
330
|
const timer = setTimeout(() => {
|
|
325
|
-
halt(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
331
|
+
halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
|
|
326
332
|
}, executionTimeoutMs);
|
|
327
333
|
timer.unref?.();
|
|
328
334
|
const finish = (error = null) => {
|
|
@@ -341,6 +347,10 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
341
347
|
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
342
348
|
let event;
|
|
343
349
|
try { event = JSON.parse(line); } catch { continue; }
|
|
350
|
+
if (['error', 'turn.failed'].includes(event.type) && providerSignInRequired(event.message ?? event.error?.message)) {
|
|
351
|
+
halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
344
354
|
if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
|
|
345
355
|
halt(new Error('Read-only analysis attempted to use a tool'));
|
|
346
356
|
return;
|
|
@@ -358,6 +368,14 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
358
368
|
});
|
|
359
369
|
child.on("error", finish);
|
|
360
370
|
child.on("close", (code) => {
|
|
371
|
+
if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
|
|
372
|
+
let envelope;
|
|
373
|
+
try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
|
|
374
|
+
if (envelope?.is_error === true && providerSignInRequired(envelope.result)) {
|
|
375
|
+
finish(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
361
379
|
finish(terminalError || (code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`)));
|
|
362
380
|
});
|
|
363
381
|
signal?.addEventListener('abort', cancelled, { once: true });
|
|
@@ -366,6 +384,12 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
|
|
|
366
384
|
});
|
|
367
385
|
}
|
|
368
386
|
|
|
387
|
+
function providerSignInRequired(message) {
|
|
388
|
+
// Match explicit provider authentication failures only, never general 403s
|
|
389
|
+
// (which can be permissions/quotas) or model-generated prose/tool output.
|
|
390
|
+
return typeof message === 'string' && /(?:not logged in|please (?:log|sign) in|invalid api key|authentication token (?:is )?(?:invalid|expired)|refresh token (?:has )?expired)/i.test(message.slice(0, 4000));
|
|
391
|
+
}
|
|
392
|
+
|
|
369
393
|
function safeReviewEnvironment(environment, provider) {
|
|
370
394
|
const allowed = [
|
|
371
395
|
"HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR",
|