@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.
Files changed (37) hide show
  1. package/README.md +91 -6
  2. package/package.json +1 -1
  3. package/src/agent.js +97 -23
  4. package/src/auto-update.js +55 -6
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/connection-reminder.mjs +5 -2
  7. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +30 -3
  8. package/src/codex-plugins/simy/hooks/dev-work.mjs +7 -1
  9. package/src/codex-plugins/simy/hooks/hooks.json +2 -2
  10. package/src/codex-work-pattern-sync.js +37 -9
  11. package/src/delivery-hooks/command.js +9 -2
  12. package/src/delivery-hooks/desktop.js +10 -12
  13. package/src/delivery-hooks/evidence.js +50 -0
  14. package/src/delivery-hooks/supervisor.js +8 -2
  15. package/src/desktop-access.js +5 -3
  16. package/src/desktop-host.js +511 -74
  17. package/src/desktop-update-stop.js +22 -0
  18. package/src/doctor.js +2 -1
  19. package/src/host-exit-diagnostics.js +28 -0
  20. package/src/index.js +1 -0
  21. package/src/provider-links.js +74 -0
  22. package/src/session-supervision/action-association-host.js +23 -10
  23. package/src/session-supervision/association-refresh.js +12 -0
  24. package/src/session-supervision/association-store.js +26 -10
  25. package/src/session-supervision/automatic-continuation.js +31 -2
  26. package/src/session-supervision/claude-sessions.js +25 -15
  27. package/src/session-supervision/codex-app-server.js +27 -4
  28. package/src/session-supervision/index.js +163 -42
  29. package/src/session-supervision/statistics.js +76 -15
  30. package/src/sqm/command.js +58 -7
  31. package/src/sqm/evidence-client.js +11 -1
  32. package/src/sqm/failure-circuit.js +28 -6
  33. package/src/sqm/history-store.js +49 -18
  34. package/src/sqm/provider-diagnostics.js +1 -0
  35. package/src/sqm/review-worker.js +70 -21
  36. package/src/sqm/workflow-update.js +12 -3
  37. 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
- if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
94
- if (queue && !view.error && view.snapshot && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
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
- view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
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 snapshot = request ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
104
- const namespace = await importEnvelope(record, snapshot, expected);
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
- view.candidates = (snapshot.candidates || []).map(publicCandidate);
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 = request ? snapshot : null;
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.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
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 snapshot = await api.candidates(
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
- snapshot.taskId !== taskId ||
145
- snapshot.contextRevision !== context.revision ||
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, snapshot.scope);
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
- nextRefresh: now() + (snapshot.binding?.action ? 15_000 : 60_000),
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: 'Could not start auto-continue. Check the original conversation and retry.' });
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() || before.size > MAX_FILE_BYTES) { metadataCache.delete(path); return null; }
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
- if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id);
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() && info.size <= MAX_FILE_BYTES)
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
- for (const file of selected.slice(0, MAX_TRANSCRIPTS)) {
189
- if (signal?.aborted) return [];
190
- const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics);
191
- if (!candidate) continue;
192
- const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
193
- if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
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
- const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
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 () => {