codesesh 0.19.0 → 1.0.1

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 (71) hide show
  1. package/README.md +29 -5
  2. package/dist/{chunk-G2BTNW3C.js → chunk-JHX5OLL4.js} +10175 -7365
  3. package/dist/chunk-JHX5OLL4.js.map +1 -0
  4. package/dist/{chunk-KD3DGWZY.js → chunk-OFTTM3UD.js} +80 -16
  5. package/dist/chunk-OFTTM3UD.js.map +1 -0
  6. package/dist/{dist-ALXOBKV2.js → dist-N2PQATP2.js} +42 -4
  7. package/dist/index.js +1862 -457
  8. package/dist/index.js.map +1 -1
  9. package/dist/scan-refresh-worker.js +274 -95
  10. package/dist/scan-refresh-worker.js.map +1 -1
  11. package/dist/search-index-worker.js +112 -52
  12. package/dist/search-index-worker.js.map +1 -1
  13. package/dist/smart-tag-worker.js +5 -3
  14. package/dist/smart-tag-worker.js.map +1 -1
  15. package/dist/web/assets/ErrorBoundary-D11tWzfn.js +1 -0
  16. package/dist/web/assets/InteractiveReceipt-DYGyHeZd.js +1 -0
  17. package/dist/web/assets/OverviewScreen-DriPmCam.js +1 -0
  18. package/dist/web/assets/PrismHighlighter-BZkkX0AU.js +6 -0
  19. package/dist/web/assets/Projects-BPDIcUBK.js +1 -0
  20. package/dist/web/assets/SearchFilterBar-Bx73B5e9.js +1 -0
  21. package/dist/web/assets/SearchResultsPanel-A6_3TPhW.js +1 -0
  22. package/dist/web/assets/SessionDetail-lQz99ARH.js +54 -0
  23. package/dist/web/assets/{session-title-CNhapdPJ.js → SmartTagChips-ux6idUGJ.js} +1 -1
  24. package/dist/web/assets/agents-BkRP_ww3.js +1 -0
  25. package/dist/web/assets/code-metrics-UYFsCC7E.js +1 -0
  26. package/dist/web/assets/contract-V6QuD0UY.js +1 -0
  27. package/dist/web/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  28. package/dist/web/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  29. package/dist/web/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  30. package/dist/web/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  31. package/dist/web/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  32. package/dist/web/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  33. package/dist/web/assets/index-B2RqUgZ5.css +1 -0
  34. package/dist/web/assets/index-BgWoUD_4.js +1410 -0
  35. package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  36. package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  37. package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  38. package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  39. package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  40. package/dist/web/assets/jsx-runtime-B-hcVAMW.js +1 -0
  41. package/dist/web/assets/panel-C-gc79Gz.js +1 -0
  42. package/dist/web/assets/session-indexes-0oT5HSS5.js +4 -0
  43. package/dist/web/assets/session-title-DdtlxtlR.js +1 -0
  44. package/dist/web/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2 +0 -0
  45. package/dist/web/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  46. package/dist/web/assets/space-grotesk-vietnamese-wght-normal-D0rl6rjA.woff2 +0 -0
  47. package/dist/web/assets/utils-DgUgu15E.js +1 -0
  48. package/dist/web/icon/agent/grok.svg +1 -0
  49. package/dist/web/index.html +45 -54
  50. package/dist/web/theme-bootstrap.js +19 -0
  51. package/package.json +16 -16
  52. package/dist/chunk-G2BTNW3C.js.map +0 -1
  53. package/dist/chunk-KD3DGWZY.js.map +0 -1
  54. package/dist/web/assets/AgentIcon-CEj7HO1m.js +0 -1
  55. package/dist/web/assets/BookmarkButton-DLEnIyeE.js +0 -1
  56. package/dist/web/assets/Dashboard-DUG0IFcy.js +0 -39
  57. package/dist/web/assets/DialogTitle-Dv6DQPIb.js +0 -1
  58. package/dist/web/assets/InteractiveReceipt-xnRqJvfd.js +0 -1
  59. package/dist/web/assets/PrismHighlighter-C7OfXHq2.js +0 -6
  60. package/dist/web/assets/Projects-BMaZc6J5.js +0 -1
  61. package/dist/web/assets/SearchFilterBar-iP6pzlZo.js +0 -1
  62. package/dist/web/assets/SearchResultsPanel-RuiLPkbP.js +0 -1
  63. package/dist/web/assets/SessionDetail-De1Wl3mu.js +0 -54
  64. package/dist/web/assets/index-B-CV7pFu.css +0 -2
  65. package/dist/web/assets/index-CQI8F_8X.js +0 -1930
  66. package/dist/web/assets/index.dom-CCLJ9_FR.js +0 -1
  67. package/dist/web/assets/jsx-runtime-n5LQ9ujS.js +0 -1
  68. package/dist/web/assets/session-indexes-BuxBafvh.js +0 -4
  69. package/dist/web/assets/useTheme-CwDcVdGz.js +0 -1
  70. package/dist/web/assets/utils-B6KiDbIe.js +0 -1
  71. /package/dist/{dist-ALXOBKV2.js.map → dist-N2PQATP2.js.map} +0 -0
package/dist/index.js CHANGED
@@ -2,20 +2,24 @@
2
2
  import {
3
3
  appLogger,
4
4
  logSearchIndexSync
5
- } from "./chunk-KD3DGWZY.js";
5
+ } from "./chunk-OFTTM3UD.js";
6
6
  import {
7
7
  FileSystemSessionSource,
8
8
  SessionAliasValidationError,
9
9
  StateStorageUnavailableError,
10
10
  addCalendarDays,
11
11
  attachMissingProjectIdentities,
12
- attachProjectMetrics,
12
+ attachProjectMetricsFromTree,
13
13
  buildAgentCacheMeta,
14
14
  buildDashboard,
15
+ buildSessionTree,
16
+ closeCacheStorage,
15
17
  computeSessionDiff,
16
18
  countCalendarDays,
17
19
  createProjectScopeMatcher,
18
20
  createRegisteredAgents,
21
+ createSessionIndex,
22
+ createSessionProjectionContext,
19
23
  deleteBookmark,
20
24
  deleteSessionAlias,
21
25
  executeSessionSearch,
@@ -24,23 +28,25 @@ import {
24
28
  formatSessionReference,
25
29
  getAgentFullSyncCursor,
26
30
  getAgentInfoMap,
27
- getAgentLastFullSyncAt,
31
+ getPricingGeneration,
28
32
  getSessionActivityTime,
29
33
  getSessionAgentKey,
34
+ getSessionRouteKey,
30
35
  importBookmarks,
31
- isAgentCacheInitialized,
32
36
  isProjectIdentityKind,
33
37
  listBookmarks,
34
38
  listCachedProjectGroups,
35
39
  listFileActivity,
40
+ listModelCostDistribution,
36
41
  listSessionAliases,
42
+ loadCachedSessionHeads,
37
43
  loadCachedSessions,
38
- markAgentCacheInitialized,
39
44
  markAgentFullSyncCompleted,
40
45
  markAgentFullSyncProgress,
41
46
  markAgentFullSyncStarted,
42
47
  matchesProjectIdentity,
43
48
  matchesProjectScope,
49
+ materializeBookmarkViews,
44
50
  materializeSessionDetailResponse,
45
51
  mergeSearchQueryOptions,
46
52
  mergeSessionsUpdatedEvents,
@@ -48,9 +54,10 @@ import {
48
54
  normalizeSessionReference,
49
55
  perf,
50
56
  publishPendingPricing,
57
+ readAgentCacheInitialization,
58
+ readAgentLastFullSyncAt,
59
+ readPendingSearchIndexMaintenance,
51
60
  refreshPricingCache,
52
- saveCachedSessionChanges,
53
- saveCachedSessions,
54
61
  scanSessions,
55
62
  sessionRoutePath,
56
63
  sessionSignature,
@@ -58,7 +65,7 @@ import {
58
65
  startOfCalendarDay,
59
66
  upsertBookmark,
60
67
  upsertSessionAlias
61
- } from "./chunk-G2BTNW3C.js";
68
+ } from "./chunk-JHX5OLL4.js";
62
69
 
63
70
  // src/index.ts
64
71
  import { defineCommand, runMain } from "citty";
@@ -67,6 +74,7 @@ import { defineCommand, runMain } from "citty";
67
74
  import { Hono as Hono2 } from "hono";
68
75
  import { bodyLimit } from "hono/body-limit";
69
76
  import { compress } from "hono/compress";
77
+ import { secureHeaders } from "hono/secure-headers";
70
78
  import { serve } from "@hono/node-server";
71
79
  import { serveStatic } from "@hono/node-server/serve-static";
72
80
  import { existsSync } from "fs";
@@ -149,8 +157,9 @@ var SMART_TAGS = [
149
157
  "exploration",
150
158
  "planning"
151
159
  ];
152
- var SEARCH_LIMIT_MAX = 100;
153
- var SEARCH_LIMIT_DEFAULT = 50;
160
+ var SEARCH_LIMIT_POLICY = { defaultValue: 50, maxValue: 100 };
161
+ var FILE_ACTIVITY_LIMIT_POLICY = { defaultValue: 50, maxValue: 200 };
162
+ var SESSION_PAGE_LIMIT_POLICY = { defaultValue: 250, maxValue: 500 };
154
163
  function searchParams(c) {
155
164
  return new URL(c.req.url ?? "http://localhost/", "http://localhost/").searchParams;
156
165
  }
@@ -164,15 +173,51 @@ function optionalQueryValue(value) {
164
173
  return normalized ? normalized : void 0;
165
174
  }
166
175
  function parseDateParam(value, fallback) {
167
- if (value == null) return fallback;
168
- const ts = new Date(value).getTime();
169
- return Number.isNaN(ts) ? fallback : ts;
176
+ const normalized = optionalQueryValue(value);
177
+ if (normalized == null) return { kind: "default", value: fallback };
178
+ const timestamp = new Date(normalized).getTime();
179
+ return Number.isNaN(timestamp) ? { kind: "invalid", error: "must be a valid date" } : { kind: "valid", value: timestamp };
180
+ }
181
+ function parseDateWindow(params, defaults) {
182
+ const from = parseDateParam(params.get("from") ?? void 0, defaults.from);
183
+ if (from.kind === "invalid") return { ...from, parameter: "from" };
184
+ const to = parseDateParam(params.get("to") ?? void 0, defaults.to);
185
+ if (to.kind === "invalid") return { ...to, parameter: "to" };
186
+ if (from.value != null && to.value != null && from.value > to.value) {
187
+ return { kind: "invalid", parameter: "from", error: "must not be after to" };
188
+ }
189
+ return { kind: "valid", from: from.value, to: to.value };
170
190
  }
171
191
  function parseNumberParam(value) {
172
192
  if (value == null || !value.trim()) return void 0;
173
193
  const number = Number(value);
174
194
  return Number.isFinite(number) ? number : void 0;
175
195
  }
196
+ function parseAgentFilter(value, knownAgentNames) {
197
+ if (value === null) return { kind: "all" };
198
+ const normalized = value.trim().toLowerCase();
199
+ if (!normalized) return { kind: "unknown" };
200
+ for (const agentName of knownAgentNames) {
201
+ if (agentName.toLowerCase() === normalized) return { kind: "known", agentName };
202
+ }
203
+ return { kind: "unknown" };
204
+ }
205
+ function parseLimit(value, policy) {
206
+ if (value === null) return { kind: "default", value: policy.defaultValue };
207
+ const normalized = value.trim();
208
+ if (!/^\d+$/.test(normalized)) {
209
+ return { kind: "invalid", error: "limit must be a positive integer" };
210
+ }
211
+ const digits = normalized.replace(/^0+/, "");
212
+ if (!digits) return { kind: "invalid", error: "limit must be a positive integer" };
213
+ const maxDigits = String(policy.maxValue);
214
+ const exceedsMaximum = digits.length > maxDigits.length || digits.length === maxDigits.length && digits > maxDigits;
215
+ return { kind: "valid", value: exceedsMaximum ? policy.maxValue : Number(digits) };
216
+ }
217
+ function parseSessionQuery(params, knownAgentNames, limitPolicy) {
218
+ const agent = parseAgentFilter(params.get("agent"), knownAgentNames);
219
+ return limitPolicy ? { agent, limit: parseLimit(params.get("limit"), limitPolicy) } : { agent };
220
+ }
176
221
  function parseSmartTags(values) {
177
222
  const tags = values.map((value) => value.toLowerCase()).filter((value) => SMART_TAGS.includes(value));
178
223
  return tags.length > 0 ? [...new Set(tags)] : void 0;
@@ -190,11 +235,10 @@ function parseProjectIdentityFilter(kindValue, keyValue) {
190
235
  if (!kind || !key || !isProjectIdentityKind(kind)) return null;
191
236
  return { kind, key };
192
237
  }
193
- function parseSearchOptions(c, defaults, projectIdentity) {
238
+ function parseSearchOptions(c, window, request, projectIdentity) {
194
239
  const params = searchParams(c);
195
- const limitValue = parseNumberParam(params.get("limit") ?? void 0);
196
240
  return {
197
- agent: optionalQueryValue(params.get("agent") ?? void 0),
241
+ agent: request.agent,
198
242
  project: optionalQueryValue(params.get("project") ?? void 0),
199
243
  projectKind: projectIdentity?.kind,
200
244
  projectKey: projectIdentity?.key,
@@ -207,15 +251,72 @@ function parseSearchOptions(c, defaults, projectIdentity) {
207
251
  ),
208
252
  costMin: parseNumberParam(params.get("costMin") ?? void 0),
209
253
  costMax: parseNumberParam(params.get("costMax") ?? void 0),
210
- from: parseDateParam(params.get("from") ?? void 0, defaults.from),
211
- to: parseDateParam(params.get("to") ?? void 0, defaults.to),
212
- limit: limitValue && limitValue > 0 ? Math.min(limitValue, SEARCH_LIMIT_MAX) : SEARCH_LIMIT_DEFAULT
254
+ from: window.from,
255
+ to: window.to,
256
+ limit: request.limit
213
257
  };
214
258
  }
215
259
  function filterSessionsByActivityWindow(sessions, from, to) {
216
260
  return filterSessionTreeByActivityWindow(sessions, from, to);
217
261
  }
218
262
 
263
+ // src/api/session-pagination.ts
264
+ import { createHash, randomUUID } from "crypto";
265
+ var identityVersions = /* @__PURE__ */ new WeakMap();
266
+ function identityVersion(identity) {
267
+ const existing = identityVersions.get(identity);
268
+ if (existing) return existing;
269
+ const version = randomUUID();
270
+ identityVersions.set(identity, version);
271
+ return version;
272
+ }
273
+ function queryFingerprint(params) {
274
+ const canonical = new URLSearchParams(params);
275
+ canonical.delete("cursor");
276
+ canonical.delete("limit");
277
+ canonical.sort();
278
+ return createHash("sha256").update(canonical.toString()).digest("base64url");
279
+ }
280
+ function encodeCursor(payload) {
281
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
282
+ }
283
+ function decodeCursor(cursor) {
284
+ if (!cursor || cursor.length > 512 || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null;
285
+ try {
286
+ const value = JSON.parse(
287
+ Buffer.from(cursor, "base64url").toString("utf8")
288
+ );
289
+ if (value.version !== 1 || typeof value.snapshot !== "string" || typeof value.view !== "string" || typeof value.query !== "string" || !Number.isSafeInteger(value.offset) || value.offset <= 0) {
290
+ return null;
291
+ }
292
+ return value;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+ function paginateSessionSnapshot(items, request) {
298
+ const snapshot = identityVersion(request.snapshotIdentity);
299
+ const view = identityVersion(request.viewIdentity);
300
+ const query = queryFingerprint(request.query);
301
+ let offset = 0;
302
+ if (request.cursor) {
303
+ const cursor = decodeCursor(request.cursor);
304
+ if (!cursor || cursor.query !== query) return { kind: "invalid_cursor" };
305
+ if (cursor.snapshot !== snapshot || cursor.view !== view) {
306
+ return { kind: "stale_snapshot" };
307
+ }
308
+ if (cursor.offset >= items.length) return { kind: "invalid_cursor" };
309
+ offset = cursor.offset;
310
+ }
311
+ const end = Math.min(offset + request.limit, items.length);
312
+ const nextCursor = end < items.length ? encodeCursor({ version: 1, snapshot, view, query, offset: end }) : void 0;
313
+ return {
314
+ kind: "page",
315
+ items: items.slice(offset, end),
316
+ ...nextCursor ? { nextCursor } : {}
317
+ };
318
+ }
319
+
219
320
  // src/api/session-aliases-view.ts
220
321
  function aliasKey(reference) {
221
322
  return `${reference.agentName.toLowerCase()}\0${reference.sessionId}`;
@@ -252,10 +353,14 @@ function invalidateAliasView() {
252
353
  cachedView = null;
253
354
  }
254
355
  function decorateBookmark(bookmark, aliases) {
255
- return {
256
- ...bookmark,
257
- session: aliases.decorate(bookmark.session, bookmark.reference)
258
- };
356
+ if (bookmark.availability === "available") {
357
+ return {
358
+ ...bookmark,
359
+ session: aliases.decorate(bookmark.session, bookmark.reference)
360
+ };
361
+ }
362
+ const displayTitle = aliases.get(bookmark.reference);
363
+ return displayTitle ? { ...bookmark, display_title: displayTitle } : bookmark;
259
364
  }
260
365
  function decorateFileActivity(activity, aliases) {
261
366
  return {
@@ -286,15 +391,34 @@ function findAliasSearchResults(query, options, scanResult, aliases) {
286
391
  reference: alias.reference,
287
392
  session: aliases.decorate(session, alias.reference),
288
393
  snippet: `Alias \xB7 ${session.directory}`,
394
+ snippetHighlights: [],
289
395
  matchType: "title"
290
396
  });
291
397
  }
292
- return filterSessionSearchCandidates(results, search.options).sort(
398
+ return filterSessionSearchCandidates(results, search.options, scanResult.sessions).sort(
293
399
  (a, b) => getSessionActivityTime(b.session) - getSessionActivityTime(a.session)
294
400
  );
295
401
  }
296
402
 
297
403
  // src/api/handlers.ts
404
+ var KNOWN_AGENT_NAMES = getAgentInfoMap({}).map((agent) => agent.name);
405
+ var KNOWN_AGENT_NAME_SET = new Set(KNOWN_AGENT_NAMES);
406
+ function reportInvalidQueryParameter(endpoint, parameter, validationOutcome) {
407
+ appLogger.warn("api.query_parameter.invalid", {
408
+ endpoint,
409
+ parameter,
410
+ validation_outcome: validationOutcome
411
+ });
412
+ }
413
+ function parseDateWindowRequest(c, endpoint, defaults) {
414
+ const outcome = parseDateWindow(searchParams(c), defaults);
415
+ if (outcome.kind === "valid") return outcome;
416
+ reportInvalidQueryParameter(endpoint, outcome.parameter, "rejected");
417
+ return {
418
+ kind: "rejected",
419
+ response: c.json({ error: `${outcome.parameter} ${outcome.error}` }, 400)
420
+ };
421
+ }
298
422
  var SNAPSHOT_AGGREGATION_CACHE_LIMIT = 64;
299
423
  var snapshotAggregationCaches = /* @__PURE__ */ new WeakMap();
300
424
  function getSnapshotAggregation(source, sessions, key, build) {
@@ -326,25 +450,6 @@ function withStorageErrors(handler, onUnavailable) {
326
450
  function isRecord(value) {
327
451
  return typeof value === "object" && value !== null;
328
452
  }
329
- function isSessionStats(value) {
330
- if (!isRecord(value)) return false;
331
- return typeof value.message_count === "number" && typeof value.total_input_tokens === "number" && typeof value.total_output_tokens === "number" && typeof value.total_cost === "number" && (value.total_tokens == null || typeof value.total_tokens === "number");
332
- }
333
- function parseBookmarkSession(value, reference) {
334
- if (!isRecord(value)) return null;
335
- if (value.id !== reference.sessionId || typeof value.slug !== "string" || typeof value.title !== "string" || typeof value.directory !== "string" || typeof value.time_created !== "number" || value.time_updated != null && typeof value.time_updated !== "number" || !isSessionStats(value.stats)) {
336
- return null;
337
- }
338
- return {
339
- id: reference.sessionId,
340
- slug: formatSessionReference(reference),
341
- title: value.title,
342
- directory: value.directory,
343
- time_created: value.time_created,
344
- time_updated: value.time_updated ?? void 0,
345
- stats: value.stats
346
- };
347
- }
348
453
  function parseSessionReferencePayload(value) {
349
454
  if (!isRecord(value) || typeof value.agentName !== "string" || typeof value.sessionId !== "string" || !value.agentName.trim() || !value.sessionId) {
350
455
  return null;
@@ -354,29 +459,30 @@ function parseSessionReferencePayload(value) {
354
459
  sessionId: value.sessionId
355
460
  });
356
461
  }
357
- function parseBookmarkPayload(value) {
462
+ function parseBookmarkReference(value) {
358
463
  if (!isRecord(value)) return null;
359
464
  const reference = parseSessionReferencePayload(value.reference);
360
- if (reference) {
361
- const session2 = parseBookmarkSession(value.session, reference);
362
- return session2 ? { reference, session: session2 } : null;
363
- }
364
- if (typeof value.agentKey !== "string" || typeof value.sessionId !== "string" || typeof value.fullPath !== "string" || !value.agentKey.trim() || !value.sessionId) {
465
+ if (reference) return reference;
466
+ if (typeof value.agentKey !== "string" || typeof value.sessionId !== "string" || !value.agentKey.trim() || !value.sessionId) {
365
467
  return null;
366
468
  }
367
- const legacyReference = normalizeSessionReference({
469
+ return normalizeSessionReference({
368
470
  agentName: value.agentKey.trim().toLowerCase(),
369
471
  sessionId: value.sessionId
370
472
  });
371
- const session = parseBookmarkSession(
372
- {
373
- ...value,
374
- id: value.sessionId,
375
- slug: value.fullPath
376
- },
377
- legacyReference
378
- );
379
- return session ? { reference: legacyReference, session } : null;
473
+ }
474
+ function parseBookmarkImport(value) {
475
+ if (!isRecord(value)) return null;
476
+ const reference = parseBookmarkReference(value);
477
+ if (!reference) return null;
478
+ const timestamp = value.bookmarkedAt ?? value.bookmarked_at;
479
+ if (timestamp != null && (typeof timestamp !== "number" || !Number.isFinite(timestamp))) {
480
+ return null;
481
+ }
482
+ return {
483
+ reference,
484
+ bookmarkedAt: typeof timestamp === "number" ? timestamp : Date.now()
485
+ };
380
486
  }
381
487
  function sanitizeClientLogData(value) {
382
488
  if (!isRecord(value)) return {};
@@ -391,9 +497,14 @@ function sanitizeClientLogData(value) {
391
497
  );
392
498
  }
393
499
  function toSessionListItem(session) {
394
- if (!session.model_usage) return session;
395
- const item = { ...session };
396
- delete item.model_usage;
500
+ const {
501
+ model_usage: _modelUsage,
502
+ project_identity_resolver_revision: _resolverRevision,
503
+ project_identity_input_signature: _identityInputSignature,
504
+ smart_tags_source_updated_at: _smartTagsSourceUpdatedAt,
505
+ smart_tags_classifier_revision: _smartTagsClassifierRevision,
506
+ ...item
507
+ } = session;
397
508
  return item;
398
509
  }
399
510
  function getSessionHeadReference(session) {
@@ -406,6 +517,7 @@ var SESSION_DETAIL_STREAM_BATCH_CHARS = 64 * 1024;
406
517
  function createSessionDetailJsonResponse(data, messages) {
407
518
  const encoder = new TextEncoder();
408
519
  const headerJson = JSON.stringify(data);
520
+ const headerPrefix = headerJson === "{}" ? '{"messages":[' : `${headerJson.slice(0, -1)},"messages":[`;
409
521
  const iterator = messages[Symbol.iterator]();
410
522
  let wroteHeader = false;
411
523
  let wroteMessage = false;
@@ -413,20 +525,30 @@ function createSessionDetailJsonResponse(data, messages) {
413
525
  new ReadableStream({
414
526
  pull(controller) {
415
527
  if (!wroteHeader) {
416
- controller.enqueue(encoder.encode(`${headerJson.slice(0, -1)},"messages":[`));
528
+ controller.enqueue(encoder.encode(headerPrefix));
417
529
  wroteHeader = true;
418
530
  return;
419
531
  }
420
532
  const batch = [];
421
533
  let batchLength = 0;
422
- let next = iterator.next();
423
- while (!next.done) {
424
- const prefix = wroteMessage ? "," : "";
425
- batch.push(prefix, next.value);
426
- batchLength += prefix.length + next.value.length;
427
- wroteMessage = true;
428
- if (batchLength >= SESSION_DETAIL_STREAM_BATCH_CHARS) break;
534
+ let next;
535
+ try {
429
536
  next = iterator.next();
537
+ while (!next.done) {
538
+ const prefix = wroteMessage ? "," : "";
539
+ batch.push(prefix, next.value);
540
+ batchLength += prefix.length + next.value.length;
541
+ wroteMessage = true;
542
+ if (batchLength >= SESSION_DETAIL_STREAM_BATCH_CHARS) break;
543
+ next = iterator.next();
544
+ }
545
+ } catch (error) {
546
+ try {
547
+ iterator.return?.();
548
+ } catch {
549
+ }
550
+ controller.error(error);
551
+ return;
430
552
  }
431
553
  if (next.done) {
432
554
  batch.push("]}");
@@ -458,8 +580,9 @@ function handleGetScanStatus(c, scanSource) {
458
580
  }
459
581
  function handleGetAgents(c, scanSource, defaults = {}) {
460
582
  const scanResult = scanSource.getSnapshot();
461
- const from = parseDateParam(c.req.query("from"), defaults.from);
462
- const to = parseDateParam(c.req.query("to"), defaults.to);
583
+ const window = parseDateWindowRequest(c, "agents", defaults);
584
+ if (window.kind === "rejected") return window.response;
585
+ const { from, to } = window;
463
586
  const agents = getSnapshotAggregation(
464
587
  scanSource,
465
588
  scanResult.sessions,
@@ -478,16 +601,18 @@ function handleGetAgents(c, scanSource, defaults = {}) {
478
601
  }
479
602
  function handleGetProjects(c, scanSource, defaults = {}) {
480
603
  const scanResult = scanSource.getSnapshot();
481
- const from = parseDateParam(c.req.query("from"), defaults.from);
482
- const to = parseDateParam(c.req.query("to"), defaults.to);
604
+ const window = parseDateWindowRequest(c, "projects", defaults);
605
+ if (window.kind === "rejected") return window.response;
606
+ const { from, to } = window;
483
607
  const projects = getSnapshotAggregation(
484
608
  scanSource,
485
609
  scanResult.sessions,
486
610
  ["projects", from, to],
487
611
  () => {
488
- const sessions = filterSessionsByActivityWindow(scanResult.sessions, from, to);
612
+ const tree = buildSessionTree(scanResult.sessions);
613
+ const sessions = filterSessionTreeByActivityWindow(scanResult.sessions, from, to, tree);
489
614
  return {
490
- projects: attachProjectMetrics(listCachedProjectGroups(sessions), sessions)
615
+ projects: attachProjectMetricsFromTree(listCachedProjectGroups(sessions), tree, from, to)
491
616
  };
492
617
  }
493
618
  );
@@ -495,7 +620,13 @@ function handleGetProjects(c, scanSource, defaults = {}) {
495
620
  }
496
621
  function handleGetSessions(c, scanSource, defaults = {}) {
497
622
  const scanResult = scanSource.getSnapshot();
498
- const agent = c.req.query("agent");
623
+ const params = searchParams(c);
624
+ const paginationRequested = params.has("limit") || params.has("cursor");
625
+ const sessionQuery = parseSessionQuery(params, KNOWN_AGENT_NAMES, SESSION_PAGE_LIMIT_POLICY);
626
+ if (sessionQuery.limit.kind === "invalid") {
627
+ reportInvalidQueryParameter("sessions", "limit", "rejected");
628
+ return c.json({ error: sessionQuery.limit.error }, 400);
629
+ }
499
630
  const q = c.req.query("q")?.toLowerCase();
500
631
  const cwd = c.req.query("cwd");
501
632
  const projectIdentity = parseProjectIdentityFilter(
@@ -506,26 +637,31 @@ function handleGetSessions(c, scanSource, defaults = {}) {
506
637
  return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
507
638
  }
508
639
  const tag = c.req.query("tag")?.toLowerCase();
509
- const from = parseDateParam(c.req.query("from"), defaults.from);
510
- const to = parseDateParam(c.req.query("to"), defaults.to);
511
- let sessions = [];
512
- if (agent && scanResult.byAgent[agent]) {
513
- sessions = [...scanResult.byAgent[agent]];
514
- } else {
515
- sessions = [...scanResult.sessions];
516
- }
517
- if (projectIdentity) {
518
- sessions = sessions.filter(
519
- (session) => matchesProjectIdentity(session.project_identity, projectIdentity)
520
- );
521
- } else if (cwd) {
522
- const projectScope = createProjectScopeMatcher(cwd);
523
- sessions = sessions.filter((s) => matchesProjectScope(s, projectScope));
524
- }
525
- sessions = filterSessionsByActivityWindow(sessions, from, to);
526
- if (tag) {
527
- sessions = sessions.filter((s) => s.smart_tags?.includes(tag));
528
- }
640
+ const window = parseDateWindowRequest(c, "sessions", defaults);
641
+ if (window.kind === "rejected") return window.response;
642
+ const { from, to } = window;
643
+ if (sessionQuery.agent.kind === "unknown") {
644
+ reportInvalidQueryParameter("sessions", "agent", "empty_result");
645
+ }
646
+ const agentFilter = sessionQuery.agent.kind === "known" ? sessionQuery.agent.agentName : sessionQuery.agent.kind;
647
+ let sessions = getSnapshotAggregation(
648
+ scanSource,
649
+ scanResult.sessions,
650
+ ["sessions", agentFilter, projectIdentity?.kind, projectIdentity?.key, cwd, tag, from, to],
651
+ () => {
652
+ let filtered = sessionQuery.agent.kind === "all" ? scanResult.sessions : sessionQuery.agent.kind === "known" ? scanResult.byAgent[sessionQuery.agent.agentName] ?? [] : [];
653
+ if (projectIdentity) {
654
+ filtered = filtered.filter(
655
+ (session) => matchesProjectIdentity(session.project_identity, projectIdentity)
656
+ );
657
+ } else if (cwd) {
658
+ const projectScope = createProjectScopeMatcher(cwd);
659
+ filtered = filtered.filter((session) => matchesProjectScope(session, projectScope));
660
+ }
661
+ filtered = filterSessionsByActivityWindow(filtered, from, to);
662
+ return tag ? filtered.filter((session) => session.smart_tags?.includes(tag)) : filtered;
663
+ }
664
+ );
529
665
  const aliases = loadAliasView();
530
666
  if (q) {
531
667
  sessions = sessions.filter((session) => {
@@ -533,15 +669,93 @@ function handleGetSessions(c, scanSource, defaults = {}) {
533
669
  return session.title.toLowerCase().includes(q) || alias?.toLowerCase().includes(q);
534
670
  });
535
671
  }
672
+ if (paginationRequested) {
673
+ const page = paginateSessionSnapshot(sessions, {
674
+ cursor: params.get("cursor") ?? void 0,
675
+ limit: sessionQuery.limit.value,
676
+ query: params,
677
+ snapshotIdentity: scanResult.sessions,
678
+ viewIdentity: aliases
679
+ });
680
+ if (page.kind === "invalid_cursor") {
681
+ reportInvalidQueryParameter("sessions", "cursor", "rejected");
682
+ return c.json({ error: "cursor is invalid for this request" }, 400);
683
+ }
684
+ if (page.kind === "stale_snapshot") {
685
+ return c.json({ error: "session snapshot changed; restart pagination" }, 409);
686
+ }
687
+ return c.json({
688
+ sessions: page.items.map(
689
+ (session) => toSessionListItem(aliases.decorate(session, getSessionHeadReference(session)))
690
+ ),
691
+ ...page.nextCursor ? { nextCursor: page.nextCursor } : {}
692
+ });
693
+ }
536
694
  return c.json({
537
695
  sessions: sessions.map(
538
696
  (session) => toSessionListItem(aliases.decorate(session, getSessionHeadReference(session)))
539
697
  )
540
698
  });
541
699
  }
700
+ function withParentContext(results, sessions, aliases) {
701
+ if (!results.some((result) => result.session.parent_reference)) return results;
702
+ const byRouteKey = new Map(
703
+ sessions.map((session) => [
704
+ getSessionRouteKey(getSessionAgentKey(session), session.id),
705
+ session
706
+ ])
707
+ );
708
+ return results.map((result) => {
709
+ const parentReference = result.session.parent_reference;
710
+ if (!parentReference) return result;
711
+ const parent = byRouteKey.get(formatSessionReference(parentReference));
712
+ if (!parent) return result;
713
+ const reference = normalizeSessionReference(parentReference);
714
+ return { ...result, parent: { reference, title: aliases.get(reference) ?? parent.title } };
715
+ });
716
+ }
717
+ var ALIAS_SEARCH_RESULT_SHARE = 0.25;
718
+ function searchResultKey(result) {
719
+ return `${result.reference.agentName}\0${result.reference.sessionId}`;
720
+ }
721
+ function uniqueSearchResults(results) {
722
+ const seen = /* @__PURE__ */ new Set();
723
+ return results.filter((result) => {
724
+ const key = searchResultKey(result);
725
+ if (seen.has(key)) return false;
726
+ seen.add(key);
727
+ return true;
728
+ });
729
+ }
730
+ function mergeAliasSearchResults(rankedResults, aliasResults, limit) {
731
+ if (limit <= 0) return [];
732
+ const ranked = uniqueSearchResults(rankedResults);
733
+ const rankedKeys = new Set(ranked.map(searchResultKey));
734
+ const aliases = uniqueSearchResults(aliasResults).filter(
735
+ (result) => !rankedKeys.has(searchResultKey(result))
736
+ );
737
+ if (ranked.length === 0) return aliases.slice(0, limit);
738
+ if (aliases.length === 0) return ranked.slice(0, limit);
739
+ const aliasQuota = Math.min(
740
+ limit - 1,
741
+ Math.max(1, Math.floor(limit * ALIAS_SEARCH_RESULT_SHARE))
742
+ );
743
+ const rankedQuota = limit - aliasQuota;
744
+ return [
745
+ ...ranked.slice(0, rankedQuota),
746
+ ...aliases.slice(0, aliasQuota),
747
+ ...ranked.slice(rankedQuota),
748
+ ...aliases.slice(aliasQuota)
749
+ ].slice(0, limit);
750
+ }
542
751
  function handleSearchSessions(c, scanSource, defaults = {}) {
543
752
  const query = c.req.query("q")?.trim() ?? "";
544
753
  const scanResult = scanSource.getSnapshot();
754
+ const sessionQuery = parseSessionQuery(searchParams(c), KNOWN_AGENT_NAMES, SEARCH_LIMIT_POLICY);
755
+ if (sessionQuery.limit.kind === "invalid") {
756
+ reportInvalidQueryParameter("search", "limit", "rejected");
757
+ return c.json({ error: sessionQuery.limit.error }, 400);
758
+ }
545
759
  const projectIdentity = parseProjectIdentityFilter(
546
760
  c.req.query("projectKind"),
547
761
  c.req.query("projectKey")
@@ -549,22 +763,42 @@ function handleSearchSessions(c, scanSource, defaults = {}) {
549
763
  if (projectIdentity === null) {
550
764
  return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
551
765
  }
552
- const searchOptions = parseSearchOptions(c, defaults, projectIdentity);
766
+ const window = parseDateWindowRequest(c, "search", defaults);
767
+ if (window.kind === "rejected") return window.response;
768
+ if (sessionQuery.agent.kind === "unknown") {
769
+ reportInvalidQueryParameter("search", "agent", "empty_result");
770
+ return c.json({ results: [] });
771
+ }
772
+ const searchOptions = parseSearchOptions(
773
+ c,
774
+ window,
775
+ {
776
+ agent: sessionQuery.agent.kind === "known" ? sessionQuery.agent.agentName : void 0,
777
+ limit: sessionQuery.limit.value
778
+ },
779
+ projectIdentity
780
+ );
553
781
  const aliases = loadAliasView();
554
782
  const results = executeSessionSearch(query, searchOptions, scanResult).map((result) => ({
555
783
  ...result,
556
784
  session: aliases.decorate(result.session, result.reference)
557
785
  }));
558
786
  const aliasResults = findAliasSearchResults(query, searchOptions, scanResult, aliases);
559
- const deduped = /* @__PURE__ */ new Map();
560
- for (const result of [...aliasResults, ...results]) {
561
- deduped.set(`${result.reference.agentName}\0${result.reference.sessionId}`, result);
562
- }
563
- return c.json({ results: [...deduped.values()].slice(0, searchOptions.limit ?? 50) });
787
+ const mergedResults = mergeAliasSearchResults(results, aliasResults, searchOptions.limit ?? 50);
788
+ return c.json({
789
+ results: withParentContext(mergedResults, scanResult.sessions, aliases)
790
+ });
564
791
  }
565
792
  function handleGetFileActivity(c, defaults = {}) {
566
- const limitValue = Number(c.req.query("limit"));
567
- const limit = Number.isFinite(limitValue) && limitValue > 0 ? Math.min(limitValue, 200) : 50;
793
+ const sessionQuery = parseSessionQuery(
794
+ searchParams(c),
795
+ KNOWN_AGENT_NAMES,
796
+ FILE_ACTIVITY_LIMIT_POLICY
797
+ );
798
+ if (sessionQuery.limit.kind === "invalid") {
799
+ reportInvalidQueryParameter("file-activity", "limit", "rejected");
800
+ return c.json({ error: sessionQuery.limit.error }, 400);
801
+ }
568
802
  const projectIdentity = parseProjectIdentityFilter(
569
803
  c.req.query("projectKind"),
570
804
  c.req.query("projectKey")
@@ -572,10 +806,16 @@ function handleGetFileActivity(c, defaults = {}) {
572
806
  if (projectIdentity === null) {
573
807
  return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
574
808
  }
809
+ const window = parseDateWindowRequest(c, "file-activity", defaults);
810
+ if (window.kind === "rejected") return window.response;
811
+ if (sessionQuery.agent.kind === "unknown") {
812
+ reportInvalidQueryParameter("file-activity", "agent", "empty_result");
813
+ return c.json({ activity: [] });
814
+ }
575
815
  const aliases = loadAliasView();
576
816
  return c.json({
577
817
  activity: listFileActivity({
578
- agent: optionalQueryValue(c.req.query("agent")),
818
+ agent: sessionQuery.agent.kind === "known" ? sessionQuery.agent.agentName : void 0,
579
819
  sessionId: optionalQueryValue(c.req.query("sessionId")),
580
820
  projectKind: projectIdentity?.kind,
581
821
  projectKey: projectIdentity?.key,
@@ -583,9 +823,9 @@ function handleGetFileActivity(c, defaults = {}) {
583
823
  cwd: optionalQueryValue(c.req.query("cwd")),
584
824
  path: optionalQueryValue(c.req.query("path")),
585
825
  kind: parseFileActivityKind(optionalQueryValue(c.req.query("kind"))),
586
- from: parseDateParam(c.req.query("from"), defaults.from),
587
- to: parseDateParam(c.req.query("to"), defaults.to),
588
- limit
826
+ from: window.from,
827
+ to: window.to,
828
+ limit: sessionQuery.limit.value
589
829
  }).map((activity) => decorateFileActivity(activity, aliases))
590
830
  });
591
831
  }
@@ -600,10 +840,12 @@ async function handleGetSessionData(c, scanSource) {
600
840
  return c.json({ error: "Missing session ID" }, 400);
601
841
  }
602
842
  try {
603
- const result = materializeSessionDetailResponse(scanSource.getSnapshot(), {
843
+ const reference = {
604
844
  agentName,
605
845
  sessionId
606
- });
846
+ };
847
+ const messageCursor = optionalQueryValue(c.req.query("messageCursor"));
848
+ const result = messageCursor ? materializeSessionDetailResponse(scanSource.getSnapshot(), reference, { messageCursor }) : materializeSessionDetailResponse(scanSource.getSnapshot(), reference);
607
849
  if (result.status === "unknown-agent") {
608
850
  return c.json({ error: `Unknown agent: ${agentName}` }, 404);
609
851
  }
@@ -619,6 +861,8 @@ async function handleGetSessionData(c, scanSource) {
619
861
  agent: agentName,
620
862
  session_id: sessionId,
621
863
  messages: result.status === "found-json" ? result.messageCount : result.data.messages.length,
864
+ sent_messages: result.status === "found-json" ? result.sentMessageCount : result.data.messages.length,
865
+ message_update: result.data.message_update ?? "reset",
622
866
  duration_ms: Math.round(performance.now() - startedAt)
623
867
  });
624
868
  const aliases = loadAliasView();
@@ -637,7 +881,7 @@ async function handleGetSessionData(c, scanSource) {
637
881
  duration_ms: Math.round(performance.now() - startedAt),
638
882
  error: message
639
883
  });
640
- return c.json({ error: message }, 500);
884
+ return c.json({ error: "Failed to load session" }, 500);
641
885
  }
642
886
  }
643
887
  async function handlePostClientLog(c) {
@@ -650,12 +894,27 @@ async function handlePostClientLog(c) {
650
894
  appLogger.info(`client.${event}`, sanitizeClientLogData(payload?.data));
651
895
  return c.json({ ok: true });
652
896
  }
653
- function handleGetBookmarks(c) {
897
+ function materializeStoredBookmarks(scanSource, bookmarks, aliases) {
898
+ const snapshot = scanSource.getSnapshot();
899
+ const sessionIndex = getSnapshotAggregation(
900
+ scanSource,
901
+ snapshot.sessions,
902
+ ["bookmark-session-index"],
903
+ () => createSessionIndex(snapshot.sessions)
904
+ );
905
+ return materializeBookmarkViews(bookmarks, {
906
+ liveSessionsByReference: sessionIndex.byRouteKey,
907
+ knownAgentNames: KNOWN_AGENT_NAME_SET,
908
+ resolveCachedSessions: loadCachedSessionHeads
909
+ }).map((bookmark) => decorateBookmark(bookmark, aliases));
910
+ }
911
+ function handleGetBookmarks(c, scanSource) {
654
912
  return withStorageErrors(
655
913
  () => {
656
914
  const aliases = loadAliasView();
915
+ const bookmarks = materializeStoredBookmarks(scanSource, listBookmarks(), aliases);
657
916
  return c.json({
658
- bookmarks: listBookmarks().map((bookmark) => decorateBookmark(bookmark, aliases)),
917
+ bookmarks,
659
918
  storageAvailable: true
660
919
  });
661
920
  },
@@ -663,7 +922,7 @@ function handleGetBookmarks(c) {
663
922
  );
664
923
  }
665
924
  async function handlePutBookmark(c) {
666
- const payload = parseBookmarkPayload(await c.req.json().catch(() => null));
925
+ const payload = parseBookmarkReference(await c.req.json().catch(() => null));
667
926
  if (!payload) {
668
927
  return c.json({ error: "Invalid bookmark payload" }, 400);
669
928
  }
@@ -672,17 +931,21 @@ async function handlePutBookmark(c) {
672
931
  () => c.json({ error: "Bookmark storage is unavailable" }, 503)
673
932
  );
674
933
  }
675
- async function handleImportBookmarks(c) {
934
+ async function handleImportBookmarks(c, scanSource) {
676
935
  const payload = await c.req.json().catch(() => null);
677
936
  if (!Array.isArray(payload)) {
678
937
  return c.json({ error: "Invalid bookmark payload" }, 400);
679
938
  }
680
- const bookmarks = payload.map((entry) => parseBookmarkPayload(entry)).filter((entry) => entry !== null);
939
+ const bookmarks = payload.map((entry) => parseBookmarkImport(entry)).filter((entry) => entry !== null);
681
940
  if (bookmarks.length !== payload.length) {
682
941
  return c.json({ error: "Invalid bookmark payload" }, 400);
683
942
  }
684
943
  return withStorageErrors(
685
- () => c.json({ bookmarks: importBookmarks(bookmarks), storageAvailable: true }),
944
+ () => {
945
+ const imported = importBookmarks(bookmarks);
946
+ const views = materializeStoredBookmarks(scanSource, imported, loadAliasView());
947
+ return c.json({ bookmarks: views, storageAvailable: true });
948
+ },
686
949
  () => c.json({ error: "Bookmark storage is unavailable" }, 503)
687
950
  );
688
951
  }
@@ -748,6 +1011,8 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
748
1011
  if (projectIdentity === null) {
749
1012
  return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
750
1013
  }
1014
+ const dateWindow = parseDateWindowRequest(c, "dashboard", defaults);
1015
+ if (dateWindow.kind === "rejected") return dateWindow.response;
751
1016
  const { from, to, days } = resolveTimeWindow({
752
1017
  mode: "dashboard",
753
1018
  query: {
@@ -762,7 +1027,8 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
762
1027
  projectKind: projectIdentity?.kind,
763
1028
  projectKey: projectIdentity?.key
764
1029
  };
765
- const fixedTo = parseDateParam(c.req.query("to"), defaults.to);
1030
+ const compare = from == null ? void 0 : { from: addCalendarDays(from, -(days ?? countCalendarDays(from, to))), to: from - 1 };
1031
+ const fixedTo = dateWindow.to;
766
1032
  const aggregate = getSnapshotAggregation(
767
1033
  scanSource,
768
1034
  scanResult.sessions,
@@ -772,7 +1038,9 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
772
1038
  scope.projectKind,
773
1039
  scope.projectKey,
774
1040
  from,
775
- fixedTo ?? startOfCalendarDay(to)
1041
+ fixedTo ?? startOfCalendarDay(to),
1042
+ compare?.from,
1043
+ compare?.to
776
1044
  ],
777
1045
  () => {
778
1046
  const agentInfo = getAgentInfoMap({});
@@ -782,7 +1050,8 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
782
1050
  scope,
783
1051
  from,
784
1052
  to,
785
- agentInfoMap
1053
+ agentInfoMap,
1054
+ compare
786
1055
  });
787
1056
  }
788
1057
  );
@@ -796,7 +1065,14 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
796
1065
  to,
797
1066
  limit: 12
798
1067
  }),
799
- window: { from, to, days }
1068
+ modelCost: listModelCostDistribution({
1069
+ agent: scope.agent,
1070
+ projectKind: scope.projectKind,
1071
+ projectKey: scope.projectKey,
1072
+ from,
1073
+ to
1074
+ }),
1075
+ window: { from, to, days, compareFrom: compare?.from, compareTo: compare?.to }
800
1076
  };
801
1077
  const aliases = loadAliasView();
802
1078
  return c.json({
@@ -811,52 +1087,174 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
811
1087
  });
812
1088
  }
813
1089
 
1090
+ // src/api/sse-event-buffer.ts
1091
+ var MAX_PENDING_CRITICAL_SSE_FRAMES = 64;
1092
+ function scanStatusMilestone(status) {
1093
+ const agentStates = Object.entries(status.agentStatuses).sort(([left], [right]) => left.localeCompare(right)).map(([agentName, agentStatus]) => [agentName, agentStatus.status, agentStatus.error ?? null]);
1094
+ return JSON.stringify([
1095
+ status.active,
1096
+ status.phase,
1097
+ status.totalAgents,
1098
+ status.pendingAgents,
1099
+ status.scanningAgents,
1100
+ status.completedAgents,
1101
+ agentStates,
1102
+ status.backfill.active,
1103
+ status.backfill.currentAgent ?? null,
1104
+ status.backfill.pendingAgents,
1105
+ status.backfill.completedAgents,
1106
+ status.backfill.failedAgents,
1107
+ status.backfill.progress?.phase ?? null,
1108
+ status.searchIndexMaintenance?.active ?? false,
1109
+ status.searchIndexMaintenance?.currentAgent ?? null,
1110
+ status.searchIndexMaintenance?.pendingAgents ?? [],
1111
+ status.searchIndexMaintenance?.completedAgents ?? [],
1112
+ status.searchIndexMaintenance?.failedAgents ?? []
1113
+ ]);
1114
+ }
1115
+ var SseEventBuffer = class {
1116
+ constructor(controller, onOverflow) {
1117
+ this.controller = controller;
1118
+ this.onOverflow = onOverflow;
1119
+ }
1120
+ controller;
1121
+ onOverflow;
1122
+ encoder = new TextEncoder();
1123
+ frames = [];
1124
+ replaceableStatus = null;
1125
+ lastStatusMilestone = null;
1126
+ criticalFrameCount = 0;
1127
+ isClosed = false;
1128
+ enqueue(event, data) {
1129
+ if (!this.freezeReplaceableStatus()) return;
1130
+ this.appendCritical(this.frame(event, data));
1131
+ }
1132
+ enqueueScanStatus(status) {
1133
+ if (this.isClosed) return;
1134
+ const milestone = scanStatusMilestone(status);
1135
+ const bytes = this.frame(status.type, status);
1136
+ if (this.lastStatusMilestone !== milestone) {
1137
+ if (!this.freezeReplaceableStatus()) return;
1138
+ this.lastStatusMilestone = milestone;
1139
+ this.appendCritical(bytes);
1140
+ return;
1141
+ }
1142
+ if (this.replaceableStatus) {
1143
+ this.replaceableStatus.bytes = bytes;
1144
+ return;
1145
+ }
1146
+ const frame = { bytes, critical: false };
1147
+ this.frames.push(frame);
1148
+ this.replaceableStatus = frame;
1149
+ this.drain();
1150
+ }
1151
+ enqueueHeartbeat() {
1152
+ if (this.isClosed || this.frames.length > 0 || this.controller.desiredSize == null || this.controller.desiredSize <= 0) {
1153
+ return;
1154
+ }
1155
+ this.controller.enqueue(this.encoder.encode(": keepalive\n\n"));
1156
+ }
1157
+ drain() {
1158
+ while (!this.isClosed && this.frames.length > 0 && this.controller.desiredSize != null && this.controller.desiredSize > 0) {
1159
+ const frame = this.frames.shift();
1160
+ if (frame === this.replaceableStatus) this.replaceableStatus = null;
1161
+ if (frame.critical) this.criticalFrameCount -= 1;
1162
+ this.controller.enqueue(frame.bytes);
1163
+ }
1164
+ }
1165
+ close() {
1166
+ this.isClosed = true;
1167
+ this.frames.length = 0;
1168
+ this.replaceableStatus = null;
1169
+ this.criticalFrameCount = 0;
1170
+ }
1171
+ freezeReplaceableStatus() {
1172
+ if (!this.replaceableStatus) return !this.isClosed;
1173
+ if (this.criticalFrameCount >= MAX_PENDING_CRITICAL_SSE_FRAMES) {
1174
+ this.overflow();
1175
+ return false;
1176
+ }
1177
+ this.replaceableStatus.critical = true;
1178
+ this.replaceableStatus = null;
1179
+ this.criticalFrameCount += 1;
1180
+ return true;
1181
+ }
1182
+ appendCritical(bytes) {
1183
+ if (this.isClosed) return;
1184
+ if (this.criticalFrameCount >= MAX_PENDING_CRITICAL_SSE_FRAMES) {
1185
+ this.overflow();
1186
+ return;
1187
+ }
1188
+ this.frames.push({ bytes, critical: true });
1189
+ this.criticalFrameCount += 1;
1190
+ this.drain();
1191
+ }
1192
+ overflow() {
1193
+ if (this.isClosed) return;
1194
+ this.close();
1195
+ this.onOverflow();
1196
+ }
1197
+ frame(event, data) {
1198
+ return this.encoder.encode(`event: ${event}
1199
+ data: ${JSON.stringify(data)}
1200
+
1201
+ `);
1202
+ }
1203
+ };
1204
+
814
1205
  // src/api/routes.ts
815
1206
  function createSseResponse(eventSource, signal) {
816
- const encoder = new TextEncoder();
817
1207
  let cancelStream = () => {
818
1208
  };
1209
+ let drainStream = () => {
1210
+ };
819
1211
  return new Response(
820
1212
  new ReadableStream({
821
1213
  start(controller) {
822
1214
  let isClosed = false;
823
- const write = (event, data) => {
824
- if (isClosed) return;
825
- controller.enqueue(encoder.encode(`event: ${event}
826
- `));
827
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
828
-
829
- `));
1215
+ let unsubscribeSessions = () => {
830
1216
  };
831
- write("connected", { timestamp: Date.now() });
832
- write("scan-status", eventSource.getScanStatus());
833
- const unsubscribeSessions = eventSource.subscribe((event) => {
834
- write(event.type, event);
835
- });
836
- const unsubscribeScanStatus = eventSource.subscribeScanStatus((event) => {
837
- write(event.type, event);
838
- });
839
- const heartbeat = setInterval(() => {
840
- if (!isClosed) controller.enqueue(encoder.encode(": keepalive\n\n"));
841
- }, 15e3);
1217
+ let unsubscribeScanStatus = () => {
1218
+ };
1219
+ let abortStream = () => {
1220
+ };
1221
+ let heartbeat;
1222
+ let buffer;
842
1223
  const cleanup = () => {
843
1224
  if (isClosed) return false;
844
1225
  isClosed = true;
845
- clearInterval(heartbeat);
1226
+ buffer.close();
1227
+ if (heartbeat) clearInterval(heartbeat);
846
1228
  unsubscribeSessions();
847
1229
  unsubscribeScanStatus();
848
1230
  signal.removeEventListener("abort", abortStream);
849
1231
  return true;
850
1232
  };
851
- const abortStream = () => {
1233
+ abortStream = () => {
852
1234
  if (cleanup()) controller.close();
853
1235
  };
1236
+ buffer = new SseEventBuffer(controller, () => {
1237
+ if (cleanup()) controller.error(new Error("SSE client fell behind"));
1238
+ });
1239
+ drainStream = () => buffer.drain();
1240
+ buffer.enqueue("connected", { timestamp: Date.now() });
1241
+ buffer.enqueueScanStatus(eventSource.getScanStatus());
1242
+ unsubscribeSessions = eventSource.subscribe((event) => {
1243
+ buffer.enqueue(event.type, event);
1244
+ });
1245
+ unsubscribeScanStatus = eventSource.subscribeScanStatus((event) => {
1246
+ buffer.enqueueScanStatus(event);
1247
+ });
1248
+ heartbeat = setInterval(() => buffer.enqueueHeartbeat(), 15e3);
854
1249
  cancelStream = () => {
855
1250
  cleanup();
856
1251
  };
857
1252
  if (signal.aborted) abortStream();
858
1253
  else signal.addEventListener("abort", abortStream, { once: true });
859
1254
  },
1255
+ pull() {
1256
+ drainStream();
1257
+ },
860
1258
  cancel() {
861
1259
  cancelStream();
862
1260
  }
@@ -888,23 +1286,140 @@ function createApiRoutes(scanSource, eventSource, options = {}) {
888
1286
  api.get("/file-activity", (c) => handleGetFileActivity(c, listDefaults));
889
1287
  api.get("/sessions/:agent/:id", (c) => handleGetSessionData(c, scanSource));
890
1288
  api.get("/dashboard", (c) => handleGetDashboard(c, scanSource, listDefaults));
891
- api.get("/bookmarks", (c) => handleGetBookmarks(c));
1289
+ api.get("/bookmarks", (c) => handleGetBookmarks(c, scanSource));
892
1290
  api.put("/bookmarks", (c) => handlePutBookmark(c));
893
- api.post("/bookmarks/import", (c) => handleImportBookmarks(c));
1291
+ api.post("/bookmarks/import", (c) => handleImportBookmarks(c, scanSource));
894
1292
  api.delete("/bookmarks/:agent/:id", (c) => handleDeleteBookmark(c));
895
1293
  api.put("/session-aliases/:agent/:id", (c) => handlePutSessionAlias(c));
896
1294
  api.delete("/session-aliases/:agent/:id", (c) => handleDeleteSessionAlias(c));
897
1295
  api.post("/logs", (c) => handlePostClientLog(c));
898
1296
  if (eventSource) {
899
- api.get("/events", (c) => createSseResponse(eventSource, c.req.raw.signal));
1297
+ api.get("/events", (c) => {
1298
+ const signal = options.shutdownSignal ? AbortSignal.any([c.req.raw.signal, options.shutdownSignal]) : c.req.raw.signal;
1299
+ return createSseResponse(eventSource, signal);
1300
+ });
900
1301
  }
901
1302
  return api;
902
1303
  }
903
1304
 
1305
+ // src/loopback-authority.ts
1306
+ import { isIP } from "net";
1307
+ var LOOPBACK_AUTHORITY_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
1308
+ function canonicalizeHostname(hostname) {
1309
+ const unwrapped = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
1310
+ const authorityHost = isIP(unwrapped) === 6 ? `[${unwrapped}]` : unwrapped;
1311
+ try {
1312
+ return new URL(`http://${authorityHost}`).hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ }
1317
+ function parseAuthority(value) {
1318
+ if (!value || value !== value.trim()) return null;
1319
+ const bracketed = /^\[([^\]]+)\]:(\d{1,5})$/.exec(value);
1320
+ const plain = /^([^:[\]]+):(\d{1,5})$/.exec(value);
1321
+ const match = bracketed ?? plain;
1322
+ if (!match) return null;
1323
+ const port = Number(match[2]);
1324
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
1325
+ const rawHostname = match[1];
1326
+ const authorityHost = bracketed ? `[${rawHostname}]` : rawHostname;
1327
+ try {
1328
+ const parsed = new URL(`http://${authorityHost}:${port}`);
1329
+ if (parsed.username || parsed.password) return null;
1330
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
1331
+ return {
1332
+ hostname,
1333
+ port,
1334
+ normalized: isIP(hostname) === 6 ? `[${hostname}]:${port}` : `${hostname}:${port}`
1335
+ };
1336
+ } catch {
1337
+ return null;
1338
+ }
1339
+ }
1340
+ function hostHeaders(rawHeaders) {
1341
+ const values = [];
1342
+ for (let index = 0; index < rawHeaders.length; index += 2) {
1343
+ if (rawHeaders[index]?.toLowerCase() === "host") values.push(rawHeaders[index + 1] ?? "");
1344
+ }
1345
+ return values;
1346
+ }
1347
+ function validateLoopbackAuthority(rawHeaders, listenerHostname, listenerPort) {
1348
+ if (listenerPort === null) return { allowed: false, reason: "listener-not-ready" };
1349
+ const hosts = hostHeaders(rawHeaders);
1350
+ if (hosts.length === 0) return { allowed: false, reason: "missing-host" };
1351
+ if (hosts.length !== 1) return { allowed: false, reason: "multiple-hosts" };
1352
+ const authority = parseAuthority(hosts[0]);
1353
+ if (!authority) return { allowed: false, reason: "invalid-host" };
1354
+ if (authority.port !== listenerPort) {
1355
+ return { allowed: false, reason: "port-mismatch", authority: authority.normalized };
1356
+ }
1357
+ const listener = canonicalizeHostname(listenerHostname);
1358
+ if (!LOOPBACK_AUTHORITY_HOSTS.has(authority.hostname) && authority.hostname !== listener) {
1359
+ return {
1360
+ allowed: false,
1361
+ reason: "hostname-not-allowed",
1362
+ authority: authority.normalized
1363
+ };
1364
+ }
1365
+ return { allowed: true, authority: authority.normalized };
1366
+ }
1367
+
1368
+ // src/loopback-write-guard.ts
1369
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
1370
+ var JSON_BODY_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
1371
+ var ALLOWED_FETCH_SITES = /* @__PURE__ */ new Set(["same-origin", "none"]);
1372
+ function mediaType(contentType) {
1373
+ return contentType?.split(";", 1)[0]?.trim().toLowerCase() || null;
1374
+ }
1375
+ function isSameOrigin(origin, requestOrigin) {
1376
+ try {
1377
+ return new URL(origin).origin === requestOrigin;
1378
+ } catch {
1379
+ return false;
1380
+ }
1381
+ }
1382
+ function evaluateLoopbackWriteRequest(request) {
1383
+ const method = request.method.toUpperCase();
1384
+ if (SAFE_METHODS.has(method)) return { allowed: true };
1385
+ if (JSON_BODY_METHODS.has(method) && mediaType(request.contentType) !== "application/json") {
1386
+ return { allowed: false, reason: "content-type", status: 415 };
1387
+ }
1388
+ if (request.fetchSite && !ALLOWED_FETCH_SITES.has(request.fetchSite)) {
1389
+ return { allowed: false, reason: "fetch-site", status: 403 };
1390
+ }
1391
+ if (request.origin && !isSameOrigin(request.origin, request.requestOrigin)) {
1392
+ return { allowed: false, reason: "origin", status: 403 };
1393
+ }
1394
+ return { allowed: true };
1395
+ }
1396
+ function loopbackWriteGuard() {
1397
+ return async (c, next) => {
1398
+ const url = new URL(c.req.url);
1399
+ const decision = evaluateLoopbackWriteRequest({
1400
+ method: c.req.method,
1401
+ contentType: c.req.header("Content-Type"),
1402
+ fetchSite: c.req.header("Sec-Fetch-Site"),
1403
+ origin: c.req.header("Origin"),
1404
+ requestOrigin: url.origin
1405
+ });
1406
+ if (!decision.allowed) {
1407
+ appLogger.warn("http.loopback_write.rejected", {
1408
+ method: c.req.method,
1409
+ path: url.pathname,
1410
+ reason: decision.reason
1411
+ });
1412
+ const error = decision.reason === "content-type" ? "Write requests require application/json" : "Cross-origin write request rejected";
1413
+ return c.json({ error }, decision.status);
1414
+ }
1415
+ await next();
1416
+ };
1417
+ }
1418
+
904
1419
  // src/remote-access.ts
905
1420
  import { randomBytes, timingSafeEqual } from "crypto";
906
1421
  import { readFileSync } from "fs";
907
- import { isIP } from "net";
1422
+ import { isIP as isIP2 } from "net";
908
1423
  var REMOTE_ACCESS_QUERY_PARAM = "access_token";
909
1424
  var RemoteTransportError = class extends Error {
910
1425
  };
@@ -935,6 +1450,13 @@ function resolveRemoteTransport(request) {
935
1450
  if (request.trustProxy) return { kind: "trusted-proxy" };
936
1451
  return isLoopbackHostname(request.hostname) ? { kind: "loopback" } : { kind: "plaintext" };
937
1452
  }
1453
+ function resolveRemoteAccessPolicy(hostname, transport) {
1454
+ const bindCategory = isLoopbackHostname(hostname) ? "loopback" : "network";
1455
+ return {
1456
+ bindCategory,
1457
+ authenticationRequired: bindCategory === "network" || transport.kind !== "loopback"
1458
+ };
1459
+ }
938
1460
  var FORWARDED_PROTO_HEADER = "X-Forwarded-Proto";
939
1461
  function requireProxyTls() {
940
1462
  return async (c, next) => {
@@ -951,7 +1473,7 @@ function createRemoteAccessToken() {
951
1473
  function isLoopbackHostname(hostname) {
952
1474
  const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
953
1475
  if (normalized === "localhost" || normalized === "::1") return true;
954
- return isIP(normalized) === 4 && normalized.startsWith("127.");
1476
+ return isIP2(normalized) === 4 && normalized.startsWith("127.");
955
1477
  }
956
1478
  function tokenMatches(actual, expected) {
957
1479
  if (!actual) return false;
@@ -981,6 +1503,7 @@ function remoteAccessAuth(expectedToken) {
981
1503
 
982
1504
  // src/server.ts
983
1505
  var MAX_API_REQUEST_BYTES = 1024 * 1024;
1506
+ var SERVER_CLOSE_GRACE_MS = 1e3;
984
1507
  function findWebDistPath() {
985
1508
  const __dirname2 = dirname(fileURLToPath(import.meta.url));
986
1509
  const packagedPath = resolve(__dirname2, "web");
@@ -1020,17 +1543,91 @@ function getListeningPort(server, fallback) {
1020
1543
  const address = server.address();
1021
1544
  return typeof address === "object" && address !== null ? address.port : fallback;
1022
1545
  }
1546
+ async function closeHttpServer(server, port) {
1547
+ if (!server) return;
1548
+ let forceCloseTimer;
1549
+ const gracefulClose = new Promise((resolve4) => server.close(() => resolve4()));
1550
+ const forceClose = new Promise((resolve4) => {
1551
+ forceCloseTimer = setTimeout(() => {
1552
+ appLogger.warn("server.shutdown.force_close", {
1553
+ port,
1554
+ grace_ms: SERVER_CLOSE_GRACE_MS
1555
+ });
1556
+ if ("closeAllConnections" in server) server.closeAllConnections();
1557
+ resolve4();
1558
+ }, SERVER_CLOSE_GRACE_MS);
1559
+ forceCloseTimer.unref();
1560
+ });
1561
+ try {
1562
+ await Promise.race([gracefulClose, forceClose]);
1563
+ } finally {
1564
+ if (forceCloseTimer) clearTimeout(forceCloseTimer);
1565
+ }
1566
+ }
1023
1567
  async function createServer(port, store, options = {}) {
1024
1568
  const app = new Hono2();
1025
1569
  const hostname = options.hostname ?? "127.0.0.1";
1026
- const isLoopback = isLoopbackHostname(hostname);
1027
- const transport = options.transport ?? (isLoopback ? { kind: "loopback" } : { kind: "plaintext" });
1028
- const remoteAccessToken = !isLoopback ? options.remoteAccessToken ?? (options.remoteAccess ? createRemoteAccessToken() : null) : null;
1029
- if (!isLoopback && !remoteAccessToken) {
1570
+ const transport = options.transport ?? resolveRemoteTransport({ hostname });
1571
+ const accessPolicy = resolveRemoteAccessPolicy(hostname, transport);
1572
+ const remoteAccessToken = accessPolicy.authenticationRequired ? options.remoteAccessToken ?? (options.remoteAccess ? createRemoteAccessToken() : null) : null;
1573
+ const loopbackAuthorityEnabled = !accessPolicy.authenticationRequired;
1574
+ const shutdownController = new AbortController();
1575
+ let actualPort = null;
1576
+ appLogger.info("server.access_policy", {
1577
+ bind_category: accessPolicy.bindCategory,
1578
+ transport: transport.kind,
1579
+ authentication: remoteAccessToken ? "token" : "none",
1580
+ loopback_authority: loopbackAuthorityEnabled ? "enabled" : "disabled"
1581
+ });
1582
+ if (accessPolicy.authenticationRequired && !remoteAccessToken) {
1030
1583
  throw new Error(
1031
1584
  `Refusing to expose CodeSesh on ${hostname} without authentication. Add --remote-access to continue.`
1032
1585
  );
1033
1586
  }
1587
+ app.use(
1588
+ "*",
1589
+ secureHeaders({
1590
+ contentSecurityPolicy: {
1591
+ defaultSrc: ["'self'"],
1592
+ baseUri: ["'none'"],
1593
+ connectSrc: ["'self'"],
1594
+ fontSrc: ["'self'", "data:"],
1595
+ formAction: ["'self'"],
1596
+ frameAncestors: ["'none'"],
1597
+ imgSrc: ["'self'", "data:"],
1598
+ objectSrc: ["'none'"],
1599
+ scriptSrc: ["'self'"],
1600
+ scriptSrcAttr: ["'none'"],
1601
+ styleSrc: ["'self'", "'unsafe-inline'"]
1602
+ },
1603
+ crossOriginOpenerPolicy: false,
1604
+ crossOriginResourcePolicy: false,
1605
+ originAgentCluster: false,
1606
+ referrerPolicy: "no-referrer",
1607
+ strictTransportSecurity: false,
1608
+ xContentTypeOptions: "nosniff",
1609
+ xDnsPrefetchControl: false,
1610
+ xDownloadOptions: false,
1611
+ xFrameOptions: "DENY",
1612
+ xPermittedCrossDomainPolicies: false,
1613
+ xXssProtection: false
1614
+ })
1615
+ );
1616
+ if (loopbackAuthorityEnabled) {
1617
+ app.use("*", async (c, next) => {
1618
+ const decision = validateLoopbackAuthority(c.env.incoming.rawHeaders, hostname, actualPort);
1619
+ if (!decision.allowed) {
1620
+ appLogger.warn("http.loopback_authority.rejected", {
1621
+ method: c.req.method,
1622
+ path: new URL(c.req.url).pathname,
1623
+ reason: decision.reason,
1624
+ authority: decision.authority
1625
+ });
1626
+ return c.json({ error: "Loopback request authority rejected" }, 403);
1627
+ }
1628
+ await next();
1629
+ });
1630
+ }
1034
1631
  app.use("*", async (c, next) => {
1035
1632
  const startedAt = performance.now();
1036
1633
  let thrown;
@@ -1057,6 +1654,7 @@ async function createServer(port, store, options = {}) {
1057
1654
  if (remoteAccessToken) {
1058
1655
  app.use("/api/*", remoteAccessAuth(remoteAccessToken));
1059
1656
  }
1657
+ app.use("/api/*", loopbackWriteGuard());
1060
1658
  app.use(
1061
1659
  "/api/*",
1062
1660
  bodyLimit({
@@ -1068,7 +1666,8 @@ async function createServer(port, store, options = {}) {
1068
1666
  const routeOptions = {
1069
1667
  defaultSessionFrom: options.defaultSessionFrom,
1070
1668
  defaultSessionTo: options.defaultSessionTo,
1071
- defaultSessionDays: options.defaultSessionDays
1669
+ defaultSessionDays: options.defaultSessionDays,
1670
+ shutdownSignal: shutdownController.signal
1072
1671
  };
1073
1672
  app.route("/api", createApiRoutes(store, store, routeOptions));
1074
1673
  const webDistPath = options.webDistPath ?? findWebDistPath();
@@ -1078,7 +1677,6 @@ async function createServer(port, store, options = {}) {
1078
1677
  }
1079
1678
  const attempts = Math.max(1, options.portFallbackAttempts ?? 1);
1080
1679
  let server = null;
1081
- let actualPort = port;
1082
1680
  for (let offset = 0; offset < attempts; offset += 1) {
1083
1681
  const candidatePort = port + offset;
1084
1682
  server = serve({
@@ -1110,7 +1708,7 @@ async function createServer(port, store, options = {}) {
1110
1708
  }
1111
1709
  }
1112
1710
  const scheme = transport.kind === "tls" ? "https" : "http";
1113
- const baseUrl = isLoopback ? `${scheme}://localhost:${actualPort}` : `${scheme}://${hostname}:${actualPort}`;
1711
+ const baseUrl = accessPolicy.bindCategory === "loopback" ? `${scheme}://localhost:${actualPort}` : `${scheme}://${hostname}:${actualPort}`;
1114
1712
  const url = remoteAccessToken ? `${baseUrl}/?${REMOTE_ACCESS_QUERY_PARAM}=${encodeURIComponent(remoteAccessToken)}` : baseUrl;
1115
1713
  appLogger.info("server.listen", {
1116
1714
  port: actualPort,
@@ -1119,7 +1717,7 @@ async function createServer(port, store, options = {}) {
1119
1717
  remote_access: Boolean(remoteAccessToken),
1120
1718
  transport: transport.kind
1121
1719
  });
1122
- if (!isLoopback) {
1720
+ if (accessPolicy.authenticationRequired) {
1123
1721
  appLogger.warn("server.listen.remote_access", {
1124
1722
  hostname,
1125
1723
  port: actualPort,
@@ -1133,6 +1731,14 @@ async function createServer(port, store, options = {}) {
1133
1731
  console.warn(
1134
1732
  `\u26A0 \u4F20\u8F93\u672A\u52A0\u5BC6\u3002\u8BBF\u95EE\u4EE4\u724C\u4E0E\u5B8C\u6574\u4F1A\u8BDD\u5185\u5BB9\u4EE5\u660E\u6587\u7ECF\u8FC7\u7F51\u7EDC\uFF0CURL \u4E2D\u7684\u4EE4\u724C\u8FD8\u53EF\u80FD\u5199\u5165\u4EE3\u7406\u8BBF\u95EE\u65E5\u5FD7\u3002
1135
1733
  \u4F7F\u7528 --tls-cert/--tls-key \u76F4\u63A5\u542F\u7528 TLS\uFF0C\u6216\u5728\u53D7\u4FE1\u53CD\u5411\u4EE3\u7406\u540E\u4F7F\u7528 --trust-proxy\u3002
1734
+ `
1735
+ );
1736
+ }
1737
+ if (transport.kind === "trusted-proxy") {
1738
+ appLogger.warn("server.listen.proxy_logging", { hostname, port: actualPort });
1739
+ console.warn(
1740
+ `\u26A0 URL \u4E2D\u7684\u8BBF\u95EE\u4EE4\u724C\u4F1A\u7528\u4E8E SSE \u91CD\u8FDE\uFF0C\u5E76\u53EF\u80FD\u5199\u5165\u53D7\u4FE1\u53CD\u5411\u4EE3\u7406\u7684\u8BBF\u95EE\u65E5\u5FD7\u3002
1741
+ \u8BF7\u5728\u4EE3\u7406\u914D\u7F6E\u4E2D\u5173\u95ED\u67E5\u8BE2\u53C2\u6570\u65E5\u5FD7\uFF0C\u6216\u5BF9 access_token \u53C2\u6570\u8131\u654F\u3002
1136
1742
  `
1137
1743
  );
1138
1744
  }
@@ -1141,14 +1747,15 @@ async function createServer(port, store, options = {}) {
1141
1747
  url,
1142
1748
  shutdown: async () => {
1143
1749
  appLogger.info("server.shutdown", { port: actualPort });
1144
- await new Promise((resolve4) => {
1145
- if (!server) {
1146
- resolve4();
1147
- return;
1148
- }
1149
- server.close(() => resolve4());
1150
- });
1151
- await store.shutdown();
1750
+ shutdownController.abort();
1751
+ try {
1752
+ appLogger.info("server.shutdown.phase", { phase: "http-closing", port: actualPort });
1753
+ await closeHttpServer(server, actualPort);
1754
+ } finally {
1755
+ appLogger.info("server.shutdown.phase", { phase: "store-closing", port: actualPort });
1756
+ await store.shutdown();
1757
+ }
1758
+ appLogger.info("server.shutdown.phase", { phase: "complete", port: actualPort });
1152
1759
  }
1153
1760
  };
1154
1761
  }
@@ -1307,12 +1914,88 @@ var AgentOperationScheduler = class {
1307
1914
  }
1308
1915
  };
1309
1916
 
1917
+ // src/backfill-lifecycle.ts
1918
+ var BackfillLifecycle = class {
1919
+ attempts = /* @__PURE__ */ new Map();
1920
+ nextAttemptId = 1;
1921
+ enqueue(agentName) {
1922
+ const current = this.attempts.get(agentName);
1923
+ if (current?.status === "queued" || current?.status === "running") return null;
1924
+ const attempt = {
1925
+ agentName,
1926
+ attemptId: this.nextAttemptId++,
1927
+ status: "queued"
1928
+ };
1929
+ this.attempts.set(agentName, attempt);
1930
+ return attempt;
1931
+ }
1932
+ startNext() {
1933
+ if ([...this.attempts.values()].some((attempt) => attempt.status === "running")) return null;
1934
+ const queued = [...this.attempts.values()].filter((attempt) => attempt.status === "queued").sort((left, right) => left.attemptId - right.attemptId)[0];
1935
+ if (!queued) return null;
1936
+ const running = { ...queued, status: "running" };
1937
+ this.attempts.set(running.agentName, running);
1938
+ return running;
1939
+ }
1940
+ updateProgress(attempt, progress) {
1941
+ const current = this.matchingRunningAttempt(attempt);
1942
+ if (!current) return false;
1943
+ this.attempts.set(attempt.agentName, {
1944
+ ...current,
1945
+ progress: { ...current.progress, ...progress }
1946
+ });
1947
+ return true;
1948
+ }
1949
+ complete(attempt, status) {
1950
+ if (!this.matchingRunningAttempt(attempt)) return false;
1951
+ this.attempts.set(attempt.agentName, { ...attempt, status });
1952
+ return true;
1953
+ }
1954
+ cancelAll() {
1955
+ for (const [agentName, attempt] of this.attempts) {
1956
+ if (attempt.status === "queued" || attempt.status === "running") {
1957
+ this.attempts.set(agentName, { ...attempt, status: "cancelled" });
1958
+ }
1959
+ }
1960
+ }
1961
+ runningAttempt() {
1962
+ const running = [...this.attempts.values()].find((attempt) => attempt.status === "running");
1963
+ return running ?? null;
1964
+ }
1965
+ stateFor(agentName) {
1966
+ const state = this.attempts.get(agentName);
1967
+ if (!state) return void 0;
1968
+ return state.status === "running" && state.progress ? { ...state, progress: { ...state.progress } } : { ...state };
1969
+ }
1970
+ status() {
1971
+ const states = [...this.attempts.values()];
1972
+ const queued = states.filter((attempt) => attempt.status === "queued").sort((left, right) => left.attemptId - right.attemptId);
1973
+ const running = states.find((attempt) => attempt.status === "running");
1974
+ const status = {
1975
+ active: running != null || queued.length > 0,
1976
+ pendingAgents: queued.map((attempt) => attempt.agentName),
1977
+ completedAgents: states.filter((attempt) => attempt.status === "committed").map((attempt) => attempt.agentName),
1978
+ failedAgents: states.filter((attempt) => attempt.status === "failed").map((attempt) => attempt.agentName)
1979
+ };
1980
+ if (running) {
1981
+ status.currentAgent = running.agentName;
1982
+ if (running.progress) status.progress = { ...running.progress };
1983
+ }
1984
+ return status;
1985
+ }
1986
+ matchingRunningAttempt(attempt) {
1987
+ const current = this.attempts.get(attempt.agentName);
1988
+ return current?.status === "running" && current.attemptId === attempt.attemptId ? current : null;
1989
+ }
1990
+ };
1991
+
1310
1992
  // src/live-session-index.ts
1311
1993
  var LiveSessionIndex = class {
1312
1994
  agents = [];
1313
1995
  agentsByName = /* @__PURE__ */ new Map();
1314
1996
  byAgent = {};
1315
1997
  sessions = [];
1998
+ scanFailures = {};
1316
1999
  signatureCaches = /* @__PURE__ */ new Map();
1317
2000
  initialize(snapshot, options = {}) {
1318
2001
  const agentMap = /* @__PURE__ */ new Map();
@@ -1324,8 +2007,12 @@ var LiveSessionIndex = class {
1324
2007
  (agent) => !options.allowedAgents || options.allowedAgents.has(agent.name.toLowerCase())
1325
2008
  );
1326
2009
  this.agentsByName = new Map(this.agents.map((agent) => [agent.name, agent]));
2010
+ this.scanFailures = { ...snapshot.scanFailures };
1327
2011
  this.byAgent = Object.fromEntries(
1328
- this.agents.map((agent) => [agent.name, sortSessions(snapshot.byAgent[agent.name] ?? [])])
2012
+ this.agents.flatMap((agent) => {
2013
+ if (this.scanFailures[agent.name] && !(agent.name in snapshot.byAgent)) return [];
2014
+ return [[agent.name, sortSessions(snapshot.byAgent[agent.name] ?? [])]];
2015
+ })
1329
2016
  );
1330
2017
  this.sessions = mergeSortedSessions(Object.values(this.byAgent));
1331
2018
  this.signatureCaches.clear();
@@ -1334,14 +2021,17 @@ var LiveSessionIndex = class {
1334
2021
  return {
1335
2022
  agents: this.agents,
1336
2023
  byAgent: this.byAgent,
1337
- sessions: this.sessions
2024
+ sessions: this.sessions,
2025
+ ...Object.keys(this.scanFailures).length > 0 ? { scanFailures: this.scanFailures } : {}
1338
2026
  };
1339
2027
  }
1340
2028
  findAgent(agentName) {
1341
2029
  return this.agentsByName.get(agentName);
1342
2030
  }
1343
2031
  commitAgentSessions(agentName, nextSessions, candidateChangedIds = []) {
2032
+ delete this.scanFailures[agentName];
1344
2033
  const previousSessions = this.byAgent[agentName] ?? [];
2034
+ const previousGlobalSessions = this.sessions;
1345
2035
  const signatureCache = this.signatureCache(agentName);
1346
2036
  const { changes, removedSessionIds, counts } = computeSessionDiff(
1347
2037
  previousSessions,
@@ -1354,19 +2044,34 @@ var LiveSessionIndex = class {
1354
2044
  this.byAgent[agentName] = sortSessions(nextSessions);
1355
2045
  this.sessions = mergeSortedSessions(Object.values(this.byAgent));
1356
2046
  if (counts.new === 0 && counts.updated === 0 && counts.removed === 0) return null;
2047
+ const changedSessionHeads = changes.map(({ session }) => ({
2048
+ reference: { agentName, sessionId: session.id },
2049
+ session
2050
+ }));
2051
+ const previousSessionIds = new Set(previousSessions.map((session) => session.id));
2052
+ const newSessionRefs = changedSessionHeads.flatMap(
2053
+ ({ reference }) => previousSessionIds.has(reference.sessionId) ? [] : [reference]
2054
+ );
2055
+ const removedSessionRefs = removedSessionIds.map((sessionId) => ({ agentName, sessionId }));
2056
+ const projectionContext = createSessionProjectionContext(
2057
+ previousGlobalSessions,
2058
+ this.sessions,
2059
+ changedSessionHeads,
2060
+ removedSessionRefs
2061
+ );
1357
2062
  return {
1358
2063
  type: "sessions-updated",
1359
2064
  changedAgents: [agentName],
1360
2065
  newSessions: counts.new,
2066
+ newSessionRefs,
1361
2067
  updatedSessions: counts.updated,
1362
2068
  removedSessions: counts.removed,
1363
2069
  totalSessions: this.sessions.length,
1364
2070
  timestamp: Date.now(),
1365
- changedSessionHeads: changes.map(({ session }) => ({
1366
- reference: { agentName, sessionId: session.id },
1367
- session
1368
- })),
1369
- removedSessionRefs: removedSessionIds.map((sessionId) => ({ agentName, sessionId }))
2071
+ changedSessionHeads,
2072
+ projectionRelatedSessionHeads: projectionContext.relatedSessionHeads,
2073
+ projectionSessionOrder: projectionContext.sessionOrder,
2074
+ removedSessionRefs
1370
2075
  };
1371
2076
  }
1372
2077
  signatureCache(agentName) {
@@ -1378,11 +2083,79 @@ var LiveSessionIndex = class {
1378
2083
  }
1379
2084
  };
1380
2085
 
2086
+ // src/latest-value-throttle.ts
2087
+ var LatestValueThrottle = class {
2088
+ constructor(intervalMs, emit) {
2089
+ this.intervalMs = intervalMs;
2090
+ this.emit = emit;
2091
+ }
2092
+ intervalMs;
2093
+ emit;
2094
+ timer = null;
2095
+ phase = null;
2096
+ pending;
2097
+ hasPending = false;
2098
+ push(value, phase) {
2099
+ if (this.phase !== phase) {
2100
+ this.flush();
2101
+ this.phase = phase;
2102
+ this.emit(value);
2103
+ this.schedule();
2104
+ return;
2105
+ }
2106
+ if (this.timer) {
2107
+ this.pending = value;
2108
+ this.hasPending = true;
2109
+ return;
2110
+ }
2111
+ this.emit(value);
2112
+ this.schedule();
2113
+ }
2114
+ flush() {
2115
+ this.clearTimer();
2116
+ if (!this.hasPending) return;
2117
+ const pending = this.pending;
2118
+ this.pending = void 0;
2119
+ this.hasPending = false;
2120
+ this.emit(pending);
2121
+ }
2122
+ cancel() {
2123
+ this.dropPending();
2124
+ this.phase = null;
2125
+ }
2126
+ schedule() {
2127
+ this.timer = setTimeout(() => {
2128
+ this.timer = null;
2129
+ if (!this.hasPending) return;
2130
+ const pending = this.pending;
2131
+ this.pending = void 0;
2132
+ this.hasPending = false;
2133
+ this.emit(pending);
2134
+ this.schedule();
2135
+ }, this.intervalMs);
2136
+ }
2137
+ clearTimer() {
2138
+ if (!this.timer) return;
2139
+ clearTimeout(this.timer);
2140
+ this.timer = null;
2141
+ }
2142
+ dropPending() {
2143
+ this.clearTimer();
2144
+ this.pending = void 0;
2145
+ this.hasPending = false;
2146
+ }
2147
+ };
2148
+
1381
2149
  // src/search-index-job-runner.ts
1382
2150
  import { existsSync as existsSync2 } from "fs";
1383
2151
  import { fileURLToPath as fileURLToPath2 } from "url";
1384
2152
  import { Worker } from "worker_threads";
1385
2153
 
2154
+ // src/errors.ts
2155
+ function toError(value) {
2156
+ return value instanceof Error ? value : new Error(String(value));
2157
+ }
2158
+
1386
2159
  // src/pending-search-index-jobs.ts
1387
2160
  var PendingSearchIndexJobBatch = class {
1388
2161
  constructor(id, context, jobs, waiter) {
@@ -1395,6 +2168,7 @@ var PendingSearchIndexJobBatch = class {
1395
2168
  jobsByAgent = /* @__PURE__ */ new Map();
1396
2169
  waiters = [];
1397
2170
  settled = false;
2171
+ started = false;
1398
2172
  get jobs() {
1399
2173
  const jobs = [];
1400
2174
  for (const pending of this.jobsByAgent.values()) {
@@ -1411,6 +2185,17 @@ var PendingSearchIndexJobBatch = class {
1411
2185
  }
1412
2186
  return count;
1413
2187
  }
2188
+ start(onError) {
2189
+ if (this.started) return;
2190
+ this.started = true;
2191
+ for (const waiter of this.waiters) {
2192
+ try {
2193
+ waiter.onStarted?.();
2194
+ } catch (error) {
2195
+ onError(error);
2196
+ }
2197
+ }
2198
+ }
1414
2199
  merge(context, jobs, waiter) {
1415
2200
  this.context = context;
1416
2201
  this.waiters.push(waiter);
@@ -1440,6 +2225,7 @@ var PendingSearchIndexJobBatch = class {
1440
2225
  };
1441
2226
  var PendingSearchIndexJobs = class {
1442
2227
  pendingBatch = null;
2228
+ batches = /* @__PURE__ */ new WeakSet();
1443
2229
  get batchCount() {
1444
2230
  return this.pendingBatch ? 1 : 0;
1445
2231
  }
@@ -1449,14 +2235,15 @@ var PendingSearchIndexJobs = class {
1449
2235
  get changeCount() {
1450
2236
  return this.pendingBatch?.changeCount ?? 0;
1451
2237
  }
1452
- enqueue(id, context, jobs) {
2238
+ enqueue(id, context, jobs, onStarted) {
1453
2239
  if (jobs.length === 0) return Promise.resolve();
1454
2240
  return new Promise((resolve4, reject) => {
1455
- const waiter = { resolve: resolve4, reject };
2241
+ const waiter = { resolve: resolve4, reject, onStarted };
1456
2242
  if (this.pendingBatch) {
1457
2243
  this.pendingBatch.merge(context, jobs, waiter);
1458
2244
  } else {
1459
2245
  this.pendingBatch = new PendingSearchIndexJobBatch(id, context, jobs, waiter);
2246
+ this.batches.add(this.pendingBatch);
1460
2247
  }
1461
2248
  });
1462
2249
  }
@@ -1466,7 +2253,9 @@ var PendingSearchIndexJobs = class {
1466
2253
  return batch;
1467
2254
  }
1468
2255
  settle(batch, error) {
1469
- return batch instanceof PendingSearchIndexJobBatch && batch.settle(error);
2256
+ if (!(batch instanceof PendingSearchIndexJobBatch) || !this.batches.has(batch)) return false;
2257
+ this.batches.delete(batch);
2258
+ return batch.settle(error);
1470
2259
  }
1471
2260
  rejectAll(error) {
1472
2261
  const batch = this.take();
@@ -1475,8 +2264,10 @@ var PendingSearchIndexJobs = class {
1475
2264
  };
1476
2265
  function createPendingChanges(job) {
1477
2266
  return {
2267
+ kind: job.kind,
1478
2268
  context: job.context,
1479
2269
  agentName: job.agentName,
2270
+ ...job.kind === "changes" && job.publicationId ? { publicationId: job.publicationId } : {},
1480
2271
  changesBySessionId: /* @__PURE__ */ new Map(),
1481
2272
  removedSessionIds: /* @__PURE__ */ new Set(),
1482
2273
  meta: {},
@@ -1484,7 +2275,11 @@ function createPendingChanges(job) {
1484
2275
  };
1485
2276
  }
1486
2277
  function mergeChanges(pending, job) {
2278
+ pending.kind = job.kind;
1487
2279
  pending.context = job.context;
2280
+ if (job.kind === "changes" && job.publicationId) {
2281
+ pending.publicationId = job.publicationId;
2282
+ }
1488
2283
  pending.searchIndexOptions = mergeSearchIndexOptions(
1489
2284
  pending.searchIndexOptions,
1490
2285
  job.searchIndexOptions
@@ -1511,8 +2306,7 @@ function mergeSearchIndexOptions(current, incoming) {
1511
2306
  return { ...current, ...incoming };
1512
2307
  }
1513
2308
  function changesJobFromPending(pending) {
1514
- return {
1515
- kind: "changes",
2309
+ const common = {
1516
2310
  context: pending.context,
1517
2311
  agentName: pending.agentName,
1518
2312
  changes: [...pending.changesBySessionId.values()],
@@ -1520,6 +2314,11 @@ function changesJobFromPending(pending) {
1520
2314
  meta: pending.meta,
1521
2315
  ...pending.searchIndexOptions ? { searchIndexOptions: pending.searchIndexOptions } : {}
1522
2316
  };
2317
+ return pending.kind === "changes" ? {
2318
+ kind: "changes",
2319
+ ...common,
2320
+ ...pending.publicationId ? { publicationId: pending.publicationId } : {}
2321
+ } : { kind: "maintenance", ...common };
1523
2322
  }
1524
2323
 
1525
2324
  // src/search-index-job-runner.ts
@@ -1529,13 +2328,20 @@ var SearchIndexJobRunner = class {
1529
2328
  activeBatch = null;
1530
2329
  nextBatchId = 1;
1531
2330
  pendingJobs = new PendingSearchIndexJobs();
2331
+ pendingMaintenanceJobs = new PendingSearchIndexJobs();
1532
2332
  isShuttingDown = false;
1533
- enqueue(context, jobs) {
2333
+ enqueue(context, jobs, onStarted) {
2334
+ return this.enqueueIn(this.pendingJobs, context, jobs, onStarted);
2335
+ }
2336
+ enqueueMaintenance(context, jobs) {
2337
+ return this.enqueueIn(this.pendingMaintenanceJobs, context, jobs);
2338
+ }
2339
+ enqueueIn(queue, context, jobs, onStarted) {
1534
2340
  if (jobs.length === 0) return Promise.resolve();
1535
2341
  if (this.isShuttingDown) return Promise.reject(new Error(SHUTDOWN_ERROR_MESSAGE));
1536
2342
  const batchId = this.nextBatchId++;
1537
- const completion = this.pendingJobs.enqueue(batchId, context, jobs);
1538
- if (this.worker) {
2343
+ const completion = queue.enqueue(batchId, context, jobs, onStarted);
2344
+ if (this.activeBatch) {
1539
2345
  const snapshot = this.snapshot();
1540
2346
  appLogger.debug("search_index.worker_queued", {
1541
2347
  batch_id: batchId,
@@ -1555,7 +2361,9 @@ var SearchIndexJobRunner = class {
1555
2361
  activeBatchId: this.activeBatch?.id,
1556
2362
  pendingBatches: this.pendingJobs.batchCount,
1557
2363
  pendingJobs: this.pendingJobs.jobCount,
1558
- pendingChanges: this.pendingJobs.changeCount
2364
+ pendingChanges: this.pendingJobs.changeCount,
2365
+ pendingMaintenanceBatches: this.pendingMaintenanceJobs.batchCount,
2366
+ pendingMaintenanceJobs: this.pendingMaintenanceJobs.jobCount
1559
2367
  };
1560
2368
  }
1561
2369
  async shutdown() {
@@ -1567,17 +2375,25 @@ var SearchIndexJobRunner = class {
1567
2375
  const shutdownError = new Error(SHUTDOWN_ERROR_MESSAGE);
1568
2376
  if (activeBatch) this.settle(activeBatch, shutdownError);
1569
2377
  this.pendingJobs.rejectAll(shutdownError);
2378
+ this.pendingMaintenanceJobs.rejectAll(shutdownError);
1570
2379
  if (worker) await worker.terminate();
1571
2380
  }
1572
2381
  startNextBatch() {
1573
- if (this.isShuttingDown || this.worker) return;
1574
- const batch = this.pendingJobs.take();
2382
+ if (this.isShuttingDown || this.activeBatch) return;
2383
+ const batch = this.pendingJobs.take() ?? this.pendingMaintenanceJobs.take();
1575
2384
  if (!batch) return;
1576
2385
  appLogger.info("search_index.worker_dequeued", {
1577
2386
  batch_id: batch.id,
1578
2387
  context: batch.context,
1579
2388
  pending_batches: this.pendingJobs.batchCount
1580
2389
  });
2390
+ batch.start((error) => {
2391
+ appLogger.error("search_index.worker_start_listener_failed", {
2392
+ batch_id: batch.id,
2393
+ context: batch.context,
2394
+ error: toError(error)
2395
+ });
2396
+ });
1581
2397
  this.startBatch(batch);
1582
2398
  }
1583
2399
  startBatch(batch) {
@@ -1585,10 +2401,27 @@ var SearchIndexJobRunner = class {
1585
2401
  this.settle(batch, new Error(SHUTDOWN_ERROR_MESSAGE));
1586
2402
  return;
1587
2403
  }
2404
+ const request = {
2405
+ type: "run",
2406
+ pricingGenerationId: getPricingGeneration().id,
2407
+ context: batch.context,
2408
+ jobs: batch.jobs
2409
+ };
2410
+ const existingWorker = this.worker;
2411
+ if (existingWorker) {
2412
+ this.activeBatch = batch;
2413
+ try {
2414
+ existingWorker.postMessage(request);
2415
+ } catch (error) {
2416
+ this.invalidateWorker(existingWorker, toError(error));
2417
+ }
2418
+ return;
2419
+ }
1588
2420
  const workerUrl = this.workerUrl();
1589
2421
  if (!workerUrl) {
1590
2422
  appLogger.warn("search_index.worker_missing", { context: batch.context });
1591
- this.settle(batch);
2423
+ this.settle(batch, new Error("Search index worker is unavailable"));
2424
+ this.startNextBatch();
1592
2425
  return;
1593
2426
  }
1594
2427
  appLogger.info("search_index.worker_started", {
@@ -1596,33 +2429,37 @@ var SearchIndexJobRunner = class {
1596
2429
  context: batch.context,
1597
2430
  jobs: batch.jobs.length
1598
2431
  });
1599
- const worker = new Worker(workerUrl, {
1600
- workerData: {
1601
- context: batch.context,
1602
- jobs: batch.jobs,
1603
- agentNames: [],
1604
- sessionsByAgent: {},
1605
- metaByAgent: {}
1606
- }
1607
- });
2432
+ let worker;
2433
+ try {
2434
+ worker = new Worker(workerUrl, { workerData: request });
2435
+ } catch (error) {
2436
+ this.settle(batch, toError(error));
2437
+ this.startNextBatch();
2438
+ return;
2439
+ }
1608
2440
  worker.unref();
1609
2441
  this.worker = worker;
1610
2442
  this.activeBatch = batch;
1611
2443
  worker.on("message", (message) => {
2444
+ if (appLogger.consumeWorkerMessage(message)) return;
2445
+ if (this.worker !== worker) return;
2446
+ const activeBatch = this.activeBatch;
2447
+ if (!activeBatch) return;
1612
2448
  if (message.type === "sync-result") {
1613
2449
  logSearchIndexSync(message.context, message.result);
1614
2450
  return;
1615
2451
  }
1616
2452
  if (message.type === "persist-failed") {
1617
2453
  appLogger.error("search_index.persist_failed", {
1618
- batch_id: batch.id,
2454
+ batch_id: activeBatch.id,
1619
2455
  context: message.context,
1620
2456
  stage: message.stage,
2457
+ publication_id: message.publicationId,
1621
2458
  agent: message.agentName,
1622
2459
  sessions: message.sessions
1623
2460
  });
1624
- this.settle(
1625
- batch,
2461
+ this.finishBatch(
2462
+ activeBatch,
1626
2463
  new Error(
1627
2464
  `Search index worker failed to persist ${message.stage} for ${message.agentName}`
1628
2465
  )
@@ -1634,30 +2471,56 @@ var SearchIndexJobRunner = class {
1634
2471
  duration_ms: Math.round(message.durationMs),
1635
2472
  sessions: message.sessions
1636
2473
  });
1637
- this.settle(batch);
2474
+ this.finishBatch(activeBatch);
1638
2475
  });
1639
2476
  worker.on("error", (error) => {
1640
- appLogger.error("search_index.worker_error", { context: batch.context, error });
1641
- this.settle(batch, error);
2477
+ appLogger.error("search_index.worker_error", {
2478
+ context: this.activeBatch?.context ?? batch.context,
2479
+ error
2480
+ });
2481
+ this.invalidateWorker(worker, toError(error));
1642
2482
  });
1643
- worker.on("exit", (code) => this.finishWorker(worker, batch, code));
2483
+ worker.on("exit", (code) => this.finishWorker(worker, code));
2484
+ }
2485
+ finishBatch(batch, error) {
2486
+ if (this.activeBatch !== batch) return;
2487
+ this.activeBatch = null;
2488
+ this.settle(batch, error);
2489
+ this.startNextBatch();
2490
+ }
2491
+ invalidateWorker(worker, error) {
2492
+ if (this.worker !== worker) return;
2493
+ this.worker = null;
2494
+ const batch = this.activeBatch;
2495
+ this.activeBatch = null;
2496
+ if (batch) this.settle(batch, error);
2497
+ void worker.terminate();
2498
+ this.startNextBatch();
1644
2499
  }
1645
- finishWorker(worker, batch, code) {
2500
+ finishWorker(worker, code) {
2501
+ const batch = this.worker === worker ? this.activeBatch : null;
1646
2502
  appLogger.info("search_index.worker_exited", {
1647
- batch_id: batch.id,
1648
- context: batch.context,
2503
+ batch_id: batch?.id,
2504
+ context: batch?.context,
1649
2505
  code,
1650
2506
  shutting_down: this.isShuttingDown || void 0
1651
2507
  });
1652
- if (this.worker === worker) this.worker = null;
1653
- if (this.activeBatch === batch) this.activeBatch = null;
1654
- const error = code === 0 ? new Error("Search index worker exited before completing its batch") : new Error(`Search index worker exited with code ${code}`);
1655
- if (code !== 0) appLogger.warn("search_index.worker_exit", { context: batch.context, code });
1656
- this.settle(batch, error);
2508
+ if (this.worker !== worker) return;
2509
+ this.worker = null;
2510
+ this.activeBatch = null;
2511
+ if (batch) {
2512
+ const error = code === 0 ? new Error("Search index worker exited before completing its batch") : new Error(`Search index worker exited with code ${code}`);
2513
+ if (code !== 0) {
2514
+ appLogger.warn("search_index.worker_exit", { context: batch.context, code });
2515
+ }
2516
+ this.settle(batch, error);
2517
+ }
1657
2518
  this.startNextBatch();
1658
2519
  }
1659
2520
  settle(batch, error) {
1660
- if (!this.pendingJobs.settle(batch, error)) return;
2521
+ if (!this.pendingJobs.settle(batch, error) && !this.pendingMaintenanceJobs.settle(batch, error)) {
2522
+ return;
2523
+ }
1661
2524
  appLogger.info("search_index.worker_settled", {
1662
2525
  batch_id: batch.id,
1663
2526
  context: batch.context,
@@ -1671,6 +2534,143 @@ var SearchIndexJobRunner = class {
1671
2534
  }
1672
2535
  };
1673
2536
 
2537
+ // src/search-index-maintenance-scheduler.ts
2538
+ var MAINTENANCE_BATCH_SIZE = 4;
2539
+ var SearchIndexMaintenanceScheduler = class {
2540
+ constructor(runner, onStatus) {
2541
+ this.runner = runner;
2542
+ this.onStatus = onStatus;
2543
+ }
2544
+ runner;
2545
+ onStatus;
2546
+ pendingAgents = /* @__PURE__ */ new Set();
2547
+ completedAgents = /* @__PURE__ */ new Set();
2548
+ failedAgents = /* @__PURE__ */ new Set();
2549
+ cachedSessionsByAgent = /* @__PURE__ */ new Map();
2550
+ currentAgent;
2551
+ remaining;
2552
+ pumpPromise = null;
2553
+ stopped = false;
2554
+ enqueue(agentName) {
2555
+ if (this.stopped) return;
2556
+ this.pendingAgents.add(agentName);
2557
+ this.completedAgents.delete(agentName);
2558
+ this.failedAgents.delete(agentName);
2559
+ this.cachedSessionsByAgent.delete(agentName);
2560
+ this.startPump();
2561
+ }
2562
+ stop() {
2563
+ this.stopped = true;
2564
+ this.pendingAgents.clear();
2565
+ this.cachedSessionsByAgent.clear();
2566
+ this.publishStatus();
2567
+ }
2568
+ async waitForIdle() {
2569
+ while (this.pumpPromise) await this.pumpPromise;
2570
+ }
2571
+ startPump() {
2572
+ if (this.pumpPromise || this.stopped) return;
2573
+ this.pumpPromise = this.pump().finally(() => {
2574
+ this.pumpPromise = null;
2575
+ if (this.pendingAgents.size > 0 && !this.stopped) this.startPump();
2576
+ });
2577
+ }
2578
+ async pump() {
2579
+ while (!this.stopped && this.pendingAgents.size > 0) {
2580
+ const agentName = this.pendingAgents.values().next().value;
2581
+ this.pendingAgents.delete(agentName);
2582
+ this.currentAgent = agentName;
2583
+ try {
2584
+ await this.runBatch(agentName);
2585
+ } catch (error) {
2586
+ this.cachedSessionsByAgent.delete(agentName);
2587
+ if (!this.stopped) {
2588
+ this.failedAgents.add(agentName);
2589
+ appLogger.error("search_index.maintenance_failed", {
2590
+ agent: agentName,
2591
+ error: toError(error)
2592
+ });
2593
+ }
2594
+ } finally {
2595
+ this.currentAgent = void 0;
2596
+ this.remaining = void 0;
2597
+ this.publishStatus();
2598
+ }
2599
+ }
2600
+ }
2601
+ async runBatch(agentName) {
2602
+ const pending = readPendingSearchIndexMaintenance(agentName, MAINTENANCE_BATCH_SIZE);
2603
+ if (!pending) throw new Error("Search index storage is unavailable");
2604
+ if (pending.total === 0) {
2605
+ this.completedAgents.add(agentName);
2606
+ return;
2607
+ }
2608
+ this.remaining = pending.total;
2609
+ this.publishStatus();
2610
+ const cached = this.cachedSessionsByAgent.get(agentName) ?? loadCachedSessions(agentName);
2611
+ if (!cached) throw new Error(`Session cache is unavailable for ${agentName}`);
2612
+ this.cachedSessionsByAgent.set(agentName, cached);
2613
+ const sessionsById = new Map(
2614
+ cached.sessions.map((session, sortIndex) => [session.id, { session, sortIndex }])
2615
+ );
2616
+ const changes = pending.sessionIds.flatMap((sessionId) => {
2617
+ const change = sessionsById.get(sessionId);
2618
+ return change ? [change] : [];
2619
+ });
2620
+ if (changes.length === 0) {
2621
+ throw new Error(`No cached sessions matched pending maintenance for ${agentName}`);
2622
+ }
2623
+ const meta = Object.fromEntries(
2624
+ changes.flatMap(({ session }) => {
2625
+ const sessionMeta = cached.meta[session.id];
2626
+ return sessionMeta ? [[session.id, sessionMeta]] : [];
2627
+ })
2628
+ );
2629
+ const job = {
2630
+ kind: "maintenance",
2631
+ context: "search.maintenance",
2632
+ agentName,
2633
+ changes,
2634
+ removedSessionIds: [],
2635
+ meta,
2636
+ searchIndexOptions: { isBulk: false }
2637
+ };
2638
+ appLogger.info("search_index.maintenance_batch_started", {
2639
+ agent: agentName,
2640
+ batch_sessions: changes.length,
2641
+ remaining: pending.total
2642
+ });
2643
+ await this.runner.enqueueMaintenance("search.maintenance", [job]);
2644
+ const next = readPendingSearchIndexMaintenance(agentName, MAINTENANCE_BATCH_SIZE);
2645
+ if (!next) throw new Error("Search index storage became unavailable");
2646
+ if (next.total >= pending.total) {
2647
+ throw new Error(`Search index maintenance made no progress for ${agentName}`);
2648
+ }
2649
+ appLogger.info("search_index.maintenance_batch_completed", {
2650
+ agent: agentName,
2651
+ indexed: pending.total - next.total,
2652
+ remaining: next.total
2653
+ });
2654
+ if (next.total > 0) {
2655
+ this.pendingAgents.add(agentName);
2656
+ this.remaining = next.total;
2657
+ return;
2658
+ }
2659
+ this.cachedSessionsByAgent.delete(agentName);
2660
+ this.completedAgents.add(agentName);
2661
+ }
2662
+ publishStatus() {
2663
+ this.onStatus({
2664
+ active: this.currentAgent != null || this.pendingAgents.size > 0,
2665
+ pendingAgents: [...this.pendingAgents],
2666
+ ...this.currentAgent ? { currentAgent: this.currentAgent } : {},
2667
+ ...this.remaining != null ? { remaining: this.remaining } : {},
2668
+ completedAgents: [...this.completedAgents],
2669
+ failedAgents: [...this.failedAgents]
2670
+ });
2671
+ }
2672
+ };
2673
+
1674
2674
  // src/scan-status-model.ts
1675
2675
  var ScanStatusModel = class {
1676
2676
  status = {
@@ -1682,7 +2682,13 @@ var ScanStatusModel = class {
1682
2682
  agentStatuses: {},
1683
2683
  totalAgents: 0,
1684
2684
  updatedAt: Date.now(),
1685
- backfill: { active: false, pendingAgents: [], completedAgents: [], failedAgents: [] }
2685
+ backfill: { active: false, pendingAgents: [], completedAgents: [], failedAgents: [] },
2686
+ searchIndexMaintenance: {
2687
+ active: false,
2688
+ pendingAgents: [],
2689
+ completedAgents: [],
2690
+ failedAgents: []
2691
+ }
1686
2692
  };
1687
2693
  snapshot() {
1688
2694
  const backfill = {
@@ -1708,7 +2714,13 @@ var ScanStatusModel = class {
1708
2714
  { ...status }
1709
2715
  ])
1710
2716
  ),
1711
- backfill
2717
+ backfill,
2718
+ searchIndexMaintenance: {
2719
+ ...this.status.searchIndexMaintenance,
2720
+ pendingAgents: [...this.status.searchIndexMaintenance.pendingAgents],
2721
+ completedAgents: [...this.status.searchIndexMaintenance.completedAgents],
2722
+ failedAgents: [...this.status.searchIndexMaintenance.failedAgents]
2723
+ }
1712
2724
  };
1713
2725
  }
1714
2726
  startBatch(agentNames, phase, sessionCounts) {
@@ -1778,7 +2790,7 @@ var ScanStatusModel = class {
1778
2790
  }
1779
2791
  updateAgent(agentName, progress) {
1780
2792
  const status = this.status.agentStatuses[agentName];
1781
- if (!status || status.status === "pending" || status.status === "complete" || status.status === "indexing" && progress.phase !== "finalizing") {
2793
+ if (!status || status.status === "pending" || status.status === "complete" || (status.status === "publish-queued" || status.status === "publishing") && progress.phase !== "finalizing") {
1782
2794
  return null;
1783
2795
  }
1784
2796
  const now = Date.now();
@@ -1801,7 +2813,7 @@ var ScanStatusModel = class {
1801
2813
  updatedAt: now
1802
2814
  });
1803
2815
  }
1804
- indexAgent(agentName) {
2816
+ queueAgentPublication(agentName) {
1805
2817
  const status = this.status.agentStatuses[agentName];
1806
2818
  if (!this.status.active || !status || status.status !== "scanning" && status.status !== "finalizing") {
1807
2819
  return null;
@@ -1809,7 +2821,24 @@ var ScanStatusModel = class {
1809
2821
  const now = Date.now();
1810
2822
  const agentStatuses = {
1811
2823
  ...this.status.agentStatuses,
1812
- [agentName]: { ...status, status: "indexing", updatedAt: now }
2824
+ [agentName]: { ...status, status: "publish-queued", updatedAt: now }
2825
+ };
2826
+ return this.set({
2827
+ ...this.status,
2828
+ phase: this.activePhase(this.status.pendingAgents, this.status.scanningAgents, agentStatuses),
2829
+ agentStatuses,
2830
+ updatedAt: now
2831
+ });
2832
+ }
2833
+ publishAgent(agentName) {
2834
+ const status = this.status.agentStatuses[agentName];
2835
+ if (!this.status.active || !status || status.status !== "scanning" && status.status !== "finalizing" && status.status !== "publish-queued") {
2836
+ return null;
2837
+ }
2838
+ const now = Date.now();
2839
+ const agentStatuses = {
2840
+ ...this.status.agentStatuses,
2841
+ [agentName]: { ...status, status: "publishing", updatedAt: now }
1813
2842
  };
1814
2843
  return this.set({
1815
2844
  ...this.status,
@@ -1851,6 +2880,38 @@ var ScanStatusModel = class {
1851
2880
  completedAt: isActive ? void 0 : now
1852
2881
  });
1853
2882
  }
2883
+ failAgent(agentName, error, sessionCount) {
2884
+ const pendingAgents = this.status.pendingAgents.filter((agent) => agent !== agentName);
2885
+ const scanningAgents = this.status.scanningAgents.filter((agent) => agent !== agentName);
2886
+ const completedAgents = this.status.completedAgents.filter((agent) => agent !== agentName);
2887
+ const isActive = pendingAgents.length > 0 || scanningAgents.length > 0;
2888
+ const now = Date.now();
2889
+ const previousStatus = this.status.agentStatuses[agentName];
2890
+ return this.set({
2891
+ ...this.status,
2892
+ active: isActive,
2893
+ phase: isActive ? this.activePhase(pendingAgents, scanningAgents, this.status.agentStatuses) : "idle",
2894
+ pendingAgents,
2895
+ scanningAgents,
2896
+ completedAgents,
2897
+ agentStatuses: {
2898
+ ...this.status.agentStatuses,
2899
+ [agentName]: {
2900
+ agentName,
2901
+ status: "failed",
2902
+ error,
2903
+ total: previousStatus?.total,
2904
+ processed: previousStatus?.processed,
2905
+ sessions: sessionCount ?? previousStatus?.sessions ?? 0,
2906
+ startedAt: previousStatus?.startedAt,
2907
+ updatedAt: now,
2908
+ completedAt: now
2909
+ }
2910
+ },
2911
+ updatedAt: now,
2912
+ completedAt: isActive ? void 0 : now
2913
+ });
2914
+ }
1854
2915
  finishBatch() {
1855
2916
  const now = Date.now();
1856
2917
  return this.set({
@@ -1862,26 +2923,49 @@ var ScanStatusModel = class {
1862
2923
  agentStatuses: Object.fromEntries(
1863
2924
  Object.entries(this.status.agentStatuses).map(([agentName, status]) => [
1864
2925
  agentName,
1865
- { ...status, status: "complete", completedAt: status.completedAt ?? now, updatedAt: now }
2926
+ status.status === "failed" ? { ...status, completedAt: status.completedAt ?? now, updatedAt: now } : {
2927
+ ...status,
2928
+ status: "complete",
2929
+ completedAt: status.completedAt ?? now,
2930
+ updatedAt: now
2931
+ }
1866
2932
  ])
1867
2933
  ),
1868
2934
  updatedAt: now,
1869
2935
  completedAt: now
1870
2936
  });
1871
2937
  }
1872
- updateBackfill(patch) {
2938
+ updateBackfill(backfill) {
2939
+ return this.set({
2940
+ ...this.status,
2941
+ backfill: {
2942
+ ...backfill,
2943
+ pendingAgents: [...backfill.pendingAgents],
2944
+ completedAgents: [...backfill.completedAgents],
2945
+ failedAgents: [...backfill.failedAgents],
2946
+ progress: backfill.progress ? { ...backfill.progress } : void 0
2947
+ },
2948
+ updatedAt: Date.now()
2949
+ });
2950
+ }
2951
+ updateSearchIndexMaintenance(searchIndexMaintenance) {
1873
2952
  return this.set({
1874
2953
  ...this.status,
1875
- backfill: { ...this.status.backfill, ...patch },
2954
+ searchIndexMaintenance: {
2955
+ ...searchIndexMaintenance,
2956
+ pendingAgents: [...searchIndexMaintenance.pendingAgents],
2957
+ completedAgents: [...searchIndexMaintenance.completedAgents],
2958
+ failedAgents: [...searchIndexMaintenance.failedAgents]
2959
+ },
1876
2960
  updatedAt: Date.now()
1877
2961
  });
1878
2962
  }
1879
2963
  activePhase(pendingAgents, scanningAgents, agentStatuses) {
1880
2964
  if (pendingAgents.length === 0 && scanningAgents.length > 0 && scanningAgents.every((agentName) => {
1881
2965
  const status = agentStatuses[agentName]?.status;
1882
- return status === "finalizing" || status === "indexing";
2966
+ return status === "finalizing" || status === "publish-queued" || status === "publishing";
1883
2967
  })) {
1884
- return "indexing";
2968
+ return "publishing";
1885
2969
  }
1886
2970
  return this.status.phase === "initializing" ? "initializing" : "scanning";
1887
2971
  }
@@ -1897,14 +2981,26 @@ var EMPTY_AGENT_REFRESH_DEBOUNCE_MS = 3e4;
1897
2981
  var SEARCH_INDEX_BULK_PENDING_PATH_THRESHOLD = 100;
1898
2982
  var BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1899
2983
  var CACHE_TRUNCATION_COVERAGE = 0.5;
1900
- function buildPersistenceDiff(previousSessions, nextSessions, candidateChangedIds = []) {
2984
+ var STATUS_PROGRESS_INTERVAL_MS = 100;
2985
+ function buildPersistenceDiff(previousSessions, nextSessions, candidateChangedIds = [], completeness = "complete", explicitRemovedSessionIds = []) {
1901
2986
  const { changes, removedSessionIds } = computeSessionDiff(
1902
2987
  previousSessions,
1903
2988
  nextSessions,
1904
2989
  candidateChangedIds,
1905
2990
  sessionSignature
1906
2991
  );
1907
- return { changedSessions: changes, removedSessionIds };
2992
+ if (completeness === "complete") {
2993
+ return { changedSessions: changes, removedSessionIds };
2994
+ }
2995
+ const explicitRemovals = new Set(explicitRemovedSessionIds);
2996
+ return {
2997
+ changedSessions: changes,
2998
+ removedSessionIds: removedSessionIds.filter((sessionId) => explicitRemovals.has(sessionId))
2999
+ };
3000
+ }
3001
+ function sourceFailureError(failures) {
3002
+ const noun = failures.length === 1 ? "source" : "sources";
3003
+ return new Error(`${failures.length} session ${noun} failed; last-known-good data retained`);
1908
3004
  }
1909
3005
  function restoreAgentCacheMeta(agent, cached) {
1910
3006
  agent.setSessionMetaMap(new Map(Object.entries(cached.meta)));
@@ -1913,21 +3009,24 @@ var AgentSyncEngine = class {
1913
3009
  constructor(options) {
1914
3010
  this.options = options;
1915
3011
  this.scheduler = new AgentOperationScheduler((agentName) => this.performRefresh(agentName));
3012
+ this.searchIndexJobs = new SearchIndexJobRunner();
3013
+ this.searchIndexMaintenance = new SearchIndexMaintenanceScheduler(
3014
+ this.searchIndexJobs,
3015
+ (status) => this.publishStatus(this.scanStatus.updateSearchIndexMaintenance(status))
3016
+ );
1916
3017
  }
1917
3018
  options;
1918
3019
  lastRefreshAtByAgent = /* @__PURE__ */ new Map();
1919
3020
  scheduler;
1920
3021
  sessionIndex = new LiveSessionIndex();
1921
- backfillQueue = [];
1922
- currentBackfillAgent;
1923
- currentBackfillProgress;
1924
- cacheIntegrityCheckedAgents = /* @__PURE__ */ new Set();
1925
- completedBackfillAgents = [];
1926
- failedBackfillAgents = [];
3022
+ backfills = new BackfillLifecycle();
3023
+ cacheIntegrityValidUntilByAgent = /* @__PURE__ */ new Map();
1927
3024
  sessionsChangedListeners = /* @__PURE__ */ new Set();
1928
3025
  statusChangedListeners = /* @__PURE__ */ new Set();
3026
+ statusProgressThrottles = /* @__PURE__ */ new Map();
1929
3027
  scanStatus = new ScanStatusModel();
1930
- searchIndexJobs = new SearchIndexJobRunner();
3028
+ searchIndexJobs;
3029
+ searchIndexMaintenance;
1931
3030
  nextPublicationId = 1;
1932
3031
  backgroundRefreshTimer = null;
1933
3032
  isShuttingDown = false;
@@ -1940,6 +3039,13 @@ var AgentSyncEngine = class {
1940
3039
  options.cacheTimestamps?.[agent.name] ?? Date.now()
1941
3040
  );
1942
3041
  }
3042
+ for (const [agentName, failure] of Object.entries(snapshot.scanFailures ?? {})) {
3043
+ this.scanStatus.failAgent(
3044
+ agentName,
3045
+ `${failure.stage}: ${failure.message}`,
3046
+ snapshot.byAgent[agentName]?.length ?? 0
3047
+ );
3048
+ }
1943
3049
  }
1944
3050
  snapshot() {
1945
3051
  return this.sessionIndex.snapshot();
@@ -1961,6 +3067,7 @@ var AgentSyncEngine = class {
1961
3067
  publicationId: this.publicationId("scan.initial"),
1962
3068
  agents: jobs.map((job) => job.agentName)
1963
3069
  });
3070
+ for (const job of jobs) this.searchIndexMaintenance.enqueue(job.agentName);
1964
3071
  }
1965
3072
  handleAgentsChanged(agentNames) {
1966
3073
  const snapshot = this.sessionIndex.snapshot();
@@ -1988,7 +3095,7 @@ var AgentSyncEngine = class {
1988
3095
  const activeOperations = {
1989
3096
  agent_operations: schedulerSnapshot.activeOperations,
1990
3097
  refreshes: schedulerSnapshot.activeRefreshes,
1991
- backfill_running: this.currentBackfillAgent != null || void 0,
3098
+ backfill_running: this.backfills.runningAttempt()?.agentName || void 0,
1992
3099
  scan_workers: this.options.workerRunner.activeCount
1993
3100
  };
1994
3101
  if (activeOperations.agent_operations > 0 || activeOperations.scan_workers > 0) {
@@ -1999,16 +3106,17 @@ var AgentSyncEngine = class {
1999
3106
  clearTimeout(this.backgroundRefreshTimer);
2000
3107
  this.backgroundRefreshTimer = null;
2001
3108
  }
2002
- this.backfillQueue.length = 0;
2003
- this.currentBackfillAgent = void 0;
2004
- this.currentBackfillProgress = void 0;
2005
- this.cacheIntegrityCheckedAgents.clear();
3109
+ this.backfills.cancelAll();
3110
+ this.searchIndexMaintenance.stop();
3111
+ this.cancelProgressStatuses();
3112
+ this.cacheIntegrityValidUntilByAgent.clear();
2006
3113
  const searchIndexSnapshot = this.searchIndexJobs.snapshot();
2007
3114
  appLogger.info("search_index.shutdown.started", {
2008
3115
  active_batch_id: searchIndexSnapshot.activeBatchId,
2009
3116
  pending_batches: searchIndexSnapshot.pendingBatches
2010
3117
  });
2011
3118
  await this.searchIndexJobs.shutdown();
3119
+ await this.searchIndexMaintenance.waitForIdle();
2012
3120
  await this.options.workerRunner.shutdown();
2013
3121
  await this.scheduler.waitForIdle();
2014
3122
  const stoppedSearchIndexSnapshot = this.searchIndexJobs.snapshot();
@@ -2028,41 +3136,106 @@ var AgentSyncEngine = class {
2028
3136
  this.publishStatus(this.scanStatus.setPhase(phase));
2029
3137
  }
2030
3138
  beginAgentScan(agentName) {
3139
+ this.cancelProgressStatus(`scan:${agentName}`);
2031
3140
  const snapshot = this.sessionIndex.snapshot();
2032
3141
  if (!this.scanStatus.snapshot().active) this.startScanBatch([agentName], "scanning");
2033
3142
  this.publishStatus(
2034
3143
  this.scanStatus.beginAgent(agentName, snapshot.byAgent[agentName]?.length ?? 0)
2035
3144
  );
2036
3145
  }
2037
- updateAgentScanProgress(agentName, progress) {
2038
- if (this.currentBackfillAgent === agentName) {
2039
- this.currentBackfillProgress = {
3146
+ updateAgentScanProgress(agentName, progress, backfillAttempt) {
3147
+ if (backfillAttempt) {
3148
+ const key2 = `backfill:${agentName}`;
3149
+ const current = this.backfills.stateFor(agentName);
3150
+ if (current?.status !== "running" || current.attemptId !== backfillAttempt.attemptId) {
3151
+ return;
3152
+ }
3153
+ const currentPhase2 = current.progress?.phase ?? "scanning";
3154
+ const nextPhase2 = progress.phase ?? "scanning";
3155
+ if (currentPhase2 && currentPhase2 !== nextPhase2) this.flushProgressStatus(key2);
3156
+ if (this.backfills.updateProgress(backfillAttempt, {
2040
3157
  phase: progress.phase,
2041
3158
  total: progress.total,
2042
3159
  processed: progress.processed,
2043
3160
  sessions: progress.sessions
2044
- };
2045
- this.publishBackfillStatus();
3161
+ })) {
3162
+ this.publishProgressStatus(
3163
+ key2,
3164
+ nextPhase2,
3165
+ this.scanStatus.updateBackfill(this.backfills.status())
3166
+ );
3167
+ }
2046
3168
  return;
2047
3169
  }
2048
- this.publishStatus(this.scanStatus.updateAgent(agentName, progress));
3170
+ const key = `scan:${agentName}`;
3171
+ const currentPhase = this.scanStatus.snapshot().agentStatuses[agentName]?.status;
3172
+ const nextPhase = progress.phase === "finalizing" ? "finalizing" : "scanning";
3173
+ if (currentPhase && currentPhase !== nextPhase) this.flushProgressStatus(key);
3174
+ const status = this.scanStatus.updateAgent(agentName, progress);
3175
+ this.publishProgressStatus(key, status?.agentStatuses[agentName]?.status ?? nextPhase, status);
3176
+ }
3177
+ beginAgentPublishing(agentName) {
3178
+ this.flushProgressStatus(`scan:${agentName}`);
3179
+ this.publishStatus(this.scanStatus.publishAgent(agentName));
2049
3180
  }
2050
- beginAgentIndexing(agentName) {
2051
- this.publishStatus(this.scanStatus.indexAgent(agentName));
3181
+ queueAgentPublication(agentName) {
3182
+ this.flushProgressStatus(`scan:${agentName}`);
3183
+ this.publishStatus(this.scanStatus.queueAgentPublication(agentName));
2052
3184
  }
2053
3185
  finishAgentScan(agentName) {
3186
+ this.flushProgressStatus(`scan:${agentName}`);
2054
3187
  const count = this.sessionIndex.snapshot().byAgent[agentName]?.length;
2055
3188
  this.publishStatus(this.scanStatus.finishAgent(agentName, count));
2056
3189
  }
2057
3190
  finishScanBatch() {
3191
+ this.flushProgressStatuses("scan:");
2058
3192
  this.publishStatus(this.scanStatus.finishBatch());
2059
3193
  }
2060
3194
  publishBackfillStatus() {
2061
- this.publishStatus(this.scanStatus.updateBackfill(this.backfillStatus()));
3195
+ this.publishStatus(this.scanStatus.updateBackfill(this.backfills.status()));
2062
3196
  }
2063
3197
  publishStatus(event) {
2064
3198
  if (!event || this.isShuttingDown) return;
2065
- for (const listener of this.statusChangedListeners) listener(event);
3199
+ for (const listener of this.statusChangedListeners) {
3200
+ try {
3201
+ listener(event);
3202
+ } catch (error) {
3203
+ appLogger.error("scan.status_listener.error", { error });
3204
+ }
3205
+ }
3206
+ }
3207
+ publishProgressStatus(key, phase, event) {
3208
+ if (!event) return;
3209
+ let throttle = this.statusProgressThrottles.get(key);
3210
+ if (!throttle) {
3211
+ throttle = new LatestValueThrottle(STATUS_PROGRESS_INTERVAL_MS, () => {
3212
+ this.publishStatus(this.scanStatus.snapshot());
3213
+ });
3214
+ this.statusProgressThrottles.set(key, throttle);
3215
+ }
3216
+ throttle.push(void 0, phase);
3217
+ }
3218
+ flushProgressStatus(key) {
3219
+ const throttle = this.statusProgressThrottles.get(key);
3220
+ if (!throttle) return;
3221
+ throttle.flush();
3222
+ throttle.cancel();
3223
+ this.statusProgressThrottles.delete(key);
3224
+ }
3225
+ cancelProgressStatus(key) {
3226
+ const throttle = this.statusProgressThrottles.get(key);
3227
+ if (!throttle) return;
3228
+ throttle.cancel();
3229
+ this.statusProgressThrottles.delete(key);
3230
+ }
3231
+ flushProgressStatuses(prefix) {
3232
+ for (const key of this.statusProgressThrottles.keys()) {
3233
+ if (key.startsWith(prefix)) this.flushProgressStatus(key);
3234
+ }
3235
+ }
3236
+ cancelProgressStatuses() {
3237
+ for (const throttle of this.statusProgressThrottles.values()) throttle.cancel();
3238
+ this.statusProgressThrottles.clear();
2066
3239
  }
2067
3240
  emitSessionsChanged(change) {
2068
3241
  if (this.isShuttingDown) return;
@@ -2070,20 +3243,33 @@ var AgentSyncEngine = class {
2070
3243
  }
2071
3244
  async performRefresh(agentName) {
2072
3245
  this.beginAgentScan(agentName);
3246
+ const startedAt = performance.now();
3247
+ let failed = false;
3248
+ let cached = null;
3249
+ let result = null;
2073
3250
  try {
2074
- return await this.runRefresh(agentName);
3251
+ if (this.findAgent(agentName)) cached = loadCachedSessions(agentName);
3252
+ result = await this.runRefresh(agentName, cached, startedAt);
3253
+ return result;
2075
3254
  } catch (error) {
3255
+ this.options.workerRunner.discard?.(agentName);
3256
+ failed = true;
3257
+ const failure = toError(error);
2076
3258
  appLogger.error("scan.refresh.error", { agent: agentName, error });
2077
3259
  console.error(`[${agentName}] Session refresh failed:`, error);
3260
+ this.flushProgressStatus(`scan:${agentName}`);
3261
+ this.publishStatus(this.scanStatus.failAgent(agentName, failure.message));
2078
3262
  return "failed";
2079
3263
  } finally {
2080
- this.finishAgentScan(agentName);
3264
+ if (!failed) this.finishAgentScan(agentName);
2081
3265
  const agent = this.findAgent(agentName);
2082
- if (agent && this.needsBackfill(agent)) this.enqueueBackfill(agentName);
3266
+ if (agent && this.needsBackfill(agent, cached, failed || result === "committed")) {
3267
+ this.enqueueBackfill(agentName);
3268
+ }
3269
+ if (agent) this.searchIndexMaintenance.enqueue(agentName);
2083
3270
  }
2084
3271
  }
2085
- async runRefresh(agentName) {
2086
- const startedAt = performance.now();
3272
+ async runRefresh(agentName, cached, startedAt) {
2087
3273
  const pendingPathCount = this.scheduler.takePendingSignalCount(agentName);
2088
3274
  const agent = this.findAgent(agentName);
2089
3275
  if (!agent) {
@@ -2091,11 +3277,20 @@ var AgentSyncEngine = class {
2091
3277
  return "skipped";
2092
3278
  }
2093
3279
  const previousSessions = this.sessionIndex.snapshot().byAgent[agentName] ?? [];
2094
- const cached = loadCachedSessions(agentName);
2095
3280
  const refreshBaseline = cached?.sessions ?? previousSessions;
2096
3281
  const cacheTimestamp = cached?.timestamp ?? this.lastRefreshAtByAgent.get(agentName) ?? 0;
2097
3282
  if (cached) restoreAgentCacheMeta(agent, cached);
2098
- const isInitialized = isAgentCacheInitialized(agentName);
3283
+ const durableMeta = new Map(agent.getSessionMetaMap());
3284
+ const durableLastRefreshAt = this.lastRefreshAtByAgent.get(agentName);
3285
+ const initialization = readAgentCacheInitialization(agentName);
3286
+ if (initialization.status === "failed") {
3287
+ appLogger.warn("scan.refresh.cache_state_unavailable", {
3288
+ agent: agentName,
3289
+ state: "initialization"
3290
+ });
3291
+ return "unchanged";
3292
+ }
3293
+ const isInitialized = initialization.value;
2099
3294
  const availabilityStartedAt = performance.now();
2100
3295
  const isAvailable = agent.isAvailable();
2101
3296
  const availabilityDuration = performance.now() - availabilityStartedAt;
@@ -2116,10 +3311,29 @@ var AgentSyncEngine = class {
2116
3311
  } else {
2117
3312
  strategyResult = await this.scanAgentFully(agent, previousSessions);
2118
3313
  }
2119
- if (strategyResult.status === "unchanged") return "unchanged";
3314
+ if (strategyResult.status === "unchanged") {
3315
+ this.options.workerRunner.commit?.(agentName);
3316
+ return "unchanged";
3317
+ }
2120
3318
  const nextSessions = attachMissingProjectIdentities(strategyResult.nextSessions);
2121
3319
  const searchIndexOptions = pendingPathCount >= SEARCH_INDEX_BULK_PENDING_PATH_THRESHOLD ? { isBulk: true } : void 0;
2122
3320
  const persistenceDiff = strategyResult.persistenceDiff;
3321
+ const publicationSessions = strategyResult.fullScanSessions ?? nextSessions;
3322
+ const publicationSessionIds = new Set(publicationSessions.map((session) => session.id));
3323
+ const missingBaselineSessions = refreshBaseline.reduce(
3324
+ (count, session) => count + Number(!publicationSessionIds.has(session.id)),
3325
+ 0
3326
+ );
3327
+ const replacementDeleteCandidates = persistenceDiff ? persistenceDiff.removedSessionIds.length : strategyResult.completeness === "complete" ? missingBaselineSessions : strategyResult.explicitRemovedSessionIds.length;
3328
+ appLogger.info("scan.refresh.persistence_candidate", {
3329
+ agent: agentName,
3330
+ scope_from: strategyResult.scope.from,
3331
+ scope_to: strategyResult.scope.to,
3332
+ publication_completeness: strategyResult.completeness,
3333
+ durable_baseline_sessions: refreshBaseline.length,
3334
+ payload_sessions: publicationSessions.length,
3335
+ delete_candidates: replacementDeleteCandidates
3336
+ });
2123
3337
  const changedSessionIds = persistenceDiff ? new Set(persistenceDiff.changedSessions.map(({ session }) => session.id)) : void 0;
2124
3338
  const persistStartedAt = performance.now();
2125
3339
  const persistentJob = persistenceDiff ? {
@@ -2134,39 +3348,62 @@ var AgentSyncEngine = class {
2134
3348
  kind: "full",
2135
3349
  context: "scan.refresh",
2136
3350
  agentName,
2137
- sessions: strategyResult.fullScanSessions ?? nextSessions,
3351
+ sessions: publicationSessions,
2138
3352
  meta: buildAgentCacheMeta(agent),
3353
+ completeness: strategyResult.completeness,
3354
+ removedSessionIds: strategyResult.explicitRemovedSessionIds,
2139
3355
  saveCache: true,
2140
3356
  ...searchIndexOptions ? { searchIndexOptions } : {}
2141
3357
  };
2142
- this.beginAgentIndexing(agentName);
2143
- const publication = await this.commitSessionPublication({
2144
- context: "scan.refresh",
2145
- agentName,
2146
- sessions: nextSessions,
2147
- candidateChangedIds: strategyResult.preciseChangedIds ?? [],
2148
- indexJob: persistentJob
2149
- });
3358
+ this.queueAgentPublication(agentName);
3359
+ let publication;
3360
+ try {
3361
+ publication = await this.commitSessionPublication({
3362
+ context: "scan.refresh",
3363
+ agentName,
3364
+ sessions: nextSessions,
3365
+ candidateChangedIds: strategyResult.preciseChangedIds ?? [],
3366
+ indexJob: persistentJob,
3367
+ onPublishing: () => this.beginAgentPublishing(agentName)
3368
+ });
3369
+ } catch (error) {
3370
+ agent.setSessionMetaMap(durableMeta);
3371
+ if (durableLastRefreshAt == null) this.lastRefreshAtByAgent.delete(agentName);
3372
+ else this.lastRefreshAtByAgent.set(agentName, durableLastRefreshAt);
3373
+ throw error;
3374
+ }
3375
+ this.options.workerRunner.commit?.(agentName);
2150
3376
  const persistDuration = performance.now() - persistStartedAt;
2151
3377
  logSearchIndexSync("scan.refresh", null, { pending_paths: pendingPathCount });
2152
3378
  const totalDurationMs = performance.now() - startedAt;
2153
3379
  this.scheduler.recordRefreshDuration(agentName, totalDurationMs);
2154
- appLogger.info("scan.refresh.done", {
2155
- agent: agentName,
2156
- duration_ms: Math.round(totalDurationMs),
2157
- sessions: nextSessions.length,
2158
- new_sessions: publication.event?.newSessions ?? 0,
2159
- updated_sessions: publication.event?.updatedSessions ?? 0,
2160
- removed_sessions: publication.event?.removedSessions ?? 0,
2161
- pending_paths: pendingPathCount,
2162
- availability_ms: Math.round(availabilityDuration),
2163
- check_ms: Math.round(strategyResult.checkDuration),
2164
- scan_ms: Math.round(strategyResult.scanDuration),
2165
- diff_ms: Math.round(publication.diffDuration),
2166
- persist_ms: Math.round(persistDuration),
2167
- search_index_ms: Math.round(persistDuration),
2168
- persistent_index_worker_job: persistentJob.kind
2169
- });
3380
+ appLogger.info(
3381
+ strategyResult.sourceFailures.length > 0 ? "scan.refresh.partial" : "scan.refresh.done",
3382
+ {
3383
+ agent: agentName,
3384
+ duration_ms: Math.round(totalDurationMs),
3385
+ sessions: nextSessions.length,
3386
+ new_sessions: publication.event?.newSessions ?? 0,
3387
+ updated_sessions: publication.event?.updatedSessions ?? 0,
3388
+ removed_sessions: publication.event?.removedSessions ?? 0,
3389
+ pending_paths: pendingPathCount,
3390
+ availability_ms: Math.round(availabilityDuration),
3391
+ check_ms: Math.round(strategyResult.checkDuration),
3392
+ scan_ms: Math.round(strategyResult.scanDuration),
3393
+ diff_ms: Math.round(publication.diffDuration),
3394
+ persist_ms: Math.round(persistDuration),
3395
+ search_index_ms: Math.round(persistDuration),
3396
+ persistent_index_worker_job: persistentJob.kind,
3397
+ failed_sources: strategyResult.sourceFailures.length
3398
+ }
3399
+ );
3400
+ if (strategyResult.sourceFailures.length > 0) {
3401
+ appLogger.warn("scan.refresh.source_failures", {
3402
+ agent: agentName,
3403
+ failures: strategyResult.sourceFailures
3404
+ });
3405
+ throw sourceFailureError(strategyResult.sourceFailures);
3406
+ }
2170
3407
  return "committed";
2171
3408
  }
2172
3409
  /**
@@ -2183,28 +3420,29 @@ var AgentSyncEngine = class {
2183
3420
  agent: agentName,
2184
3421
  retained_sessions: previousSessions.length
2185
3422
  });
2186
- return this.refreshStrategyResult(previousSessions, { status: "unchanged" });
3423
+ return this.refreshStrategyResult(previousSessions, "partial", {}, { status: "unchanged" });
2187
3424
  }
2188
- return this.refreshStrategyResult([]);
3425
+ return this.refreshStrategyResult([], "partial", {});
2189
3426
  }
2190
3427
  async initializeAgent(agent, previousSessions) {
2191
3428
  this.setScanPhase("initializing");
2192
3429
  const scanStartedAt = performance.now();
2193
- const result = await this.runWorker(agent, previousSessions, null, this.startupScanOptions(), {
2194
- checkpoint: true
2195
- });
3430
+ const scope = this.startupScanOptions();
3431
+ const result = await this.runWorker(agent, previousSessions, { kind: "full-scan" }, scope);
2196
3432
  agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
2197
3433
  const sessions = attachMissingProjectIdentities(result.sessions);
2198
3434
  this.lastRefreshAtByAgent.set(agent.name, Date.now());
2199
- return this.refreshStrategyResult(sessions, {
3435
+ return this.refreshStrategyResult(sessions, result.completeness, scope, {
2200
3436
  fullScanSessions: sessions,
2201
- scanDuration: performance.now() - scanStartedAt
3437
+ explicitRemovedSessionIds: result.explicitRemovedSessionIds,
3438
+ scanDuration: performance.now() - scanStartedAt,
3439
+ sourceFailures: result.sourceFailures ?? []
2202
3440
  });
2203
3441
  }
2204
3442
  async syncAgentSources(agent, cached, refreshStartedAt) {
2205
3443
  const scanStartedAt = performance.now();
2206
- const result = await this.runWorker(agent, cached.sessions, null, this.startupScanOptions(), {
2207
- sourceSync: true,
3444
+ const scope = this.startupScanOptions();
3445
+ const result = await this.runWorker(agent, cached.sessions, { kind: "source-refresh" }, scope, {
2208
3446
  meta: cached.meta
2209
3447
  });
2210
3448
  agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
@@ -2212,62 +3450,130 @@ var AgentSyncEngine = class {
2212
3450
  const preciseChangedIds = result.changedIds ?? [];
2213
3451
  const persistenceDiff = buildPersistenceDiff(cached.sessions, sessions, preciseChangedIds);
2214
3452
  this.lastRefreshAtByAgent.set(agent.name, Date.now());
2215
- if (persistenceDiff.changedSessions.length === 0 && persistenceDiff.removedSessionIds.length === 0) {
3453
+ if (persistenceDiff.changedSessions.length === 0 && persistenceDiff.removedSessionIds.length === 0 && (result.sourceFailures?.length ?? 0) === 0) {
2216
3454
  this.logUnchangedRefresh(agent.name, refreshStartedAt);
2217
- return this.refreshStrategyResult(sessions, {
3455
+ return this.refreshStrategyResult(sessions, result.completeness, scope, {
2218
3456
  status: "unchanged",
2219
3457
  scanDuration: performance.now() - scanStartedAt
2220
3458
  });
2221
3459
  }
2222
- return this.refreshStrategyResult(sessions, {
3460
+ return this.refreshStrategyResult(sessions, result.completeness, scope, {
2223
3461
  preciseChangedIds,
2224
3462
  persistenceDiff,
2225
- scanDuration: performance.now() - scanStartedAt
3463
+ scanDuration: performance.now() - scanStartedAt,
3464
+ sourceFailures: result.sourceFailures ?? []
2226
3465
  });
2227
3466
  }
2228
3467
  async refreshChangedAgent(agent, baseline, cacheTimestamp, refreshStartedAt) {
3468
+ const scope = this.startupScanOptions();
2229
3469
  const checkStartedAt = performance.now();
2230
3470
  const checkResult = await Promise.resolve(agent.checkForChanges(cacheTimestamp, baseline));
2231
3471
  const checkDuration = performance.now() - checkStartedAt;
3472
+ if (checkResult.status === "failed") {
3473
+ appLogger.warn("scan.refresh.change_check_failed", {
3474
+ agent: agent.name,
3475
+ source_path: checkResult.failure.sourcePath,
3476
+ error_class: checkResult.failure.errorClass,
3477
+ message: checkResult.failure.message
3478
+ });
3479
+ return this.refreshStrategyResult(
3480
+ baseline,
3481
+ "partial",
3482
+ {},
3483
+ { status: "unchanged", checkDuration }
3484
+ );
3485
+ }
2232
3486
  this.lastRefreshAtByAgent.set(agent.name, checkResult.timestamp);
2233
3487
  if (!checkResult.hasChanges) {
2234
- this.logUnchangedRefresh(agent.name, refreshStartedAt);
2235
- return this.refreshStrategyResult(baseline, { status: "unchanged", checkDuration });
3488
+ const scanStartedAt2 = performance.now();
3489
+ const result = await this.runWorker(agent, baseline, { kind: "recompute-derived" }, {});
3490
+ agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
3491
+ const sessions2 = attachMissingProjectIdentities(result.sessions);
3492
+ const persistenceDiff = buildPersistenceDiff(baseline, sessions2);
3493
+ if (persistenceDiff.changedSessions.length === 0 && persistenceDiff.removedSessionIds.length === 0) {
3494
+ this.logUnchangedRefresh(agent.name, refreshStartedAt);
3495
+ return this.refreshStrategyResult(
3496
+ sessions2,
3497
+ result.completeness,
3498
+ {},
3499
+ {
3500
+ status: "unchanged",
3501
+ checkDuration,
3502
+ scanDuration: performance.now() - scanStartedAt2
3503
+ }
3504
+ );
3505
+ }
3506
+ return this.refreshStrategyResult(
3507
+ sessions2,
3508
+ result.completeness,
3509
+ {},
3510
+ {
3511
+ persistenceDiff,
3512
+ checkDuration,
3513
+ scanDuration: performance.now() - scanStartedAt2,
3514
+ sourceFailures: result.sourceFailures ?? []
3515
+ }
3516
+ );
2236
3517
  }
2237
3518
  const preciseChangedIds = checkResult.changedIds ?? null;
2238
3519
  const scanStartedAt = performance.now();
2239
3520
  if (preciseChangedIds === null) {
2240
- const result = await this.runWorker(agent, baseline, null, {}, { checkpoint: true });
3521
+ const result = await this.runWorker(agent, baseline, { kind: "full-scan" }, scope);
2241
3522
  agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
2242
3523
  const sessions2 = attachMissingProjectIdentities(result.sessions);
2243
- return this.refreshStrategyResult(sessions2, {
2244
- persistenceDiff: buildPersistenceDiff(baseline, sessions2),
3524
+ return this.refreshStrategyResult(sessions2, result.completeness, scope, {
3525
+ // Meta-only changes (e.g. a pricing capture epoch bump) leave the head
3526
+ // signature intact; without the worker-reported ids they would never
3527
+ // persist and checkForChanges would rescan on every startup.
3528
+ persistenceDiff: buildPersistenceDiff(
3529
+ baseline,
3530
+ sessions2,
3531
+ result.changedIds ?? [],
3532
+ result.completeness,
3533
+ result.explicitRemovedSessionIds
3534
+ ),
2245
3535
  checkDuration,
2246
- scanDuration: performance.now() - scanStartedAt
3536
+ scanDuration: performance.now() - scanStartedAt,
3537
+ sourceFailures: result.sourceFailures ?? []
2247
3538
  });
2248
3539
  }
3540
+ this.options.workerRunner.discard?.(agent.name);
2249
3541
  const sessions = attachMissingProjectIdentities(
2250
- await Promise.resolve(agent.incrementalScan(baseline, preciseChangedIds, checkResult.refs))
3542
+ await Promise.resolve(
3543
+ agent.incrementalScan(baseline, preciseChangedIds, checkResult.refs, scope)
3544
+ )
2251
3545
  );
2252
- return this.refreshStrategyResult(sessions, {
3546
+ const sourceFailures = checkResult.sourceFailures ?? [];
3547
+ const completeness = scope.from == null && scope.to == null && sourceFailures.length === 0 ? "complete" : "partial";
3548
+ return this.refreshStrategyResult(sessions, completeness, scope, {
2253
3549
  preciseChangedIds,
2254
- persistenceDiff: buildPersistenceDiff(baseline, sessions, preciseChangedIds),
3550
+ persistenceDiff: buildPersistenceDiff(
3551
+ baseline,
3552
+ sessions,
3553
+ preciseChangedIds,
3554
+ completeness,
3555
+ preciseChangedIds
3556
+ ),
2255
3557
  checkDuration,
2256
- scanDuration: performance.now() - scanStartedAt
3558
+ scanDuration: performance.now() - scanStartedAt,
3559
+ sourceFailures
2257
3560
  });
2258
3561
  }
2259
3562
  async scanAgentFully(agent, previousSessions) {
2260
3563
  const scanStartedAt = performance.now();
2261
- const result = await this.runWorker(agent, previousSessions, null, {}, { checkpoint: true });
3564
+ const scope = this.startupScanOptions();
3565
+ const result = await this.runWorker(agent, previousSessions, { kind: "full-scan" }, scope);
2262
3566
  agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
2263
3567
  const sessions = attachMissingProjectIdentities(result.sessions);
2264
3568
  this.lastRefreshAtByAgent.set(agent.name, Date.now());
2265
- return this.refreshStrategyResult(sessions, {
3569
+ return this.refreshStrategyResult(sessions, result.completeness, scope, {
2266
3570
  fullScanSessions: sessions,
2267
- scanDuration: performance.now() - scanStartedAt
3571
+ explicitRemovedSessionIds: result.explicitRemovedSessionIds,
3572
+ scanDuration: performance.now() - scanStartedAt,
3573
+ sourceFailures: result.sourceFailures ?? []
2268
3574
  });
2269
3575
  }
2270
- refreshStrategyResult(nextSessions, overrides = {}) {
3576
+ refreshStrategyResult(nextSessions, completeness, scope, overrides = {}) {
2271
3577
  return {
2272
3578
  status: "continue",
2273
3579
  nextSessions,
@@ -2276,86 +3582,55 @@ var AgentSyncEngine = class {
2276
3582
  persistenceDiff: null,
2277
3583
  checkDuration: 0,
2278
3584
  scanDuration: 0,
3585
+ sourceFailures: [],
3586
+ completeness,
3587
+ scope,
3588
+ explicitRemovedSessionIds: [],
2279
3589
  ...overrides
2280
3590
  };
2281
3591
  }
2282
- runWorker(agent, previousSessions, changedIds, scanOptions, workerOptions = {}) {
3592
+ runWorker(agent, previousSessions, operation, scanOptions, runOptions = {}) {
2283
3593
  return this.options.workerRunner.run(agent.name, {
2284
3594
  previousSessions,
2285
- changedIds,
3595
+ operation,
2286
3596
  scanOptions,
2287
- sourceSync: workerOptions.sourceSync,
2288
- backfill: workerOptions.backfill,
2289
- backfillCursor: workerOptions.backfillCursor,
2290
- checkpoint: workerOptions.checkpoint,
2291
- meta: workerOptions.meta ?? buildAgentCacheMeta(agent),
2292
- onProgress: (progress) => this.updateAgentScanProgress(agent.name, progress),
2293
- onCheckpoint: (checkpoint) => this.handleWorkerCheckpoint(agent, checkpoint)
3597
+ meta: runOptions.meta ?? buildAgentCacheMeta(agent),
3598
+ onProgress: (progress) => this.updateAgentScanProgress(agent.name, progress, runOptions.backfillAttempt),
3599
+ onCheckpoint: runOptions.onCheckpoint
2294
3600
  });
2295
3601
  }
2296
- handleWorkerCheckpoint(agent, checkpoint) {
2297
- if (this.isShuttingDown) return;
2298
- if (checkpoint.stage === "scanned") {
2299
- let persisted2 = false;
2300
- try {
2301
- persisted2 = saveCachedSessions(agent.name, checkpoint.sessions, checkpoint.meta);
2302
- if (persisted2) markAgentCacheInitialized(agent.name);
2303
- } catch (error) {
2304
- appLogger.error("scan.checkpoint.failed", {
2305
- agent: agent.name,
2306
- stage: checkpoint.stage,
2307
- error
2308
- });
2309
- }
2310
- agent.setSessionMetaMap(new Map(Object.entries(checkpoint.meta)));
2311
- const event = this.sessionIndex.commitAgentSessions(agent.name, checkpoint.sessions);
2312
- this.emitSessionsChanged({
2313
- agentName: agent.name,
2314
- sessions: this.sessionIndex.snapshot().byAgent[agent.name] ?? [],
2315
- event
2316
- });
2317
- appLogger.info("scan.checkpoint.persisted", {
2318
- agent: agent.name,
2319
- stage: checkpoint.stage,
2320
- sessions: checkpoint.sessions.length,
2321
- persisted: persisted2
2322
- });
2323
- return;
2324
- }
2325
- let persisted = false;
2326
- try {
2327
- persisted = saveCachedSessionChanges(agent.name, checkpoint.changes, [], checkpoint.meta);
2328
- } catch (error) {
2329
- appLogger.error("scan.checkpoint.failed", {
2330
- agent: agent.name,
2331
- stage: checkpoint.stage,
2332
- sessions: checkpoint.changes.length,
2333
- error
2334
- });
2335
- }
2336
- if (persisted && checkpoint.backfillCursor) {
2337
- markAgentFullSyncProgress(agent.name, checkpoint.backfillCursor);
2338
- }
2339
- appLogger.debug("scan.checkpoint.persisted", {
2340
- agent: agent.name,
2341
- stage: checkpoint.stage,
2342
- sessions: checkpoint.changes.length,
2343
- backfill_cursor: checkpoint.backfillCursor,
2344
- persisted
3602
+ handleBackfillCheckpoint(agentName, checkpoint) {
3603
+ if (checkpoint.stage !== "finalizing" || !checkpoint.backfillCursor) return;
3604
+ markAgentFullSyncProgress(agentName, checkpoint.backfillCursor);
3605
+ appLogger.debug("scan.backfill.checkpoint", {
3606
+ agent: agentName,
3607
+ cursor: checkpoint.backfillCursor,
3608
+ sessions: checkpoint.changes.length
2345
3609
  });
2346
3610
  }
2347
- needsBackfill(agent) {
3611
+ needsBackfill(agent, cached, reloadCached = false) {
2348
3612
  const startupScanOptions = this.startupScanOptions();
2349
3613
  if (startupScanOptions.from == null && startupScanOptions.to == null) return false;
2350
- if (!agent.isAvailable()) return false;
2351
- const lastSyncAt = getAgentLastFullSyncAt(agent.name);
2352
- if (lastSyncAt == null || Date.now() - lastSyncAt > BACKFILL_INTERVAL_MS) return true;
3614
+ const now = Date.now();
3615
+ if ((this.cacheIntegrityValidUntilByAgent.get(agent.name) ?? 0) >= now) return false;
3616
+ const lastSync = readAgentLastFullSyncAt(agent.name);
3617
+ if (lastSync.status === "failed") {
3618
+ appLogger.warn("scan.backfill.cache_state_unavailable", {
3619
+ agent: agent.name,
3620
+ state: "last_full_sync"
3621
+ });
3622
+ return false;
3623
+ }
3624
+ const lastSyncAt = lastSync.value;
3625
+ if (lastSyncAt == null || now - lastSyncAt > BACKFILL_INTERVAL_MS) {
3626
+ return agent.isAvailable();
3627
+ }
2353
3628
  if (!(agent instanceof FileSystemSessionSource)) return false;
2354
- if (this.cacheIntegrityCheckedAgents.has(agent.name)) return false;
2355
- const cached = loadCachedSessions(agent.name);
3629
+ if (!agent.isAvailable()) return false;
3630
+ const cachedSessions = reloadCached || cached === void 0 ? loadCachedSessions(agent.name) : cached;
2356
3631
  const sourceCount = agent.listSessionSources().length;
2357
- const cachedCount = cached?.sessions.length ?? 0;
2358
- this.cacheIntegrityCheckedAgents.add(agent.name);
3632
+ const cachedCount = cachedSessions?.sessions.length ?? 0;
3633
+ this.cacheIntegrityValidUntilByAgent.set(agent.name, lastSyncAt + BACKFILL_INTERVAL_MS);
2359
3634
  if (sourceCount > 0 && cachedCount / sourceCount < CACHE_TRUNCATION_COVERAGE) {
2360
3635
  appLogger.warn("scan.backfill.cache_truncated", {
2361
3636
  agent: agent.name,
@@ -2368,43 +3643,45 @@ var AgentSyncEngine = class {
2368
3643
  return false;
2369
3644
  }
2370
3645
  enqueueBackfill(agentName) {
2371
- if (this.isShuttingDown || this.currentBackfillAgent === agentName || this.backfillQueue.includes(agentName)) {
2372
- return;
2373
- }
2374
- this.backfillQueue.push(agentName);
3646
+ if (this.isShuttingDown || !this.backfills.enqueue(agentName)) return;
2375
3647
  this.publishBackfillStatus();
2376
3648
  this.pumpBackfillQueue();
2377
3649
  }
2378
3650
  pumpBackfillQueue() {
2379
- if (this.isShuttingDown || this.currentBackfillAgent) return;
2380
- const agentName = this.backfillQueue.shift();
2381
- if (!agentName) return;
2382
- this.currentBackfillAgent = agentName;
2383
- this.currentBackfillProgress = void 0;
3651
+ if (this.isShuttingDown) return;
3652
+ const attempt = this.backfills.startNext();
3653
+ if (!attempt) return;
2384
3654
  this.publishBackfillStatus();
2385
- void this.runBackfill(agentName).then((result) => {
2386
- if (this.isShuttingDown) return;
2387
- this.currentBackfillAgent = void 0;
2388
- this.currentBackfillProgress = void 0;
2389
- if (result === "committed") {
2390
- if (!this.completedBackfillAgents.includes(agentName)) {
2391
- this.completedBackfillAgents.push(agentName);
3655
+ void this.runBackfill(attempt).then((result) => this.completeBackfillAttempt(attempt, result)).catch((error) => this.rejectBackfillAttempt(attempt, error));
3656
+ }
3657
+ completeBackfillAttempt(attempt, result) {
3658
+ if (this.isShuttingDown) return;
3659
+ const current = this.backfills.stateFor(attempt.agentName);
3660
+ if (current?.status === "running" && current.attemptId === attempt.attemptId) {
3661
+ this.flushProgressStatus(`backfill:${attempt.agentName}`);
3662
+ if (this.backfills.complete(attempt, result)) {
3663
+ if (result === "failed") {
3664
+ this.cacheIntegrityValidUntilByAgent.delete(attempt.agentName);
3665
+ } else if (result === "committed") {
3666
+ this.cacheIntegrityValidUntilByAgent.set(
3667
+ attempt.agentName,
3668
+ Date.now() + BACKFILL_INTERVAL_MS
3669
+ );
2392
3670
  }
2393
- this.failedBackfillAgents = this.failedBackfillAgents.filter(
2394
- (failedAgent) => failedAgent !== agentName
2395
- );
2396
- } else if (!this.failedBackfillAgents.includes(agentName)) {
2397
- this.failedBackfillAgents.push(agentName);
2398
- this.cacheIntegrityCheckedAgents.delete(agentName);
3671
+ this.publishBackfillStatus();
2399
3672
  }
2400
- this.publishBackfillStatus();
2401
- this.pumpBackfillQueue();
2402
- });
3673
+ }
3674
+ this.pumpBackfillQueue();
2403
3675
  }
2404
- runBackfill(agentName) {
2405
- return this.scheduler.run(agentName, "backfill", () => this.performBackfill(agentName));
3676
+ rejectBackfillAttempt(attempt, error) {
3677
+ appLogger.error("scan.backfill.queue_error", { agent: attempt.agentName, error });
3678
+ this.completeBackfillAttempt(attempt, "failed");
2406
3679
  }
2407
- async performBackfill(agentName) {
3680
+ runBackfill(attempt) {
3681
+ return this.scheduler.run(attempt.agentName, "backfill", () => this.performBackfill(attempt));
3682
+ }
3683
+ async performBackfill(attempt) {
3684
+ const { agentName } = attempt;
2408
3685
  const startedAt = performance.now();
2409
3686
  const agent = this.findAgent(agentName);
2410
3687
  if (!agent || !agent.isAvailable()) return "skipped";
@@ -2414,25 +3691,42 @@ var AgentSyncEngine = class {
2414
3691
  const meta = cached?.meta ?? buildAgentCacheMeta(agent);
2415
3692
  const backfillCursor = getAgentFullSyncCursor(agentName);
2416
3693
  if (cached) restoreAgentCacheMeta(agent, cached);
3694
+ let durableCommitted = false;
2417
3695
  try {
2418
3696
  markAgentFullSyncStarted(agentName);
3697
+ const operation = agent instanceof FileSystemSessionSource ? { kind: "source-backfill", cursor: backfillCursor, checkpoint: "durable" } : { kind: "full-backfill", cursor: backfillCursor, checkpoint: "durable" };
3698
+ appLogger.info("scan.backfill.started", {
3699
+ agent: agentName,
3700
+ cursor: backfillCursor ?? void 0,
3701
+ durable_checkpoints: operation.checkpoint === "durable"
3702
+ });
2419
3703
  const result = await this.runWorker(
2420
3704
  agent,
2421
3705
  baseline,
2422
- null,
3706
+ operation,
2423
3707
  {},
2424
3708
  {
2425
- sourceSync: agent instanceof FileSystemSessionSource,
2426
- backfill: true,
2427
- backfillCursor,
3709
+ backfillAttempt: attempt,
2428
3710
  meta,
2429
- checkpoint: true
3711
+ onCheckpoint: (checkpoint) => this.handleBackfillCheckpoint(agentName, checkpoint)
2430
3712
  }
2431
3713
  );
2432
3714
  agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
2433
3715
  const fullSessions = attachMissingProjectIdentities(result.sessions);
2434
- this.currentBackfillProgress = { phase: "indexing", sessions: fullSessions.length };
2435
- this.publishBackfillStatus();
3716
+ this.flushProgressStatus(`backfill:${agentName}`);
3717
+ const updatePublicationPhase = (phase) => {
3718
+ if (this.backfills.updateProgress(attempt, {
3719
+ phase,
3720
+ sessions: fullSessions.length
3721
+ })) {
3722
+ this.publishProgressStatus(
3723
+ `backfill:${agentName}`,
3724
+ phase,
3725
+ this.scanStatus.updateBackfill(this.backfills.status())
3726
+ );
3727
+ }
3728
+ };
3729
+ updatePublicationPhase("publish-queued");
2436
3730
  await this.commitSessionPublication({
2437
3731
  context: "scan.backfill",
2438
3732
  agentName,
@@ -2444,9 +3738,15 @@ var AgentSyncEngine = class {
2444
3738
  agentName,
2445
3739
  sessions: fullSessions,
2446
3740
  meta: buildAgentCacheMeta(agent),
3741
+ completeness: result.completeness,
3742
+ removedSessionIds: result.explicitRemovedSessionIds,
2447
3743
  saveCache: true
2448
- }
3744
+ },
3745
+ onPublishing: () => updatePublicationPhase("publishing")
2449
3746
  });
3747
+ durableCommitted = true;
3748
+ this.options.workerRunner.commit?.(agentName);
3749
+ if (result.sourceFailures?.length) throw sourceFailureError(result.sourceFailures);
2450
3750
  markAgentFullSyncCompleted(agentName);
2451
3751
  appLogger.info("scan.backfill.done", {
2452
3752
  agent: agentName,
@@ -2456,38 +3756,39 @@ var AgentSyncEngine = class {
2456
3756
  });
2457
3757
  return "committed";
2458
3758
  } catch (error) {
3759
+ if (!durableCommitted) agent.setSessionMetaMap(new Map(Object.entries(meta)));
3760
+ this.options.workerRunner.discard?.(agentName);
2459
3761
  appLogger.error("scan.backfill.error", { agent: agentName, error });
2460
3762
  console.error(`[${agentName}] Backfill failed:`, error);
2461
3763
  return "failed";
2462
3764
  }
2463
3765
  }
2464
- backfillStatus() {
2465
- return {
2466
- active: this.currentBackfillAgent != null || this.backfillQueue.length > 0,
2467
- pendingAgents: [...this.backfillQueue],
2468
- currentAgent: this.currentBackfillAgent,
2469
- progress: this.currentBackfillProgress,
2470
- completedAgents: [...this.completedBackfillAgents],
2471
- failedAgents: [...this.failedBackfillAgents]
2472
- };
2473
- }
2474
3766
  buildFullSearchIndexJobs(context) {
2475
3767
  const snapshot = this.sessionIndex.snapshot();
2476
- return snapshot.agents.map((agent) => {
3768
+ return snapshot.agents.flatMap((agent) => {
3769
+ if (!(agent.name in snapshot.byAgent)) return [];
2477
3770
  const cached = loadCachedSessions(agent.name);
2478
- return cached ? {
2479
- kind: "full",
2480
- context,
2481
- agentName: agent.name,
2482
- sessions: cached.sessions,
2483
- meta: cached.meta
2484
- } : {
2485
- kind: "full",
2486
- context,
2487
- agentName: agent.name,
2488
- sessions: snapshot.byAgent[agent.name] ?? [],
2489
- meta: buildAgentCacheMeta(agent)
2490
- };
3771
+ return [
3772
+ cached ? {
3773
+ kind: "full",
3774
+ context,
3775
+ agentName: agent.name,
3776
+ sessions: cached.sessions,
3777
+ meta: cached.meta,
3778
+ completeness: "partial",
3779
+ removedSessionIds: [],
3780
+ searchIndexOptions: { includePendingReindex: false }
3781
+ } : {
3782
+ kind: "full",
3783
+ context,
3784
+ agentName: agent.name,
3785
+ sessions: snapshot.byAgent[agent.name] ?? [],
3786
+ meta: buildAgentCacheMeta(agent),
3787
+ completeness: "partial",
3788
+ removedSessionIds: [],
3789
+ searchIndexOptions: { includePendingReindex: false }
3790
+ }
3791
+ ];
2491
3792
  });
2492
3793
  }
2493
3794
  publicationId(context, agentName) {
@@ -2503,7 +3804,11 @@ var AgentSyncEngine = class {
2503
3804
  jobs: jobs.length
2504
3805
  });
2505
3806
  try {
2506
- await this.searchIndexJobs.enqueue(context, jobs);
3807
+ const publicationJobs = jobs.map((job) => ({
3808
+ ...job,
3809
+ publicationId: details.publicationId
3810
+ }));
3811
+ await (details.onStarted ? this.searchIndexJobs.enqueue(context, publicationJobs, details.onStarted) : this.searchIndexJobs.enqueue(context, publicationJobs));
2507
3812
  } catch (error) {
2508
3813
  appLogger.error("session.publication.failed", {
2509
3814
  publication_id: details.publicationId,
@@ -2524,7 +3829,8 @@ var AgentSyncEngine = class {
2524
3829
  const publicationId = this.publicationId(publication.context, publication.agentName);
2525
3830
  await this.commitSearchIndex(publication.context, [publication.indexJob], {
2526
3831
  publicationId,
2527
- agent: publication.agentName
3832
+ agent: publication.agentName,
3833
+ ...publication.onPublishing ? { onStarted: publication.onPublishing } : {}
2528
3834
  });
2529
3835
  const diffStartedAt = performance.now();
2530
3836
  const event = this.sessionIndex.commitAgentSessions(
@@ -2701,7 +4007,7 @@ var SessionWatcher = class {
2701
4007
  }
2702
4008
  }
2703
4009
  this.stablePaths.clear();
2704
- await Promise.all(this.watchers.map((watcher) => watcher.close()));
4010
+ await Promise.all(this.watchers.map(({ watcher }) => watcher.close()));
2705
4011
  this.watchers = [];
2706
4012
  this.fallbackWatchScopes.clear();
2707
4013
  this.listeners.clear();
@@ -2724,7 +4030,7 @@ var SessionWatcher = class {
2724
4030
  watcher.on("error", (error) => {
2725
4031
  this.reportWatchError("watch.error", { path, recursive, error });
2726
4032
  });
2727
- this.watchers.push(watcher);
4033
+ this.watchers.push({ path, recursive, watcher });
2728
4034
  return true;
2729
4035
  } catch (error) {
2730
4036
  if (recursive && isRecursiveWatchUnavailable(error)) {
@@ -2770,7 +4076,20 @@ var SessionWatcher = class {
2770
4076
  this.watchDirectoryTree(path, scopes);
2771
4077
  }
2772
4078
  } catch {
4079
+ this.unwatchFallbackTree(path);
4080
+ }
4081
+ }
4082
+ unwatchFallbackTree(path) {
4083
+ const active = [];
4084
+ for (const registration of this.watchers) {
4085
+ if (registration.recursive || !isSameOrChildPath(path, registration.path)) {
4086
+ active.push(registration);
4087
+ continue;
4088
+ }
4089
+ registration.watcher.close();
4090
+ this.fallbackWatchScopes.delete(registration.path);
2773
4091
  }
4092
+ this.watchers = active;
2774
4093
  }
2775
4094
  handleWatchEvent(watchPath, scopes, eventType, filename) {
2776
4095
  const changedPath = resolveWatchEventPath(watchPath, filename);
@@ -2852,6 +4171,7 @@ var SessionWatcher = class {
2852
4171
  import { Worker as Worker2 } from "worker_threads";
2853
4172
  var SHUTDOWN_ERROR_MESSAGE2 = "Scan refresh worker shut down";
2854
4173
  function applySessionChanges2(previousSessions, changes, removedSessionIds) {
4174
+ if (changes.length === 0 && removedSessionIds.length === 0) return previousSessions;
2855
4175
  const replacedIds = new Set(removedSessionIds);
2856
4176
  for (const { session } of changes) replacedIds.add(session.id);
2857
4177
  const retained = previousSessions.filter((session) => !replacedIds.has(session.id));
@@ -2874,8 +4194,10 @@ function applySessionChanges2(previousSessions, changes, removedSessionIds) {
2874
4194
  return next;
2875
4195
  }
2876
4196
  function applyMetaChanges(previous, changed, replacedSessionIds) {
4197
+ const replacedIds = [...replacedSessionIds];
4198
+ if (Object.keys(changed).length === 0 && replacedIds.length === 0) return previous;
2877
4199
  const next = { ...previous };
2878
- for (const id of replacedSessionIds) delete next[id];
4200
+ for (const id of replacedIds) delete next[id];
2879
4201
  return Object.assign(next, changed);
2880
4202
  }
2881
4203
  var ThreadWorkerRunner = class {
@@ -2888,23 +4210,27 @@ var ThreadWorkerRunner = class {
2888
4210
  isShuttingDown = false;
2889
4211
  get activeCount() {
2890
4212
  let count = 0;
2891
- for (const slot of this.workers.values()) count += slot.pending.size;
4213
+ for (const slot of this.workers.values()) {
4214
+ count += slot.pending.size + Number(slot.awaitingCommit != null);
4215
+ }
2892
4216
  return count;
2893
4217
  }
2894
4218
  run(agentName, payload) {
2895
4219
  if (this.isShuttingDown) return Promise.reject(new Error(SHUTDOWN_ERROR_MESSAGE2));
4220
+ const existingSlot = this.workers.get(agentName);
4221
+ if (existingSlot && (existingSlot.pending.size > 0 || existingSlot.awaitingCommit)) {
4222
+ return Promise.reject(new Error(`Scan refresh worker for ${agentName} is busy`));
4223
+ }
4224
+ const generation = existingSlot?.generation ?? 0;
2896
4225
  const request = {
2897
4226
  type: "run",
2898
4227
  requestId: this.nextRequestId++,
2899
4228
  agentName,
2900
- previousSessions: payload.previousSessions,
2901
- changedIds: payload.changedIds,
2902
- sourceSync: payload.sourceSync,
2903
- backfill: payload.backfill,
2904
- backfillCursor: payload.backfillCursor,
2905
- checkpoint: payload.checkpoint,
4229
+ generation,
4230
+ pricingGenerationId: getPricingGeneration().id,
4231
+ operation: payload.operation,
2906
4232
  scanOptions: payload.scanOptions,
2907
- meta: payload.meta
4233
+ ...existingSlot ? {} : { previousSessions: payload.previousSessions, meta: payload.meta }
2908
4234
  };
2909
4235
  return new Promise((resolve4, reject) => {
2910
4236
  let slot = this.workers.get(agentName);
@@ -2913,7 +4239,7 @@ var ThreadWorkerRunner = class {
2913
4239
  try {
2914
4240
  slot = this.createWorker(agentName, request);
2915
4241
  } catch (error) {
2916
- reject(error instanceof Error ? error : new Error(String(error)));
4242
+ reject(toError(error));
2917
4243
  return;
2918
4244
  }
2919
4245
  }
@@ -2921,6 +4247,7 @@ var ThreadWorkerRunner = class {
2921
4247
  resolve: resolve4,
2922
4248
  reject,
2923
4249
  payload,
4250
+ generation,
2924
4251
  onProgress: payload.onProgress,
2925
4252
  onCheckpoint: payload.onCheckpoint
2926
4253
  });
@@ -2929,11 +4256,33 @@ var ThreadWorkerRunner = class {
2929
4256
  slot.worker.postMessage(request);
2930
4257
  } catch (error) {
2931
4258
  slot.pending.delete(request.requestId);
2932
- reject(error instanceof Error ? error : new Error(String(error)));
4259
+ reject(toError(error));
4260
+ this.invalidateWorker(agentName, slot);
2933
4261
  }
2934
4262
  }
2935
4263
  });
2936
4264
  }
4265
+ commit(agentName) {
4266
+ const slot = this.workers.get(agentName);
4267
+ const awaiting = slot?.awaitingCommit;
4268
+ if (!slot || !awaiting) return;
4269
+ const request = {
4270
+ type: "commit",
4271
+ requestId: awaiting.requestId,
4272
+ generation: awaiting.generation
4273
+ };
4274
+ try {
4275
+ slot.worker.postMessage(request);
4276
+ slot.awaitingCommit = null;
4277
+ slot.generation += 1;
4278
+ } catch {
4279
+ this.invalidateWorker(agentName, slot);
4280
+ }
4281
+ }
4282
+ discard(agentName) {
4283
+ const slot = this.workers.get(agentName);
4284
+ if (slot) this.invalidateWorker(agentName, slot);
4285
+ }
2937
4286
  async shutdown() {
2938
4287
  this.isShuttingDown = true;
2939
4288
  const slots = [...this.workers.values()];
@@ -2948,14 +4297,22 @@ var ThreadWorkerRunner = class {
2948
4297
  }
2949
4298
  createWorker(agentName, request) {
2950
4299
  const worker = new Worker2(this.workerUrl, { workerData: request });
2951
- const slot = { worker, pending: /* @__PURE__ */ new Map(), closed: false };
4300
+ const slot = {
4301
+ agentName,
4302
+ worker,
4303
+ pending: /* @__PURE__ */ new Map(),
4304
+ generation: request.generation,
4305
+ awaitingCommit: null,
4306
+ closed: false
4307
+ };
2952
4308
  worker.unref();
2953
4309
  this.workers.set(agentName, slot);
2954
4310
  worker.on("message", (message) => {
4311
+ if (appLogger.consumeWorkerMessage(message)) return;
2955
4312
  this.handleMessage(slot, message);
2956
4313
  });
2957
4314
  worker.on("error", (error) => {
2958
- this.closeWorker(agentName, slot, error);
4315
+ this.closeWorker(agentName, slot, toError(error));
2959
4316
  });
2960
4317
  worker.on("exit", (code) => {
2961
4318
  if (slot.closed) return;
@@ -2970,34 +4327,59 @@ var ThreadWorkerRunner = class {
2970
4327
  handleMessage(slot, message) {
2971
4328
  const pending = slot.pending.get(message.requestId);
2972
4329
  if (!pending) return;
4330
+ if (message.generation !== pending.generation) {
4331
+ const error = new Error(
4332
+ `Scan refresh generation mismatch: expected ${pending.generation}, received ${message.generation}`
4333
+ );
4334
+ slot.pending.delete(message.requestId);
4335
+ pending.reject(error);
4336
+ this.invalidateWorker(slot.agentName, slot);
4337
+ return;
4338
+ }
2973
4339
  if (message.type === "progress") {
2974
4340
  pending.onProgress?.(message.progress);
2975
4341
  return;
2976
4342
  }
2977
4343
  if (message.type === "checkpoint") {
2978
- pending.onCheckpoint?.(message.checkpoint);
4344
+ try {
4345
+ pending.onCheckpoint?.(message.checkpoint);
4346
+ } catch (error) {
4347
+ slot.pending.delete(message.requestId);
4348
+ pending.reject(toError(error));
4349
+ this.invalidateWorker(slot.agentName, slot);
4350
+ }
2979
4351
  return;
2980
4352
  }
2981
4353
  slot.pending.delete(message.requestId);
2982
4354
  if (message.type === "error") {
2983
4355
  pending.reject(new Error(message.error));
4356
+ this.invalidateWorker(slot.agentName, slot);
2984
4357
  return;
2985
4358
  }
2986
4359
  const changedIds = message.changes.map(({ session }) => session.id);
2987
4360
  const replacedSessionIds = [...changedIds, ...message.removedSessionIds];
2988
4361
  const removedMetaIds = [...message.removedSessionIds, ...message.removedMetaIds];
2989
4362
  try {
2990
- pending.resolve({
4363
+ const result = {
2991
4364
  sessions: applySessionChanges2(
2992
4365
  pending.payload.previousSessions,
2993
4366
  message.changes,
2994
4367
  message.removedSessionIds
2995
4368
  ),
2996
4369
  meta: applyMetaChanges(pending.payload.meta, message.meta, removedMetaIds),
2997
- changedIds: pending.payload.sourceSync ? replacedSessionIds : void 0
2998
- });
4370
+ changedIds: replacedSessionIds,
4371
+ sourceFailures: message.sourceFailures,
4372
+ completeness: message.completeness,
4373
+ explicitRemovedSessionIds: message.explicitRemovedSessionIds
4374
+ };
4375
+ slot.awaitingCommit = {
4376
+ requestId: message.requestId,
4377
+ generation: message.generation
4378
+ };
4379
+ pending.resolve(result);
2999
4380
  } catch (error) {
3000
- pending.reject(error instanceof Error ? error : new Error(String(error)));
4381
+ pending.reject(toError(error));
4382
+ this.invalidateWorker(slot.agentName, slot);
3001
4383
  }
3002
4384
  }
3003
4385
  closeWorker(agentName, slot, error) {
@@ -3006,6 +4388,15 @@ var ThreadWorkerRunner = class {
3006
4388
  if (this.workers.get(agentName) === slot) this.workers.delete(agentName);
3007
4389
  for (const pending of slot.pending.values()) pending.reject(error);
3008
4390
  slot.pending.clear();
4391
+ slot.awaitingCommit = null;
4392
+ }
4393
+ invalidateWorker(agentName, slot) {
4394
+ this.closeWorker(
4395
+ agentName,
4396
+ slot,
4397
+ new Error(`Scan refresh worker state discarded for ${agentName}`)
4398
+ );
4399
+ void slot.worker.terminate().catch(() => void 0);
3009
4400
  }
3010
4401
  };
3011
4402
 
@@ -3128,6 +4519,7 @@ var LiveScanStore = class {
3128
4519
  await this.watcher.dispose();
3129
4520
  this.watcher = null;
3130
4521
  }
4522
+ closeCacheStorage();
3131
4523
  }
3132
4524
  emit(event) {
3133
4525
  if (this.shuttingDown) return;
@@ -3192,6 +4584,12 @@ function printScanResults(agents) {
3192
4584
  }
3193
4585
 
3194
4586
  // src/session-index-output.ts
4587
+ function formatScanFailureDiagnostics(snapshot) {
4588
+ return Object.values(snapshot.scanFailures ?? {}).map((failure) => {
4589
+ const source = failure.sourcePath ? ` at ${failure.sourcePath}` : "";
4590
+ return `[${failure.agentName}] Scan failed during ${failure.stage}${source} (${failure.errorClass}): ${failure.message}`;
4591
+ });
4592
+ }
3195
4593
  function buildSessionIndexOutput(snapshot, window = {}) {
3196
4594
  const sessions = filterSessionTreeByActivityWindow(snapshot.sessions, window.from, window.to);
3197
4595
  const agents = getAgentInfoMap(
@@ -3387,12 +4785,6 @@ var main = defineCommand({
3387
4785
  const clearCache = args["clear-cache"];
3388
4786
  const hostname = args.host;
3389
4787
  const remoteAccess = args["remote-access"];
3390
- if (!isLoopbackHostname(hostname) && !remoteAccess) {
3391
- console.error(
3392
- `Refusing to expose CodeSesh on ${hostname} without authentication. Add --remote-access to continue.`
3393
- );
3394
- process.exit(1);
3395
- }
3396
4788
  let transport;
3397
4789
  try {
3398
4790
  transport = resolveRemoteTransport({
@@ -3405,6 +4797,12 @@ var main = defineCommand({
3405
4797
  console.error(error instanceof Error ? error.message : String(error));
3406
4798
  process.exit(1);
3407
4799
  }
4800
+ if (resolveRemoteAccessPolicy(hostname, transport).authenticationRequired && !remoteAccess) {
4801
+ console.error(
4802
+ `Refusing to expose CodeSesh on ${hostname} without authentication. Add --remote-access to continue.`
4803
+ );
4804
+ process.exit(1);
4805
+ }
3408
4806
  if (trace) {
3409
4807
  perf.enable();
3410
4808
  }
@@ -3418,7 +4816,7 @@ var main = defineCommand({
3418
4816
  log_path: appLogger.getLogPath()
3419
4817
  });
3420
4818
  if (clearCache) {
3421
- const { clearCache: clear } = await import("./dist-ALXOBKV2.js");
4819
+ const { clearCache: clear } = await import("./dist-N2PQATP2.js");
3422
4820
  clear();
3423
4821
  appLogger.info("cache.clear");
3424
4822
  console.log("Cache cleared.");
@@ -3468,6 +4866,13 @@ var main = defineCommand({
3468
4866
  }
3469
4867
  if (jsonOnly) {
3470
4868
  await pricingRefresh.cancel();
4869
+ const scanFailureDiagnostics = formatScanFailureDiagnostics(result);
4870
+ if (scanFailureDiagnostics.length > 0) {
4871
+ await store.shutdown();
4872
+ for (const diagnostic of scanFailureDiagnostics) console.error(diagnostic);
4873
+ process.exitCode = 1;
4874
+ return;
4875
+ }
3471
4876
  const output = buildSessionIndexOutput(result, { from: listDefaultFrom, to: listDefaultTo });
3472
4877
  appLogger.info("cli.json_output", {
3473
4878
  sessions: output.sessions.length,