codesesh 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  createProjectScopeMatcher,
12
12
  createRegisteredAgents,
13
13
  deleteBookmark,
14
+ executeSessionSearch,
14
15
  extractSessionFileActivity,
15
16
  filterSessions,
16
17
  getAgentInfoMap,
@@ -22,6 +23,7 @@ import {
22
23
  getTotalTokens,
23
24
  importBookmarks,
24
25
  isAgentCacheInitialized,
26
+ isProjectIdentityKind,
25
27
  listBookmarks,
26
28
  listCachedProjectGroups,
27
29
  listFileActivity,
@@ -29,26 +31,25 @@ import {
29
31
  loadCachedSessionData,
30
32
  loadCachedSessions,
31
33
  markAgentFullSyncCompleted,
34
+ matchesProjectIdentity,
32
35
  matchesProjectScope,
33
- parseSearchQuery,
34
36
  perf,
35
37
  realFs,
36
38
  refreshPricingCache,
37
39
  resolveProviderRoots,
38
40
  scanSessions,
39
- searchFileActivitySessions,
40
- searchSessions,
41
41
  sessionSignature,
42
42
  sortSessions,
43
43
  startOfLocalDay,
44
44
  upsertBookmark
45
- } from "./chunk-GCOAE7KI.js";
45
+ } from "./chunk-BV65IEWZ.js";
46
46
 
47
47
  // src/index.ts
48
48
  import { defineCommand, runMain } from "citty";
49
49
 
50
50
  // src/server.ts
51
51
  import { Hono as Hono2 } from "hono";
52
+ import { bodyLimit } from "hono/body-limit";
52
53
  import { serve } from "@hono/node-server";
53
54
  import { serveStatic } from "@hono/node-server/serve-static";
54
55
  import { existsSync as existsSync2 } from "fs";
@@ -227,7 +228,7 @@ function parseBookmarkPayload(value) {
227
228
  title: value.title,
228
229
  directory: value.directory,
229
230
  time_created: value.time_created,
230
- time_updated: value.time_updated,
231
+ time_updated: value.time_updated ?? void 0,
231
232
  stats: value.stats
232
233
  };
233
234
  }
@@ -265,13 +266,14 @@ function parseSmartTags(values) {
265
266
  );
266
267
  return tags.length > 0 ? [...new Set(tags)] : void 0;
267
268
  }
268
- function parseSearchOptions(c, defaults) {
269
+ function parseSearchOptions(c, defaults, projectIdentity) {
269
270
  const params = searchParams(c);
270
271
  const limitValue = parseNumberParam(params.get("limit") ?? void 0);
271
272
  return {
272
273
  agent: optionalQueryValue(params.get("agent") ?? void 0),
273
274
  project: optionalQueryValue(params.get("project") ?? void 0),
274
- projectKey: optionalQueryValue(params.get("projectKey") ?? void 0),
275
+ projectKind: projectIdentity?.kind,
276
+ projectKey: projectIdentity?.key,
275
277
  cwd: optionalQueryValue(params.get("cwd") ?? void 0),
276
278
  tags: parseSmartTags(queryValues(params, "tag", "tags", "signal")),
277
279
  tools: queryValues(params, "tool", "tools").map((tool) => tool.toLowerCase()),
@@ -310,49 +312,6 @@ function sanitizeClientLogData(value) {
310
312
  })
311
313
  );
312
314
  }
313
- function sessionMatchesCostFilter(session, options) {
314
- const cost = session.stats.total_cost;
315
- if (options.costMin != null) {
316
- if (options.costMinExclusive ? cost <= options.costMin : cost < options.costMin) return false;
317
- }
318
- if (options.costMax != null) {
319
- if (options.costMaxExclusive ? cost >= options.costMax : cost > options.costMax) return false;
320
- }
321
- return true;
322
- }
323
- function mergeSearchLists(left, right) {
324
- const values = [...left ?? [], ...right ?? []];
325
- return values.length > 0 ? [...new Set(values)] : void 0;
326
- }
327
- function mergeSearchOptions(options, filters) {
328
- return {
329
- ...options,
330
- agent: options.agent ?? filters.agent,
331
- project: options.project ?? filters.project,
332
- projectKey: options.projectKey ?? filters.projectKey,
333
- cwd: options.cwd ?? filters.cwd,
334
- tags: mergeSearchLists(options.tags, filters.tags),
335
- tools: mergeSearchLists(options.tools, filters.tools),
336
- file: options.file ?? filters.file,
337
- fileKind: options.fileKind ?? filters.fileKind,
338
- costMin: options.costMin ?? filters.costMin,
339
- costMax: options.costMax ?? filters.costMax,
340
- costMinExclusive: options.costMinExclusive ?? filters.costMinExclusive,
341
- costMaxExclusive: options.costMaxExclusive ?? filters.costMaxExclusive
342
- };
343
- }
344
- function mergeSearchResults(results, limit) {
345
- const seen = /* @__PURE__ */ new Set();
346
- const merged = [];
347
- for (const result of results) {
348
- const key = `${result.agentName}/${result.session.id}`;
349
- if (seen.has(key)) continue;
350
- seen.add(key);
351
- merged.push(result);
352
- if (merged.length >= limit) break;
353
- }
354
- return merged;
355
- }
356
315
  function getProjectGroupKey(identityKind, identityKey) {
357
316
  return `${identityKind}:${identityKey}`;
358
317
  }
@@ -408,46 +367,15 @@ function attachProjectMetrics(projects, sessions) {
408
367
  };
409
368
  });
410
369
  }
411
- function matchesRecentSearchFilters(session, options, projectScope) {
412
- if (options.projectKey && session.project_identity?.key !== options.projectKey) return false;
413
- if (projectScope && !matchesProjectScope(session, projectScope)) return false;
414
- if (options.project) {
415
- const projectNeedle = options.project.toLowerCase();
416
- const projectText = [
417
- session.project_identity?.key,
418
- session.project_identity?.displayName,
419
- session.directory
420
- ].filter(Boolean).join("\n").toLowerCase();
421
- if (!projectText.includes(projectNeedle)) return false;
422
- }
423
- if (options.tags?.length && !options.tags.every((tag) => session.smart_tags?.includes(tag))) {
424
- return false;
425
- }
426
- if (!sessionMatchesCostFilter(session, options)) return false;
427
- return true;
428
- }
429
- function recentSearchSessions(scanResult, options) {
430
- const projectScope = options.cwd ? createProjectScopeMatcher(options.cwd) : null;
431
- const entries = options.agent ? [[options.agent, scanResult.byAgent[options.agent] ?? []]] : Object.entries(scanResult.byAgent);
432
- return entries.flatMap(
433
- ([agentName, sessions]) => filterSessionsByActivityWindow(sessions, options.from, options.to).filter((session) => matchesRecentSearchFilters(session, options, projectScope)).map((session) => ({ agentName, session }))
434
- ).toSorted(
435
- (a, b) => (b.session.time_updated ?? b.session.time_created) - (a.session.time_updated ?? a.session.time_created)
436
- ).slice(0, options.limit).map(({ agentName, session }) => ({
437
- agentName,
438
- session,
439
- snippet: `Recent session \xB7 ${session.directory}`,
440
- matchType: "recent"
441
- }));
442
- }
443
370
  function handleGetConfig(c, defaults) {
444
- return c.json({
371
+ const payload = {
445
372
  window: {
446
373
  from: defaults.from,
447
374
  to: defaults.to,
448
375
  days: defaults.days
449
376
  }
450
- });
377
+ };
378
+ return c.json(payload);
451
379
  }
452
380
  function handleGetScanStatus(c, scanSource) {
453
381
  return c.json(scanSource.getScanStatus());
@@ -477,7 +405,13 @@ function handleGetSessions(c, scanSource, defaults = {}) {
477
405
  const agent = c.req.query("agent");
478
406
  const q = c.req.query("q")?.toLowerCase();
479
407
  const cwd = c.req.query("cwd");
480
- const projectKey = c.req.query("projectKey");
408
+ const projectIdentity = parseProjectIdentityFilter(
409
+ c.req.query("projectKind"),
410
+ c.req.query("projectKey")
411
+ );
412
+ if (projectIdentity === null) {
413
+ return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
414
+ }
481
415
  const tag = c.req.query("tag")?.toLowerCase();
482
416
  const from = parseDateParam(c.req.query("from"), defaults.from);
483
417
  const to = parseDateParam(c.req.query("to"), defaults.to);
@@ -487,8 +421,10 @@ function handleGetSessions(c, scanSource, defaults = {}) {
487
421
  } else {
488
422
  sessions = [...scanResult.sessions];
489
423
  }
490
- if (projectKey) {
491
- sessions = sessions.filter((s) => s.project_identity?.key === projectKey);
424
+ if (projectIdentity) {
425
+ sessions = sessions.filter(
426
+ (session) => matchesProjectIdentity(session.project_identity, projectIdentity)
427
+ );
492
428
  } else if (cwd) {
493
429
  const projectScope = createProjectScopeMatcher(cwd);
494
430
  sessions = sessions.filter((s) => matchesProjectScope(s, projectScope));
@@ -505,29 +441,15 @@ function handleGetSessions(c, scanSource, defaults = {}) {
505
441
  function handleSearchSessions(c, scanSource, defaults = {}) {
506
442
  const query = c.req.query("q")?.trim() ?? "";
507
443
  const scanResult = scanSource.getSnapshot();
508
- const searchOptions = parseSearchOptions(c, defaults);
509
- const parsedQuery = parseSearchQuery(query);
510
- const mergedSearchOptions = mergeSearchOptions(searchOptions, parsedQuery.filters);
511
- const textQuery = parsedQuery.text || (parsedQuery.hasQualifiers ? "" : query);
512
- const needsIndexedSearch = Boolean(
513
- textQuery || mergedSearchOptions.file || mergedSearchOptions.fileKind || mergedSearchOptions.tools?.length
444
+ const projectIdentity = parseProjectIdentityFilter(
445
+ c.req.query("projectKind"),
446
+ c.req.query("projectKey")
514
447
  );
515
- if (!needsIndexedSearch) {
516
- return c.json({
517
- results: recentSearchSessions(
518
- scanResult,
519
- mergedSearchOptions
520
- )
521
- });
448
+ if (projectIdentity === null) {
449
+ return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
522
450
  }
523
- const fileQuery = mergedSearchOptions.file ?? (!parsedQuery.text ? parsedQuery.filters.file : void 0) ?? (!parsedQuery.hasQualifiers && query ? parsedQuery.text || query : "");
524
- const results = mergeSearchResults(
525
- [
526
- ...fileQuery ? searchFileActivitySessions(fileQuery, mergedSearchOptions) : [],
527
- ...searchSessions(query, mergedSearchOptions)
528
- ],
529
- mergedSearchOptions.limit ?? 50
530
- );
451
+ const searchOptions = parseSearchOptions(c, defaults, projectIdentity);
452
+ const results = executeSessionSearch(query, searchOptions, scanResult);
531
453
  return c.json({ results });
532
454
  }
533
455
  function parseFileActivityKind(value) {
@@ -540,14 +462,29 @@ function optionalQueryValue(value) {
540
462
  const normalized = value?.trim();
541
463
  return normalized ? normalized : void 0;
542
464
  }
465
+ function parseProjectIdentityFilter(kindValue, keyValue) {
466
+ const kind = optionalQueryValue(kindValue);
467
+ const key = optionalQueryValue(keyValue);
468
+ if (!kind && !key) return void 0;
469
+ if (!kind || !key || !isProjectIdentityKind(kind)) return null;
470
+ return { kind, key };
471
+ }
543
472
  function handleGetFileActivity(c, defaults = {}) {
544
473
  const limitValue = Number(c.req.query("limit"));
545
474
  const limit = Number.isFinite(limitValue) && limitValue > 0 ? Math.min(limitValue, 200) : 50;
475
+ const projectIdentity = parseProjectIdentityFilter(
476
+ c.req.query("projectKind"),
477
+ c.req.query("projectKey")
478
+ );
479
+ if (projectIdentity === null) {
480
+ return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
481
+ }
546
482
  return c.json({
547
483
  activity: listFileActivity({
548
484
  agent: optionalQueryValue(c.req.query("agent")),
549
485
  sessionId: optionalQueryValue(c.req.query("sessionId")),
550
- projectKey: optionalQueryValue(c.req.query("projectKey")),
486
+ projectKind: projectIdentity?.kind,
487
+ projectKey: projectIdentity?.key,
551
488
  project: optionalQueryValue(c.req.query("project")),
552
489
  cwd: optionalQueryValue(c.req.query("cwd")),
553
490
  path: optionalQueryValue(c.req.query("path")),
@@ -563,6 +500,9 @@ async function handleGetSessionData(c, scanSource) {
563
500
  const scanResult = scanSource.getSnapshot();
564
501
  const agentName = c.req.param("agent");
565
502
  const sessionId = c.req.param("id");
503
+ if (!agentName) {
504
+ return c.json({ error: "Missing agent name" }, 400);
505
+ }
566
506
  if (!sessionId) {
567
507
  return c.json({ error: "Missing session ID" }, 400);
568
508
  }
@@ -712,6 +652,13 @@ function resolveDashboardWindow(defaults, queryDays, queryFrom, queryTo) {
712
652
  }
713
653
  function handleGetDashboard(c, scanSource, defaults = {}) {
714
654
  const scanResult = scanSource.getSnapshot();
655
+ const projectIdentity = parseProjectIdentityFilter(
656
+ c.req.query("projectKind"),
657
+ c.req.query("projectKey")
658
+ );
659
+ if (projectIdentity === null) {
660
+ return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
661
+ }
715
662
  const { from, to, days } = resolveDashboardWindow(
716
663
  defaults,
717
664
  c.req.query("days"),
@@ -720,8 +667,8 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
720
667
  );
721
668
  const scope = {
722
669
  agent: optionalQueryValue(c.req.query("agent"))?.toLowerCase(),
723
- projectKind: optionalQueryValue(c.req.query("projectKind")),
724
- projectKey: optionalQueryValue(c.req.query("projectKey"))
670
+ projectKind: projectIdentity?.kind,
671
+ projectKey: projectIdentity?.key
725
672
  };
726
673
  const agentInfo = getAgentInfoMap({});
727
674
  const agentInfoMap = new Map(agentInfo.map((a) => [a.name, a]));
@@ -736,6 +683,7 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
736
683
  ...aggregate,
737
684
  recentFileActivities: listFileActivity({
738
685
  agent: scope.agent,
686
+ projectKind: scope.projectKind,
739
687
  projectKey: scope.projectKey,
740
688
  from,
741
689
  to,
@@ -749,10 +697,14 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
749
697
  // src/api/routes.ts
750
698
  function createSseResponse(store, signal) {
751
699
  const encoder = new TextEncoder();
700
+ let cancelStream = () => {
701
+ };
752
702
  return new Response(
753
703
  new ReadableStream({
754
704
  start(controller) {
705
+ let isClosed = false;
755
706
  const write = (event, data) => {
707
+ if (isClosed) return;
756
708
  controller.enqueue(encoder.encode(`event: ${event}
757
709
  `));
758
710
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
@@ -768,18 +720,28 @@ function createSseResponse(store, signal) {
768
720
  write(event.type, event);
769
721
  });
770
722
  const heartbeat = setInterval(() => {
771
- controller.enqueue(encoder.encode(": keepalive\n\n"));
723
+ if (!isClosed) controller.enqueue(encoder.encode(": keepalive\n\n"));
772
724
  }, 15e3);
773
- const close = () => {
725
+ const cleanup = () => {
726
+ if (isClosed) return false;
727
+ isClosed = true;
774
728
  clearInterval(heartbeat);
775
729
  unsubscribeSessions();
776
730
  unsubscribeScanStatus();
777
- controller.close();
731
+ signal.removeEventListener("abort", abortStream);
732
+ return true;
778
733
  };
779
- signal.addEventListener("abort", close, { once: true });
734
+ const abortStream = () => {
735
+ if (cleanup()) controller.close();
736
+ };
737
+ cancelStream = () => {
738
+ cleanup();
739
+ };
740
+ if (signal.aborted) abortStream();
741
+ else signal.addEventListener("abort", abortStream, { once: true });
780
742
  },
781
743
  cancel() {
782
- return;
744
+ cancelStream();
783
745
  }
784
746
  }),
785
747
  {
@@ -820,7 +782,46 @@ function createApiRoutes(scanSource, store, options = {}) {
820
782
  return api;
821
783
  }
822
784
 
785
+ // src/remote-access.ts
786
+ import { randomBytes, timingSafeEqual } from "crypto";
787
+ import { isIP } from "net";
788
+ var REMOTE_ACCESS_QUERY_PARAM = "access_token";
789
+ function createRemoteAccessToken() {
790
+ return randomBytes(32).toString("base64url");
791
+ }
792
+ function isLoopbackHostname(hostname) {
793
+ const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
794
+ if (normalized === "localhost" || normalized === "::1") return true;
795
+ return isIP(normalized) === 4 && normalized.startsWith("127.");
796
+ }
797
+ function tokenMatches(actual, expected) {
798
+ if (!actual) return false;
799
+ const actualBuffer = Buffer.from(actual);
800
+ const expectedBuffer = Buffer.from(expected);
801
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
802
+ }
803
+ function bearerToken(c) {
804
+ const authorization = c.req.header("Authorization");
805
+ if (!authorization?.startsWith("Bearer ")) return void 0;
806
+ return authorization.slice("Bearer ".length);
807
+ }
808
+ function requestToken(c) {
809
+ const bearer = bearerToken(c);
810
+ if (bearer) return bearer;
811
+ if (c.req.method !== "GET") return void 0;
812
+ return c.req.query(REMOTE_ACCESS_QUERY_PARAM);
813
+ }
814
+ function remoteAccessAuth(expectedToken) {
815
+ return async (c, next) => {
816
+ if (!tokenMatches(requestToken(c), expectedToken)) {
817
+ return c.json({ error: "Remote access authentication required" }, 401);
818
+ }
819
+ await next();
820
+ };
821
+ }
822
+
823
823
  // src/server.ts
824
+ var MAX_API_REQUEST_BYTES = 1024 * 1024;
824
825
  function findWebDistPath() {
825
826
  const __dirname2 = dirname(fileURLToPath(import.meta.url));
826
827
  const packagedPath = resolve(__dirname2, "web");
@@ -862,6 +863,14 @@ function getListeningPort(server, fallback) {
862
863
  }
863
864
  async function createServer(port, store, options = {}) {
864
865
  const app = new Hono2();
866
+ const hostname = options.hostname ?? "127.0.0.1";
867
+ const isLoopback = isLoopbackHostname(hostname);
868
+ const remoteAccessToken = !isLoopback ? options.remoteAccessToken ?? (options.remoteAccess ? createRemoteAccessToken() : null) : null;
869
+ if (!isLoopback && !remoteAccessToken) {
870
+ throw new Error(
871
+ `Refusing to expose CodeSesh on ${hostname} without authentication. Add --remote-access to continue.`
872
+ );
873
+ }
865
874
  app.use("*", async (c, next) => {
866
875
  const startedAt = performance.now();
867
876
  let thrown;
@@ -882,6 +891,16 @@ async function createServer(port, store, options = {}) {
882
891
  });
883
892
  }
884
893
  });
894
+ if (remoteAccessToken) {
895
+ app.use("/api/*", remoteAccessAuth(remoteAccessToken));
896
+ }
897
+ app.use(
898
+ "/api/*",
899
+ bodyLimit({
900
+ maxSize: MAX_API_REQUEST_BYTES,
901
+ onError: (c) => c.json({ error: "Request body too large" }, 413)
902
+ })
903
+ );
885
904
  const routeOptions = {
886
905
  defaultSessionFrom: options.defaultSessionFrom,
887
906
  defaultSessionTo: options.defaultSessionTo,
@@ -901,7 +920,6 @@ async function createServer(port, store, options = {}) {
901
920
  app.get("/*", serveStatic({ root: webDistPath, path: "index.html" }));
902
921
  }
903
922
  const attempts = Math.max(1, options.portFallbackAttempts ?? 1);
904
- const hostname = options.hostname ?? "127.0.0.1";
905
923
  let server = null;
906
924
  let actualPort = port;
907
925
  for (let offset = 0; offset < attempts; offset += 1) {
@@ -928,16 +946,19 @@ async function createServer(port, store, options = {}) {
928
946
  throw new Error(getServerStartupErrorMessage(error, candidatePort));
929
947
  }
930
948
  }
931
- const isLoopback = hostname === "127.0.0.1" || hostname === "localhost";
932
- const url = isLoopback ? `http://localhost:${actualPort}` : `http://${hostname}:${actualPort}`;
933
- appLogger.info("server.listen", { port: actualPort, requested_port: port, hostname, url });
949
+ const baseUrl = isLoopback ? `http://localhost:${actualPort}` : `http://${hostname}:${actualPort}`;
950
+ const url = remoteAccessToken ? `${baseUrl}/?${REMOTE_ACCESS_QUERY_PARAM}=${encodeURIComponent(remoteAccessToken)}` : baseUrl;
951
+ appLogger.info("server.listen", {
952
+ port: actualPort,
953
+ requested_port: port,
954
+ hostname,
955
+ remote_access: Boolean(remoteAccessToken)
956
+ });
934
957
  if (!isLoopback) {
935
- appLogger.warn("server.listen.exposed", { hostname, port: actualPort });
936
- console.warn(
937
- `
938
- \u26A0 \u670D\u52A1\u76D1\u542C ${hostname}\uFF0C\u5C40\u57DF\u7F51\u5185\u5176\u4ED6\u8BBE\u5907\u53EF\u8BFB\u53D6\u4F60\u7684\u5168\u90E8 AI \u4F1A\u8BDD\u8BB0\u5F55\uFF08\u65E0\u9274\u6743\uFF09\u3002
939
- `
940
- );
958
+ appLogger.warn("server.listen.remote_access", { hostname, port: actualPort });
959
+ console.warn(`
960
+ \u26A0 \u8FDC\u7A0B\u8BBF\u95EE\u5DF2\u542F\u7528\u3002\u4EFB\u4F55\u6301\u6709\u542F\u52A8 URL \u7684\u4EBA\u90FD\u53EF\u4EE5\u8BFB\u53D6\u4F60\u7684 AI \u4F1A\u8BDD\u8BB0\u5F55\u3002
961
+ `);
941
962
  }
942
963
  return {
943
964
  url,
@@ -958,12 +979,661 @@ async function createServer(port, store, options = {}) {
958
979
  }
959
980
 
960
981
  // src/live-scan.ts
961
- import { existsSync as existsSync4 } from "fs";
982
+ import { existsSync as existsSync5 } from "fs";
983
+ import { fileURLToPath as fileURLToPath3 } from "url";
984
+ import { Worker as Worker2 } from "worker_threads";
985
+
986
+ // src/search-index-job-runner.ts
987
+ import { existsSync as existsSync3 } from "fs";
962
988
  import { fileURLToPath as fileURLToPath2 } from "url";
963
989
  import { Worker } from "worker_threads";
964
990
 
991
+ // src/pending-search-index-jobs.ts
992
+ var PendingSearchIndexJobBatch = class {
993
+ constructor(id, context, jobs, waiter) {
994
+ this.id = id;
995
+ this.context = context;
996
+ this.merge(context, jobs, waiter);
997
+ }
998
+ id;
999
+ context;
1000
+ jobsByAgent = /* @__PURE__ */ new Map();
1001
+ waiters = [];
1002
+ settled = false;
1003
+ get jobs() {
1004
+ const jobs = [];
1005
+ for (const pending of this.jobsByAgent.values()) {
1006
+ if (pending.full) jobs.push(pending.full);
1007
+ if (pending.changes) jobs.push(changesJobFromPending(pending.changes));
1008
+ }
1009
+ return jobs;
1010
+ }
1011
+ get changeCount() {
1012
+ let count = 0;
1013
+ for (const pending of this.jobsByAgent.values()) {
1014
+ if (!pending.changes) continue;
1015
+ count += pending.changes.changesBySessionId.size + pending.changes.removedSessionIds.size;
1016
+ }
1017
+ return count;
1018
+ }
1019
+ merge(context, jobs, waiter) {
1020
+ this.context = context;
1021
+ this.waiters.push(waiter);
1022
+ for (const job of jobs) this.mergeJob(job);
1023
+ }
1024
+ settle(error) {
1025
+ if (this.settled) return false;
1026
+ this.settled = true;
1027
+ for (const waiter of this.waiters) {
1028
+ if (error) waiter.reject(error);
1029
+ else waiter.resolve();
1030
+ }
1031
+ this.waiters.length = 0;
1032
+ return true;
1033
+ }
1034
+ mergeJob(job) {
1035
+ const pending = this.jobsByAgent.get(job.agentName) ?? {};
1036
+ this.jobsByAgent.set(job.agentName, pending);
1037
+ if (job.kind === "full") {
1038
+ pending.full = job;
1039
+ pending.changes = void 0;
1040
+ return;
1041
+ }
1042
+ pending.changes ??= createPendingChanges(job);
1043
+ mergeChanges(pending.changes, job);
1044
+ }
1045
+ };
1046
+ var PendingSearchIndexJobs = class {
1047
+ pendingBatch = null;
1048
+ get batchCount() {
1049
+ return this.pendingBatch ? 1 : 0;
1050
+ }
1051
+ get jobCount() {
1052
+ return this.pendingBatch?.jobs.length ?? 0;
1053
+ }
1054
+ get changeCount() {
1055
+ return this.pendingBatch?.changeCount ?? 0;
1056
+ }
1057
+ enqueue(id, context, jobs) {
1058
+ if (jobs.length === 0) return Promise.resolve();
1059
+ return new Promise((resolve4, reject) => {
1060
+ const waiter = { resolve: resolve4, reject };
1061
+ if (this.pendingBatch) {
1062
+ this.pendingBatch.merge(context, jobs, waiter);
1063
+ } else {
1064
+ this.pendingBatch = new PendingSearchIndexJobBatch(id, context, jobs, waiter);
1065
+ }
1066
+ });
1067
+ }
1068
+ take() {
1069
+ const batch = this.pendingBatch;
1070
+ this.pendingBatch = null;
1071
+ return batch;
1072
+ }
1073
+ settle(batch, error) {
1074
+ return batch instanceof PendingSearchIndexJobBatch && batch.settle(error);
1075
+ }
1076
+ rejectAll(error) {
1077
+ const batch = this.take();
1078
+ if (batch) this.settle(batch, error);
1079
+ }
1080
+ };
1081
+ function createPendingChanges(job) {
1082
+ return {
1083
+ context: job.context,
1084
+ agentName: job.agentName,
1085
+ changesBySessionId: /* @__PURE__ */ new Map(),
1086
+ removedSessionIds: /* @__PURE__ */ new Set(),
1087
+ meta: {},
1088
+ searchIndexOptions: job.searchIndexOptions
1089
+ };
1090
+ }
1091
+ function mergeChanges(pending, job) {
1092
+ pending.context = job.context;
1093
+ pending.searchIndexOptions = mergeSearchIndexOptions(
1094
+ pending.searchIndexOptions,
1095
+ job.searchIndexOptions
1096
+ );
1097
+ for (const sessionId of job.removedSessionIds) {
1098
+ pending.changesBySessionId.delete(sessionId);
1099
+ pending.removedSessionIds.add(sessionId);
1100
+ delete pending.meta[sessionId];
1101
+ }
1102
+ for (const change of job.changes) {
1103
+ const sessionId = change.session.id;
1104
+ pending.removedSessionIds.delete(sessionId);
1105
+ pending.changesBySessionId.set(sessionId, change);
1106
+ if (Object.hasOwn(job.meta, sessionId)) pending.meta[sessionId] = job.meta[sessionId];
1107
+ else delete pending.meta[sessionId];
1108
+ }
1109
+ for (const [sessionId, meta] of Object.entries(job.meta)) {
1110
+ if (!pending.removedSessionIds.has(sessionId)) pending.meta[sessionId] = meta;
1111
+ }
1112
+ }
1113
+ function mergeSearchIndexOptions(current, incoming) {
1114
+ if (!current) return incoming;
1115
+ if (!incoming) return current;
1116
+ return { ...current, ...incoming };
1117
+ }
1118
+ function changesJobFromPending(pending) {
1119
+ return {
1120
+ kind: "changes",
1121
+ context: pending.context,
1122
+ agentName: pending.agentName,
1123
+ changes: [...pending.changesBySessionId.values()],
1124
+ removedSessionIds: [...pending.removedSessionIds],
1125
+ meta: pending.meta,
1126
+ ...pending.searchIndexOptions ? { searchIndexOptions: pending.searchIndexOptions } : {}
1127
+ };
1128
+ }
1129
+
1130
+ // src/search-index-job-runner.ts
1131
+ var SHUTDOWN_ERROR_MESSAGE = "Live scan store shut down";
1132
+ var SearchIndexJobRunner = class {
1133
+ worker = null;
1134
+ activeBatch = null;
1135
+ nextBatchId = 1;
1136
+ pendingJobs = new PendingSearchIndexJobs();
1137
+ isShuttingDown = false;
1138
+ hasCheckedFtsIntegrity = false;
1139
+ enqueue(context, jobs) {
1140
+ if (jobs.length === 0) return Promise.resolve();
1141
+ if (this.isShuttingDown) return Promise.reject(new Error(SHUTDOWN_ERROR_MESSAGE));
1142
+ const batchId = this.nextBatchId++;
1143
+ const completion = this.pendingJobs.enqueue(batchId, context, jobs);
1144
+ if (this.worker) {
1145
+ const snapshot = this.snapshot();
1146
+ appLogger.debug("search_index.worker_queued", {
1147
+ batch_id: batchId,
1148
+ context,
1149
+ jobs: jobs.length,
1150
+ pending_batches: snapshot.pendingBatches,
1151
+ pending_jobs: snapshot.pendingJobs,
1152
+ pending_changes: snapshot.pendingChanges
1153
+ });
1154
+ } else {
1155
+ this.startNextBatch();
1156
+ }
1157
+ return completion;
1158
+ }
1159
+ snapshot() {
1160
+ return {
1161
+ activeBatchId: this.activeBatch?.id,
1162
+ pendingBatches: this.pendingJobs.batchCount,
1163
+ pendingJobs: this.pendingJobs.jobCount,
1164
+ pendingChanges: this.pendingJobs.changeCount
1165
+ };
1166
+ }
1167
+ async shutdown() {
1168
+ this.isShuttingDown = true;
1169
+ const activeBatch = this.activeBatch;
1170
+ const worker = this.worker;
1171
+ this.activeBatch = null;
1172
+ this.worker = null;
1173
+ const shutdownError = new Error(SHUTDOWN_ERROR_MESSAGE);
1174
+ if (activeBatch) this.settle(activeBatch, shutdownError);
1175
+ this.pendingJobs.rejectAll(shutdownError);
1176
+ if (worker) await worker.terminate();
1177
+ }
1178
+ startNextBatch() {
1179
+ if (this.isShuttingDown || this.worker) return;
1180
+ const batch = this.pendingJobs.take();
1181
+ if (!batch) return;
1182
+ appLogger.info("search_index.worker_dequeued", {
1183
+ batch_id: batch.id,
1184
+ context: batch.context,
1185
+ pending_batches: this.pendingJobs.batchCount
1186
+ });
1187
+ this.startBatch(batch);
1188
+ }
1189
+ startBatch(batch) {
1190
+ if (this.isShuttingDown) {
1191
+ this.settle(batch, new Error(SHUTDOWN_ERROR_MESSAGE));
1192
+ return;
1193
+ }
1194
+ const workerUrl = this.workerUrl();
1195
+ if (!workerUrl) {
1196
+ appLogger.warn("search_index.worker_missing", { context: batch.context });
1197
+ this.settle(batch);
1198
+ return;
1199
+ }
1200
+ appLogger.info("search_index.worker_started", {
1201
+ batch_id: batch.id,
1202
+ context: batch.context,
1203
+ jobs: batch.jobs.length
1204
+ });
1205
+ const worker = new Worker(workerUrl, {
1206
+ workerData: {
1207
+ context: batch.context,
1208
+ jobs: batch.jobs,
1209
+ agentNames: [],
1210
+ sessionsByAgent: {},
1211
+ metaByAgent: {},
1212
+ skipFtsIntegrityCheck: this.hasCheckedFtsIntegrity
1213
+ }
1214
+ });
1215
+ worker.unref();
1216
+ this.worker = worker;
1217
+ this.activeBatch = batch;
1218
+ worker.on("message", (message) => {
1219
+ if (message.type === "sync-result") {
1220
+ logSearchIndexSync(message.context, message.result);
1221
+ return;
1222
+ }
1223
+ if (message.type !== "done") return;
1224
+ appLogger.info(`${message.context}.done`, {
1225
+ duration_ms: Math.round(message.durationMs),
1226
+ sessions: message.sessions
1227
+ });
1228
+ this.hasCheckedFtsIntegrity = true;
1229
+ this.settle(batch);
1230
+ });
1231
+ worker.on("error", (error) => {
1232
+ appLogger.error("search_index.worker_error", { context: batch.context, error });
1233
+ this.settle(batch, error);
1234
+ });
1235
+ worker.on("exit", (code) => this.finishWorker(worker, batch, code));
1236
+ }
1237
+ finishWorker(worker, batch, code) {
1238
+ appLogger.info("search_index.worker_exited", {
1239
+ batch_id: batch.id,
1240
+ context: batch.context,
1241
+ code,
1242
+ shutting_down: this.isShuttingDown || void 0
1243
+ });
1244
+ if (this.worker === worker) this.worker = null;
1245
+ if (this.activeBatch === batch) this.activeBatch = null;
1246
+ const error = code === 0 ? new Error("Search index worker exited before completing its batch") : new Error(`Search index worker exited with code ${code}`);
1247
+ if (code !== 0) appLogger.warn("search_index.worker_exit", { context: batch.context, code });
1248
+ this.settle(batch, error);
1249
+ this.startNextBatch();
1250
+ }
1251
+ settle(batch, error) {
1252
+ if (!this.pendingJobs.settle(batch, error)) return;
1253
+ appLogger.info("search_index.worker_settled", {
1254
+ batch_id: batch.id,
1255
+ context: batch.context,
1256
+ result: error ? "rejected" : "resolved"
1257
+ });
1258
+ }
1259
+ workerUrl() {
1260
+ const workerUrl = new URL("./search-index-worker.js", import.meta.url);
1261
+ if (workerUrl.protocol === "file:" && !existsSync3(fileURLToPath2(workerUrl))) return null;
1262
+ return workerUrl;
1263
+ }
1264
+ };
1265
+
1266
+ // src/scan-status-model.ts
1267
+ var ScanStatusModel = class {
1268
+ status = {
1269
+ active: false,
1270
+ phase: "idle",
1271
+ pendingAgents: [],
1272
+ scanningAgents: [],
1273
+ completedAgents: [],
1274
+ agentStatuses: {},
1275
+ totalAgents: 0,
1276
+ updatedAt: Date.now(),
1277
+ backfill: { active: false, pendingAgents: [], completedAgents: [] }
1278
+ };
1279
+ snapshot() {
1280
+ return {
1281
+ type: "scan-status",
1282
+ ...this.status,
1283
+ pendingAgents: [...this.status.pendingAgents],
1284
+ scanningAgents: [...this.status.scanningAgents],
1285
+ completedAgents: [...this.status.completedAgents],
1286
+ agentStatuses: Object.fromEntries(
1287
+ Object.entries(this.status.agentStatuses).map(([agentName, status]) => [
1288
+ agentName,
1289
+ { ...status }
1290
+ ])
1291
+ ),
1292
+ backfill: {
1293
+ ...this.status.backfill,
1294
+ pendingAgents: [...this.status.backfill.pendingAgents],
1295
+ completedAgents: [...this.status.backfill.completedAgents]
1296
+ }
1297
+ };
1298
+ }
1299
+ startBatch(agentNames, phase, sessionCounts) {
1300
+ const uniqueAgentNames = [...new Set(agentNames)];
1301
+ const now = Date.now();
1302
+ const agentStatuses = Object.fromEntries(
1303
+ uniqueAgentNames.map((agentName) => [
1304
+ agentName,
1305
+ {
1306
+ agentName,
1307
+ status: "pending",
1308
+ processed: 0,
1309
+ sessions: sessionCounts[agentName] ?? 0,
1310
+ updatedAt: now
1311
+ }
1312
+ ])
1313
+ );
1314
+ return this.set({
1315
+ ...this.status,
1316
+ active: uniqueAgentNames.length > 0,
1317
+ phase: uniqueAgentNames.length > 0 ? phase : "idle",
1318
+ pendingAgents: uniqueAgentNames,
1319
+ scanningAgents: [],
1320
+ completedAgents: [],
1321
+ agentStatuses,
1322
+ totalAgents: uniqueAgentNames.length,
1323
+ startedAt: uniqueAgentNames.length > 0 ? now : void 0,
1324
+ updatedAt: now,
1325
+ completedAt: uniqueAgentNames.length > 0 ? void 0 : now
1326
+ });
1327
+ }
1328
+ setPhase(phase) {
1329
+ if (!this.status.active) return null;
1330
+ return this.set({ ...this.status, phase, updatedAt: Date.now() });
1331
+ }
1332
+ beginAgent(agentName, sessionCount) {
1333
+ if (!this.status.active)
1334
+ this.startBatch([agentName], "scanning", { [agentName]: sessionCount });
1335
+ const pendingAgents = this.status.pendingAgents.filter((agent) => agent !== agentName);
1336
+ const scanningAgents = [.../* @__PURE__ */ new Set([...this.status.scanningAgents, agentName])];
1337
+ const completedAgents = this.status.completedAgents.filter((agent) => agent !== agentName);
1338
+ const existingStatus = this.status.agentStatuses[agentName];
1339
+ const now = Date.now();
1340
+ return this.set({
1341
+ ...this.status,
1342
+ active: true,
1343
+ phase: this.status.phase === "initializing" ? "initializing" : "scanning",
1344
+ pendingAgents,
1345
+ scanningAgents,
1346
+ completedAgents,
1347
+ agentStatuses: {
1348
+ ...this.status.agentStatuses,
1349
+ [agentName]: {
1350
+ agentName,
1351
+ status: "scanning",
1352
+ total: existingStatus?.total,
1353
+ processed: existingStatus?.processed ?? 0,
1354
+ sessions: existingStatus?.sessions ?? sessionCount,
1355
+ startedAt: existingStatus?.startedAt ?? now,
1356
+ updatedAt: now
1357
+ }
1358
+ },
1359
+ totalAgents: Math.max(this.status.totalAgents, pendingAgents.length + scanningAgents.length),
1360
+ updatedAt: now,
1361
+ completedAt: void 0
1362
+ });
1363
+ }
1364
+ updateAgent(agentName, progress) {
1365
+ const status = this.status.agentStatuses[agentName];
1366
+ if (!status || status.status !== "scanning") return null;
1367
+ const now = Date.now();
1368
+ return this.set({
1369
+ ...this.status,
1370
+ agentStatuses: {
1371
+ ...this.status.agentStatuses,
1372
+ [agentName]: {
1373
+ ...status,
1374
+ total: progress.total ?? status.total,
1375
+ processed: progress.processed ?? status.processed,
1376
+ sessions: progress.sessions ?? status.sessions,
1377
+ updatedAt: now
1378
+ }
1379
+ },
1380
+ updatedAt: now
1381
+ });
1382
+ }
1383
+ finishAgent(agentName, sessionCount) {
1384
+ const pendingAgents = this.status.pendingAgents.filter((agent) => agent !== agentName);
1385
+ const scanningAgents = this.status.scanningAgents.filter((agent) => agent !== agentName);
1386
+ const completedAgents = [.../* @__PURE__ */ new Set([...this.status.completedAgents, agentName])];
1387
+ const isActive = pendingAgents.length > 0 || scanningAgents.length > 0;
1388
+ const now = Date.now();
1389
+ const previousStatus = this.status.agentStatuses[agentName];
1390
+ const total = previousStatus?.total ?? previousStatus?.processed;
1391
+ return this.set({
1392
+ ...this.status,
1393
+ active: isActive,
1394
+ phase: isActive ? "scanning" : "idle",
1395
+ pendingAgents,
1396
+ scanningAgents,
1397
+ completedAgents,
1398
+ agentStatuses: {
1399
+ ...this.status.agentStatuses,
1400
+ [agentName]: {
1401
+ agentName,
1402
+ status: "complete",
1403
+ total,
1404
+ processed: total,
1405
+ sessions: sessionCount ?? previousStatus?.sessions ?? 0,
1406
+ startedAt: previousStatus?.startedAt,
1407
+ updatedAt: now,
1408
+ completedAt: now
1409
+ }
1410
+ },
1411
+ updatedAt: now,
1412
+ completedAt: isActive ? void 0 : now
1413
+ });
1414
+ }
1415
+ finishBatch() {
1416
+ const now = Date.now();
1417
+ return this.set({
1418
+ ...this.status,
1419
+ active: false,
1420
+ phase: "idle",
1421
+ pendingAgents: [],
1422
+ scanningAgents: [],
1423
+ agentStatuses: Object.fromEntries(
1424
+ Object.entries(this.status.agentStatuses).map(([agentName, status]) => [
1425
+ agentName,
1426
+ { ...status, status: "complete", completedAt: status.completedAt ?? now, updatedAt: now }
1427
+ ])
1428
+ ),
1429
+ updatedAt: now,
1430
+ completedAt: now
1431
+ });
1432
+ }
1433
+ updateBackfill(patch) {
1434
+ return this.set({
1435
+ ...this.status,
1436
+ backfill: { ...this.status.backfill, ...patch },
1437
+ updatedAt: Date.now()
1438
+ });
1439
+ }
1440
+ set(status) {
1441
+ this.status = status;
1442
+ return this.snapshot();
1443
+ }
1444
+ };
1445
+
1446
+ // src/backfill-coordinator.ts
1447
+ var BackfillCoordinator = class {
1448
+ queue = [];
1449
+ currentAgent;
1450
+ completedAgents = [];
1451
+ get isRunning() {
1452
+ return this.currentAgent != null;
1453
+ }
1454
+ enqueue(agentName) {
1455
+ if (this.currentAgent === agentName || this.queue.includes(agentName)) return null;
1456
+ this.queue.push(agentName);
1457
+ return this.snapshot();
1458
+ }
1459
+ take() {
1460
+ if (this.currentAgent) return null;
1461
+ const agentName = this.queue.shift();
1462
+ if (!agentName) return null;
1463
+ this.currentAgent = agentName;
1464
+ return { agentName, status: this.snapshot() };
1465
+ }
1466
+ complete(agentName) {
1467
+ if (this.currentAgent === agentName) this.currentAgent = void 0;
1468
+ if (!this.completedAgents.includes(agentName)) this.completedAgents.push(agentName);
1469
+ return this.snapshot();
1470
+ }
1471
+ clear() {
1472
+ this.queue.length = 0;
1473
+ this.currentAgent = void 0;
1474
+ }
1475
+ snapshot() {
1476
+ return {
1477
+ active: this.currentAgent != null || this.queue.length > 0,
1478
+ pendingAgents: [...this.queue],
1479
+ currentAgent: this.currentAgent,
1480
+ completedAgents: [...this.completedAgents]
1481
+ };
1482
+ }
1483
+ };
1484
+
1485
+ // src/refresh-coordinator.ts
1486
+ var PENDING_REFRESH_DELAY_MS = 100;
1487
+ var MAX_ADAPTIVE_REFRESH_DELAY_MS = 3e4;
1488
+ var ADAPTIVE_REFRESH_DELAY_MULTIPLIER = 4;
1489
+ var RefreshCoordinator = class {
1490
+ states = /* @__PURE__ */ new Map();
1491
+ operationGenerations = /* @__PURE__ */ new Map();
1492
+ operationTails = /* @__PURE__ */ new Map();
1493
+ isShuttingDown = false;
1494
+ get activeOperationCount() {
1495
+ return this.operationTails.size;
1496
+ }
1497
+ get activeRefreshCount() {
1498
+ return [...this.states.values()].filter((state) => state.isRunning).length;
1499
+ }
1500
+ recordChangedPaths(agentName, count = 1) {
1501
+ this.state(agentName).pendingPathCount += count;
1502
+ }
1503
+ takePendingPathCount(agentName) {
1504
+ const state = this.state(agentName);
1505
+ const count = state.pendingPathCount;
1506
+ state.pendingPathCount = 0;
1507
+ return count;
1508
+ }
1509
+ lastRefreshAt(agentName) {
1510
+ return this.state(agentName).lastRefreshAt;
1511
+ }
1512
+ setLastRefreshAt(agentName, timestamp) {
1513
+ this.state(agentName).lastRefreshAt = timestamp;
1514
+ }
1515
+ setLastRefreshDuration(agentName, durationMs) {
1516
+ this.state(agentName).lastRefreshDurationMs = durationMs;
1517
+ }
1518
+ schedule(agentName, delayMs, refresh) {
1519
+ if (this.isShuttingDown) return;
1520
+ const state = this.state(agentName);
1521
+ const adaptiveDelayMs = Math.min(
1522
+ state.lastRefreshDurationMs * ADAPTIVE_REFRESH_DELAY_MULTIPLIER,
1523
+ MAX_ADAPTIVE_REFRESH_DELAY_MS
1524
+ );
1525
+ const effectiveDelayMs = Math.max(delayMs, adaptiveDelayMs);
1526
+ const deadline = Date.now() + effectiveDelayMs;
1527
+ if (state.timer) {
1528
+ if (deadline >= state.timerDeadline) return;
1529
+ clearTimeout(state.timer);
1530
+ }
1531
+ appLogger.debug("scan.refresh.schedule", { agent: agentName, delay_ms: effectiveDelayMs });
1532
+ state.timerDeadline = deadline;
1533
+ state.timer = setTimeout(() => {
1534
+ state.timer = null;
1535
+ void refresh();
1536
+ }, effectiveDelayMs);
1537
+ }
1538
+ async runRefresh(agentName, operation) {
1539
+ const state = this.state(agentName);
1540
+ if (state.isRunning) {
1541
+ appLogger.debug("scan.refresh.pending", { agent: agentName });
1542
+ state.hasPendingRerun = true;
1543
+ return;
1544
+ }
1545
+ state.isRunning = true;
1546
+ try {
1547
+ await this.serialize(agentName, "refresh", operation);
1548
+ } finally {
1549
+ state.isRunning = false;
1550
+ if (state.hasPendingRerun && !this.isShuttingDown) {
1551
+ state.hasPendingRerun = false;
1552
+ this.schedule(
1553
+ agentName,
1554
+ PENDING_REFRESH_DELAY_MS,
1555
+ () => this.runRefresh(agentName, operation)
1556
+ );
1557
+ }
1558
+ }
1559
+ }
1560
+ serialize(agentName, kind, operation) {
1561
+ const previous = this.operationTails.get(agentName) ?? Promise.resolve();
1562
+ const run = previous.then(async () => {
1563
+ if (this.isShuttingDown) return "skipped";
1564
+ const lifecycle = this.beginOperation(agentName, kind);
1565
+ try {
1566
+ const result = await operation();
1567
+ this.completeOperation(lifecycle, result);
1568
+ return result;
1569
+ } catch (error) {
1570
+ this.completeOperation(lifecycle, "failed");
1571
+ throw error;
1572
+ }
1573
+ });
1574
+ const tail = run.then(
1575
+ () => void 0,
1576
+ () => void 0
1577
+ );
1578
+ this.operationTails.set(agentName, tail);
1579
+ void tail.finally(() => {
1580
+ if (this.operationTails.get(agentName) === tail) this.operationTails.delete(agentName);
1581
+ });
1582
+ return run;
1583
+ }
1584
+ async shutdown() {
1585
+ this.isShuttingDown = true;
1586
+ for (const state of this.states.values()) {
1587
+ if (!state.timer) continue;
1588
+ clearTimeout(state.timer);
1589
+ state.timer = null;
1590
+ state.timerDeadline = 0;
1591
+ }
1592
+ await Promise.allSettled(this.operationTails.values());
1593
+ }
1594
+ state(agentName) {
1595
+ const existing = this.states.get(agentName);
1596
+ if (existing) return existing;
1597
+ const state = {
1598
+ timer: null,
1599
+ timerDeadline: 0,
1600
+ isRunning: false,
1601
+ hasPendingRerun: false,
1602
+ lastRefreshAt: 0,
1603
+ lastRefreshDurationMs: 0,
1604
+ pendingPathCount: 0
1605
+ };
1606
+ this.states.set(agentName, state);
1607
+ return state;
1608
+ }
1609
+ beginOperation(agentName, kind) {
1610
+ const generation = (this.operationGenerations.get(agentName) ?? 0) + 1;
1611
+ const startedAt = Date.now();
1612
+ this.operationGenerations.set(agentName, generation);
1613
+ appLogger.info("scan.agent_operation.started", {
1614
+ agent: agentName,
1615
+ operation: kind,
1616
+ generation,
1617
+ started_at: startedAt
1618
+ });
1619
+ return { agentName, kind, generation, startedAt };
1620
+ }
1621
+ completeOperation(lifecycle, result) {
1622
+ const completedAt = Date.now();
1623
+ appLogger.info("scan.agent_operation.completed", {
1624
+ agent: lifecycle.agentName,
1625
+ operation: lifecycle.kind,
1626
+ generation: lifecycle.generation,
1627
+ started_at: lifecycle.startedAt,
1628
+ completed_at: completedAt,
1629
+ duration_ms: completedAt - lifecycle.startedAt,
1630
+ result
1631
+ });
1632
+ }
1633
+ };
1634
+
965
1635
  // src/session-watcher.ts
966
- import { existsSync as existsSync3, readdirSync as readdirSync2, statSync as statSync2, watch } from "fs";
1636
+ import { existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, watch } from "fs";
967
1637
  import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
968
1638
  var WRITE_STABILITY_THRESHOLD_MS = 250;
969
1639
  var WRITE_STABILITY_POLL_MS = 100;
@@ -971,11 +1641,11 @@ function toAbsolutePath(path) {
971
1641
  return isAbsolute(path) ? path : resolve2(path);
972
1642
  }
973
1643
  function closestWatchablePath(targetPath) {
974
- if (!isAbsolute(targetPath) && !existsSync3(targetPath)) {
1644
+ if (!isAbsolute(targetPath) && !existsSync4(targetPath)) {
975
1645
  return null;
976
1646
  }
977
1647
  let current = toAbsolutePath(targetPath);
978
- while (!existsSync3(current)) {
1648
+ while (!existsSync4(current)) {
979
1649
  const parent = dirname2(current);
980
1650
  if (parent === current) {
981
1651
  return null;
@@ -1283,9 +1953,6 @@ var SessionWatcher = class {
1283
1953
  // src/live-scan.ts
1284
1954
  var REFRESH_DEBOUNCE_MS = 200;
1285
1955
  var EMPTY_AGENT_REFRESH_DEBOUNCE_MS = 3e4;
1286
- var PENDING_REFRESH_DELAY_MS = 100;
1287
- var MAX_ADAPTIVE_REFRESH_DELAY_MS = 3e4;
1288
- var ADAPTIVE_REFRESH_DELAY_MULTIPLIER = 4;
1289
1956
  var NEW_SESSION_EVENT_WINDOW_MS = 250;
1290
1957
  var SEARCH_INDEX_BULK_PENDING_PATH_THRESHOLD = 100;
1291
1958
  var BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1e3;
@@ -1364,45 +2031,17 @@ var LiveScanStore = class {
1364
2031
  sessions = [];
1365
2032
  listeners = /* @__PURE__ */ new Set();
1366
2033
  scanStatusListeners = /* @__PURE__ */ new Set();
1367
- scanStatus = {
1368
- active: false,
1369
- phase: "idle",
1370
- pendingAgents: [],
1371
- scanningAgents: [],
1372
- completedAgents: [],
1373
- agentStatuses: {},
1374
- totalAgents: 0,
1375
- updatedAt: Date.now(),
1376
- backfill: { active: false, pendingAgents: [], completedAgents: [] }
1377
- };
1378
- backfillQueue = [];
1379
- backfillRunning = false;
1380
- refreshStates = /* @__PURE__ */ new Map();
2034
+ scanStatus = new ScanStatusModel();
2035
+ backfills = new BackfillCoordinator();
2036
+ refreshes = new RefreshCoordinator();
1381
2037
  watcher = null;
1382
2038
  pendingEvent = null;
1383
2039
  pendingEventTimer = null;
1384
2040
  backgroundRefreshTimer = null;
1385
- searchIndexWorker = null;
1386
- pendingSearchIndexJobs = [];
2041
+ scanRefreshWorkers = /* @__PURE__ */ new Set();
2042
+ searchIndexJobs = new SearchIndexJobRunner();
2043
+ shutdownPromise = null;
1387
2044
  shuttingDown = false;
1388
- /** Set once a search-index worker in this process has completed the FTS integrity check. */
1389
- ftsIntegrityChecked = false;
1390
- getRefreshState(agentName) {
1391
- let state = this.refreshStates.get(agentName);
1392
- if (!state) {
1393
- state = {
1394
- timer: null,
1395
- timerDeadline: 0,
1396
- inFlight: false,
1397
- pendingRerun: false,
1398
- lastRefreshAt: 0,
1399
- lastRefreshDurationMs: 0,
1400
- pendingPathCount: 0
1401
- };
1402
- this.refreshStates.set(agentName, state);
1403
- }
1404
- return state;
1405
- }
1406
2045
  async initialize() {
1407
2046
  const startedAt = performance.now();
1408
2047
  const deferInitialRefresh = this.storeOptions.deferInitialRefresh === true;
@@ -1427,7 +2066,7 @@ var LiveScanStore = class {
1427
2066
  this.applyScanResult(initialResult);
1428
2067
  const indexStartedAt = performance.now();
1429
2068
  if (!deferInitialRefresh) {
1430
- await this.enqueueSearchIndexJobs(
2069
+ await this.searchIndexJobs.enqueue(
1431
2070
  "scan.initial",
1432
2071
  this.buildFullSearchIndexJobs("scan.initial")
1433
2072
  );
@@ -1459,7 +2098,7 @@ var LiveScanStore = class {
1459
2098
  this.watcher = new SessionWatcher();
1460
2099
  this.watcher.onAgentsChanged((agentNames) => {
1461
2100
  for (const agentName of agentNames) {
1462
- this.getRefreshState(agentName).pendingPathCount += 1;
2101
+ this.refreshes.recordChangedPaths(agentName);
1463
2102
  const delayMs = (this.byAgent[agentName]?.length ?? 0) === 0 ? EMPTY_AGENT_REFRESH_DEBOUNCE_MS : REFRESH_DEBOUNCE_MS;
1464
2103
  this.scheduleRefresh(agentName, delayMs);
1465
2104
  }
@@ -1496,24 +2135,7 @@ var LiveScanStore = class {
1496
2135
  };
1497
2136
  }
1498
2137
  getScanStatus() {
1499
- return {
1500
- type: "scan-status",
1501
- ...this.scanStatus,
1502
- pendingAgents: [...this.scanStatus.pendingAgents],
1503
- scanningAgents: [...this.scanStatus.scanningAgents],
1504
- completedAgents: [...this.scanStatus.completedAgents],
1505
- agentStatuses: Object.fromEntries(
1506
- Object.entries(this.scanStatus.agentStatuses).map(([agentName, status]) => [
1507
- agentName,
1508
- { ...status }
1509
- ])
1510
- ),
1511
- backfill: {
1512
- ...this.scanStatus.backfill,
1513
- pendingAgents: [...this.scanStatus.backfill.pendingAgents],
1514
- completedAgents: [...this.scanStatus.backfill.completedAgents]
1515
- }
1516
- };
2138
+ return this.scanStatus.snapshot();
1517
2139
  }
1518
2140
  subscribe(listener) {
1519
2141
  this.listeners.add(listener);
@@ -1527,15 +2149,26 @@ var LiveScanStore = class {
1527
2149
  this.scanStatusListeners.delete(listener);
1528
2150
  };
1529
2151
  }
1530
- async shutdown() {
2152
+ shutdown() {
2153
+ this.shutdownPromise ??= this.performShutdown();
2154
+ return this.shutdownPromise;
2155
+ }
2156
+ async performShutdown() {
1531
2157
  this.shuttingDown = true;
1532
- for (const state of this.refreshStates.values()) {
1533
- if (state.timer) {
1534
- clearTimeout(state.timer);
1535
- state.timer = null;
1536
- state.timerDeadline = 0;
1537
- }
2158
+ const activeOperations = {
2159
+ agent_operations: this.refreshes.activeOperationCount,
2160
+ refreshes: this.refreshes.activeRefreshCount,
2161
+ backfill_running: this.backfills.isRunning || void 0,
2162
+ scan_workers: this.scanRefreshWorkers.size
2163
+ };
2164
+ if (activeOperations.agent_operations > 0 || activeOperations.scan_workers > 0) {
2165
+ appLogger.warn("scan.shutdown.active_operations", activeOperations);
1538
2166
  }
2167
+ const searchIndexSnapshot = this.searchIndexJobs.snapshot();
2168
+ appLogger.info("search_index.shutdown.started", {
2169
+ active_batch_id: searchIndexSnapshot.activeBatchId,
2170
+ pending_batches: searchIndexSnapshot.pendingBatches
2171
+ });
1539
2172
  if (this.pendingEventTimer) {
1540
2173
  clearTimeout(this.pendingEventTimer);
1541
2174
  this.pendingEventTimer = null;
@@ -1544,21 +2177,24 @@ var LiveScanStore = class {
1544
2177
  clearTimeout(this.backgroundRefreshTimer);
1545
2178
  this.backgroundRefreshTimer = null;
1546
2179
  }
1547
- if (this.searchIndexWorker) {
1548
- await this.searchIndexWorker.terminate();
1549
- this.searchIndexWorker = null;
1550
- }
1551
- for (const batch of this.pendingSearchIndexJobs) {
1552
- batch.reject(new Error("Live scan store shut down"));
1553
- }
1554
- this.pendingSearchIndexJobs = [];
2180
+ this.backfills.clear();
2181
+ await this.searchIndexJobs.shutdown();
2182
+ const scanWorkers = [...this.scanRefreshWorkers];
2183
+ await Promise.allSettled(scanWorkers.map((scanWorker) => scanWorker.terminate()));
2184
+ await this.refreshes.shutdown();
1555
2185
  this.pendingEvent = null;
1556
2186
  if (this.watcher) {
1557
2187
  await this.watcher.dispose();
1558
2188
  this.watcher = null;
1559
2189
  }
2190
+ const stoppedSearchIndexSnapshot = this.searchIndexJobs.snapshot();
2191
+ appLogger.info("search_index.shutdown.completed", {
2192
+ active_batch_id: searchIndexSnapshot.activeBatchId,
2193
+ pending_batches: stoppedSearchIndexSnapshot.pendingBatches
2194
+ });
1560
2195
  }
1561
2196
  emit(event) {
2197
+ if (this.shuttingDown) return;
1562
2198
  if (this.pendingEvent || event.newSessions > 0) {
1563
2199
  this.queueEvent(event);
1564
2200
  return;
@@ -1570,164 +2206,36 @@ var LiveScanStore = class {
1570
2206
  listener(event);
1571
2207
  }
1572
2208
  }
1573
- emitScanStatus() {
1574
- const event = this.getScanStatus();
1575
- for (const listener of this.scanStatusListeners) {
1576
- listener(event);
1577
- }
1578
- }
1579
- updateScanStatus(next) {
1580
- this.scanStatus = next;
1581
- this.emitScanStatus();
1582
- }
1583
2209
  startScanBatch(agentNames, phase) {
1584
- const uniqueAgentNames = [...new Set(agentNames)];
1585
- const now = Date.now();
1586
- const agentStatuses = Object.fromEntries(
1587
- uniqueAgentNames.map((agentName) => [
1588
- agentName,
1589
- {
1590
- agentName,
1591
- status: "pending",
1592
- processed: 0,
1593
- sessions: this.byAgent[agentName]?.length ?? 0,
1594
- updatedAt: now
1595
- }
1596
- ])
2210
+ const sessionCounts = Object.fromEntries(
2211
+ agentNames.map((agentName) => [agentName, this.byAgent[agentName]?.length ?? 0])
1597
2212
  );
1598
- this.updateScanStatus({
1599
- ...this.scanStatus,
1600
- active: uniqueAgentNames.length > 0,
1601
- phase: uniqueAgentNames.length > 0 ? phase : "idle",
1602
- pendingAgents: uniqueAgentNames,
1603
- scanningAgents: [],
1604
- completedAgents: [],
1605
- agentStatuses,
1606
- totalAgents: uniqueAgentNames.length,
1607
- startedAt: uniqueAgentNames.length > 0 ? now : void 0,
1608
- updatedAt: now,
1609
- completedAt: uniqueAgentNames.length > 0 ? void 0 : now
1610
- });
2213
+ this.publishScanStatus(this.scanStatus.startBatch(agentNames, phase, sessionCounts));
1611
2214
  }
1612
2215
  setScanPhase(phase) {
1613
- if (!this.scanStatus.active) return;
1614
- this.updateScanStatus({
1615
- ...this.scanStatus,
1616
- phase,
1617
- updatedAt: Date.now()
1618
- });
2216
+ this.publishScanStatus(this.scanStatus.setPhase(phase));
1619
2217
  }
1620
2218
  beginAgentScan(agentName) {
1621
- if (!this.scanStatus.active) {
1622
- this.startScanBatch([agentName], "scanning");
1623
- }
1624
- const pendingAgents = this.scanStatus.pendingAgents.filter((agent) => agent !== agentName);
1625
- const scanningAgents = [.../* @__PURE__ */ new Set([...this.scanStatus.scanningAgents, agentName])];
1626
- const completedAgents = this.scanStatus.completedAgents.filter((agent) => agent !== agentName);
1627
- const existingStatus = this.scanStatus.agentStatuses[agentName];
1628
- const agentStatuses = {
1629
- ...this.scanStatus.agentStatuses,
1630
- [agentName]: {
1631
- agentName,
1632
- status: "scanning",
1633
- total: existingStatus?.total,
1634
- processed: existingStatus?.processed ?? 0,
1635
- sessions: existingStatus?.sessions ?? this.byAgent[agentName]?.length ?? 0,
1636
- startedAt: existingStatus?.startedAt ?? Date.now(),
1637
- updatedAt: Date.now()
1638
- }
1639
- };
1640
- this.updateScanStatus({
1641
- ...this.scanStatus,
1642
- active: true,
1643
- phase: this.scanStatus.phase === "initializing" ? "initializing" : "scanning",
1644
- pendingAgents,
1645
- scanningAgents,
1646
- completedAgents,
1647
- agentStatuses,
1648
- totalAgents: Math.max(
1649
- this.scanStatus.totalAgents,
1650
- pendingAgents.length + scanningAgents.length
1651
- ),
1652
- updatedAt: Date.now(),
1653
- completedAt: void 0
1654
- });
2219
+ if (!this.scanStatus.snapshot().active) this.startScanBatch([agentName], "scanning");
2220
+ this.publishScanStatus(
2221
+ this.scanStatus.beginAgent(agentName, this.byAgent[agentName]?.length ?? 0)
2222
+ );
1655
2223
  }
1656
2224
  updateAgentScanProgress(agentName, progress) {
1657
- const status = this.scanStatus.agentStatuses[agentName];
1658
- if (!status || status.status !== "scanning") return;
1659
- this.updateScanStatus({
1660
- ...this.scanStatus,
1661
- agentStatuses: {
1662
- ...this.scanStatus.agentStatuses,
1663
- [agentName]: {
1664
- ...status,
1665
- total: progress.total ?? status.total,
1666
- processed: progress.processed ?? status.processed,
1667
- sessions: progress.sessions ?? status.sessions,
1668
- updatedAt: Date.now()
1669
- }
1670
- },
1671
- updatedAt: Date.now()
1672
- });
2225
+ this.publishScanStatus(this.scanStatus.updateAgent(agentName, progress));
1673
2226
  }
1674
2227
  finishAgentScan(agentName) {
1675
- const pendingAgents = this.scanStatus.pendingAgents.filter((agent) => agent !== agentName);
1676
- const scanningAgents = this.scanStatus.scanningAgents.filter((agent) => agent !== agentName);
1677
- const completedAgents = [.../* @__PURE__ */ new Set([...this.scanStatus.completedAgents, agentName])];
1678
- const active = pendingAgents.length > 0 || scanningAgents.length > 0;
1679
- const now = Date.now();
1680
- const previousStatus = this.scanStatus.agentStatuses[agentName];
1681
- const sessions = this.byAgent[agentName]?.length ?? previousStatus?.sessions ?? 0;
1682
- const total = previousStatus?.total ?? previousStatus?.processed;
1683
- this.updateScanStatus({
1684
- ...this.scanStatus,
1685
- active,
1686
- phase: active ? "scanning" : "idle",
1687
- pendingAgents,
1688
- scanningAgents,
1689
- completedAgents,
1690
- agentStatuses: {
1691
- ...this.scanStatus.agentStatuses,
1692
- [agentName]: {
1693
- agentName,
1694
- status: "complete",
1695
- total,
1696
- processed: total,
1697
- sessions,
1698
- startedAt: previousStatus?.startedAt,
1699
- updatedAt: now,
1700
- completedAt: now
1701
- }
1702
- },
1703
- updatedAt: now,
1704
- completedAt: active ? void 0 : now
1705
- });
2228
+ this.publishScanStatus(this.scanStatus.finishAgent(agentName, this.byAgent[agentName]?.length));
1706
2229
  }
1707
2230
  finishScanBatch() {
1708
- const now = Date.now();
1709
- this.updateScanStatus({
1710
- ...this.scanStatus,
1711
- active: false,
1712
- phase: "idle",
1713
- pendingAgents: [],
1714
- scanningAgents: [],
1715
- agentStatuses: Object.fromEntries(
1716
- Object.entries(this.scanStatus.agentStatuses).map(([agentName, status]) => [
1717
- agentName,
1718
- { ...status, status: "complete", completedAt: status.completedAt ?? now, updatedAt: now }
1719
- ])
1720
- ),
1721
- updatedAt: now,
1722
- completedAt: now
1723
- });
2231
+ this.publishScanStatus(this.scanStatus.finishBatch());
1724
2232
  }
1725
2233
  updateBackfillStatus(patch) {
1726
- this.updateScanStatus({
1727
- ...this.scanStatus,
1728
- backfill: { ...this.scanStatus.backfill, ...patch },
1729
- updatedAt: Date.now()
1730
- });
2234
+ this.publishScanStatus(this.scanStatus.updateBackfill(patch));
2235
+ }
2236
+ publishScanStatus(event) {
2237
+ if (!event || this.shuttingDown) return;
2238
+ for (const listener of this.scanStatusListeners) listener(event);
1731
2239
  }
1732
2240
  /**
1733
2241
  * Only FileSystemSessionSource agents pay the O(history) enumeration cost this
@@ -1743,35 +2251,32 @@ var LiveScanStore = class {
1743
2251
  return lastSyncAt == null || Date.now() - lastSyncAt > BACKFILL_INTERVAL_MS;
1744
2252
  }
1745
2253
  enqueueBackfill(agentName) {
1746
- if (this.backfillQueue.includes(agentName) || this.scanStatus.backfill.currentAgent === agentName) {
1747
- return;
1748
- }
1749
- this.backfillQueue.push(agentName);
1750
- this.updateBackfillStatus({ active: true, pendingAgents: [...this.backfillQueue] });
2254
+ if (this.shuttingDown) return;
2255
+ const status = this.backfills.enqueue(agentName);
2256
+ if (!status) return;
2257
+ this.updateBackfillStatus(status);
1751
2258
  this.pumpBackfillQueue();
1752
2259
  }
1753
2260
  pumpBackfillQueue() {
1754
- if (this.backfillRunning) return;
1755
- const agentName = this.backfillQueue.shift();
1756
- if (!agentName) return;
1757
- this.backfillRunning = true;
1758
- this.updateBackfillStatus({ currentAgent: agentName, pendingAgents: [...this.backfillQueue] });
1759
- void this.runBackfill(agentName).finally(() => {
1760
- this.backfillRunning = false;
1761
- this.updateBackfillStatus({
1762
- currentAgent: void 0,
1763
- completedAgents: [.../* @__PURE__ */ new Set([...this.scanStatus.backfill.completedAgents, agentName])],
1764
- active: this.backfillQueue.length > 0
1765
- });
2261
+ if (this.shuttingDown) return;
2262
+ const work = this.backfills.take();
2263
+ if (!work) return;
2264
+ this.updateBackfillStatus(work.status);
2265
+ void this.runBackfill(work.agentName).finally(() => {
2266
+ if (this.shuttingDown) return;
2267
+ this.updateBackfillStatus(this.backfills.complete(work.agentName));
1766
2268
  this.pumpBackfillQueue();
1767
2269
  });
1768
2270
  }
1769
2271
  /** Unbounded per-source sync to reconcile the full session history, not just the display window. */
1770
2272
  async runBackfill(agentName) {
2273
+ await this.refreshes.serialize(agentName, "backfill", () => this.performBackfill(agentName));
2274
+ }
2275
+ async performBackfill(agentName) {
1771
2276
  const startedAt = performance.now();
1772
2277
  const agent = this.agents.find((item) => item.name === agentName);
1773
2278
  if (!agent || !(agent instanceof FileSystemSessionSource) || !agent.isAvailable()) {
1774
- return;
2279
+ return "skipped";
1775
2280
  }
1776
2281
  const cached = loadCachedSessions(agentName);
1777
2282
  const baseline = cached?.sessions ?? this.byAgent[agentName] ?? [];
@@ -1798,7 +2303,7 @@ var LiveScanStore = class {
1798
2303
  );
1799
2304
  this.byAgent[agentName] = sortSessions(filtered);
1800
2305
  this.rebuildSessions();
1801
- await this.enqueueSearchIndexJobs("scan.backfill", [
2306
+ await this.searchIndexJobs.enqueue("scan.backfill", [
1802
2307
  {
1803
2308
  kind: "full",
1804
2309
  context: "scan.backfill",
@@ -1819,9 +2324,11 @@ var LiveScanStore = class {
1819
2324
  sessions: fullSessions.length,
1820
2325
  changed: result.changedIds?.length ?? 0
1821
2326
  });
2327
+ return "committed";
1822
2328
  } catch (error) {
1823
2329
  appLogger.error("scan.backfill.error", { agent: agentName, error });
1824
2330
  console.error(`[${agentName}] Backfill failed:`, error);
2331
+ return "failed";
1825
2332
  }
1826
2333
  }
1827
2334
  queueEvent(event) {
@@ -1841,16 +2348,9 @@ var LiveScanStore = class {
1841
2348
  rebuildSessions() {
1842
2349
  this.sessions = sortSessions(Object.values(this.byAgent).flat());
1843
2350
  }
1844
- getSearchIndexWorkerUrl() {
1845
- const workerUrl = new URL("./search-index-worker.js", import.meta.url);
1846
- if (workerUrl.protocol === "file:" && !existsSync4(fileURLToPath2(workerUrl))) {
1847
- return null;
1848
- }
1849
- return workerUrl;
1850
- }
1851
2351
  getSmartTagWorkerUrl() {
1852
2352
  const workerUrl = new URL("./smart-tag-worker.js", import.meta.url);
1853
- if (workerUrl.protocol === "file:" && !existsSync4(fileURLToPath2(workerUrl))) {
2353
+ if (workerUrl.protocol === "file:" && !existsSync5(fileURLToPath3(workerUrl))) {
1854
2354
  return null;
1855
2355
  }
1856
2356
  return workerUrl;
@@ -1861,7 +2361,7 @@ var LiveScanStore = class {
1861
2361
  scanAgentInWorker(agent, previousSessions, changedIds, scanOptions, workerOptions = {}) {
1862
2362
  const workerUrl = this.getScanRefreshWorkerUrl();
1863
2363
  return new Promise((resolve4, reject) => {
1864
- const worker = new Worker(workerUrl, {
2364
+ const worker = new Worker2(workerUrl, {
1865
2365
  workerData: {
1866
2366
  agentName: agent.name,
1867
2367
  previousSessions,
@@ -1872,11 +2372,13 @@ var LiveScanStore = class {
1872
2372
  }
1873
2373
  });
1874
2374
  worker.unref();
2375
+ this.scanRefreshWorkers.add(worker);
1875
2376
  let settled = false;
1876
- const finish = (callback) => {
2377
+ const finish = (callback, terminate = true) => {
1877
2378
  if (settled) return;
1878
2379
  settled = true;
1879
- void worker.terminate();
2380
+ this.scanRefreshWorkers.delete(worker);
2381
+ if (terminate) void worker.terminate();
1880
2382
  callback();
1881
2383
  };
1882
2384
  worker.on("message", (message) => {
@@ -1900,8 +2402,15 @@ var LiveScanStore = class {
1900
2402
  finish(() => reject(error));
1901
2403
  });
1902
2404
  worker.once("exit", (code) => {
1903
- if (!settled && code !== 0) {
1904
- finish(() => reject(new Error(`Scan refresh worker exited with code ${code}`)));
2405
+ if (!settled) {
2406
+ appLogger.warn("scan.refresh_worker.exit_before_done", {
2407
+ agent: agent.name,
2408
+ code
2409
+ });
2410
+ finish(
2411
+ () => reject(new Error(`Scan refresh worker exited before completing (code ${code})`)),
2412
+ false
2413
+ );
1905
2414
  }
1906
2415
  });
1907
2416
  });
@@ -1927,77 +2436,6 @@ var LiveScanStore = class {
1927
2436
  };
1928
2437
  });
1929
2438
  }
1930
- enqueueSearchIndexJobs(context, jobs) {
1931
- if (jobs.length === 0) return Promise.resolve();
1932
- return new Promise((resolve4, reject) => {
1933
- const batch = { context, jobs, resolve: resolve4, reject };
1934
- if (this.searchIndexWorker) {
1935
- this.pendingSearchIndexJobs.push(batch);
1936
- appLogger.debug("search_index.worker_queued", {
1937
- context,
1938
- jobs: jobs.length,
1939
- pending_jobs: this.pendingSearchIndexJobs.length
1940
- });
1941
- return;
1942
- }
1943
- this.startSearchIndexJobBatch(batch);
1944
- });
1945
- }
1946
- startSearchIndexJobBatch(batch) {
1947
- const workerUrl = this.getSearchIndexWorkerUrl();
1948
- if (!workerUrl) {
1949
- appLogger.warn("search_index.worker_missing", { context: batch.context });
1950
- batch.resolve();
1951
- return;
1952
- }
1953
- let settled = false;
1954
- const worker = new Worker(workerUrl, {
1955
- workerData: {
1956
- context: batch.context,
1957
- jobs: batch.jobs,
1958
- agentNames: [],
1959
- sessionsByAgent: {},
1960
- metaByAgent: {},
1961
- skipFtsIntegrityCheck: this.ftsIntegrityChecked
1962
- }
1963
- });
1964
- worker.unref();
1965
- this.searchIndexWorker = worker;
1966
- worker.on("message", (message) => {
1967
- if (message.type === "sync-result") {
1968
- logSearchIndexSync(message.context, message.result);
1969
- } else if (message.type === "done") {
1970
- appLogger.info(`${message.context}.done`, {
1971
- duration_ms: Math.round(message.durationMs),
1972
- sessions: message.sessions
1973
- });
1974
- settled = true;
1975
- this.ftsIntegrityChecked = true;
1976
- batch.resolve();
1977
- }
1978
- });
1979
- worker.on("error", (error) => {
1980
- appLogger.error("search_index.worker_error", { context: batch.context, error });
1981
- if (!settled) {
1982
- settled = true;
1983
- batch.reject(error);
1984
- }
1985
- });
1986
- worker.on("exit", (code) => {
1987
- this.searchIndexWorker = null;
1988
- if (code !== 0) {
1989
- appLogger.warn("search_index.worker_exit", { context: batch.context, code });
1990
- if (!settled) {
1991
- settled = true;
1992
- batch.reject(new Error(`Search index worker exited with code ${code}`));
1993
- }
1994
- }
1995
- if (this.pendingSearchIndexJobs.length > 0) {
1996
- const pendingBatch = this.pendingSearchIndexJobs.shift();
1997
- this.startSearchIndexJobBatch(pendingBatch);
1998
- }
1999
- });
2000
- }
2001
2439
  applyScanResult(result) {
2002
2440
  const knownAgents = createRegisteredAgents();
2003
2441
  const agentMap = /* @__PURE__ */ new Map();
@@ -2019,7 +2457,10 @@ var LiveScanStore = class {
2019
2457
  this.byAgent = {};
2020
2458
  for (const agent of this.agents) {
2021
2459
  this.byAgent[agent.name] = sortSessions(result.byAgent[agent.name] ?? []);
2022
- this.getRefreshState(agent.name).lastRefreshAt = result.cacheTimestamps?.[agent.name] ?? Date.now();
2460
+ this.refreshes.setLastRefreshAt(
2461
+ agent.name,
2462
+ result.cacheTimestamps?.[agent.name] ?? Date.now()
2463
+ );
2023
2464
  }
2024
2465
  this.rebuildSessions();
2025
2466
  }
@@ -2036,7 +2477,7 @@ var LiveScanStore = class {
2036
2477
  const startedAt = performance.now();
2037
2478
  const context = "scan.initial.background";
2038
2479
  try {
2039
- await this.enqueueSearchIndexJobs(context, this.buildFullSearchIndexJobs(context));
2480
+ await this.searchIndexJobs.enqueue(context, this.buildFullSearchIndexJobs(context));
2040
2481
  appLogger.info(`${context}.complete`, {
2041
2482
  duration_ms: Math.round(performance.now() - startedAt),
2042
2483
  sessions: this.sessions.length
@@ -2056,63 +2497,34 @@ var LiveScanStore = class {
2056
2497
  * backoff elapses — push the deadline out forever and starve the refresh.
2057
2498
  */
2058
2499
  scheduleRefresh(agentName, delayMs = REFRESH_DEBOUNCE_MS) {
2059
- const state = this.getRefreshState(agentName);
2060
- const adaptiveDelayMs = Math.min(
2061
- state.lastRefreshDurationMs * ADAPTIVE_REFRESH_DELAY_MULTIPLIER,
2062
- MAX_ADAPTIVE_REFRESH_DELAY_MS
2063
- );
2064
- const effectiveDelayMs = Math.max(delayMs, adaptiveDelayMs);
2065
- const deadline = Date.now() + effectiveDelayMs;
2066
- if (state.timer !== null) {
2067
- if (deadline >= state.timerDeadline) {
2068
- return;
2069
- }
2070
- clearTimeout(state.timer);
2071
- }
2072
- appLogger.debug("scan.refresh.schedule", { agent: agentName, delay_ms: effectiveDelayMs });
2073
- state.timerDeadline = deadline;
2074
- state.timer = setTimeout(() => {
2075
- state.timer = null;
2076
- void this.refreshAgent(agentName);
2077
- }, effectiveDelayMs);
2500
+ this.refreshes.schedule(agentName, delayMs, () => this.refreshAgent(agentName));
2078
2501
  }
2079
2502
  async refreshAgent(agentName) {
2080
- const state = this.getRefreshState(agentName);
2081
- if (state.inFlight) {
2082
- appLogger.debug("scan.refresh.pending", { agent: agentName });
2083
- state.pendingRerun = true;
2084
- return;
2085
- }
2086
- state.inFlight = true;
2087
- this.beginAgentScan(agentName);
2088
- try {
2089
- await this.runRefresh(agentName);
2090
- } catch (error) {
2091
- appLogger.error("scan.refresh.error", { agent: agentName, error });
2092
- console.error(`[${agentName}] Session refresh failed:`, error);
2093
- } finally {
2094
- state.inFlight = false;
2095
- this.finishAgentScan(agentName);
2096
- if (state.pendingRerun) {
2097
- state.pendingRerun = false;
2098
- this.scheduleRefresh(agentName, PENDING_REFRESH_DELAY_MS);
2503
+ await this.refreshes.runRefresh(agentName, async () => {
2504
+ this.beginAgentScan(agentName);
2505
+ try {
2506
+ return await this.runRefresh(agentName);
2507
+ } catch (error) {
2508
+ appLogger.error("scan.refresh.error", { agent: agentName, error });
2509
+ console.error(`[${agentName}] Session refresh failed:`, error);
2510
+ return "failed";
2511
+ } finally {
2512
+ this.finishAgentScan(agentName);
2099
2513
  }
2100
- }
2514
+ });
2101
2515
  }
2102
2516
  async runRefresh(agentName) {
2103
2517
  const startedAt = performance.now();
2104
- const state = this.getRefreshState(agentName);
2105
- const pendingPathCount = state.pendingPathCount;
2106
- state.pendingPathCount = 0;
2518
+ const pendingPathCount = this.refreshes.takePendingPathCount(agentName);
2107
2519
  const agent = this.agents.find((item) => item.name === agentName);
2108
2520
  if (!agent) {
2109
2521
  appLogger.warn("scan.refresh.missing_agent", { agent: agentName });
2110
- return;
2522
+ return "skipped";
2111
2523
  }
2112
2524
  const previousSessions = this.byAgent[agentName] ?? [];
2113
2525
  const cached = loadCachedSessions(agentName);
2114
2526
  const refreshBaseline = cached?.sessions ?? previousSessions;
2115
- const cacheTimestamp = cached?.timestamp ?? state.lastRefreshAt;
2527
+ const cacheTimestamp = cached?.timestamp ?? this.refreshes.lastRefreshAt(agentName);
2116
2528
  if (cached) {
2117
2529
  restoreAgentCacheMeta(agent, cached.meta);
2118
2530
  }
@@ -2135,7 +2547,7 @@ var LiveScanStore = class {
2135
2547
  availabilityDuration = performance.now() - availabilityStartedAt;
2136
2548
  if (!isAvailable) {
2137
2549
  nextSessions = [];
2138
- state.lastRefreshAt = Date.now();
2550
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
2139
2551
  } else if (!isInitialized) {
2140
2552
  this.setScanPhase("initializing");
2141
2553
  const scanStartedAt = performance.now();
@@ -2150,7 +2562,7 @@ var LiveScanStore = class {
2150
2562
  fullScanSessions = attachMissingProjectIdentities(nextSessions);
2151
2563
  nextSessions = fullScanSessions;
2152
2564
  scanDuration = performance.now() - scanStartedAt;
2153
- state.lastRefreshAt = Date.now();
2565
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
2154
2566
  } else if (cached && agent instanceof FileSystemSessionSource) {
2155
2567
  const scanStartedAt = performance.now();
2156
2568
  const result = await this.scanAgentInWorker(
@@ -2174,7 +2586,7 @@ var LiveScanStore = class {
2174
2586
  preciseChangedIds
2175
2587
  );
2176
2588
  scanDuration = performance.now() - scanStartedAt;
2177
- state.lastRefreshAt = Date.now();
2589
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
2178
2590
  if (preciseChangedIds.length === 0) {
2179
2591
  appLogger.debug("scan.refresh.unchanged", {
2180
2592
  agent: agentName,
@@ -2187,13 +2599,13 @@ var LiveScanStore = class {
2187
2599
  agent.checkForChanges(cacheTimestamp, refreshBaseline)
2188
2600
  );
2189
2601
  checkDuration = performance.now() - checkStartedAt;
2190
- state.lastRefreshAt = checkResult.timestamp;
2602
+ this.refreshes.setLastRefreshAt(agentName, checkResult.timestamp);
2191
2603
  if (!checkResult.hasChanges) {
2192
2604
  appLogger.debug("scan.refresh.unchanged", {
2193
2605
  agent: agentName,
2194
2606
  duration_ms: Math.round(performance.now() - startedAt)
2195
2607
  });
2196
- return;
2608
+ return "unchanged";
2197
2609
  }
2198
2610
  preciseChangedIds = checkResult.changedIds ?? null;
2199
2611
  usedIncrementalScan = Array.isArray(checkResult.changedIds);
@@ -2218,7 +2630,7 @@ var LiveScanStore = class {
2218
2630
  fullScanSessions = attachMissingProjectIdentities(nextSessions);
2219
2631
  nextSessions = fullScanSessions;
2220
2632
  scanDuration = performance.now() - scanStartedAt;
2221
- state.lastRefreshAt = Date.now();
2633
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
2222
2634
  }
2223
2635
  nextSessions = attachMissingProjectIdentities(nextSessions);
2224
2636
  const filterStartedAt = performance.now();
@@ -2258,7 +2670,7 @@ var LiveScanStore = class {
2258
2670
  } : null;
2259
2671
  if (persistentJob) {
2260
2672
  persistentJobKind = persistentJob.kind;
2261
- const persist = this.enqueueSearchIndexJobs("scan.refresh", [persistentJob]);
2673
+ const persist = this.searchIndexJobs.enqueue("scan.refresh", [persistentJob]);
2262
2674
  if (!isInitialized && persistentJob.kind === "full") {
2263
2675
  await persist;
2264
2676
  } else {
@@ -2280,7 +2692,7 @@ var LiveScanStore = class {
2280
2692
  this.emit(event);
2281
2693
  }
2282
2694
  const totalDurationMs = performance.now() - startedAt;
2283
- state.lastRefreshDurationMs = totalDurationMs;
2695
+ this.refreshes.setLastRefreshDuration(agentName, totalDurationMs);
2284
2696
  appLogger.info("scan.refresh.done", {
2285
2697
  agent: agentName,
2286
2698
  duration_ms: Math.round(totalDurationMs),
@@ -2299,6 +2711,7 @@ var LiveScanStore = class {
2299
2711
  persistent_index_worker_job: persistentJobKind,
2300
2712
  persistent_index_skipped: !persistentJob || void 0
2301
2713
  });
2714
+ return "committed";
2302
2715
  }
2303
2716
  };
2304
2717
 
@@ -2308,8 +2721,8 @@ import { consola } from "consola";
2308
2721
  // src/version.ts
2309
2722
  import { readFileSync } from "fs";
2310
2723
  import { resolve as resolve3, dirname as dirname3 } from "path";
2311
- import { fileURLToPath as fileURLToPath3 } from "url";
2312
- var __dirname = dirname3(fileURLToPath3(import.meta.url));
2724
+ import { fileURLToPath as fileURLToPath4 } from "url";
2725
+ var __dirname = dirname3(fileURLToPath4(import.meta.url));
2313
2726
  var pkg = JSON.parse(readFileSync(resolve3(__dirname, "../package.json"), "utf-8"));
2314
2727
  var VERSION = pkg.version;
2315
2728
 
@@ -2359,6 +2772,18 @@ function parseSessionUri(uri) {
2359
2772
  if (!match) return null;
2360
2773
  return { agent: match[1], sessionId: match[2] };
2361
2774
  }
2775
+ function appendStartupPath(startupUrl, path) {
2776
+ const url = new URL(startupUrl);
2777
+ url.pathname = path;
2778
+ return url.toString();
2779
+ }
2780
+ function redactStartupUrl(startupUrl) {
2781
+ const url = new URL(startupUrl);
2782
+ for (const key of url.searchParams.keys()) {
2783
+ url.searchParams.set(key, "[redacted]");
2784
+ }
2785
+ return url.toString();
2786
+ }
2362
2787
  var main = defineCommand({
2363
2788
  meta: {
2364
2789
  name: "codesesh",
@@ -2377,6 +2802,11 @@ var main = defineCommand({
2377
2802
  description: "HTTP server bind address (default 127.0.0.1, local access only)",
2378
2803
  default: "127.0.0.1"
2379
2804
  },
2805
+ "remote-access": {
2806
+ type: "boolean",
2807
+ description: "Allow authenticated access when binding to a non-loopback address",
2808
+ default: false
2809
+ },
2380
2810
  agent: {
2381
2811
  type: "string",
2382
2812
  alias: "a",
@@ -2441,6 +2871,14 @@ var main = defineCommand({
2441
2871
  const trace = args.trace;
2442
2872
  const useCache = args.cache;
2443
2873
  const clearCache = args["clear-cache"];
2874
+ const hostname = args.host;
2875
+ const remoteAccess = args["remote-access"];
2876
+ if (!isLoopbackHostname(hostname) && !remoteAccess) {
2877
+ console.error(
2878
+ `Refusing to expose CodeSesh on ${hostname} without authentication. Add --remote-access to continue.`
2879
+ );
2880
+ process.exit(1);
2881
+ }
2444
2882
  if (trace) {
2445
2883
  perf.enable();
2446
2884
  }
@@ -2454,7 +2892,7 @@ var main = defineCommand({
2454
2892
  log_path: appLogger.getLogPath()
2455
2893
  });
2456
2894
  if (clearCache) {
2457
- const { clearCache: clear } = await import("./dist-TZTL6VP4.js");
2895
+ const { clearCache: clear } = await import("./dist-VD54GDPO.js");
2458
2896
  clear();
2459
2897
  appLogger.info("cache.clear");
2460
2898
  console.log("Cache cleared.");
@@ -2544,7 +2982,8 @@ var main = defineCommand({
2544
2982
  defaultSessionTo: listDefaultTo,
2545
2983
  defaultSessionDays: listDefaultDays,
2546
2984
  portFallbackAttempts: explicitPort ? 1 : DEFAULT_PORT_FALLBACK_ATTEMPTS,
2547
- hostname: args.host
2985
+ hostname,
2986
+ remoteAccess
2548
2987
  });
2549
2988
  } catch (error) {
2550
2989
  console.error(getServerStartupErrorMessage(error, port));
@@ -2571,14 +3010,14 @@ var main = defineCommand({
2571
3010
  console.log(` ${url}`);
2572
3011
  console.log("");
2573
3012
  appLogger.info("cli.ready", {
2574
- url,
3013
+ url: redactStartupUrl(url),
2575
3014
  duration_ms: Math.round(performance.now() - startedAt),
2576
3015
  log_path: appLogger.getLogPath()
2577
3016
  });
2578
3017
  if (!noOpen) {
2579
3018
  const open = (await import("open")).default;
2580
- const targetUrl = targetSession ? `${url}/${targetSession.agent.toLowerCase()}/${targetSession.sessionId}` : url;
2581
- appLogger.info("browser.open", { url: targetUrl });
3019
+ const targetUrl = targetSession ? appendStartupPath(url, `/${targetSession.agent.toLowerCase()}/${targetSession.sessionId}`) : url;
3020
+ appLogger.info("browser.open", { url: redactStartupUrl(targetUrl) });
2582
3021
  await open(targetUrl);
2583
3022
  }
2584
3023
  }