@awak-app/simy-cli 0.6.16 → 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/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
+ }
@@ -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 () => {