@awak-app/simy-cli 0.7.33 → 0.7.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.33",
3
+ "version": "0.7.35",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.33";
17
+ export const CLI_VERSION = "0.7.35";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -7,7 +7,7 @@ import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { webApiHeaders, webApiUrl } from './web-api.js';
9
9
  // Keep this leaf module package-safe: hooks load it before the full CLI contract.
10
- const CLI_VERSION = '0.7.33';
10
+ const CLI_VERSION = '0.7.35';
11
11
 
12
12
  const STORE = 'cli-observability-state.json';
13
13
  const LEGACY_CONFIG = 'cli-observability.json';
@@ -340,7 +340,8 @@ export async function readRecord(root = rootDirectory(), provider, session) {
340
340
  } catch { return null; } // Match listRecords' treatment of unreadable records.
341
341
  }
342
342
  const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
343
- const RECORD_CACHE_LIMIT = 500;
343
+ const RECORD_CACHE_LIMIT = 10_000;
344
+ const RECORD_CACHE_BYTES = 32 * 1024 * 1024;
344
345
  const READ_CONCURRENCY = 8;
345
346
  async function recordNames(root) {
346
347
  try { return (await readdir(root)).filter(name => RECORD_NAME.test(name)).sort(); }
@@ -367,13 +368,16 @@ export async function listRecords(root = rootDirectory()) {
367
368
  // A shared candidate/display index, never mutation authority. Every list checks
368
369
  // metadata so external hooks, removals and direct writes invalidate cached JSON.
369
370
  // Fresh execution reads and updateRecord locks remain independent of this index.
370
- export function createRecordReader(root = rootDirectory()) {
371
+ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECORD_CACHE_LIMIT, cacheBytes = RECORD_CACHE_BYTES } = {}) {
372
+ if (!Number.isSafeInteger(cacheEntries) || cacheEntries < 1 || cacheEntries > RECORD_CACHE_LIMIT ||
373
+ !Number.isSafeInteger(cacheBytes) || cacheBytes < 1 || cacheBytes > RECORD_CACHE_BYTES) throw new Error('Invalid record cache limits.');
371
374
  const cache = new Map();
372
- let inFlight = null, closed = false;
375
+ let inFlight = null, closed = false, retainedBytes = 0;
376
+ const discard = name => { retainedBytes -= cache.get(name)?.bytes || 0; cache.delete(name); };
373
377
  const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
374
378
  const scan = async () => {
375
- const names = await recordNames(root), live = new Set(names.slice(0, RECORD_CACHE_LIMIT));
376
- for (const name of cache.keys()) if (!live.has(name)) cache.delete(name);
379
+ const names = await recordNames(root), live = new Set(names.slice(0, cacheEntries));
380
+ for (const name of cache.keys()) if (!live.has(name)) discard(name);
377
381
  return mapRecordNames(names, async name => {
378
382
  const path = join(root, name);
379
383
  try {
@@ -384,10 +388,14 @@ export function createRecordReader(root = rootDirectory()) {
384
388
  // Do not retain a value when a write races the read; the next list must
385
389
  // inspect it again. This also prevents malformed transient JSON sticking.
386
390
  const after = await stat(path, { bigint: true });
387
- if (live.has(name) && fingerprint(after) === key) cache.set(name, { key, record });
388
- else cache.delete(name);
391
+ discard(name);
392
+ const bytes = Number(after.size);
393
+ if (live.has(name) && bytes <= cacheBytes && fingerprint(after) === key) {
394
+ while (retainedBytes + bytes > cacheBytes && cache.size) discard(cache.keys().next().value);
395
+ cache.set(name, { key, record, bytes }); retainedBytes += bytes;
396
+ }
389
397
  return record;
390
- } catch { cache.delete(name); return null; }
398
+ } catch { discard(name); return null; }
391
399
  });
392
400
  };
393
401
  return {
@@ -398,7 +406,7 @@ export function createRecordReader(root = rootDirectory()) {
398
406
  // cached records or a simultaneous caller's view.
399
407
  return currentProviderConsent(root, structuredClone(await inFlight));
400
408
  },
401
- async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); },
409
+ async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); retainedBytes = 0; },
402
410
  };
403
411
  }
404
412
  function positive(value, fallback, max) {
@@ -82,9 +82,9 @@ import {
82
82
  } from "./desktop-process-lock.js";
83
83
 
84
84
  const silentLogger = { log() {}, error() {} };
85
- // Match the native DesktopStatusFrames limit: 200 conversation summaries can
86
- // exceed 128 KiB, including after a signed runtime handoff.
87
- const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
85
+ // Match the native DesktopStatusFrames limit for full retained histories,
86
+ // including after a signed runtime handoff. Native delivery remains bounded.
87
+ const MAX_DESKTOP_FRAME_BYTES = 64 * 1024 * 1024;
88
88
 
89
89
  async function withReadCancellation(signal, work) {
90
90
  signal.throwIfAborted();
@@ -171,11 +171,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
171
171
  // volatile; reuse the encoded body after comparing freshly authorized data.
172
172
  // Detach the comparison value because registries may mutate nested objects.
173
173
  let encodedStatus = null, lastStatus = null, updateProgress = null;
174
+ let projectCatalogFrame = status => status;
175
+ let resolveFirstStatus;
176
+ const firstStatus = new Promise(resolve => { resolveFirstStatus = resolve; });
177
+ let startupPublication;
174
178
  // Per verified source reader, never accepted from a projection or command.
175
179
  const acceptanceReaderId = randomUUID();
176
180
  let acceptanceReadbackSequence = 0;
177
181
  let remoteHosts;
178
182
  const emitStatus = status => {
183
+ status = projectCatalogFrame(status);
179
184
  const updateSettings = updater?.snapshot?.() || {};
180
185
  status = { ...status, update: { ...status.update,
181
186
  auto_update_supported: updateSettings.auto_update_supported === true,
@@ -207,6 +212,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
207
212
  }
208
213
  const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
209
214
  frames.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
215
+ resolveFirstStatus?.(); resolveFirstStatus = null;
210
216
  };
211
217
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
212
218
  const controllerPreferences = createControllerStore(root);
@@ -224,6 +230,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
224
230
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
225
231
  const deliveryRoot = join(root, "delivery-hooks");
226
232
  const recordReader = createRecordReader(deliveryRoot);
233
+ let frameRecordScope = null, frameRecords = new Map();
234
+ const pendingCatalogPreferences = new Map();
227
235
  let providerLinks = await readProviderLinks(root);
228
236
  const providerLinkBusy = new Set(), providerLinkFeedback = {}, explicitAnalyses = new Set();
229
237
  const linkedSessions = sessions => sessions.filter(item => providerLinks[item.provider]?.connected === true);
@@ -291,6 +299,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
291
299
  nativeCompletionInFlight.clear();
292
300
  }
293
301
  if (previousScope !== accessScope(currentAccess)) {
302
+ frameRecordScope = null; frameRecords.clear();
303
+ pendingCatalogPreferences.clear();
294
304
  statisticsCache.clear();
295
305
  activeStatisticsRead?.controller.abort();
296
306
  explicitAnalyses.clear();
@@ -539,6 +549,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
539
549
  let commands;
540
550
  let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
541
551
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
552
+ let catalogRevision = 0;
542
553
  let enrichmentController = null, sessionEnrichment = null;
543
554
  let catalogAnalysisJob = null, catalogAnalysisAgain = false;
544
555
  const withCurrentSettings = (sessions, records) => {
@@ -636,17 +647,21 @@ export async function startDesktopHost({ input = process.stdin, output = process
636
647
  sessionStatus = { ...sessionStatus, ...value,
637
648
  sessions: linkedSessions(merge(sessionStatus.sessions, value.sessions)).sort(compareSessionWork),
638
649
  selectionCandidates: merge(sessionStatus.selectionCandidates || [], value.selectionCandidates),
639
- initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt };
650
+ initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt, catalogRevision: ++catalogRevision };
640
651
  sessionRefreshedAt = Date.now();
641
652
  void publish().catch(() => {});
642
653
  void refreshCatalogAnalysis();
643
654
  };
644
- sessionRefresh = boundedDiscovery(signal => (dependencies.discoverSessions || discoverSessions)({
655
+ const firstPageTimeoutMs = dependencies.discoveryTimeoutMs ?? 30_000;
656
+ const historyTimeoutMs = dependencies.historyDiscoveryTimeoutMs ?? dependencies.discoveryTimeoutMs ?? 300_000;
657
+ sessionRefresh = boundedDiscovery((signal, startHistory) => (dependencies.discoverSessions || discoverSessions)({
645
658
  root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true,
659
+ readRecords: () => recordReader.list(),
646
660
  connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected),
647
- timeoutMs: dependencies.discoveryTimeoutMs ?? 30_000,
648
- onProgress: value => applyCatalog(value, signal),
649
- }), { signal: controller.signal, timeoutMs: (dependencies.discoveryTimeoutMs ?? 30_000) + 1000, onDrain: dependencies.discoverSessions ? undefined : trackDiscoveryDrain })
661
+ timeoutMs: firstPageTimeoutMs, historyTimeoutMs,
662
+ onProgress: value => { if (value.sessions?.length) startHistory(); return applyCatalog(value, signal); },
663
+ }), { signal: controller.signal, timeoutMs: firstPageTimeoutMs + 1000, historyTimeoutMs: historyTimeoutMs + 1000,
664
+ onDrain: dependencies.discoverSessions ? undefined : trackDiscoveryDrain })
650
665
  .then(async value => {
651
666
  await applyCatalog(value);
652
667
  if (closed || relay || controller.signal.aborted) return;
@@ -678,6 +693,33 @@ export async function startDesktopHost({ input = process.stdin, output = process
678
693
  }
679
694
  return sessionStatus;
680
695
  };
696
+ // Provider metadata is already scoped and cancelable. A connection heartbeat
697
+ // can publish it while optional Action/deadline enrichment owns a slow full
698
+ // projection. These rows do not authorize execution or mint record readbacks.
699
+ projectCatalogFrame = status => {
700
+ if (!desktopAccessReady(status.access) || accessScope(status.access) !== accessScope(currentAccess) ||
701
+ sessionStatus.initialLoading !== false || !sessionStatus.discoveryAttempt) return status;
702
+ const previous = status.sessionSupervision;
703
+ if (!pendingCatalogPreferences.size && previous?.discoveryAttempt?.id === sessionStatus.discoveryAttempt.id &&
704
+ previous.catalogRevision === sessionStatus.catalogRevision && previous.initialLoading === false &&
705
+ previous.historyLoading === sessionStatus.historyLoading) return status;
706
+ const prior = new Map((previous?.sessions || []).map(row => [`${row.provider}:${row.session}`, row]));
707
+ const records = frameRecordScope === accessScope(currentAccess) ? frameRecords : new Map();
708
+ const sessions = linkedSessions(sessionStatus.sessions).map(candidate => {
709
+ const key = `${candidate.provider}:${candidate.session}`, record = pendingCatalogPreferences.get(key) || records.get(key);
710
+ const observed = projectSessionAutoContinue({ ...prior.get(key), ...candidate }, record);
711
+ const analyzed = analysisManager?.project(observed) || observed;
712
+ const row = automaticContinuation?.project(analyzed) || analyzed;
713
+ const projected = { ...row, actionDeadline: row.actionDeadline || { status: 'unavailable', online: false, reminder: null },
714
+ workState: sessionWorkState(row, record?.run, { provider: sessionStatus.providers?.[candidate.provider] }) };
715
+ return { ...projected, conversationPresentation: conversationPresentation(projected, record?.run) };
716
+ }).sort(compareSessionWork);
717
+ const providers = { ...sessionStatus.providers };
718
+ for (const provider of PROVIDER_LINKS) {
719
+ if (!providerLinks[provider].connected) providers[provider] = { discovery: 'disconnected', reason: null };
720
+ }
721
+ return { ...status, sessionSupervision: { ...sessionStatus, sessions, providers } };
722
+ };
681
723
  const claudeDesktopNative = dependencies.claudeDesktopNative || createClaudeDesktopNativeAdapter();
682
724
  const archiveClaudeDesktop = createClaudeDesktopArchiveController({ native: claudeDesktopNative, ...(dependencies.readClaudeDesktopTarget ? { readTarget: dependencies.readClaudeDesktopTarget } : {}) });
683
725
  const selectedSession = async (command, { reuseDiscovery = false, preferObserved = false } = {}) => {
@@ -883,10 +925,24 @@ export async function startDesktopHost({ input = process.stdin, output = process
883
925
  publishAgain = true;
884
926
  return;
885
927
  }
928
+ for (const [key, saved] of pendingCatalogPreferences) {
929
+ const current = await readRecord(deliveryRoot, saved.provider, saved.session);
930
+ if (pendingCatalogPreferences.get(key) === saved) pendingCatalogPreferences.set(key, current || {
931
+ provider: saved.provider, session: saved.session, cwd: saved.cwd,
932
+ autoContinue: false, autoContinueEpoch: null, tracked: false, monitor: false });
933
+ }
934
+ if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
935
+ if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks) { publishAgain = true; return; }
936
+ const preferenceAccess = agent.getDesktopAccess ? agent.getDesktopAccess() : await accessSnapshot();
937
+ if (!sameAccess(preferenceAccess, status.access)) { publishAgain = true; return; }
886
938
  // Select cached record evidence after all asynchronous authorization:
887
939
  // a full projection may have advanced it while those reads were waiting.
888
940
  const sameScope = desktopAccessReady(currentAccess) &&
889
941
  accessScope(lastStatus?.access) === accessScope(currentAccess);
942
+ const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? structuredClone(lastStatus) : null;
943
+ // A heartbeat is a complete frame: retain optional scoped fields until
944
+ // their replacement is ready, while fresh access/provider data wins.
945
+ if (cached) status = { ...cached, ...status };
890
946
  status.deliveryHooks = sameScope && lastStatus?.deliveryHooks
891
947
  ? structuredClone(lastStatus.deliveryHooks) : { runs: [], providers: {} };
892
948
  status.sessionSupervision = sameScope && lastStatus?.sessionSupervision
@@ -896,7 +952,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
896
952
  status.nativeDevelopmentReports = sameScope && sameReportController(nativeDevelopmentReportsController, {
897
953
  scope: accessScope(status.access), controllerScope: controllerScope(status), deviceSession: reportDeviceSession(),
898
954
  }) && nativeDevelopmentReportsScope === accessScope(currentAccess)
899
- ? structuredClone(nativeDevelopmentReports) : { state: 'loading', enrollments: [] };
955
+ ? { ...structuredClone(cached?.nativeDevelopmentReports || {}), ...structuredClone(nativeDevelopmentReports) } : { state: 'loading', enrollments: [] };
900
956
  // Cached enrichment must follow the current connection preference even
901
957
  // while a full projection is waiting on records or remote metadata.
902
958
  status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
@@ -936,6 +992,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
936
992
  if (preferenceScope) await controllerPreferences.load(preferenceScope);
937
993
  const requestedLinks = await decorateProviderSettings(status);
938
994
  const allRecords = await recordReader.list();
995
+ if (accessScope(currentAccess) === requestedScope) {
996
+ // Keep explicit preference overrides until the scope changes. A full
997
+ // projection that started before the save can finish after this read.
998
+ // Heartbeats refresh these identities from disk, including external OFF.
999
+ frameRecordScope = requestedScope;
1000
+ frameRecords = new Map(allRecords.map(record => [`${record.provider}:${record.session}`, record]));
1001
+ }
1002
+ // Begin the usable catalog before optional record/Action enrichment.
1003
+ refreshSessions();
939
1004
  const records = linkedSessions(allRecords);
940
1005
  const update = status.update || updater.snapshot?.() || {};
941
1006
  if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(allRecords);
@@ -966,7 +1031,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
966
1031
  if (reportController.controllerScope) void syncEmailPreferences(reportController);
967
1032
  const preferenceState = reportController.controllerScope ? await controllerPreferences.load(reportController.controllerScope) : null;
968
1033
  status.notificationPreferences = preferenceState?.notificationPreferences || {};
969
- status.nativeDevelopmentReports = { ...structuredClone(sameReportController(reportController, nativeDevelopmentReportsController) ? nativeDevelopmentReports : { state: 'loading', enrollments: [] }), deliveries: Object.values(preferenceState?.conversations || {}).flatMap(row => Object.values(row.emailDeliveries || {})), preferences: Object.values(preferenceState?.conversations || {}).flatMap(row => row.emailPreference ? [{ ...row.emailPreference.request, sync: row.emailPreference.state }] : []) };
1034
+ status.nativeDevelopmentReports = { ...structuredClone(sameReportController(reportController, nativeDevelopmentReportsController) ? nativeDevelopmentReports : { state: 'loading', enrollments: [] }), deliveries: Object.values(preferenceState?.conversations || {}).flatMap(row => Object.values(row.emailDeliveries || {})).map(({ receipt_id, ...delivery }) => delivery), preferences: Object.values(preferenceState?.conversations || {}).flatMap(row => row.emailPreference ? [{ ...row.emailPreference.request, sync: row.emailPreference.state }] : []) };
970
1035
  // This is an asynchronous local attestation. It neither changes a
971
1036
  // Delivery Loop nor places receipt or device data in status frames.
972
1037
  let reportCompletions = [];
@@ -1266,7 +1331,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
1266
1331
  }
1267
1332
  } });
1268
1333
  await refreshPlugins();
1269
- await publish();
1334
+ startupPublication = publish();
1335
+ void startupPublication.catch(() => {});
1270
1336
  const maintenance = new Map();
1271
1337
  const tick = worker => {
1272
1338
  if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
@@ -1920,8 +1986,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1920
1986
  if (command.enabled) await authorizeWork(commandScope);
1921
1987
  const saved = await (dependencies.setSessionAutoContinue || saveSessionAutoContinuePreference)({ root: join(root, 'delivery-hooks'),
1922
1988
  provider: command.provider, session: command.session, enabled: command.enabled, initialContinuationPolicy: command.continuationPolicy, admission, target, expectedProviderEpoch: providerEpoch });
1923
- if (saved) sessionStatus = { ...sessionStatus,
1924
- sessions: sessionStatus.sessions.map(candidate => projectSessionAutoContinue(candidate, saved)) };
1989
+ if (saved) {
1990
+ pendingCatalogPreferences.set(`${saved.provider}:${saved.session}`, saved);
1991
+ sessionStatus = { ...sessionStatus, catalogRevision: ++catalogRevision,
1992
+ sessions: sessionStatus.sessions.map(candidate => projectSessionAutoContinue(candidate, saved)) };
1993
+ }
1925
1994
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
1926
1995
  // Acknowledge the durable preference before discovery or analysis.
1927
1996
  emit({ type: "result", id: command.id, ok: true });
@@ -2252,6 +2321,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
2252
2321
  ownerTransport = await createOwnerTransport({ root, nonce: lock.nonce, allowedActions, allowControllerArchive: env.SIMY_REMOTE_CHILD !== '1', dispatch: command => commands.emit('line', JSON.stringify(command)) });
2253
2322
  await publish();
2254
2323
  }
2324
+ // Register controls before awaiting initial presentation. A fast, authorized
2325
+ // catalog frame is sufficient; unrelated optional enrichment may still wait.
2326
+ try { await Promise.race([startupPublication, firstStatus]); }
2327
+ catch (error) { await close(); throw error; }
2255
2328
  return { close, publish, agent };
2256
2329
  }
2257
2330
 
@@ -235,11 +235,12 @@ export function codexThreadMetadata(thread) {
235
235
  };
236
236
  }
237
237
 
238
- export async function listCodexSessions({ client, all = false, signal, onPage, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
238
+ export async function listCodexSessions({ client, all = false, signal, onPage, maxPages = 40, pageSize = 25, historyPageSize = 200, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
239
239
  const sessions = [];
240
240
  const windowSize = all ? Infinity : Math.min(maxSessions, maxPages * pageSize);
241
241
  const cursors = new Set(), ids = new Set();
242
242
  let cursor = null, listed = 0, pages = 0, batchSize = pageSize, nextProgress = 0;
243
+ let reducedBatch = false;
243
244
  try {
244
245
  while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
245
246
  signal?.throwIfAborted();
@@ -251,6 +252,7 @@ export async function listCodexSessions({ client, all = false, signal, onPage, m
251
252
  // Keep the transport's byte limit. Retry the same cursor in smaller
252
253
  // batches rather than dropping discovery for large metadata envelopes.
253
254
  if (error?.code !== 'response_too_large' || limit <= 1) throw error;
255
+ reducedBatch = true;
254
256
  batchSize = Math.max(1, Math.floor(limit / 2));
255
257
  continue;
256
258
  }
@@ -267,6 +269,10 @@ export async function listCodexSessions({ client, all = false, signal, onPage, m
267
269
  if (onPage && (pages === 1 || sessions.length >= nextProgress)) {
268
270
  await onPage(sessions); nextProgress = Math.max(100, sessions.length * 2);
269
271
  }
272
+ // Keep the first frame small, then amortize expensive metadata RPCs over
273
+ // bounded history pages. The transport byte ceiling still wins: once a
274
+ // response is oversized, retain its reduced batch for the rest of the scan.
275
+ if (all && pages === 1 && !reducedBatch) batchSize = Math.max(pageSize, Math.min(200, historyPageSize));
270
276
  cursor = response?.nextCursor || null;
271
277
  if (!cursor) break;
272
278
  if (typeof cursor !== 'string' || cursors.has(cursor))
@@ -165,20 +165,25 @@ export async function discoverSessions(options = {}) {
165
165
  }
166
166
 
167
167
  async function discoverSessionCatalog({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
168
- inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false, catalogSnapshot, onProgress } = {}) {
168
+ inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false, catalogSnapshot, recordSnapshot, archiveReconciliations = new Map(), readRecords = listRecords, onProgress } = {}) {
169
169
  const now = requestedNow ?? Date.now();
170
170
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
171
171
  assertActive();
172
172
  const connected = new Set(connectedProviders);
173
- const records = (await listRecords(root)).filter(record => connected.has(record.provider));
173
+ const records = (recordSnapshot || await readRecords(root)).filter(record => connected.has(record.provider));
174
174
  const priorities = records.filter(retainedSession);
175
175
  let archivedCodex = [];
176
176
  let discovered = [], codexError = null, codexReasonCode = null, claude = [], claudeError = null;
177
177
  let archiveStates = new Map();
178
178
  const progress = async snapshot => {
179
179
  if (!onProgress || signal?.aborted) return;
180
+ const observed = new Set([...(snapshot.discovered || []), ...(snapshot.claude || []), ...(snapshot.archivedCodex || [])]
181
+ .map(item => `${item.provider}:${item.session}`));
180
182
  const value = await discoverSessionCatalog({ root, inspectProcess, now: requestedNow, signal, connectedProviders, includeArchived, codexHome,
181
- catalogSnapshot: snapshot, listCodexChildren: async () => [], listCodexTitles: async () => new Map() });
183
+ // Pages reuse this attempt's records. Do not rescan or project the entire
184
+ // retained corpus for every page; the complete catalog still includes it.
185
+ catalogSnapshot: snapshot, recordSnapshot: records.filter(record => observed.has(`${record.provider}:${record.session}`)), archiveReconciliations,
186
+ listCodexChildren: async () => [], listCodexTitles: async () => new Map() });
182
187
  if (!signal?.aborted) await onProgress(value);
183
188
  };
184
189
  if (catalogSnapshot) {
@@ -234,11 +239,22 @@ async function discoverSessionCatalog({ root, openClient = openCodexAppServer, l
234
239
  const claudeItem = record.provider === 'claude' ? claude.find(item => item.session === record.session && item.cwd === record.cwd) : null;
235
240
  const archiveState = record.provider === 'codex' ? archiveStates.get(record.session) ?? (activeCodex.has(record.session) ? false : undefined) : claudeItem?.archived;
236
241
  if (typeof archiveState !== 'boolean') continue;
242
+ const key = `${record.provider}:${record.session}`;
243
+ const reconciled = archiveReconciliations.get(key);
244
+ if (reconciled?.archived === archiveState) {
245
+ records[index] = reconciled.record;
246
+ continue;
247
+ }
248
+ // Pages repeat an ever-growing prefix. Persist positive archive fencing
249
+ // before exposing it once, rather than repeatedly refreshing the same old
250
+ // observation and contending with hook writers on every page. This map is
251
+ // private to one scoped attempt; execution still reads fresh durable state.
252
+ const remember = () => archiveReconciliations.set(key, { archived: archiveState, record: records[index] });
237
253
  const isArchived = archiveState;
238
254
  if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
239
- if (isArchived && record.providerArchived === true) continue;
240
- if (!isArchived && !(record.providerArchived === true && archiveState === false)) continue;
241
- try { records[index] = await updateRecord(root, record.provider, record.session, current => {
255
+ if (isArchived && record.providerArchived === true) { remember(); continue; }
256
+ if (!isArchived && !(record.providerArchived === true && archiveState === false)) { remember(); continue; }
257
+ try { const updated = await updateRecord(root, record.provider, record.session, current => {
242
258
  if (!current) return current;
243
259
  current.providerArchived = isArchived;
244
260
  if (isArchived) {
@@ -251,11 +267,16 @@ async function discoverSessionCatalog({ root, openClient = openCodexAppServer, l
251
267
  }
252
268
  return current;
253
269
  });
270
+ if (updated) records[index] = updated;
254
271
  if (!isArchived) await recordProviderArchive(root, record.provider, record.session, false, now);
272
+ remember();
255
273
  } catch {
256
274
  // Reconciliation can contend with a hook writer or an unreclaimable
257
275
  // legacy lock. Preserve stored state and retry on the next refresh;
258
276
  // one record must not discard either provider's entire catalog.
277
+ // A successfully persisted positive fence already revokes execution.
278
+ // Retry its JSON reconciliation on the next attempt, not each old prefix.
279
+ if (isArchived) remember();
259
280
  }
260
281
  }
261
282
  assertActive();
@@ -18,7 +18,10 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
18
18
  let latest;
19
19
  const send = (peer, frame) => {
20
20
  if (peer.destroyed) return;
21
- if (frame.type !== 'status' && peer.writableLength > 8 * 1024 * 1024) { peer.destroy(); return; }
21
+ // A valid full-history status can already occupy up to 64 MiB of the
22
+ // socket queue. Preserve room for bounded control receipts rather than
23
+ // disconnecting the viewer as soon as it requests a result behind it.
24
+ if (frame.type !== 'status' && peer.writableLength > (64 + 8) * 1024 * 1024) { peer.destroy(); return; }
22
25
  writers.get(peer).write(JSON.stringify(frame) + '\n', frame.type === 'status');
23
26
  };
24
27
  const server = createServer(peer => {
@@ -1,7 +1,9 @@
1
1
  // A deadline releases the caller even when an adapter ignores cancellation.
2
2
  // The adapter still receives an abort signal; late values never reach the UI.
3
- export async function boundedDiscovery(work, { signal, timeoutMs = 30_000, onDrain } = {}) {
3
+ export async function boundedDiscovery(work, { signal, timeoutMs = 30_000, historyTimeoutMs = timeoutMs, onDrain } = {}) {
4
4
  const controller = new AbortController();
5
+ const startedAt = Date.now();
6
+ let historyStarted = false;
5
7
  let timer, rejectStop;
6
8
  const stopped = new Promise((_, reject) => { rejectStop = reject; });
7
9
  const cancel = () => {
@@ -9,14 +11,23 @@ export async function boundedDiscovery(work, { signal, timeoutMs = 30_000, onDra
9
11
  controller.abort(error); rejectStop(error);
10
12
  };
11
13
  signal?.addEventListener('abort', cancel, { once: true });
12
- timer = setTimeout(() => {
14
+ const expire = () => {
13
15
  const error = Object.assign(new Error('Conversation discovery timed out. Refresh to retry.'), { code: 'discovery_timeout' });
14
16
  controller.abort(error); rejectStop(error);
15
- }, timeoutMs);
17
+ };
18
+ timer = setTimeout(expire, timeoutMs);
19
+ const startHistory = () => {
20
+ if (historyStarted || controller.signal.aborted) return;
21
+ historyStarted = true;
22
+ clearTimeout(timer);
23
+ // The background budget is measured from the original request. Repeated
24
+ // pages cannot postpone the absolute deadline indefinitely.
25
+ timer = setTimeout(expire, Math.max(0, startedAt + Math.max(timeoutMs, historyTimeoutMs) - Date.now()));
26
+ };
16
27
  try {
17
28
  if (signal?.aborted) cancel();
18
29
  const operation = Promise.resolve().then(() => {
19
- controller.signal.throwIfAborted(); return work(controller.signal);
30
+ controller.signal.throwIfAborted(); return work(controller.signal, startHistory);
20
31
  });
21
32
  onDrain?.(operation.then(() => {}, () => {}));
22
33
  return await Promise.race([stopped, operation]);
@@ -27,7 +38,7 @@ export async function boundedDiscovery(work, { signal, timeoutMs = 30_000, onDra
27
38
  }
28
39
  }
29
40
 
30
- export async function discoverProgressively({ discover, onProgress, signal, timeoutMs = 30_000,
41
+ export async function discoverProgressively({ discover, onProgress, signal, timeoutMs = 30_000, historyTimeoutMs = timeoutMs,
31
42
  connectedProviders = ['codex', 'claude'], onDrain, ...options }) {
32
43
  const values = new Map(), complete = new Set(), settled = new Set();
33
44
  let notifications = Promise.resolve();
@@ -46,10 +57,12 @@ export async function discoverProgressively({ discover, onProgress, signal, time
46
57
  };
47
58
  await Promise.all(connectedProviders.map(async provider => {
48
59
  try {
49
- const value = await boundedDiscovery(childSignal => discover({ ...options, signal: childSignal,
60
+ const value = await boundedDiscovery((childSignal, startHistory) => discover({ ...options, signal: childSignal,
50
61
  connectedProviders: [provider], onProgress: async value => {
51
- childSignal.throwIfAborted(); values.set(provider, value); await notify();
52
- } }), { signal, timeoutMs, onDrain });
62
+ childSignal.throwIfAborted();
63
+ if (value.sessions?.length) startHistory();
64
+ values.set(provider, value); await notify();
65
+ } }), { signal, timeoutMs, historyTimeoutMs, onDrain });
53
66
  signal?.throwIfAborted();
54
67
  values.set(provider, value);
55
68
  if (['ready', 'local_index'].includes(value.providers?.[provider]?.discovery)) complete.add(provider);
@@ -9,7 +9,7 @@ import { compareSessionWork } from './work-state.js';
9
9
  import { manualAcceptanceCommand, matchesManualAcceptanceControls, manualAcceptanceContinuation } from './manual-acceptance-command.js';
10
10
  import { remoteHostRecovery } from './remote-host-recovery.js';
11
11
 
12
- const MAX_FRAME = 8 * 1024 * 1024;
12
+ const MAX_FRAME = 64 * 1024 * 1024;
13
13
  const MAX_HOSTS = 8;
14
14
  const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
15
15
  // Resolve the saved alias on every connection. A user's multiplexed SSH master
@@ -16,7 +16,8 @@ const profiles = {
16
16
  // finite catalog-owned deadline while allowing slow local process I/O.
17
17
  { id: 'repair-and-stop-boundaries', timeoutMs: 1_200_000, files: ['test/purpose-search.test.js', 'test/delivery-explicit-resume.test.js', 'test/purpose-continuation.test.js'] },
18
18
  { id: 'sqm-runner', files: ['test/sqm-local-tests.test.js'] },
19
- { id: 'provider-startup', files: ['test/codex-app-server-discovery.test.js', 'test/session-supervision.test.js'] },
19
+ { id: 'provider-startup', files: ['test/codex-app-server-discovery.test.js', 'test/session-supervision.test.js',
20
+ 'test/progressive-discovery.test.js', 'test/desktop-record-index.test.js', 'test/desktop-background-performance.test.js'] },
20
21
  { id: 'handoff-and-audit-recovery', files: ['test/consistency-handoff.test.js', 'test/consistency-monitor.test.js', 'test/consistency-auditor.test.js', 'test/desktop-host.test.js'] },
21
22
  { id: 'migration-and-current-outcomes', files: ['test/autonomous-upgrade.test.js', 'test/local-delivery-runtime.test.js', 'test/delivery-migration-crash.test.js', 'test/purpose-inspection.test.js', 'test/purpose-search-scenarios.test.js', 'test/sqm-large-diff.test.js'] },
22
23
  ],