@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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Confirmed updates must not wait indefinitely on a provider or remote flush.
|
|
2
|
+
// SIGKILL is ownership checked by the caller; the desktop host itself survives
|
|
3
|
+
// long enough to settle its local checkpoints and perform the signed handoff.
|
|
4
|
+
export async function stopDesktopWorkForUpdate({ stopWork, killOwned, timeoutMs = 12_000 }) {
|
|
5
|
+
let timer;
|
|
6
|
+
const stopping = Promise.resolve().then(stopWork);
|
|
7
|
+
// A kill failure or deadline can win the race before checkpoint cancellation
|
|
8
|
+
// settles. Observe its eventual rejection as well.
|
|
9
|
+
void stopping.catch(() => {});
|
|
10
|
+
const deadline = new Promise((_, reject) => {
|
|
11
|
+
timer = setTimeout(() => reject(new Error('Task shutdown timed out. Restart SIMY and retry the update.')), timeoutMs);
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
await Promise.race([(async () => {
|
|
15
|
+
// Start cancellation before taking the process snapshot so queued work is
|
|
16
|
+
// fenced and live workers observe their stop intent before termination.
|
|
17
|
+
await Promise.resolve();
|
|
18
|
+
await killOwned();
|
|
19
|
+
await stopping;
|
|
20
|
+
})(), deadline]);
|
|
21
|
+
} finally { clearTimeout(timer); }
|
|
22
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { connectionDiscoveryPath } from "./connection-discovery.js";
|
|
|
4
4
|
import { readSession, isSessionValid, defaultSessionRoot } from "./session-store.js";
|
|
5
5
|
import { inspectProviderStatus } from "./desktop-status.js";
|
|
6
6
|
import { readDiagnosticLog } from "./diagnostic-log.js";
|
|
7
|
+
import { readHostExitDiagnostics } from "./host-exit-diagnostics.js";
|
|
7
8
|
|
|
8
9
|
export async function collectDiagnostics({ webOrigin, cliVersion, root = defaultSessionRoot(),
|
|
9
10
|
readSessionImpl = readSession, inspectProvider = inspectProviderStatus, fetchImpl = fetch,
|
|
@@ -37,7 +38,7 @@ export async function collectDiagnostics({ webOrigin, cliVersion, root = default
|
|
|
37
38
|
return { schema_version: 1, cli_version: cliVersion, web_origin: webOrigin, daemon,
|
|
38
39
|
session: { state: valid ? "signed_in" : session ? "expired" : "signed_out",
|
|
39
40
|
expires_at: Number.isFinite(Date.parse(session?.expires_at)) ? session.expires_at : null },
|
|
40
|
-
providers, failures,
|
|
41
|
+
providers, failures, host_exit: await readHostExitDiagnostics(root),
|
|
41
42
|
purpose_verification: await purposeVerificationCapability(purposeSigningKeyPath(root)).then(value => ({ state: value ? "available" : "not_initialized", key_id: value?.key_id || null, execution: "explicit_frozen_recipes_only" })).catch(() => ({ state: "unavailable", key_id: null, execution: "explicit_frozen_recipes_only" })),
|
|
42
43
|
actions: [
|
|
43
44
|
...(!valid ? [{ id: "reconnect_cli", command: "simy" }] : []),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// The native launcher retains the latest abnormal exit. Read only its bounded
|
|
6
|
+
// header: stderr may contain private paths, prompts or credentials.
|
|
7
|
+
export async function readHostExitDiagnostics(root) {
|
|
8
|
+
let file;
|
|
9
|
+
try {
|
|
10
|
+
file = await open(join(root, "logs", "desktop-host-exit.log"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
11
|
+
if (!(await file.stat()).isFile()) return { state: "unavailable" };
|
|
12
|
+
const buffer = Buffer.alloc(256);
|
|
13
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
14
|
+
const header = buffer.subarray(0, bytesRead).toString("utf8").split("\n")[0];
|
|
15
|
+
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) desktop host exited with status (-?\d{1,10})$/.exec(header);
|
|
16
|
+
const status = match ? Number(match[2]) : NaN;
|
|
17
|
+
if (!match || !Number.isFinite(Date.parse(match[1])) ||
|
|
18
|
+
new Date(match[1]).toISOString() !== match[1].replace("Z", ".000Z") ||
|
|
19
|
+
!Number.isInteger(status) || status === 0 || status < -2147483648 || status > 2147483647) {
|
|
20
|
+
return { state: "unavailable" };
|
|
21
|
+
}
|
|
22
|
+
return { state: "recorded", at: match[1], exit_status: status, scope: "local_desktop_host", retention: "latest_abnormal_exit" };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return { state: error.code === "ENOENT" ? "not_recorded" : "unavailable" };
|
|
25
|
+
} finally {
|
|
26
|
+
await file?.close().catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.js
CHANGED
|
@@ -138,6 +138,7 @@ async function runCommand(installMode) {
|
|
|
138
138
|
simy --no-tui Start the foreground HTTP agent without the interactive console
|
|
139
139
|
simy sqm min-check Run the fast deterministic SQM diff preflight
|
|
140
140
|
simy sqm check Run full scenarios, write proof.json, and sign the run
|
|
141
|
+
simy sqm upload-proof Recover storage of an original signed SQM proof
|
|
141
142
|
simy sqm lint Validate module reliability before publication
|
|
142
143
|
simy sqm replay Replay modules against labelled historical snapshots
|
|
143
144
|
simy update --check Check the installed and latest stable CLI versions
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const PROVIDER_LINKS = Object.freeze(['codex', 'claude']);
|
|
7
|
+
const defaultRoot = () => process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
8
|
+
const writeQueues = new Map();
|
|
9
|
+
const defaults = () => Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, { connected: true, epoch: null, requiresConsent: false }]));
|
|
10
|
+
const disconnected = () => Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, { connected: false, epoch: null, requiresConsent: true, settingsError: true }]));
|
|
11
|
+
const validateProvider = provider => { if (!PROVIDER_LINKS.includes(provider)) throw new Error('Unsupported SIMY provider connection.'); };
|
|
12
|
+
|
|
13
|
+
async function readSettings(root) {
|
|
14
|
+
let payload;
|
|
15
|
+
try { payload = JSON.parse(await readFile(join(root, 'provider-links.json'), 'utf8')); }
|
|
16
|
+
catch (error) { if (error.code === 'ENOENT') return defaults(); throw error; }
|
|
17
|
+
if (payload?.schemaVersion !== 1 || !payload.providers || PROVIDER_LINKS.some(provider => {
|
|
18
|
+
const value = payload.providers[provider];
|
|
19
|
+
return !value || typeof value.connected !== 'boolean' || typeof value.requiresConsent !== 'boolean' ||
|
|
20
|
+
value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128);
|
|
21
|
+
})) throw new Error('Invalid SIMY provider connection settings.');
|
|
22
|
+
return Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
|
|
23
|
+
connected: payload.providers[provider].connected,
|
|
24
|
+
epoch: payload.providers[provider].epoch,
|
|
25
|
+
requiresConsent: payload.providers[provider].requiresConsent,
|
|
26
|
+
}]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Existing installations stay linked until the user explicitly disconnects.
|
|
30
|
+
// An unreadable saved preference fails closed instead of restoring revoked consent.
|
|
31
|
+
export async function readProviderLinks(root = defaultRoot()) {
|
|
32
|
+
try { return await readSettings(root); } catch { return disconnected(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function providerLinkConnected(root = defaultRoot(), provider) {
|
|
36
|
+
validateProvider(provider);
|
|
37
|
+
return (await readProviderLinks(root))[provider].connected;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Session APIs use a delivery-hooks root, while desktop/agent policy uses SIMY_HOME.
|
|
41
|
+
export async function requireProviderLink({ root = join(defaultRoot(), 'delivery-hooks'), provider, expectedEpoch } = {}) {
|
|
42
|
+
validateProvider(provider);
|
|
43
|
+
const link = (await readProviderLinks(dirname(resolve(root))))[provider];
|
|
44
|
+
if (!link.connected) {
|
|
45
|
+
throw Object.assign(new Error('Reconnect this provider to SIMY before using it.'), { code: 'provider_link_disconnected' });
|
|
46
|
+
}
|
|
47
|
+
if (expectedEpoch !== undefined && expectedEpoch !== link.epoch) {
|
|
48
|
+
throw Object.assign(new Error('SIMY provider connection changed. Select the conversation again.'), { code: 'provider_link_changed' });
|
|
49
|
+
}
|
|
50
|
+
return link;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writeProviderLink(root = defaultRoot(), provider, connected) {
|
|
54
|
+
validateProvider(provider);
|
|
55
|
+
if (typeof connected !== 'boolean') throw new Error('Choose whether this provider is connected to SIMY.');
|
|
56
|
+
const key = resolve(root);
|
|
57
|
+
const previous = writeQueues.get(key) || Promise.resolve();
|
|
58
|
+
const pending = previous.catch(() => {}).then(async () => {
|
|
59
|
+
const providers = await readSettings(root);
|
|
60
|
+
// Reconnection restores discovery only. Session execution consent is cleared
|
|
61
|
+
// separately and must be selected again, including after a host restart.
|
|
62
|
+
providers[provider] = { connected, epoch: randomUUID(), requiresConsent: true };
|
|
63
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
64
|
+
const target = join(root, 'provider-links.json'), temporary = `${target}.${randomUUID()}.tmp`;
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers })}\n`, { mode: 0o600 });
|
|
67
|
+
await rename(temporary, target);
|
|
68
|
+
} finally { await rm(temporary, { force: true }); }
|
|
69
|
+
return providers;
|
|
70
|
+
});
|
|
71
|
+
writeQueues.set(key, pending);
|
|
72
|
+
void pending.finally(() => { if (writeQueues.get(key) === pending) writeQueues.delete(key); }).catch(() => {});
|
|
73
|
+
return pending;
|
|
74
|
+
}
|
|
@@ -3,6 +3,7 @@ import { listRecords, readRecord, updateRecord, TERMINAL, managedBudgetReason }
|
|
|
3
3
|
import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
|
|
4
4
|
import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
|
|
5
5
|
settleActionAssociation, cancelActionAssociation } from './action-association.js';
|
|
6
|
+
import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
|
|
6
7
|
|
|
7
8
|
const namespaceFor = (origin, scope) => JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
|
|
8
9
|
const identity = record => ({ provider: record.provider, session: record.session, taskId: record.run.managed.taskId });
|
|
@@ -90,22 +91,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
90
91
|
let view = views.get(target.taskId);
|
|
91
92
|
const request = actionCandidateRequest(record);
|
|
92
93
|
const contextRevision = request?.context.revision || null;
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const active = !TERMINAL.has(record.run.state);
|
|
95
|
+
if (!force && view?.auth === expected && view.active === active && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
|
|
96
|
+
if (queue && !view.error && hasFreshCandidates(view, now()) && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
|
-
|
|
99
|
+
const search = request && (needsCandidateSearch(view, expected, contextRevision, active, force) ||
|
|
100
|
+
association?.outbox && !view?.snapshot);
|
|
101
|
+
view = { ...view, auth: expected, active, contextRevision, nextRefresh: now() + refreshMs };
|
|
98
102
|
views.delete(target.taskId); views.set(target.taskId, view);
|
|
99
103
|
while (views.size > 500) views.delete(views.keys().next().value);
|
|
100
104
|
try {
|
|
101
105
|
// Never publish an outbox from an earlier organization or login before a
|
|
102
106
|
// scoped server response confirms who owns the current request.
|
|
103
|
-
const
|
|
104
|
-
const namespace = await importEnvelope(record,
|
|
107
|
+
const envelope = search ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
|
|
108
|
+
const namespace = await importEnvelope(record, envelope, expected);
|
|
109
|
+
const reusable = view.auth === expected && view.namespace === namespace && view.snapshot?.contextRevision === contextRevision;
|
|
110
|
+
const snapshot = search ? envelope : { ...(reusable ? view.snapshot : {}), ...envelope };
|
|
105
111
|
view.namespace = namespace; view.error = null;
|
|
106
|
-
|
|
112
|
+
if (search) {
|
|
113
|
+
view.candidateAuth = expected; view.candidateContextRevision = contextRevision;
|
|
114
|
+
view.candidatesExpireAt = now() + CANDIDATE_DISPLAY_MS;
|
|
115
|
+
} else if (!reusable) {
|
|
116
|
+
view.candidateAuth = null; view.candidateContextRevision = null;
|
|
117
|
+
}
|
|
118
|
+
view.candidates = (search || hasFreshCandidates(view, now()) ? snapshot.candidates || [] : []).map(publicCandidate);
|
|
107
119
|
view.candidateTruncated = snapshot.retrieval?.truncated === true || view.candidates.length > 5;
|
|
108
|
-
view.snapshot =
|
|
120
|
+
view.snapshot = snapshot.candidateRevision ? snapshot : null;
|
|
121
|
+
if (!active && !snapshot.binding?.action && !association?.outbox) view.nextRefresh = Infinity;
|
|
109
122
|
let latest = await fresh(target);
|
|
110
123
|
if (!latest || !await valid(expected)) return;
|
|
111
124
|
const pending = latest.run.managed.actionAssociation?.outbox;
|
|
@@ -123,7 +136,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
123
136
|
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now(), validate: validateScope(expected) });
|
|
124
137
|
return;
|
|
125
138
|
}
|
|
126
|
-
if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
|
|
139
|
+
if (queue && request && hasFreshCandidates(view, now()) && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
|
|
127
140
|
// A read turn is queued only at the original session's safe boundary.
|
|
128
141
|
// Blocked tasks retain candidates for auxiliary correction in the UI.
|
|
129
142
|
await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
@@ -245,7 +258,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
245
258
|
async prepareCached(record) {
|
|
246
259
|
const expected = await current();
|
|
247
260
|
const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
|
|
248
|
-
if (closed || !auth || !view || view.auth !== auth || view.error || !view
|
|
261
|
+
if (closed || !auth || !view || view.auth !== auth || view.error || !hasFreshCandidates(view, now()) || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
|
|
249
262
|
try {
|
|
250
263
|
const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
251
264
|
return prepared || record;
|
|
@@ -268,7 +281,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
268
281
|
!view || view.auth !== expected ? { status: 'loading', binding: null, candidates: [], canCorrect: false } : {
|
|
269
282
|
status: view.error || local?.status || 'waiting_boundary',
|
|
270
283
|
binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
|
|
271
|
-
candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
|
|
284
|
+
candidates: (hasFreshCandidates(view, now()) ? view.candidates || [] : []).slice(0,5), canCorrect: !view.error,
|
|
272
285
|
contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
273
286
|
};
|
|
274
287
|
let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Candidate search is event-driven; binding reads remain independently renewable.
|
|
2
|
+
// Retain only the search identity after expiry, so a timer cannot restart search
|
|
3
|
+
// or keep displaying candidate text after its authorization window has elapsed.
|
|
4
|
+
export const CANDIDATE_DISPLAY_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
export function needsCandidateSearch(view, auth, contextRevision, active, force = false) {
|
|
7
|
+
return force || active && (view?.candidateAuth !== auth || view.candidateContextRevision !== contextRevision);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function hasFreshCandidates(view, now) {
|
|
11
|
+
return Boolean(view?.snapshot?.candidateRevision && now < view.candidatesExpireAt);
|
|
12
|
+
}
|
|
@@ -3,9 +3,14 @@ import {
|
|
|
3
3
|
listRecords,
|
|
4
4
|
readRecord,
|
|
5
5
|
updateRecord,
|
|
6
|
+
TERMINAL,
|
|
6
7
|
} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
7
8
|
import { associationRevision } from './action-association.js';
|
|
8
9
|
import { ActionAssociationApiError } from './action-association-api.js';
|
|
10
|
+
import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
|
|
11
|
+
|
|
12
|
+
const activeConversation = (candidate, record) => !TERMINAL.has(record.run?.state) &&
|
|
13
|
+
(candidate.isRunning !== false || candidate.autoContinue === true || candidate.analysis?.state === 'continue');
|
|
9
14
|
|
|
10
15
|
export const sessionAssociationContext = (candidate) => {
|
|
11
16
|
const grounded =
|
|
@@ -111,6 +116,7 @@ export function createOrdinaryAssociationStore({
|
|
|
111
116
|
if (record.run?.managed) return;
|
|
112
117
|
const auth = await api.authRevision(),
|
|
113
118
|
context = sessionAssociationContext(candidate);
|
|
119
|
+
const active = activeConversation(candidate, record);
|
|
114
120
|
let view = views.get(taskId);
|
|
115
121
|
if (!auth) {
|
|
116
122
|
rememberView(taskId, { auth, status: 'signed_out' });
|
|
@@ -120,13 +126,15 @@ export function createOrdinaryAssociationStore({
|
|
|
120
126
|
!force &&
|
|
121
127
|
view?.auth === auth &&
|
|
122
128
|
view.contextRevision === context.revision &&
|
|
129
|
+
view.active === active &&
|
|
123
130
|
now() < view.nextRefresh
|
|
124
131
|
)
|
|
125
132
|
return;
|
|
126
133
|
const previous = view?.auth === auth ? view : null;
|
|
127
134
|
const sameContext = previous?.contextRevision === context.revision;
|
|
135
|
+
const search = needsCandidateSearch(previous, auth, context.revision, active, force);
|
|
128
136
|
view = {
|
|
129
|
-
...previous, auth, contextRevision: context.revision,
|
|
137
|
+
...previous, auth, active, contextRevision: context.revision,
|
|
130
138
|
// Background renewal retains the last stable presentation. A user refresh
|
|
131
139
|
// can show progress without flashing every cached result back to loading.
|
|
132
140
|
status: force ? 'loading' : previous?.status || 'loading',
|
|
@@ -134,19 +142,22 @@ export function createOrdinaryAssociationStore({
|
|
|
134
142
|
};
|
|
135
143
|
rememberView(taskId, view);
|
|
136
144
|
try {
|
|
137
|
-
const
|
|
145
|
+
const envelope = search ? await api.candidates(
|
|
138
146
|
{ taskId, context, limit: 10 },
|
|
139
147
|
{ signal: abort.signal },
|
|
140
|
-
);
|
|
148
|
+
) : await api.readBinding(taskId, { signal: abort.signal });
|
|
141
149
|
if (closed || (await api.authRevision()) !== auth)
|
|
142
150
|
throw new ActionAssociationApiError('account_changed');
|
|
143
151
|
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
(snapshot.binding && snapshot.binding.taskId !== taskId)
|
|
152
|
+
search && (envelope.taskId !== taskId || envelope.contextRevision !== context.revision) ||
|
|
153
|
+
(envelope.binding && envelope.binding.taskId !== taskId)
|
|
147
154
|
)
|
|
148
155
|
throw new ActionAssociationApiError('invalid_response');
|
|
149
|
-
const namespace = namespaceFor(webOrigin,
|
|
156
|
+
const namespace = namespaceFor(webOrigin, envelope.scope);
|
|
157
|
+
const reusable = sameContext && previous.namespace === namespace && hasFreshCandidates(previous, now());
|
|
158
|
+
const snapshot = search ? envelope : {
|
|
159
|
+
...(reusable ? previous.snapshot : {}), ...envelope, taskId, contextRevision: context.revision,
|
|
160
|
+
};
|
|
150
161
|
const persisted = await updateRecord(
|
|
151
162
|
root,
|
|
152
163
|
candidate.provider,
|
|
@@ -184,7 +195,10 @@ export function createOrdinaryAssociationStore({
|
|
|
184
195
|
Object.assign(view, {
|
|
185
196
|
namespace,
|
|
186
197
|
snapshot,
|
|
187
|
-
|
|
198
|
+
candidateAuth: search ? auth : previous?.namespace === namespace ? previous?.candidateAuth : null,
|
|
199
|
+
candidateContextRevision: search ? context.revision : previous?.namespace === namespace ? previous?.candidateContextRevision : null,
|
|
200
|
+
candidatesExpireAt: search ? now() + CANDIDATE_DISPLAY_MS : previous?.candidatesExpireAt,
|
|
201
|
+
nextRefresh: snapshot.binding?.action ? now() + 15_000 : active ? now() + 60_000 : Infinity,
|
|
188
202
|
failures: 0,
|
|
189
203
|
status: snapshot.binding?.action
|
|
190
204
|
? 'bound'
|
|
@@ -230,7 +244,8 @@ export function createOrdinaryAssociationStore({
|
|
|
230
244
|
candidate.binding = { ...candidate.binding, taskId };
|
|
231
245
|
if (record.run?.managed) continue;
|
|
232
246
|
const cached = views.get(taskId),
|
|
233
|
-
context = sessionAssociationContext(candidate)
|
|
247
|
+
context = sessionAssociationContext(candidate),
|
|
248
|
+
active = activeConversation(candidate, record);
|
|
234
249
|
if (
|
|
235
250
|
auth &&
|
|
236
251
|
!closed &&
|
|
@@ -238,6 +253,7 @@ export function createOrdinaryAssociationStore({
|
|
|
238
253
|
(!cached ||
|
|
239
254
|
cached.auth !== auth ||
|
|
240
255
|
cached.contextRevision !== context.revision ||
|
|
256
|
+
cached.active !== active ||
|
|
241
257
|
now() >= cached.nextRefresh)
|
|
242
258
|
) {
|
|
243
259
|
const scopedBinding = cached?.auth === auth && cached.snapshot && record.actionAssociation?.authRevision === auth && record.actionAssociation.namespace === cached.namespace;
|
|
@@ -247,7 +263,7 @@ export function createOrdinaryAssociationStore({
|
|
|
247
263
|
}
|
|
248
264
|
const view = views.get(taskId);
|
|
249
265
|
const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
|
|
250
|
-
const snapshot = bindingSnapshot?.contextRevision === context.revision ? bindingSnapshot : null;
|
|
266
|
+
const snapshot = bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
|
|
251
267
|
const local = record.actionAssociation;
|
|
252
268
|
const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
|
|
253
269
|
const newer = scoped &&
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { continueSession, readSessionAutoContinue } from './index.js';
|
|
2
2
|
import { readContinuationContext } from './continuation-analysis.js';
|
|
3
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
4
|
+
|
|
5
|
+
function continuationFailureReason(error) {
|
|
6
|
+
// Provider exceptions can contain private paths/output. Only known codes
|
|
7
|
+
// select actionable UI copy; unrecognized failures retain a safe fallback.
|
|
8
|
+
const reasons = {
|
|
9
|
+
desktop_access_required: 'Sign in to an organization with SQM and Developer access before auto-continue can run.',
|
|
10
|
+
provider_sign_in_required: 'Sign in to the original AI provider, then retry auto-continue.',
|
|
11
|
+
unavailable: 'The local provider is unavailable. Open or configure it, then retry auto-continue.',
|
|
12
|
+
unsupported: 'Update the original AI provider to a compatible version, then retry auto-continue.',
|
|
13
|
+
process_exited: 'The original Claude process has exited. Return to the original conversation, then refresh.',
|
|
14
|
+
session_ended: 'This Claude session has ended. Return to the original conversation, then refresh.',
|
|
15
|
+
hook_missing: 'SIMY has not observed the original Claude session-start hook. Check the plugin and hook execution, then refresh.',
|
|
16
|
+
};
|
|
17
|
+
return Object.hasOwn(reasons, error?.code) ? reasons[error.code] : 'Could not start auto-continue. Check the original conversation and retry.';
|
|
18
|
+
}
|
|
3
19
|
|
|
4
20
|
// Shared by explicit Continue and automatic enrollment. Analysis alone never
|
|
5
21
|
// dispatches work: the exact current provider context is checked again here.
|
|
6
22
|
export async function continueAnalyzedSession({ candidate, manager, root, settings = {}, signal,
|
|
7
23
|
readContext = readContinuationContext, enroll = continueSession, beforeEnroll = () => {} }) {
|
|
24
|
+
const link = await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: settings.expectedProviderEpoch });
|
|
8
25
|
const recommendation = manager.requireRecommendation(candidate);
|
|
9
26
|
if (candidate.isRunning || candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
10
27
|
const context = await readContext(candidate, { signal });
|
|
@@ -14,8 +31,10 @@ export async function continueAnalyzedSession({ candidate, manager, root, settin
|
|
|
14
31
|
}
|
|
15
32
|
manager.requireRecommendation(candidate);
|
|
16
33
|
await beforeEnroll(candidate);
|
|
34
|
+
await requireProviderLink({ root, provider: candidate.provider, expectedEpoch: link.epoch });
|
|
35
|
+
manager.requireRecommendation(candidate);
|
|
17
36
|
if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
|
|
18
|
-
return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
|
|
37
|
+
return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings, expectedProviderEpoch: link.epoch });
|
|
19
38
|
}
|
|
20
39
|
|
|
21
40
|
// One pending enrollment per identity. A failed attempt is retried only after
|
|
@@ -54,6 +73,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
54
73
|
errors.delete(key);
|
|
55
74
|
const controller = new AbortController();
|
|
56
75
|
const guard = async () => {
|
|
76
|
+
await requireProviderLink({ root, provider: candidate.provider });
|
|
57
77
|
const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
|
|
58
78
|
if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
|
|
59
79
|
return setting;
|
|
@@ -67,7 +87,7 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
67
87
|
settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
|
|
68
88
|
}).catch(error => {
|
|
69
89
|
if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
|
|
70
|
-
errors.set(key, { epoch: candidate.autoContinueEpoch, reason:
|
|
90
|
+
errors.set(key, { epoch: candidate.autoContinueEpoch, reason: continuationFailureReason(error) });
|
|
71
91
|
}
|
|
72
92
|
}).finally(() => { pending.delete(key); if (!closed) onChange(); });
|
|
73
93
|
pending.set(key, { job, controller });
|
|
@@ -79,11 +99,20 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
|
|
|
79
99
|
if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
|
|
80
100
|
},
|
|
81
101
|
project(candidate) {
|
|
102
|
+
if (candidate.autoContinue === true && candidate.isRunning !== true &&
|
|
103
|
+
['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged') && candidate.analysis?.state === 'needs_user') return {
|
|
104
|
+
...candidate, autoContinueStatus: 'needs_user', autoContinueReason: candidate.analysis.reason || 'Your decision is needed before automatic continuation can proceed.',
|
|
105
|
+
};
|
|
82
106
|
const error = errors.get(`${candidate.provider}:${candidate.session}`);
|
|
83
107
|
if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
|
|
84
108
|
!['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
|
|
85
109
|
...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
|
|
86
110
|
};
|
|
111
|
+
if (candidate.autoContinue === true && !candidate.isRunning && candidate.capabilities?.canContinue === true) {
|
|
112
|
+
const statuses = { no_action: 'finished', needs_user: 'needs_user', waiting: 'waiting', uncertain: 'error', failed: 'error' };
|
|
113
|
+
const status = statuses[candidate.analysis?.state];
|
|
114
|
+
if (status && candidate.analysis?.reason) return { ...candidate, autoContinueStatus: status, autoContinueReason: candidate.analysis.reason };
|
|
115
|
+
}
|
|
87
116
|
return candidate;
|
|
88
117
|
},
|
|
89
118
|
revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
|
|
@@ -12,7 +12,6 @@ const MAX_PROJECTS = 256;
|
|
|
12
12
|
const MAX_TRANSCRIPTS = 4096;
|
|
13
13
|
const MAX_PREFIX_BYTES = 256 * 1024;
|
|
14
14
|
const MAX_LINES = 256;
|
|
15
|
-
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
16
15
|
// Metadata is observation only. Exact-session/context readers always open the
|
|
17
16
|
// transcript again; cached values contain no conversation message bodies.
|
|
18
17
|
const metadataCache = new Map();
|
|
@@ -99,7 +98,8 @@ async function latestCustomTitle(handle, size, id) {
|
|
|
99
98
|
return title;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false) {
|
|
101
|
+
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal) {
|
|
102
|
+
signal?.throwIfAborted();
|
|
103
103
|
const id = fileName.slice(0, -'.jsonl'.length);
|
|
104
104
|
if (!UUID.test(id)) return null;
|
|
105
105
|
const project = join(root, projectName);
|
|
@@ -108,7 +108,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
108
108
|
let handle;
|
|
109
109
|
try {
|
|
110
110
|
const before = await lstat(path);
|
|
111
|
-
if (!before.isFile() || before.isSymbolicLink()
|
|
111
|
+
if (!before.isFile() || before.isSymbolicLink()) { metadataCache.delete(path); return null; }
|
|
112
112
|
if (!within(root, await realpath(path))) { metadataCache.delete(path); return null; }
|
|
113
113
|
let key;
|
|
114
114
|
if (cacheMetadata) {
|
|
@@ -141,10 +141,11 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
141
141
|
}
|
|
142
142
|
if (!metadata) return null;
|
|
143
143
|
const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
|
|
144
|
-
|
|
144
|
+
signal?.throwIfAborted();
|
|
145
|
+
if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
|
|
145
146
|
if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
|
|
146
147
|
return candidate;
|
|
147
|
-
} catch { metadataCache.delete(path); return null; }
|
|
148
|
+
} catch (error) { metadataCache.delete(path); if (signal?.aborted) throw error; return null; }
|
|
148
149
|
finally { await handle?.close(); }
|
|
149
150
|
}
|
|
150
151
|
|
|
@@ -174,7 +175,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
174
175
|
const file = candidates[next++];
|
|
175
176
|
try {
|
|
176
177
|
const info = await lstat(join(project, file.name));
|
|
177
|
-
if (info.isFile() && !info.isSymbolicLink()
|
|
178
|
+
if (info.isFile() && !info.isSymbolicLink())
|
|
178
179
|
descriptors.push({ projectName: entry.name, fileName: file.name,
|
|
179
180
|
session: file.name.slice(0, -'.jsonl'.length), lastActivityAt: info.mtimeMs });
|
|
180
181
|
} catch { /* A disappearing transcript does not stop discovery. */ }
|
|
@@ -185,13 +186,20 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
185
186
|
const selected = session ? descriptors : recentSessionWindow(descriptors, {
|
|
186
187
|
limit: maximum, priority: item => priorities.has(item.session),
|
|
187
188
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
|
|
190
|
+
let nextFile = 0;
|
|
191
|
+
// Catalog prefixes are bounded independently of full transcript size. Use a
|
|
192
|
+
// small I/O pool; exact-context/statistics reads stay serial and identity-safe.
|
|
193
|
+
await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
|
|
194
|
+
while (nextFile < filesToInspect.length && !signal?.aborted) {
|
|
195
|
+
const file = filesToInspect[nextFile++];
|
|
196
|
+
const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
|
|
197
|
+
if (!candidate) continue;
|
|
198
|
+
const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
|
|
199
|
+
if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
if (signal?.aborted) return [];
|
|
195
203
|
const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
|
|
196
204
|
return session ? sorted : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
|
|
197
205
|
}
|
|
@@ -249,8 +257,10 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
|
|
|
249
257
|
return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
export async function readClaudeStatistics(candidate, { home } = {}) {
|
|
260
|
+
export async function readClaudeStatistics(candidate, { home, signal } = {}) {
|
|
261
|
+
signal?.throwIfAborted();
|
|
253
262
|
if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
|
|
254
|
-
const matches = (await collect({ home, session: candidate.session, includeStatistics: true })).filter(item => item.cwd === candidate.cwd);
|
|
263
|
+
const matches = (await collect({ home, session: candidate.session, includeStatistics: true, signal })).filter(item => item.cwd === candidate.cwd);
|
|
264
|
+
signal?.throwIfAborted();
|
|
255
265
|
return matches.length === 1 ? matches[0].statistics : unavailableStatistics();
|
|
256
266
|
}
|
|
@@ -15,7 +15,9 @@ const ANALYSIS_MAX_LINE = 1024 * 1024;
|
|
|
15
15
|
const ANALYSIS_MAX_TURNS = 16;
|
|
16
16
|
const ANALYSIS_PAGE_SIZE = 6;
|
|
17
17
|
const ANALYSIS_MAX_WIRE_LINE = 8 * 1024 * 1024;
|
|
18
|
-
|
|
18
|
+
// Non-interactive exec and unknown origins are not evidence of a human-created
|
|
19
|
+
// conversation. Explicit bindings remain visible through discoverSessions.
|
|
20
|
+
const SOURCE_KINDS = ['cli', 'vscode', 'appServer'];
|
|
19
21
|
|
|
20
22
|
export class CodexAppServerError extends Error {
|
|
21
23
|
constructor(code, message) {
|
|
@@ -190,7 +192,7 @@ export function codexThreadMetadata(thread) {
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
|
|
195
|
+
export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
|
|
194
196
|
const sessions = [];
|
|
195
197
|
const windowSize = Math.min(maxSessions, maxPages * pageSize);
|
|
196
198
|
let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
|
|
@@ -210,18 +212,39 @@ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25,
|
|
|
210
212
|
listed += threads.length; pages++;
|
|
211
213
|
for (const thread of threads) {
|
|
212
214
|
const metadata = codexThreadMetadata(thread);
|
|
213
|
-
if (metadata) sessions.push(metadata);
|
|
215
|
+
if (metadata && SOURCE_KINDS.includes(metadata.source)) sessions.push(metadata);
|
|
214
216
|
}
|
|
215
217
|
cursor = response?.nextCursor || null;
|
|
216
218
|
if (!cursor) break;
|
|
217
219
|
}
|
|
220
|
+
const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
|
|
221
|
+
// A recent page can omit an explicitly supervised conversation, including
|
|
222
|
+
// a newly created desktop chat. Read its exact metadata without loading or
|
|
223
|
+
// resuming it; only an active rollout path proves that it is not archived.
|
|
224
|
+
if (!archived && prioritySessions.length) {
|
|
225
|
+
const recoveryDeadline = Math.min(deadline, Date.now() + Math.max(1, (deadline - Date.now()) / 3));
|
|
226
|
+
const listedIds = new Set(sessions.map(item => item.session));
|
|
227
|
+
const missing = [...new Set(prioritySessions)].filter(id => !listedIds.has(id));
|
|
228
|
+
let next = 0;
|
|
229
|
+
await Promise.all(Array.from({ length: Math.min(3, missing.length) }, async () => {
|
|
230
|
+
while (next < missing.length && Date.now() < recoveryDeadline) {
|
|
231
|
+
const id = missing[next++];
|
|
232
|
+
try {
|
|
233
|
+
const response = await client.request('thread/read', { threadId: id, includeTurns: false },
|
|
234
|
+
Math.max(1, Math.min(1000, recoveryDeadline - Date.now())));
|
|
235
|
+
const metadata = codexThreadMetadata(response?.thread);
|
|
236
|
+
if (metadata?.session === id && SOURCE_KINDS.includes(metadata.source) &&
|
|
237
|
+
codexArchivePathState(response.thread.path, id, codexHome) === false) sessions.push(metadata);
|
|
238
|
+
} catch { /* Missing or unverifiable metadata remains a retained hook record. */ }
|
|
239
|
+
}
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
218
242
|
if (inspectState) {
|
|
219
243
|
// Status-only pages have no conversation text. A separate app-server often
|
|
220
244
|
// reports notLoaded rather than the original writer's actual turn state.
|
|
221
245
|
// Bound this metadata fan-out independently of the analysis worker pool.
|
|
222
246
|
const priorities = new Set(prioritySessions);
|
|
223
247
|
const ordered = [...sessions].sort((a, b) => Number(priorities.has(b.session)) - Number(priorities.has(a.session)) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
224
|
-
const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
|
|
225
248
|
let next = 0, unavailable = false;
|
|
226
249
|
for (const metadata of sessions) metadata.goalReadState = 'unavailable';
|
|
227
250
|
await Promise.all(Array.from({ length: Math.min(3, sessions.length) }, async () => {
|