codesesh 0.11.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,9 +11,11 @@ import {
11
11
  createProjectScopeMatcher,
12
12
  createRegisteredAgents,
13
13
  deleteBookmark,
14
+ executeSessionSearch,
14
15
  extractSessionFileActivity,
15
16
  filterSessions,
16
17
  getAgentInfoMap,
18
+ getAgentLastFullSyncAt,
17
19
  getCursorDataPath,
18
20
  getSessionActivityTime,
19
21
  getSessionAgentName,
@@ -21,32 +23,33 @@ import {
21
23
  getTotalTokens,
22
24
  importBookmarks,
23
25
  isAgentCacheInitialized,
26
+ isProjectIdentityKind,
24
27
  listBookmarks,
25
28
  listCachedProjectGroups,
26
29
  listFileActivity,
27
30
  listSessionFileActivity,
28
31
  loadCachedSessionData,
29
32
  loadCachedSessions,
33
+ markAgentFullSyncCompleted,
34
+ matchesProjectIdentity,
30
35
  matchesProjectScope,
31
- parseSearchQuery,
32
36
  perf,
33
37
  realFs,
34
38
  refreshPricingCache,
35
39
  resolveProviderRoots,
36
40
  scanSessions,
37
- searchFileActivitySessions,
38
- searchSessions,
39
41
  sessionSignature,
40
42
  sortSessions,
41
43
  startOfLocalDay,
42
44
  upsertBookmark
43
- } from "./chunk-BIXOP5QX.js";
45
+ } from "./chunk-BV65IEWZ.js";
44
46
 
45
47
  // src/index.ts
46
48
  import { defineCommand, runMain } from "citty";
47
49
 
48
50
  // src/server.ts
49
51
  import { Hono as Hono2 } from "hono";
52
+ import { bodyLimit } from "hono/body-limit";
50
53
  import { serve } from "@hono/node-server";
51
54
  import { serveStatic } from "@hono/node-server/serve-static";
52
55
  import { existsSync as existsSync2 } from "fs";
@@ -225,7 +228,7 @@ function parseBookmarkPayload(value) {
225
228
  title: value.title,
226
229
  directory: value.directory,
227
230
  time_created: value.time_created,
228
- time_updated: value.time_updated,
231
+ time_updated: value.time_updated ?? void 0,
229
232
  stats: value.stats
230
233
  };
231
234
  }
@@ -263,13 +266,14 @@ function parseSmartTags(values) {
263
266
  );
264
267
  return tags.length > 0 ? [...new Set(tags)] : void 0;
265
268
  }
266
- function parseSearchOptions(c, defaults) {
269
+ function parseSearchOptions(c, defaults, projectIdentity) {
267
270
  const params = searchParams(c);
268
271
  const limitValue = parseNumberParam(params.get("limit") ?? void 0);
269
272
  return {
270
273
  agent: optionalQueryValue(params.get("agent") ?? void 0),
271
274
  project: optionalQueryValue(params.get("project") ?? void 0),
272
- projectKey: optionalQueryValue(params.get("projectKey") ?? void 0),
275
+ projectKind: projectIdentity?.kind,
276
+ projectKey: projectIdentity?.key,
273
277
  cwd: optionalQueryValue(params.get("cwd") ?? void 0),
274
278
  tags: parseSmartTags(queryValues(params, "tag", "tags", "signal")),
275
279
  tools: queryValues(params, "tool", "tools").map((tool) => tool.toLowerCase()),
@@ -308,49 +312,6 @@ function sanitizeClientLogData(value) {
308
312
  })
309
313
  );
310
314
  }
311
- function sessionMatchesCostFilter(session, options) {
312
- const cost = session.stats.total_cost;
313
- if (options.costMin != null) {
314
- if (options.costMinExclusive ? cost <= options.costMin : cost < options.costMin) return false;
315
- }
316
- if (options.costMax != null) {
317
- if (options.costMaxExclusive ? cost >= options.costMax : cost > options.costMax) return false;
318
- }
319
- return true;
320
- }
321
- function mergeSearchLists(left, right) {
322
- const values = [...left ?? [], ...right ?? []];
323
- return values.length > 0 ? [...new Set(values)] : void 0;
324
- }
325
- function mergeSearchOptions(options, filters) {
326
- return {
327
- ...options,
328
- agent: options.agent ?? filters.agent,
329
- project: options.project ?? filters.project,
330
- projectKey: options.projectKey ?? filters.projectKey,
331
- cwd: options.cwd ?? filters.cwd,
332
- tags: mergeSearchLists(options.tags, filters.tags),
333
- tools: mergeSearchLists(options.tools, filters.tools),
334
- file: options.file ?? filters.file,
335
- fileKind: options.fileKind ?? filters.fileKind,
336
- costMin: options.costMin ?? filters.costMin,
337
- costMax: options.costMax ?? filters.costMax,
338
- costMinExclusive: options.costMinExclusive ?? filters.costMinExclusive,
339
- costMaxExclusive: options.costMaxExclusive ?? filters.costMaxExclusive
340
- };
341
- }
342
- function mergeSearchResults(results, limit) {
343
- const seen = /* @__PURE__ */ new Set();
344
- const merged = [];
345
- for (const result of results) {
346
- const key = `${result.agentName}/${result.session.id}`;
347
- if (seen.has(key)) continue;
348
- seen.add(key);
349
- merged.push(result);
350
- if (merged.length >= limit) break;
351
- }
352
- return merged;
353
- }
354
315
  function getProjectGroupKey(identityKind, identityKey) {
355
316
  return `${identityKind}:${identityKey}`;
356
317
  }
@@ -406,46 +367,15 @@ function attachProjectMetrics(projects, sessions) {
406
367
  };
407
368
  });
408
369
  }
409
- function matchesRecentSearchFilters(session, options, projectScope) {
410
- if (options.projectKey && session.project_identity?.key !== options.projectKey) return false;
411
- if (projectScope && !matchesProjectScope(session, projectScope)) return false;
412
- if (options.project) {
413
- const projectNeedle = options.project.toLowerCase();
414
- const projectText = [
415
- session.project_identity?.key,
416
- session.project_identity?.displayName,
417
- session.directory
418
- ].filter(Boolean).join("\n").toLowerCase();
419
- if (!projectText.includes(projectNeedle)) return false;
420
- }
421
- if (options.tags?.length && !options.tags.every((tag) => session.smart_tags?.includes(tag))) {
422
- return false;
423
- }
424
- if (!sessionMatchesCostFilter(session, options)) return false;
425
- return true;
426
- }
427
- function recentSearchSessions(scanResult, options) {
428
- const projectScope = options.cwd ? createProjectScopeMatcher(options.cwd) : null;
429
- const entries = options.agent ? [[options.agent, scanResult.byAgent[options.agent] ?? []]] : Object.entries(scanResult.byAgent);
430
- return entries.flatMap(
431
- ([agentName, sessions]) => filterSessionsByActivityWindow(sessions, options.from, options.to).filter((session) => matchesRecentSearchFilters(session, options, projectScope)).map((session) => ({ agentName, session }))
432
- ).toSorted(
433
- (a, b) => (b.session.time_updated ?? b.session.time_created) - (a.session.time_updated ?? a.session.time_created)
434
- ).slice(0, options.limit).map(({ agentName, session }) => ({
435
- agentName,
436
- session,
437
- snippet: `Recent session \xB7 ${session.directory}`,
438
- matchType: "recent"
439
- }));
440
- }
441
370
  function handleGetConfig(c, defaults) {
442
- return c.json({
371
+ const payload = {
443
372
  window: {
444
373
  from: defaults.from,
445
374
  to: defaults.to,
446
375
  days: defaults.days
447
376
  }
448
- });
377
+ };
378
+ return c.json(payload);
449
379
  }
450
380
  function handleGetScanStatus(c, scanSource) {
451
381
  return c.json(scanSource.getScanStatus());
@@ -475,7 +405,13 @@ function handleGetSessions(c, scanSource, defaults = {}) {
475
405
  const agent = c.req.query("agent");
476
406
  const q = c.req.query("q")?.toLowerCase();
477
407
  const cwd = c.req.query("cwd");
478
- 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
+ }
479
415
  const tag = c.req.query("tag")?.toLowerCase();
480
416
  const from = parseDateParam(c.req.query("from"), defaults.from);
481
417
  const to = parseDateParam(c.req.query("to"), defaults.to);
@@ -485,8 +421,10 @@ function handleGetSessions(c, scanSource, defaults = {}) {
485
421
  } else {
486
422
  sessions = [...scanResult.sessions];
487
423
  }
488
- if (projectKey) {
489
- sessions = sessions.filter((s) => s.project_identity?.key === projectKey);
424
+ if (projectIdentity) {
425
+ sessions = sessions.filter(
426
+ (session) => matchesProjectIdentity(session.project_identity, projectIdentity)
427
+ );
490
428
  } else if (cwd) {
491
429
  const projectScope = createProjectScopeMatcher(cwd);
492
430
  sessions = sessions.filter((s) => matchesProjectScope(s, projectScope));
@@ -503,29 +441,15 @@ function handleGetSessions(c, scanSource, defaults = {}) {
503
441
  function handleSearchSessions(c, scanSource, defaults = {}) {
504
442
  const query = c.req.query("q")?.trim() ?? "";
505
443
  const scanResult = scanSource.getSnapshot();
506
- const searchOptions = parseSearchOptions(c, defaults);
507
- const parsedQuery = parseSearchQuery(query);
508
- const mergedSearchOptions = mergeSearchOptions(searchOptions, parsedQuery.filters);
509
- const textQuery = parsedQuery.text || (parsedQuery.hasQualifiers ? "" : query);
510
- const needsIndexedSearch = Boolean(
511
- textQuery || mergedSearchOptions.file || mergedSearchOptions.fileKind || mergedSearchOptions.tools?.length
444
+ const projectIdentity = parseProjectIdentityFilter(
445
+ c.req.query("projectKind"),
446
+ c.req.query("projectKey")
512
447
  );
513
- if (!needsIndexedSearch) {
514
- return c.json({
515
- results: recentSearchSessions(
516
- scanResult,
517
- mergedSearchOptions
518
- )
519
- });
448
+ if (projectIdentity === null) {
449
+ return c.json({ error: "projectKind and projectKey must form a valid project identity" }, 400);
520
450
  }
521
- const fileQuery = mergedSearchOptions.file ?? (!parsedQuery.text ? parsedQuery.filters.file : void 0) ?? (!parsedQuery.hasQualifiers && query ? parsedQuery.text || query : "");
522
- const results = mergeSearchResults(
523
- [
524
- ...fileQuery ? searchFileActivitySessions(fileQuery, mergedSearchOptions) : [],
525
- ...searchSessions(query, mergedSearchOptions)
526
- ],
527
- mergedSearchOptions.limit ?? 50
528
- );
451
+ const searchOptions = parseSearchOptions(c, defaults, projectIdentity);
452
+ const results = executeSessionSearch(query, searchOptions, scanResult);
529
453
  return c.json({ results });
530
454
  }
531
455
  function parseFileActivityKind(value) {
@@ -538,14 +462,29 @@ function optionalQueryValue(value) {
538
462
  const normalized = value?.trim();
539
463
  return normalized ? normalized : void 0;
540
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
+ }
541
472
  function handleGetFileActivity(c, defaults = {}) {
542
473
  const limitValue = Number(c.req.query("limit"));
543
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
+ }
544
482
  return c.json({
545
483
  activity: listFileActivity({
546
484
  agent: optionalQueryValue(c.req.query("agent")),
547
485
  sessionId: optionalQueryValue(c.req.query("sessionId")),
548
- projectKey: optionalQueryValue(c.req.query("projectKey")),
486
+ projectKind: projectIdentity?.kind,
487
+ projectKey: projectIdentity?.key,
549
488
  project: optionalQueryValue(c.req.query("project")),
550
489
  cwd: optionalQueryValue(c.req.query("cwd")),
551
490
  path: optionalQueryValue(c.req.query("path")),
@@ -561,6 +500,9 @@ async function handleGetSessionData(c, scanSource) {
561
500
  const scanResult = scanSource.getSnapshot();
562
501
  const agentName = c.req.param("agent");
563
502
  const sessionId = c.req.param("id");
503
+ if (!agentName) {
504
+ return c.json({ error: "Missing agent name" }, 400);
505
+ }
564
506
  if (!sessionId) {
565
507
  return c.json({ error: "Missing session ID" }, 400);
566
508
  }
@@ -710,6 +652,13 @@ function resolveDashboardWindow(defaults, queryDays, queryFrom, queryTo) {
710
652
  }
711
653
  function handleGetDashboard(c, scanSource, defaults = {}) {
712
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
+ }
713
662
  const { from, to, days } = resolveDashboardWindow(
714
663
  defaults,
715
664
  c.req.query("days"),
@@ -718,8 +667,8 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
718
667
  );
719
668
  const scope = {
720
669
  agent: optionalQueryValue(c.req.query("agent"))?.toLowerCase(),
721
- projectKind: optionalQueryValue(c.req.query("projectKind")),
722
- projectKey: optionalQueryValue(c.req.query("projectKey"))
670
+ projectKind: projectIdentity?.kind,
671
+ projectKey: projectIdentity?.key
723
672
  };
724
673
  const agentInfo = getAgentInfoMap({});
725
674
  const agentInfoMap = new Map(agentInfo.map((a) => [a.name, a]));
@@ -734,6 +683,7 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
734
683
  ...aggregate,
735
684
  recentFileActivities: listFileActivity({
736
685
  agent: scope.agent,
686
+ projectKind: scope.projectKind,
737
687
  projectKey: scope.projectKey,
738
688
  from,
739
689
  to,
@@ -747,10 +697,14 @@ function handleGetDashboard(c, scanSource, defaults = {}) {
747
697
  // src/api/routes.ts
748
698
  function createSseResponse(store, signal) {
749
699
  const encoder = new TextEncoder();
700
+ let cancelStream = () => {
701
+ };
750
702
  return new Response(
751
703
  new ReadableStream({
752
704
  start(controller) {
705
+ let isClosed = false;
753
706
  const write = (event, data) => {
707
+ if (isClosed) return;
754
708
  controller.enqueue(encoder.encode(`event: ${event}
755
709
  `));
756
710
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
@@ -766,18 +720,28 @@ function createSseResponse(store, signal) {
766
720
  write(event.type, event);
767
721
  });
768
722
  const heartbeat = setInterval(() => {
769
- controller.enqueue(encoder.encode(": keepalive\n\n"));
723
+ if (!isClosed) controller.enqueue(encoder.encode(": keepalive\n\n"));
770
724
  }, 15e3);
771
- const close = () => {
725
+ const cleanup = () => {
726
+ if (isClosed) return false;
727
+ isClosed = true;
772
728
  clearInterval(heartbeat);
773
729
  unsubscribeSessions();
774
730
  unsubscribeScanStatus();
775
- controller.close();
731
+ signal.removeEventListener("abort", abortStream);
732
+ return true;
733
+ };
734
+ const abortStream = () => {
735
+ if (cleanup()) controller.close();
736
+ };
737
+ cancelStream = () => {
738
+ cleanup();
776
739
  };
777
- signal.addEventListener("abort", close, { once: true });
740
+ if (signal.aborted) abortStream();
741
+ else signal.addEventListener("abort", abortStream, { once: true });
778
742
  },
779
743
  cancel() {
780
- return;
744
+ cancelStream();
781
745
  }
782
746
  }),
783
747
  {
@@ -818,7 +782,46 @@ function createApiRoutes(scanSource, store, options = {}) {
818
782
  return api;
819
783
  }
820
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
+
821
823
  // src/server.ts
824
+ var MAX_API_REQUEST_BYTES = 1024 * 1024;
822
825
  function findWebDistPath() {
823
826
  const __dirname2 = dirname(fileURLToPath(import.meta.url));
824
827
  const packagedPath = resolve(__dirname2, "web");
@@ -860,6 +863,14 @@ function getListeningPort(server, fallback) {
860
863
  }
861
864
  async function createServer(port, store, options = {}) {
862
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
+ }
863
874
  app.use("*", async (c, next) => {
864
875
  const startedAt = performance.now();
865
876
  let thrown;
@@ -880,6 +891,16 @@ async function createServer(port, store, options = {}) {
880
891
  });
881
892
  }
882
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
+ );
883
904
  const routeOptions = {
884
905
  defaultSessionFrom: options.defaultSessionFrom,
885
906
  defaultSessionTo: options.defaultSessionTo,
@@ -903,7 +924,7 @@ async function createServer(port, store, options = {}) {
903
924
  let actualPort = port;
904
925
  for (let offset = 0; offset < attempts; offset += 1) {
905
926
  const candidatePort = port + offset;
906
- server = serve({ fetch: app.fetch, port: candidatePort });
927
+ server = serve({ fetch: app.fetch, port: candidatePort, hostname });
907
928
  try {
908
929
  await waitForListening(server);
909
930
  actualPort = getListeningPort(server, candidatePort);
@@ -925,8 +946,20 @@ async function createServer(port, store, options = {}) {
925
946
  throw new Error(getServerStartupErrorMessage(error, candidatePort));
926
947
  }
927
948
  }
928
- const url = `http://localhost:${actualPort}`;
929
- appLogger.info("server.listen", { port: actualPort, requested_port: port, 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
+ });
957
+ if (!isLoopback) {
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
+ `);
962
+ }
930
963
  return {
931
964
  url,
932
965
  shutdown: async () => {
@@ -946,12 +979,661 @@ async function createServer(port, store, options = {}) {
946
979
  }
947
980
 
948
981
  // src/live-scan.ts
949
- 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";
950
988
  import { fileURLToPath as fileURLToPath2 } from "url";
951
989
  import { Worker } from "worker_threads";
952
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
+
953
1635
  // src/session-watcher.ts
954
- 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";
955
1637
  import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
956
1638
  var WRITE_STABILITY_THRESHOLD_MS = 250;
957
1639
  var WRITE_STABILITY_POLL_MS = 100;
@@ -959,11 +1641,11 @@ function toAbsolutePath(path) {
959
1641
  return isAbsolute(path) ? path : resolve2(path);
960
1642
  }
961
1643
  function closestWatchablePath(targetPath) {
962
- if (!isAbsolute(targetPath) && !existsSync3(targetPath)) {
1644
+ if (!isAbsolute(targetPath) && !existsSync4(targetPath)) {
963
1645
  return null;
964
1646
  }
965
1647
  let current = toAbsolutePath(targetPath);
966
- while (!existsSync3(current)) {
1648
+ while (!existsSync4(current)) {
967
1649
  const parent = dirname2(current);
968
1650
  if (parent === current) {
969
1651
  return null;
@@ -1271,9 +1953,9 @@ var SessionWatcher = class {
1271
1953
  // src/live-scan.ts
1272
1954
  var REFRESH_DEBOUNCE_MS = 200;
1273
1955
  var EMPTY_AGENT_REFRESH_DEBOUNCE_MS = 3e4;
1274
- var PENDING_REFRESH_DELAY_MS = 100;
1275
1956
  var NEW_SESSION_EVENT_WINDOW_MS = 250;
1276
1957
  var SEARCH_INDEX_BULK_PENDING_PATH_THRESHOLD = 100;
1958
+ var BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1277
1959
  function buildRefreshDiff(agentName, previousSessions, nextSessions, candidateChangedIds = []) {
1278
1960
  const { changes, removedSessionIds, counts } = computeSessionDiff(
1279
1961
  previousSessions,
@@ -1349,27 +2031,16 @@ var LiveScanStore = class {
1349
2031
  sessions = [];
1350
2032
  listeners = /* @__PURE__ */ new Set();
1351
2033
  scanStatusListeners = /* @__PURE__ */ new Set();
1352
- scanStatus = {
1353
- active: false,
1354
- phase: "idle",
1355
- pendingAgents: [],
1356
- scanningAgents: [],
1357
- completedAgents: [],
1358
- agentStatuses: {},
1359
- totalAgents: 0,
1360
- updatedAt: Date.now()
1361
- };
1362
- refreshTimers = /* @__PURE__ */ new Map();
1363
- refreshTimestamps = /* @__PURE__ */ new Map();
1364
- refreshInFlight = /* @__PURE__ */ new Set();
1365
- pendingRefreshes = /* @__PURE__ */ new Set();
1366
- pendingRefreshPathCounts = /* @__PURE__ */ new Map();
2034
+ scanStatus = new ScanStatusModel();
2035
+ backfills = new BackfillCoordinator();
2036
+ refreshes = new RefreshCoordinator();
1367
2037
  watcher = null;
1368
2038
  pendingEvent = null;
1369
2039
  pendingEventTimer = null;
1370
2040
  backgroundRefreshTimer = null;
1371
- searchIndexWorker = null;
1372
- pendingSearchIndexJobs = [];
2041
+ scanRefreshWorkers = /* @__PURE__ */ new Set();
2042
+ searchIndexJobs = new SearchIndexJobRunner();
2043
+ shutdownPromise = null;
1373
2044
  shuttingDown = false;
1374
2045
  async initialize() {
1375
2046
  const startedAt = performance.now();
@@ -1395,7 +2066,7 @@ var LiveScanStore = class {
1395
2066
  this.applyScanResult(initialResult);
1396
2067
  const indexStartedAt = performance.now();
1397
2068
  if (!deferInitialRefresh) {
1398
- await this.enqueueSearchIndexJobs(
2069
+ await this.searchIndexJobs.enqueue(
1399
2070
  "scan.initial",
1400
2071
  this.buildFullSearchIndexJobs("scan.initial")
1401
2072
  );
@@ -1427,10 +2098,7 @@ var LiveScanStore = class {
1427
2098
  this.watcher = new SessionWatcher();
1428
2099
  this.watcher.onAgentsChanged((agentNames) => {
1429
2100
  for (const agentName of agentNames) {
1430
- this.pendingRefreshPathCounts.set(
1431
- agentName,
1432
- (this.pendingRefreshPathCounts.get(agentName) ?? 0) + 1
1433
- );
2101
+ this.refreshes.recordChangedPaths(agentName);
1434
2102
  const delayMs = (this.byAgent[agentName]?.length ?? 0) === 0 ? EMPTY_AGENT_REFRESH_DEBOUNCE_MS : REFRESH_DEBOUNCE_MS;
1435
2103
  this.scheduleRefresh(agentName, delayMs);
1436
2104
  }
@@ -1452,6 +2120,11 @@ var LiveScanStore = class {
1452
2120
  if (agentNames.length === 0) {
1453
2121
  this.finishScanBatch();
1454
2122
  }
2123
+ for (const agent of this.agents) {
2124
+ if (this.needsBackfill(agent)) {
2125
+ this.enqueueBackfill(agent.name);
2126
+ }
2127
+ }
1455
2128
  }, 0);
1456
2129
  }
1457
2130
  getSnapshot() {
@@ -1462,19 +2135,7 @@ var LiveScanStore = class {
1462
2135
  };
1463
2136
  }
1464
2137
  getScanStatus() {
1465
- return {
1466
- type: "scan-status",
1467
- ...this.scanStatus,
1468
- pendingAgents: [...this.scanStatus.pendingAgents],
1469
- scanningAgents: [...this.scanStatus.scanningAgents],
1470
- completedAgents: [...this.scanStatus.completedAgents],
1471
- agentStatuses: Object.fromEntries(
1472
- Object.entries(this.scanStatus.agentStatuses).map(([agentName, status]) => [
1473
- agentName,
1474
- { ...status }
1475
- ])
1476
- )
1477
- };
2138
+ return this.scanStatus.snapshot();
1478
2139
  }
1479
2140
  subscribe(listener) {
1480
2141
  this.listeners.add(listener);
@@ -1488,13 +2149,26 @@ var LiveScanStore = class {
1488
2149
  this.scanStatusListeners.delete(listener);
1489
2150
  };
1490
2151
  }
1491
- async shutdown() {
2152
+ shutdown() {
2153
+ this.shutdownPromise ??= this.performShutdown();
2154
+ return this.shutdownPromise;
2155
+ }
2156
+ async performShutdown() {
1492
2157
  this.shuttingDown = true;
1493
- for (const timer of this.refreshTimers.values()) {
1494
- clearTimeout(timer);
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);
1495
2166
  }
1496
- this.refreshTimers.clear();
1497
- this.pendingRefreshPathCounts.clear();
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
+ });
1498
2172
  if (this.pendingEventTimer) {
1499
2173
  clearTimeout(this.pendingEventTimer);
1500
2174
  this.pendingEventTimer = null;
@@ -1503,21 +2177,24 @@ var LiveScanStore = class {
1503
2177
  clearTimeout(this.backgroundRefreshTimer);
1504
2178
  this.backgroundRefreshTimer = null;
1505
2179
  }
1506
- if (this.searchIndexWorker) {
1507
- await this.searchIndexWorker.terminate();
1508
- this.searchIndexWorker = null;
1509
- }
1510
- for (const batch of this.pendingSearchIndexJobs) {
1511
- batch.reject(new Error("Live scan store shut down"));
1512
- }
1513
- 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();
1514
2185
  this.pendingEvent = null;
1515
2186
  if (this.watcher) {
1516
2187
  await this.watcher.dispose();
1517
2188
  this.watcher = null;
1518
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
+ });
1519
2195
  }
1520
2196
  emit(event) {
2197
+ if (this.shuttingDown) return;
1521
2198
  if (this.pendingEvent || event.newSessions > 0) {
1522
2199
  this.queueEvent(event);
1523
2200
  return;
@@ -1529,157 +2206,131 @@ var LiveScanStore = class {
1529
2206
  listener(event);
1530
2207
  }
1531
2208
  }
1532
- emitScanStatus() {
1533
- const event = this.getScanStatus();
1534
- for (const listener of this.scanStatusListeners) {
1535
- listener(event);
1536
- }
1537
- }
1538
- updateScanStatus(next) {
1539
- this.scanStatus = next;
1540
- this.emitScanStatus();
1541
- }
1542
2209
  startScanBatch(agentNames, phase) {
1543
- const uniqueAgentNames = [...new Set(agentNames)];
1544
- const now = Date.now();
1545
- const agentStatuses = Object.fromEntries(
1546
- uniqueAgentNames.map((agentName) => [
1547
- agentName,
1548
- {
1549
- agentName,
1550
- status: "pending",
1551
- processed: 0,
1552
- sessions: this.byAgent[agentName]?.length ?? 0,
1553
- updatedAt: now
1554
- }
1555
- ])
2210
+ const sessionCounts = Object.fromEntries(
2211
+ agentNames.map((agentName) => [agentName, this.byAgent[agentName]?.length ?? 0])
1556
2212
  );
1557
- this.updateScanStatus({
1558
- active: uniqueAgentNames.length > 0,
1559
- phase: uniqueAgentNames.length > 0 ? phase : "idle",
1560
- pendingAgents: uniqueAgentNames,
1561
- scanningAgents: [],
1562
- completedAgents: [],
1563
- agentStatuses,
1564
- totalAgents: uniqueAgentNames.length,
1565
- startedAt: uniqueAgentNames.length > 0 ? now : void 0,
1566
- updatedAt: now,
1567
- completedAt: uniqueAgentNames.length > 0 ? void 0 : now
1568
- });
2213
+ this.publishScanStatus(this.scanStatus.startBatch(agentNames, phase, sessionCounts));
1569
2214
  }
1570
2215
  setScanPhase(phase) {
1571
- if (!this.scanStatus.active) return;
1572
- this.updateScanStatus({
1573
- ...this.scanStatus,
1574
- phase,
1575
- updatedAt: Date.now()
1576
- });
2216
+ this.publishScanStatus(this.scanStatus.setPhase(phase));
1577
2217
  }
1578
2218
  beginAgentScan(agentName) {
1579
- if (!this.scanStatus.active) {
1580
- this.startScanBatch([agentName], "scanning");
1581
- }
1582
- const pendingAgents = this.scanStatus.pendingAgents.filter((agent) => agent !== agentName);
1583
- const scanningAgents = [.../* @__PURE__ */ new Set([...this.scanStatus.scanningAgents, agentName])];
1584
- const completedAgents = this.scanStatus.completedAgents.filter((agent) => agent !== agentName);
1585
- const existingStatus = this.scanStatus.agentStatuses[agentName];
1586
- const agentStatuses = {
1587
- ...this.scanStatus.agentStatuses,
1588
- [agentName]: {
1589
- agentName,
1590
- status: "scanning",
1591
- total: existingStatus?.total,
1592
- processed: existingStatus?.processed ?? 0,
1593
- sessions: existingStatus?.sessions ?? this.byAgent[agentName]?.length ?? 0,
1594
- startedAt: existingStatus?.startedAt ?? Date.now(),
1595
- updatedAt: Date.now()
1596
- }
1597
- };
1598
- this.updateScanStatus({
1599
- ...this.scanStatus,
1600
- active: true,
1601
- phase: this.scanStatus.phase === "initializing" ? "initializing" : "scanning",
1602
- pendingAgents,
1603
- scanningAgents,
1604
- completedAgents,
1605
- agentStatuses,
1606
- totalAgents: Math.max(
1607
- this.scanStatus.totalAgents,
1608
- pendingAgents.length + scanningAgents.length
1609
- ),
1610
- updatedAt: Date.now(),
1611
- completedAt: void 0
1612
- });
2219
+ if (!this.scanStatus.snapshot().active) this.startScanBatch([agentName], "scanning");
2220
+ this.publishScanStatus(
2221
+ this.scanStatus.beginAgent(agentName, this.byAgent[agentName]?.length ?? 0)
2222
+ );
1613
2223
  }
1614
2224
  updateAgentScanProgress(agentName, progress) {
1615
- const status = this.scanStatus.agentStatuses[agentName];
1616
- if (!status || status.status !== "scanning") return;
1617
- this.updateScanStatus({
1618
- ...this.scanStatus,
1619
- agentStatuses: {
1620
- ...this.scanStatus.agentStatuses,
1621
- [agentName]: {
1622
- ...status,
1623
- total: progress.total ?? status.total,
1624
- processed: progress.processed ?? status.processed,
1625
- sessions: progress.sessions ?? status.sessions,
1626
- updatedAt: Date.now()
1627
- }
1628
- },
1629
- updatedAt: Date.now()
1630
- });
2225
+ this.publishScanStatus(this.scanStatus.updateAgent(agentName, progress));
1631
2226
  }
1632
2227
  finishAgentScan(agentName) {
1633
- const pendingAgents = this.scanStatus.pendingAgents.filter((agent) => agent !== agentName);
1634
- const scanningAgents = this.scanStatus.scanningAgents.filter((agent) => agent !== agentName);
1635
- const completedAgents = [.../* @__PURE__ */ new Set([...this.scanStatus.completedAgents, agentName])];
1636
- const active = pendingAgents.length > 0 || scanningAgents.length > 0;
1637
- const now = Date.now();
1638
- const previousStatus = this.scanStatus.agentStatuses[agentName];
1639
- const sessions = this.byAgent[agentName]?.length ?? previousStatus?.sessions ?? 0;
1640
- const total = previousStatus?.total ?? previousStatus?.processed;
1641
- this.updateScanStatus({
1642
- ...this.scanStatus,
1643
- active,
1644
- phase: active ? "scanning" : "idle",
1645
- pendingAgents,
1646
- scanningAgents,
1647
- completedAgents,
1648
- agentStatuses: {
1649
- ...this.scanStatus.agentStatuses,
1650
- [agentName]: {
1651
- agentName,
1652
- status: "complete",
1653
- total,
1654
- processed: total,
1655
- sessions,
1656
- startedAt: previousStatus?.startedAt,
1657
- updatedAt: now,
1658
- completedAt: now
1659
- }
1660
- },
1661
- updatedAt: now,
1662
- completedAt: active ? void 0 : now
1663
- });
2228
+ this.publishScanStatus(this.scanStatus.finishAgent(agentName, this.byAgent[agentName]?.length));
1664
2229
  }
1665
2230
  finishScanBatch() {
1666
- const now = Date.now();
1667
- this.updateScanStatus({
1668
- ...this.scanStatus,
1669
- active: false,
1670
- phase: "idle",
1671
- pendingAgents: [],
1672
- scanningAgents: [],
1673
- agentStatuses: Object.fromEntries(
1674
- Object.entries(this.scanStatus.agentStatuses).map(([agentName, status]) => [
1675
- agentName,
1676
- { ...status, status: "complete", completedAt: status.completedAt ?? now, updatedAt: now }
1677
- ])
1678
- ),
1679
- updatedAt: now,
1680
- completedAt: now
2231
+ this.publishScanStatus(this.scanStatus.finishBatch());
2232
+ }
2233
+ updateBackfillStatus(patch) {
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);
2239
+ }
2240
+ /**
2241
+ * Only FileSystemSessionSource agents pay the O(history) enumeration cost this
2242
+ * guards against: database agents already do a cheap single-file mtime check.
2243
+ * With no startup window configured, the regular refresh path already walks
2244
+ * full history, so backfill would be redundant.
2245
+ */
2246
+ needsBackfill(agent) {
2247
+ if (this.startupScanOptions.from == null && this.startupScanOptions.to == null) return false;
2248
+ if (!(agent instanceof FileSystemSessionSource)) return false;
2249
+ if (!agent.isAvailable()) return false;
2250
+ const lastSyncAt = getAgentLastFullSyncAt(agent.name);
2251
+ return lastSyncAt == null || Date.now() - lastSyncAt > BACKFILL_INTERVAL_MS;
2252
+ }
2253
+ enqueueBackfill(agentName) {
2254
+ if (this.shuttingDown) return;
2255
+ const status = this.backfills.enqueue(agentName);
2256
+ if (!status) return;
2257
+ this.updateBackfillStatus(status);
2258
+ this.pumpBackfillQueue();
2259
+ }
2260
+ pumpBackfillQueue() {
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));
2268
+ this.pumpBackfillQueue();
1681
2269
  });
1682
2270
  }
2271
+ /** Unbounded per-source sync to reconcile the full session history, not just the display window. */
2272
+ async runBackfill(agentName) {
2273
+ await this.refreshes.serialize(agentName, "backfill", () => this.performBackfill(agentName));
2274
+ }
2275
+ async performBackfill(agentName) {
2276
+ const startedAt = performance.now();
2277
+ const agent = this.agents.find((item) => item.name === agentName);
2278
+ if (!agent || !(agent instanceof FileSystemSessionSource) || !agent.isAvailable()) {
2279
+ return "skipped";
2280
+ }
2281
+ const cached = loadCachedSessions(agentName);
2282
+ const baseline = cached?.sessions ?? this.byAgent[agentName] ?? [];
2283
+ const meta = cached?.meta ?? buildAgentCacheMeta(agent);
2284
+ if (cached) {
2285
+ restoreAgentCacheMeta(agent, cached.meta);
2286
+ }
2287
+ try {
2288
+ const result = await this.scanAgentInWorker(
2289
+ agent,
2290
+ baseline,
2291
+ null,
2292
+ {},
2293
+ { sourceSync: true, meta }
2294
+ );
2295
+ agent.setSessionMetaMap(new Map(Object.entries(result.meta)));
2296
+ const fullSessions = attachMissingProjectIdentities(result.sessions);
2297
+ const filtered = this.applyFilters(fullSessions);
2298
+ const diff = buildRefreshDiff(
2299
+ agentName,
2300
+ this.byAgent[agentName] ?? [],
2301
+ filtered,
2302
+ result.changedIds ?? []
2303
+ );
2304
+ this.byAgent[agentName] = sortSessions(filtered);
2305
+ this.rebuildSessions();
2306
+ await this.searchIndexJobs.enqueue("scan.backfill", [
2307
+ {
2308
+ kind: "full",
2309
+ context: "scan.backfill",
2310
+ agentName,
2311
+ sessions: fullSessions,
2312
+ meta: buildAgentCacheMeta(agent),
2313
+ saveCache: true
2314
+ }
2315
+ ]);
2316
+ markAgentFullSyncCompleted(agentName);
2317
+ if (diff.event) {
2318
+ diff.event.totalSessions = this.sessions.length;
2319
+ this.emit(diff.event);
2320
+ }
2321
+ appLogger.info("scan.backfill.done", {
2322
+ agent: agentName,
2323
+ duration_ms: Math.round(performance.now() - startedAt),
2324
+ sessions: fullSessions.length,
2325
+ changed: result.changedIds?.length ?? 0
2326
+ });
2327
+ return "committed";
2328
+ } catch (error) {
2329
+ appLogger.error("scan.backfill.error", { agent: agentName, error });
2330
+ console.error(`[${agentName}] Backfill failed:`, error);
2331
+ return "failed";
2332
+ }
2333
+ }
1683
2334
  queueEvent(event) {
1684
2335
  this.pendingEvent = this.pendingEvent ? mergeEvents(this.pendingEvent, event) : event;
1685
2336
  if (this.pendingEventTimer) {
@@ -1697,16 +2348,9 @@ var LiveScanStore = class {
1697
2348
  rebuildSessions() {
1698
2349
  this.sessions = sortSessions(Object.values(this.byAgent).flat());
1699
2350
  }
1700
- getSearchIndexWorkerUrl() {
1701
- const workerUrl = new URL("./search-index-worker.js", import.meta.url);
1702
- if (workerUrl.protocol === "file:" && !existsSync4(fileURLToPath2(workerUrl))) {
1703
- return null;
1704
- }
1705
- return workerUrl;
1706
- }
1707
2351
  getSmartTagWorkerUrl() {
1708
2352
  const workerUrl = new URL("./smart-tag-worker.js", import.meta.url);
1709
- if (workerUrl.protocol === "file:" && !existsSync4(fileURLToPath2(workerUrl))) {
2353
+ if (workerUrl.protocol === "file:" && !existsSync5(fileURLToPath3(workerUrl))) {
1710
2354
  return null;
1711
2355
  }
1712
2356
  return workerUrl;
@@ -1717,7 +2361,7 @@ var LiveScanStore = class {
1717
2361
  scanAgentInWorker(agent, previousSessions, changedIds, scanOptions, workerOptions = {}) {
1718
2362
  const workerUrl = this.getScanRefreshWorkerUrl();
1719
2363
  return new Promise((resolve4, reject) => {
1720
- const worker = new Worker(workerUrl, {
2364
+ const worker = new Worker2(workerUrl, {
1721
2365
  workerData: {
1722
2366
  agentName: agent.name,
1723
2367
  previousSessions,
@@ -1728,11 +2372,13 @@ var LiveScanStore = class {
1728
2372
  }
1729
2373
  });
1730
2374
  worker.unref();
2375
+ this.scanRefreshWorkers.add(worker);
1731
2376
  let settled = false;
1732
- const finish = (callback) => {
2377
+ const finish = (callback, terminate = true) => {
1733
2378
  if (settled) return;
1734
2379
  settled = true;
1735
- void worker.terminate();
2380
+ this.scanRefreshWorkers.delete(worker);
2381
+ if (terminate) void worker.terminate();
1736
2382
  callback();
1737
2383
  };
1738
2384
  worker.on("message", (message) => {
@@ -1756,8 +2402,15 @@ var LiveScanStore = class {
1756
2402
  finish(() => reject(error));
1757
2403
  });
1758
2404
  worker.once("exit", (code) => {
1759
- if (!settled && code !== 0) {
1760
- 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
+ );
1761
2414
  }
1762
2415
  });
1763
2416
  });
@@ -1783,75 +2436,6 @@ var LiveScanStore = class {
1783
2436
  };
1784
2437
  });
1785
2438
  }
1786
- enqueueSearchIndexJobs(context, jobs) {
1787
- if (jobs.length === 0) return Promise.resolve();
1788
- return new Promise((resolve4, reject) => {
1789
- const batch = { context, jobs, resolve: resolve4, reject };
1790
- if (this.searchIndexWorker) {
1791
- this.pendingSearchIndexJobs.push(batch);
1792
- appLogger.debug("search_index.worker_queued", {
1793
- context,
1794
- jobs: jobs.length,
1795
- pending_jobs: this.pendingSearchIndexJobs.length
1796
- });
1797
- return;
1798
- }
1799
- this.startSearchIndexJobBatch(batch);
1800
- });
1801
- }
1802
- startSearchIndexJobBatch(batch) {
1803
- const workerUrl = this.getSearchIndexWorkerUrl();
1804
- if (!workerUrl) {
1805
- appLogger.warn("search_index.worker_missing", { context: batch.context });
1806
- batch.resolve();
1807
- return;
1808
- }
1809
- let settled = false;
1810
- const worker = new Worker(workerUrl, {
1811
- workerData: {
1812
- context: batch.context,
1813
- jobs: batch.jobs,
1814
- agentNames: [],
1815
- sessionsByAgent: {},
1816
- metaByAgent: {}
1817
- }
1818
- });
1819
- worker.unref();
1820
- this.searchIndexWorker = worker;
1821
- worker.on("message", (message) => {
1822
- if (message.type === "sync-result") {
1823
- logSearchIndexSync(message.context, message.result);
1824
- } else if (message.type === "done") {
1825
- appLogger.info(`${message.context}.done`, {
1826
- duration_ms: Math.round(message.durationMs),
1827
- sessions: message.sessions
1828
- });
1829
- settled = true;
1830
- batch.resolve();
1831
- }
1832
- });
1833
- worker.on("error", (error) => {
1834
- appLogger.error("search_index.worker_error", { context: batch.context, error });
1835
- if (!settled) {
1836
- settled = true;
1837
- batch.reject(error);
1838
- }
1839
- });
1840
- worker.on("exit", (code) => {
1841
- this.searchIndexWorker = null;
1842
- if (code !== 0) {
1843
- appLogger.warn("search_index.worker_exit", { context: batch.context, code });
1844
- if (!settled) {
1845
- settled = true;
1846
- batch.reject(new Error(`Search index worker exited with code ${code}`));
1847
- }
1848
- }
1849
- if (this.pendingSearchIndexJobs.length > 0) {
1850
- const pendingBatch = this.pendingSearchIndexJobs.shift();
1851
- this.startSearchIndexJobBatch(pendingBatch);
1852
- }
1853
- });
1854
- }
1855
2439
  applyScanResult(result) {
1856
2440
  const knownAgents = createRegisteredAgents();
1857
2441
  const agentMap = /* @__PURE__ */ new Map();
@@ -1873,7 +2457,10 @@ var LiveScanStore = class {
1873
2457
  this.byAgent = {};
1874
2458
  for (const agent of this.agents) {
1875
2459
  this.byAgent[agent.name] = sortSessions(result.byAgent[agent.name] ?? []);
1876
- this.refreshTimestamps.set(agent.name, result.cacheTimestamps?.[agent.name] ?? Date.now());
2460
+ this.refreshes.setLastRefreshAt(
2461
+ agent.name,
2462
+ result.cacheTimestamps?.[agent.name] ?? Date.now()
2463
+ );
1877
2464
  }
1878
2465
  this.rebuildSessions();
1879
2466
  }
@@ -1890,7 +2477,7 @@ var LiveScanStore = class {
1890
2477
  const startedAt = performance.now();
1891
2478
  const context = "scan.initial.background";
1892
2479
  try {
1893
- await this.enqueueSearchIndexJobs(context, this.buildFullSearchIndexJobs(context));
2480
+ await this.searchIndexJobs.enqueue(context, this.buildFullSearchIndexJobs(context));
1894
2481
  appLogger.info(`${context}.complete`, {
1895
2482
  duration_ms: Math.round(performance.now() - startedAt),
1896
2483
  sessions: this.sessions.length
@@ -1903,52 +2490,41 @@ var LiveScanStore = class {
1903
2490
  console.error("[search] Background index sync failed:", error);
1904
2491
  }
1905
2492
  }
2493
+ /**
2494
+ * Throttles rather than debounces: a pending timer only gets replaced by a
2495
+ * request with an earlier deadline. Plain debounce (reset on every call)
2496
+ * would let a steady stream of events — each arriving before the adaptive
2497
+ * backoff elapses — push the deadline out forever and starve the refresh.
2498
+ */
1906
2499
  scheduleRefresh(agentName, delayMs = REFRESH_DEBOUNCE_MS) {
1907
- appLogger.debug("scan.refresh.schedule", { agent: agentName, delay_ms: delayMs });
1908
- const existing = this.refreshTimers.get(agentName);
1909
- if (existing) {
1910
- clearTimeout(existing);
1911
- }
1912
- const timer = setTimeout(() => {
1913
- this.refreshTimers.delete(agentName);
1914
- void this.refreshAgent(agentName);
1915
- }, delayMs);
1916
- this.refreshTimers.set(agentName, timer);
2500
+ this.refreshes.schedule(agentName, delayMs, () => this.refreshAgent(agentName));
1917
2501
  }
1918
2502
  async refreshAgent(agentName) {
1919
- if (this.refreshInFlight.has(agentName)) {
1920
- appLogger.debug("scan.refresh.pending", { agent: agentName });
1921
- this.pendingRefreshes.add(agentName);
1922
- return;
1923
- }
1924
- this.refreshInFlight.add(agentName);
1925
- this.beginAgentScan(agentName);
1926
- try {
1927
- await this.runRefresh(agentName);
1928
- } catch (error) {
1929
- appLogger.error("scan.refresh.error", { agent: agentName, error });
1930
- console.error(`[${agentName}] Session refresh failed:`, error);
1931
- } finally {
1932
- this.refreshInFlight.delete(agentName);
1933
- this.finishAgentScan(agentName);
1934
- if (this.pendingRefreshes.delete(agentName)) {
1935
- 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);
1936
2513
  }
1937
- }
2514
+ });
1938
2515
  }
1939
2516
  async runRefresh(agentName) {
1940
2517
  const startedAt = performance.now();
1941
- const pendingPathCount = this.pendingRefreshPathCounts.get(agentName) ?? 0;
1942
- this.pendingRefreshPathCounts.delete(agentName);
2518
+ const pendingPathCount = this.refreshes.takePendingPathCount(agentName);
1943
2519
  const agent = this.agents.find((item) => item.name === agentName);
1944
2520
  if (!agent) {
1945
2521
  appLogger.warn("scan.refresh.missing_agent", { agent: agentName });
1946
- return;
2522
+ return "skipped";
1947
2523
  }
1948
2524
  const previousSessions = this.byAgent[agentName] ?? [];
1949
2525
  const cached = loadCachedSessions(agentName);
1950
2526
  const refreshBaseline = cached?.sessions ?? previousSessions;
1951
- const cacheTimestamp = cached?.timestamp ?? this.refreshTimestamps.get(agentName) ?? 0;
2527
+ const cacheTimestamp = cached?.timestamp ?? this.refreshes.lastRefreshAt(agentName);
1952
2528
  if (cached) {
1953
2529
  restoreAgentCacheMeta(agent, cached.meta);
1954
2530
  }
@@ -1971,24 +2547,29 @@ var LiveScanStore = class {
1971
2547
  availabilityDuration = performance.now() - availabilityStartedAt;
1972
2548
  if (!isAvailable) {
1973
2549
  nextSessions = [];
1974
- this.refreshTimestamps.set(agentName, Date.now());
2550
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
1975
2551
  } else if (!isInitialized) {
1976
2552
  this.setScanPhase("initializing");
1977
2553
  const scanStartedAt = performance.now();
1978
- const result = await this.scanAgentInWorker(agent, previousSessions, null, {});
2554
+ const result = await this.scanAgentInWorker(
2555
+ agent,
2556
+ previousSessions,
2557
+ null,
2558
+ this.startupScanOptions
2559
+ );
1979
2560
  nextSessions = result.sessions;
1980
2561
  agent.setSessionMetaMap?.(new Map(Object.entries(result.meta)));
1981
2562
  fullScanSessions = attachMissingProjectIdentities(nextSessions);
1982
2563
  nextSessions = fullScanSessions;
1983
2564
  scanDuration = performance.now() - scanStartedAt;
1984
- this.refreshTimestamps.set(agentName, Date.now());
2565
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
1985
2566
  } else if (cached && agent instanceof FileSystemSessionSource) {
1986
2567
  const scanStartedAt = performance.now();
1987
2568
  const result = await this.scanAgentInWorker(
1988
2569
  agent,
1989
2570
  cached.sessions,
1990
2571
  null,
1991
- {},
2572
+ this.startupScanOptions,
1992
2573
  {
1993
2574
  sourceSync: true,
1994
2575
  meta: cached.meta
@@ -2005,7 +2586,7 @@ var LiveScanStore = class {
2005
2586
  preciseChangedIds
2006
2587
  );
2007
2588
  scanDuration = performance.now() - scanStartedAt;
2008
- this.refreshTimestamps.set(agentName, Date.now());
2589
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
2009
2590
  if (preciseChangedIds.length === 0) {
2010
2591
  appLogger.debug("scan.refresh.unchanged", {
2011
2592
  agent: agentName,
@@ -2018,13 +2599,13 @@ var LiveScanStore = class {
2018
2599
  agent.checkForChanges(cacheTimestamp, refreshBaseline)
2019
2600
  );
2020
2601
  checkDuration = performance.now() - checkStartedAt;
2021
- this.refreshTimestamps.set(agentName, checkResult.timestamp);
2602
+ this.refreshes.setLastRefreshAt(agentName, checkResult.timestamp);
2022
2603
  if (!checkResult.hasChanges) {
2023
2604
  appLogger.debug("scan.refresh.unchanged", {
2024
2605
  agent: agentName,
2025
2606
  duration_ms: Math.round(performance.now() - startedAt)
2026
2607
  });
2027
- return;
2608
+ return "unchanged";
2028
2609
  }
2029
2610
  preciseChangedIds = checkResult.changedIds ?? null;
2030
2611
  usedIncrementalScan = Array.isArray(checkResult.changedIds);
@@ -2049,7 +2630,7 @@ var LiveScanStore = class {
2049
2630
  fullScanSessions = attachMissingProjectIdentities(nextSessions);
2050
2631
  nextSessions = fullScanSessions;
2051
2632
  scanDuration = performance.now() - scanStartedAt;
2052
- this.refreshTimestamps.set(agentName, Date.now());
2633
+ this.refreshes.setLastRefreshAt(agentName, Date.now());
2053
2634
  }
2054
2635
  nextSessions = attachMissingProjectIdentities(nextSessions);
2055
2636
  const filterStartedAt = performance.now();
@@ -2089,7 +2670,7 @@ var LiveScanStore = class {
2089
2670
  } : null;
2090
2671
  if (persistentJob) {
2091
2672
  persistentJobKind = persistentJob.kind;
2092
- const persist = this.enqueueSearchIndexJobs("scan.refresh", [persistentJob]);
2673
+ const persist = this.searchIndexJobs.enqueue("scan.refresh", [persistentJob]);
2093
2674
  if (!isInitialized && persistentJob.kind === "full") {
2094
2675
  await persist;
2095
2676
  } else {
@@ -2110,9 +2691,11 @@ var LiveScanStore = class {
2110
2691
  event.totalSessions = this.sessions.length;
2111
2692
  this.emit(event);
2112
2693
  }
2694
+ const totalDurationMs = performance.now() - startedAt;
2695
+ this.refreshes.setLastRefreshDuration(agentName, totalDurationMs);
2113
2696
  appLogger.info("scan.refresh.done", {
2114
2697
  agent: agentName,
2115
- duration_ms: Math.round(performance.now() - startedAt),
2698
+ duration_ms: Math.round(totalDurationMs),
2116
2699
  sessions: nextSessions.length,
2117
2700
  new_sessions: event?.newSessions ?? 0,
2118
2701
  updated_sessions: event?.updatedSessions ?? 0,
@@ -2128,6 +2711,7 @@ var LiveScanStore = class {
2128
2711
  persistent_index_worker_job: persistentJobKind,
2129
2712
  persistent_index_skipped: !persistentJob || void 0
2130
2713
  });
2714
+ return "committed";
2131
2715
  }
2132
2716
  };
2133
2717
 
@@ -2137,8 +2721,8 @@ import { consola } from "consola";
2137
2721
  // src/version.ts
2138
2722
  import { readFileSync } from "fs";
2139
2723
  import { resolve as resolve3, dirname as dirname3 } from "path";
2140
- import { fileURLToPath as fileURLToPath3 } from "url";
2141
- var __dirname = dirname3(fileURLToPath3(import.meta.url));
2724
+ import { fileURLToPath as fileURLToPath4 } from "url";
2725
+ var __dirname = dirname3(fileURLToPath4(import.meta.url));
2142
2726
  var pkg = JSON.parse(readFileSync(resolve3(__dirname, "../package.json"), "utf-8"));
2143
2727
  var VERSION = pkg.version;
2144
2728
 
@@ -2188,6 +2772,18 @@ function parseSessionUri(uri) {
2188
2772
  if (!match) return null;
2189
2773
  return { agent: match[1], sessionId: match[2] };
2190
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
+ }
2191
2787
  var main = defineCommand({
2192
2788
  meta: {
2193
2789
  name: "codesesh",
@@ -2201,6 +2797,16 @@ var main = defineCommand({
2201
2797
  description: "HTTP server port",
2202
2798
  default: String(DEFAULT_PORT)
2203
2799
  },
2800
+ host: {
2801
+ type: "string",
2802
+ description: "HTTP server bind address (default 127.0.0.1, local access only)",
2803
+ default: "127.0.0.1"
2804
+ },
2805
+ "remote-access": {
2806
+ type: "boolean",
2807
+ description: "Allow authenticated access when binding to a non-loopback address",
2808
+ default: false
2809
+ },
2204
2810
  agent: {
2205
2811
  type: "string",
2206
2812
  alias: "a",
@@ -2265,6 +2871,14 @@ var main = defineCommand({
2265
2871
  const trace = args.trace;
2266
2872
  const useCache = args.cache;
2267
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
+ }
2268
2882
  if (trace) {
2269
2883
  perf.enable();
2270
2884
  }
@@ -2278,7 +2892,7 @@ var main = defineCommand({
2278
2892
  log_path: appLogger.getLogPath()
2279
2893
  });
2280
2894
  if (clearCache) {
2281
- const { clearCache: clear } = await import("./dist-T737I76S.js");
2895
+ const { clearCache: clear } = await import("./dist-VD54GDPO.js");
2282
2896
  clear();
2283
2897
  appLogger.info("cache.clear");
2284
2898
  console.log("Cache cleared.");
@@ -2367,7 +2981,9 @@ var main = defineCommand({
2367
2981
  defaultSessionFrom: listDefaultFrom,
2368
2982
  defaultSessionTo: listDefaultTo,
2369
2983
  defaultSessionDays: listDefaultDays,
2370
- portFallbackAttempts: explicitPort ? 1 : DEFAULT_PORT_FALLBACK_ATTEMPTS
2984
+ portFallbackAttempts: explicitPort ? 1 : DEFAULT_PORT_FALLBACK_ATTEMPTS,
2985
+ hostname,
2986
+ remoteAccess
2371
2987
  });
2372
2988
  } catch (error) {
2373
2989
  console.error(getServerStartupErrorMessage(error, port));
@@ -2394,14 +3010,14 @@ var main = defineCommand({
2394
3010
  console.log(` ${url}`);
2395
3011
  console.log("");
2396
3012
  appLogger.info("cli.ready", {
2397
- url,
3013
+ url: redactStartupUrl(url),
2398
3014
  duration_ms: Math.round(performance.now() - startedAt),
2399
3015
  log_path: appLogger.getLogPath()
2400
3016
  });
2401
3017
  if (!noOpen) {
2402
3018
  const open = (await import("open")).default;
2403
- const targetUrl = targetSession ? `${url}/${targetSession.agent.toLowerCase()}/${targetSession.sessionId}` : url;
2404
- 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) });
2405
3021
  await open(targetUrl);
2406
3022
  }
2407
3023
  }