aicodeman 1.11.0 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +10 -10
  2. package/README.zh-CN.md +8 -8
  3. package/dist/docker-hosts.d.ts.map +1 -1
  4. package/dist/docker-hosts.js +3 -0
  5. package/dist/docker-hosts.js.map +1 -1
  6. package/dist/services/unified-session-service.d.ts.map +1 -1
  7. package/dist/services/unified-session-service.js +13 -2
  8. package/dist/services/unified-session-service.js.map +1 -1
  9. package/dist/session.d.ts +8 -3
  10. package/dist/session.d.ts.map +1 -1
  11. package/dist/session.js +22 -13
  12. package/dist/session.js.map +1 -1
  13. package/dist/tmux-manager.js +1 -1
  14. package/dist/types/session.d.ts +9 -0
  15. package/dist/types/session.d.ts.map +1 -1
  16. package/dist/types/session.js.map +1 -1
  17. package/dist/utils/claude-cli-resolver.d.ts +1 -1
  18. package/dist/utils/claude-cli-resolver.js +1 -1
  19. package/dist/utils/cloudflared-resolver.d.ts +1 -1
  20. package/dist/utils/cloudflared-resolver.js +1 -1
  21. package/dist/web/public/admin-ui.js.gz +0 -0
  22. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  23. package/dist/web/public/{app.166af5f1.js → app.ce412e69.js} +5 -5
  24. package/dist/web/public/app.ce412e69.js.br +0 -0
  25. package/dist/web/public/app.ce412e69.js.gz +0 -0
  26. package/dist/web/public/constants.e7175b95.js.gz +0 -0
  27. package/dist/web/public/cron-ui.js.gz +0 -0
  28. package/dist/web/public/entrance-animations.js.gz +0 -0
  29. package/dist/web/public/i18n.0fbea500.js.gz +0 -0
  30. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  31. package/dist/web/public/index.html +9 -5
  32. package/dist/web/public/index.html.br +0 -0
  33. package/dist/web/public/index.html.gz +0 -0
  34. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  35. package/dist/web/public/keyboard-accessory.9936227d.js.gz +0 -0
  36. package/dist/web/public/mobile-handlers.bc2b8c1d.js.gz +0 -0
  37. package/dist/web/public/mobile-overview.js +6 -0
  38. package/dist/web/public/mobile-overview.js.br +0 -0
  39. package/dist/web/public/mobile-overview.js.gz +0 -0
  40. package/dist/web/public/mobile.32cacde5.css.gz +0 -0
  41. package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
  42. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  43. package/dist/web/public/panels-ui.1d4203e0.js.gz +0 -0
  44. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  45. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  46. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  47. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  48. package/dist/web/public/session-ui.1356a4f4.js.gz +0 -0
  49. package/dist/web/public/{settings-ui.ed9cdb3f.js → settings-ui.01a1ae7d.js} +1 -1
  50. package/dist/web/public/settings-ui.01a1ae7d.js.br +0 -0
  51. package/dist/web/public/settings-ui.01a1ae7d.js.gz +0 -0
  52. package/dist/web/public/styles.50cd52bd.css +1 -0
  53. package/dist/web/public/styles.50cd52bd.css.br +0 -0
  54. package/dist/web/public/styles.50cd52bd.css.gz +0 -0
  55. package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
  56. package/dist/web/public/sw.js.gz +0 -0
  57. package/dist/web/public/terminal-ui.3ac6f8c2.js.gz +0 -0
  58. package/dist/web/public/ultracode-panel.js.gz +0 -0
  59. package/dist/web/public/ultracode-windows.js.gz +0 -0
  60. package/dist/web/public/upload.html.gz +0 -0
  61. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  62. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  63. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  64. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  65. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  66. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  67. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  68. package/dist/web/public/vendor/xterm.css.gz +0 -0
  69. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  70. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  71. package/dist/web/public/webview-tabs.js.gz +0 -0
  72. package/dist/web/routes/session-routes.d.ts.map +1 -1
  73. package/dist/web/routes/session-routes.js +229 -86
  74. package/dist/web/routes/session-routes.js.map +1 -1
  75. package/dist/web/schemas.js +1 -1
  76. package/dist/web/schemas.js.map +1 -1
  77. package/dist/web/server.d.ts.map +1 -1
  78. package/dist/web/server.js +4 -0
  79. package/dist/web/server.js.map +1 -1
  80. package/package.json +2 -1
  81. package/dist/web/public/app.166af5f1.js.br +0 -0
  82. package/dist/web/public/app.166af5f1.js.gz +0 -0
  83. package/dist/web/public/settings-ui.ed9cdb3f.js.br +0 -0
  84. package/dist/web/public/settings-ui.ed9cdb3f.js.gz +0 -0
  85. package/dist/web/public/styles.2e8dc8b7.css +0 -1
  86. package/dist/web/public/styles.2e8dc8b7.css.br +0 -0
  87. package/dist/web/public/styles.2e8dc8b7.css.gz +0 -0
@@ -311,7 +311,7 @@ export function registerSessionRoutes(app, ctx) {
311
311
  //
312
312
  // For keys the caller is actively setting, strip any stale disk entry a prior
313
313
  // Codeman version may have written. Scope limited to:
314
- // - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
314
+ // - Claude mode (OpenCode/Codex/Gemini/Antigravity don't read .claude/settings.local.json)
315
315
  // - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
316
316
  // territory — we never mutate .claude/settings.local.json in arbitrary user
317
317
  // repos that POST /api/sessions can target, as those may have hand-authored
@@ -766,84 +766,110 @@ export function registerSessionRoutes(app, ctx) {
766
766
  return {};
767
767
  });
768
768
  // ========== Get Last Response (from transcript JSONL) ==========
769
- // Resolves the most recent Claude conversation id for a session's cwd by
770
- // tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
771
- // to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
772
- // that does not rely on project-local hooks (we intentionally don't install
773
- // hooks in arbitrary user repos, see the POST /api/sessions comment).
769
+ // How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and
770
+ // still be the same submission. Claude appends to history as it accepts the
771
+ // prompt, so the true gap is milliseconds — this is slack for a loaded box,
772
+ // not a search radius.
773
+ const CLAUDE_SUBMIT_MATCH_MS = 10_000;
774
+ // history.jsonl grows forever; only the tail can hold entries near a submit.
775
+ const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024;
776
+ // Resolves the Claude conversation id THIS pane is currently on by matching
777
+ // ~/.claude/history.jsonl (which logs every submitted prompt as
778
+ // {project, sessionId, timestamp}) against the pane's last Enter. After
779
+ // `/clear` Claude keeps writing to a new <uuid>.jsonl, and history.jsonl is
780
+ // the only source-of-truth update that does not rely on project-local hooks
781
+ // (we intentionally don't install hooks in arbitrary user repos, see the
782
+ // POST /api/sessions comment).
774
783
  //
775
- // Entries from OTHER Codeman sessions in the same cwd are filtered out by
776
- // their known claudeSessionIds so concurrent tabs don't shadow each other,
777
- // as long as each has had its id resolved at least once.
784
+ // The pane's own Enter is what makes an entry OURS. `project` alone is not:
785
+ // a cwd is shared by every other Codeman tab on it, by tabs long since
786
+ // closed, and by any plain `claude` the user runs in their own terminal —
787
+ // adopting the newest entry for the cwd pinned the viewer to whichever of
788
+ // those conversations was typed in last, so the eye showed a stranger's
789
+ // transcript. With no correlated entry we keep the id we have; a viewer one
790
+ // turn behind beats a viewer showing someone else's conversation.
791
+ const claudeHistoryPinCache = new LRUMap({ maxSize: 1024 });
778
792
  async function resolveActiveClaudeSessionIdFromHistory(session, projectsDir) {
779
- const historyPath = join(homedir(), '.claude', 'history.jsonl');
793
+ const submitAt = session.lastSubmitAt;
794
+ if (!submitAt)
795
+ return null; // never typed through Codeman — nothing to credit
796
+ const cached = claudeHistoryPinCache.get(session.id);
797
+ if (cached && cached.submitAt === submitAt)
798
+ return cached.claudeSessionId;
799
+ // Ids another live pane is already pinned to can never be ours, and every
800
+ // pane sharing this cwd competes for the entry we are about to claim —
801
+ // including non-Claude panes, since a shell pane can run `claude` too.
780
802
  const otherClaudeIds = new Set();
803
+ const otherSubmits = [];
781
804
  for (const s of ctx.sessions.values()) {
782
- if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
805
+ if (s.id === session.id || s.workingDir !== session.workingDir)
806
+ continue;
807
+ if (s.claudeSessionId)
783
808
  otherClaudeIds.add(s.claudeSessionId);
784
- }
809
+ if (s.lastSubmitAt)
810
+ otherSubmits.push(s.lastSubmitAt);
785
811
  }
786
- let candidateSid = null;
787
- try {
788
- const content = await fs.readFile(historyPath, 'utf8');
789
- const lines = content.split('\n');
790
- for (let i = lines.length - 1; i >= 0; i--) {
791
- const line = lines[i];
792
- if (!line)
793
- continue;
794
- try {
795
- const entry = JSON.parse(line);
796
- if (entry.project === session.workingDir &&
797
- typeof entry.sessionId === 'string' &&
798
- !otherClaudeIds.has(entry.sessionId)) {
799
- candidateSid = entry.sessionId;
800
- break;
801
- }
802
- }
803
- catch {
804
- // Skip unparseable lines
805
- }
812
+ const historyPath = join(homedir(), '.claude', 'history.jsonl');
813
+ const stat = await fs.stat(historyPath).catch(() => null);
814
+ if (!stat || stat.size === 0)
815
+ return null;
816
+ const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
817
+ if (!tail)
818
+ return null;
819
+ let best;
820
+ for (const line of tail.split('\n')) {
821
+ if (!line)
822
+ continue;
823
+ let entry;
824
+ try {
825
+ entry = JSON.parse(line);
806
826
  }
827
+ catch {
828
+ continue; // the first tail line is usually cut mid-JSON
829
+ }
830
+ const { sessionId, timestamp } = entry;
831
+ if (entry.project !== session.workingDir)
832
+ continue;
833
+ if (typeof sessionId !== 'string' || !sessionId)
834
+ continue;
835
+ if (typeof timestamp !== 'number')
836
+ continue;
837
+ if (otherClaudeIds.has(sessionId))
838
+ continue;
839
+ const dist = Math.abs(timestamp - submitAt);
840
+ if (dist > CLAUDE_SUBMIT_MATCH_MS)
841
+ continue;
842
+ if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist))
843
+ continue; // another pane is closer
844
+ if (!best || dist <= best.dist)
845
+ best = { sessionId, dist }; // ties: the newer entry wins
807
846
  }
808
- catch {
847
+ if (!best)
809
848
  return null;
810
- }
811
- if (!candidateSid || candidateSid === session.id)
812
- return candidateSid;
813
- // Safety: only adopt if the candidate's jsonl is more recently written
814
- // than our initial conversation's jsonl. Blocks stale ids inherited from
815
- // a prior Codeman session that happened to share this cwd.
816
- try {
817
- const projectDirs = await fs.readdir(projectsDir);
849
+ // Sanity: the conversation we switch to must exist on disk and must not be
850
+ // staler than the one we are leaving. A `/clear` successor never is.
851
+ const currentSessionId = session.claudeSessionId || session.id;
852
+ if (best.sessionId !== currentSessionId) {
853
+ const projectDirs = await fs.readdir(projectsDir).catch(() => null);
854
+ if (!projectDirs)
855
+ return null;
818
856
  let candidateMtime = 0;
819
- let initialMtime = 0;
857
+ let currentMtime = 0;
820
858
  for (const projDir of projectDirs) {
821
- try {
822
- const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
823
- if (cs.mtimeMs > candidateMtime)
824
- candidateMtime = cs.mtimeMs;
825
- }
826
- catch {
827
- /* not in this dir */
828
- }
829
- try {
830
- const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
831
- if (is.mtimeMs > initialMtime)
832
- initialMtime = is.mtimeMs;
833
- }
834
- catch {
835
- /* not in this dir */
836
- }
859
+ const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null);
860
+ if (candidateStat && candidateStat.mtimeMs > candidateMtime)
861
+ candidateMtime = candidateStat.mtimeMs;
862
+ const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null);
863
+ if (currentStat && currentStat.mtimeMs > currentMtime)
864
+ currentMtime = currentStat.mtimeMs;
837
865
  }
838
866
  if (candidateMtime === 0)
867
+ return null; // transcript not written yet — retry next poll
868
+ if (currentMtime > 0 && candidateMtime < currentMtime)
839
869
  return null;
840
- if (initialMtime > 0 && candidateMtime <= initialMtime)
841
- return null;
842
- }
843
- catch {
844
- return null;
845
870
  }
846
- return candidateSid;
871
+ claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId });
872
+ return best.sessionId;
847
873
  }
848
874
  function extractClaudeText(content, separator) {
849
875
  if (typeof content === 'string')
@@ -1022,6 +1048,11 @@ export function registerSessionRoutes(app, ctx) {
1022
1048
  const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
1023
1049
  if (activeId && activeId !== session.claudeSessionId) {
1024
1050
  session.adoptClaudeSessionId(activeId);
1051
+ // Flush the Enter that vouched for this adoption to state.json. A `/clear`
1052
+ // emits no completion event, so without this the anchor could still be
1053
+ // unpersisted when the server restarts — and recovery would fall back to
1054
+ // the launch conversation.
1055
+ ctx.persistSessionState(session);
1025
1056
  // Docker sessions: keep the case's resume seed following the live conversation.
1026
1057
  if (session.docker) {
1027
1058
  void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(() => { });
@@ -1083,7 +1114,7 @@ export function registerSessionRoutes(app, ctx) {
1083
1114
  }
1084
1115
  return { cwd, originator };
1085
1116
  }
1086
- // The pane's last Enter (Session.codexLastSubmitAt) correlated against
1117
+ // The pane's last Enter (Session.lastSubmitAt) correlated against
1087
1118
  // ~/.codex/history.jsonl, which logs every submitted user message as
1088
1119
  // {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
1089
1120
  // is the only signal that survives /resume, /new and /fork typed inside the
@@ -1092,7 +1123,7 @@ export function registerSessionRoutes(app, ctx) {
1092
1123
  // can't steal the attribution.
1093
1124
  const codexHistoryPinCache = new LRUMap({ maxSize: 1024 });
1094
1125
  async function resolveCodexThreadFromHistory(session, codexHome) {
1095
- const submitAt = session.codexLastSubmitAt || 0;
1126
+ const submitAt = session.lastSubmitAt || 0;
1096
1127
  if (!submitAt)
1097
1128
  return null;
1098
1129
  const cached = codexHistoryPinCache.get(session.id);
@@ -1108,8 +1139,8 @@ export function registerSessionRoutes(app, ctx) {
1108
1139
  const WINDOW_MS = 15_000;
1109
1140
  const otherSubmits = [];
1110
1141
  for (const s of ctx.sessions.values()) {
1111
- if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
1112
- otherSubmits.push(s.codexLastSubmitAt);
1142
+ if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
1143
+ otherSubmits.push(s.lastSubmitAt);
1113
1144
  }
1114
1145
  }
1115
1146
  let best;
@@ -1692,7 +1723,7 @@ export function registerSessionRoutes(app, ctx) {
1692
1723
  if (!host)
1693
1724
  return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
1694
1725
  // Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
1695
- // tmux setenv, effort/model CLI args, codex/gemini/opencode config) does NOT
1726
+ // tmux setenv, effort/model CLI args, codex/gemini/antigravity/opencode config) does NOT
1696
1727
  // cross ssh, so it would silently no-op. Reject rather than pretend it worked —
1697
1728
  // remote command/env customization goes through the per-host command override.
1698
1729
  if ((envOverrides && Object.keys(envOverrides).length > 0) ||
@@ -1979,7 +2010,7 @@ export function registerSessionRoutes(app, ctx) {
1979
2010
  ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
1980
2011
  }
1981
2012
  else {
1982
- // 'claude', 'opencode', 'codex', and 'gemini' modes use startInteractive()
2013
+ // 'claude', 'opencode', 'codex', 'gemini', and 'antigravity' modes use startInteractive()
1983
2014
  await session.startInteractive();
1984
2015
  getLifecycleLog().log({
1985
2016
  event: 'started',
@@ -2092,6 +2123,66 @@ export function registerSessionRoutes(app, ctx) {
2092
2123
  }
2093
2124
  return undefined;
2094
2125
  }
2126
+ /**
2127
+ * Is this `entrypoint` value an automated/SDK-driven invocation?
2128
+ *
2129
+ * ⚠️ Deliberately a BLOCKLIST on the SDK shape, not an allowlist on `'cli'`.
2130
+ * The exclusion below hides rows, so an allowlist fails CLOSED on any value
2131
+ * Claude Code has not shipped yet: the day it stamps a new interactive
2132
+ * entrypoint (a rename, or a second interactive host), every transcript stops
2133
+ * matching `'cli'` and the whole Past Sessions list goes blank with nothing in
2134
+ * the UI to explain it. A blocklist fails OPEN instead — an automated
2135
+ * entrypoint we do not recognize yet costs a few noisy rows, which is the
2136
+ * annoyance this filter set out to fix rather than a broken feature.
2137
+ *
2138
+ * Observed values: `cli` (interactive), `sdk-cli` / `sdk-py` (automated).
2139
+ */
2140
+ function isAutomatedEntrypoint(entrypoint) {
2141
+ return /^sdk(-|$)/.test(entrypoint);
2142
+ }
2143
+ /**
2144
+ * The `entrypoint` field Claude Code stamps on its own message records:
2145
+ * 'cli' for a real interactive session, something else (e.g. 'sdk-py') for
2146
+ * an SDK/automated invocation. Used to exclude non-interactive transcripts
2147
+ * (CI review bots, etc.) from the resumable history list — they were never
2148
+ * something a user can resume into.
2149
+ *
2150
+ * Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint
2151
+ * field — not just the first one — and returns 'cli' the moment ANY of them
2152
+ * carries it. A transcript is excluded only when every entrypoint-bearing
2153
+ * message says something else; "first field wins" would misattribute a
2154
+ * transcript that started under an older Claude Code version (no entrypoint
2155
+ * on its true first message) and later picked up a non-'cli' entrypoint on
2156
+ * some later message, wrongly hiding a genuinely interactive session. This
2157
+ * deliberately errs toward keeping a session visible: one real interactive
2158
+ * message anywhere is enough. Returns undefined ("unknown", fail-open) only
2159
+ * when nothing scanned carries the field at all.
2160
+ */
2161
+ function extractTranscriptEntrypoint(text) {
2162
+ let start = 0;
2163
+ let sawNonCli;
2164
+ while (start < text.length) {
2165
+ const end = text.indexOf('\n', start);
2166
+ const line = end === -1 ? text.slice(start) : text.slice(start, end);
2167
+ start = end === -1 ? text.length : end + 1;
2168
+ if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"'))
2169
+ continue;
2170
+ if (!line.includes('"entrypoint"'))
2171
+ continue;
2172
+ try {
2173
+ const entry = JSON.parse(line);
2174
+ if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') {
2175
+ if (entry.entrypoint === 'cli')
2176
+ return 'cli';
2177
+ sawNonCli ??= entry.entrypoint;
2178
+ }
2179
+ }
2180
+ catch {
2181
+ // Malformed/truncated line — skip
2182
+ }
2183
+ }
2184
+ return sawNonCli;
2185
+ }
2095
2186
  /**
2096
2187
  * Extract the text of the LAST user message from a JSONL transcript chunk
2097
2188
  * (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
@@ -2314,7 +2405,7 @@ export function registerSessionRoutes(app, ctx) {
2314
2405
  .catch(() => false);
2315
2406
  return finalExists ? current : process.env.HOME || '/tmp';
2316
2407
  }
2317
- /** Read the first 16KB of a file for content sniffing. */
2408
+ /** Read the first `buf.length` bytes of a file for content sniffing. */
2318
2409
  async function readFileHead(path, buf) {
2319
2410
  try {
2320
2411
  const fd = await fs.open(path, 'r');
@@ -2347,7 +2438,7 @@ export function registerSessionRoutes(app, ctx) {
2347
2438
  }
2348
2439
  // Scan a single project directory and return all valid history sessions in it.
2349
2440
  // Reused by both the global overview and the single-folder drill-down.
2350
- async function scanProjectDir(projPath, projDir, headBuf) {
2441
+ async function scanProjectDir(projPath, projDir, smallHeadBuf, headBuf) {
2351
2442
  const out = [];
2352
2443
  const stat = await fs.stat(projPath).catch(() => null);
2353
2444
  if (!stat?.isDirectory())
@@ -2366,12 +2457,35 @@ export function registerSessionRoutes(app, ctx) {
2366
2457
  continue;
2367
2458
  if (fileStat.size < 4000)
2368
2459
  continue;
2369
- let firstPrompt;
2370
- const head = await readFileHead(filePath, headBuf);
2371
2460
  const hasConversation = (text) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
2461
+ // Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough
2462
+ // for the vast majority of transcripts -- and only escalate to the full
2463
+ // headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for
2464
+ // EVERY file in the directory roughly quadrupled the cost of a full scan
2465
+ // (measured against a real ~/.claude/projects tree: ~4x both bytes read and
2466
+ // wall time) to fix a problem only ~28% of files actually have. Escalating
2467
+ // resolves the restart-bookkeeping case (the reason 128KB exists at all)
2468
+ // without ever touching the tail-read fallback below for most of that 28%.
2469
+ let head = await readFileHead(filePath, smallHeadBuf);
2372
2470
  let foundContent = head ? hasConversation(head) : false;
2471
+ let firstPrompt = head ? extractFirstUserPrompt(head) : undefined;
2472
+ if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) {
2473
+ const biggerHead = await readFileHead(filePath, headBuf);
2474
+ if (biggerHead) {
2475
+ head = biggerHead;
2476
+ if (!foundContent)
2477
+ foundContent = hasConversation(head);
2478
+ if (!firstPrompt)
2479
+ firstPrompt = extractFirstUserPrompt(head);
2480
+ }
2481
+ }
2373
2482
  let tail = null;
2374
- if (!foundContent && fileStat.size > 16384) {
2483
+ // `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of
2484
+ // files) must also get a shot at the tail, not just "file bigger than the
2485
+ // head buffer". Losing this dropped the session from history entirely
2486
+ // instead of giving it a second chance, for any file at or under the head
2487
+ // buffer size whose head read happened to fail.
2488
+ if (!foundContent && (head === null || fileStat.size > headBuf.length)) {
2375
2489
  const tailBuf = Buffer.alloc(32768);
2376
2490
  tail = await readFileTail(filePath, tailBuf, fileStat.size);
2377
2491
  if (tail)
@@ -2379,9 +2493,9 @@ export function registerSessionRoutes(app, ctx) {
2379
2493
  }
2380
2494
  if (!foundContent)
2381
2495
  continue;
2382
- if (head)
2383
- firstPrompt = extractFirstUserPrompt(head);
2384
- if (!firstPrompt && fileStat.size > 65536) {
2496
+ // firstPrompt was already attempted from head (both tiers) above; this is
2497
+ // purely the tail fallback for whatever's left unresolved.
2498
+ if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) {
2385
2499
  if (!tail) {
2386
2500
  const tailBuf = Buffer.alloc(32768);
2387
2501
  tail = await readFileTail(filePath, tailBuf, fileStat.size);
@@ -2391,13 +2505,34 @@ export function registerSessionRoutes(app, ctx) {
2391
2505
  }
2392
2506
  // COD-145: last (most recent) user prompt lives near the END of the file, so
2393
2507
  // prefer the tail. For large files where no tail was read yet, read one
2394
- // (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
2395
- // then contains the whole transcript — scan it for the last match instead.
2396
- if (!tail && fileStat.size > 65536) {
2508
+ // (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`,
2509
+ // which then contains the whole transcript — scan it for the last match instead.
2510
+ if (!tail && fileStat.size > headBuf.length) {
2397
2511
  const tailBuf = Buffer.alloc(32768);
2398
2512
  tail = await readFileTail(filePath, tailBuf, fileStat.size);
2399
2513
  }
2400
2514
  const lastPrompt = (tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
2515
+ // Automated/SDK-driven invocations (CI review bots, etc.) write transcripts
2516
+ // into the same ~/.claude/projects tree as interactive sessions but were
2517
+ // never something a user can resume into — no PTY, no running process, and
2518
+ // their "conversation" is typically a single one-shot prompt (often with a
2519
+ // full diff embedded, which is exactly why it dwarfs this scanner's read
2520
+ // windows and shows up above as blank or as an identical boilerplate
2521
+ // sentence across many rows). Checked last, so it reuses whatever `head`/
2522
+ // `tail` the prompt extraction above already read rather than triggering
2523
+ // an extra file read. Missing entrypoint (older transcripts) reads as
2524
+ // interactive — fail open, matching every other gating check in this
2525
+ // codebase.
2526
+ //
2527
+ // head and tail are checked independently and merged with "cli wins" (not
2528
+ // a first-truthy-value `??` chain): a large file's head might land on a
2529
+ // non-'cli' message while a real interactive message sits in the tail (or
2530
+ // vice versa), and either one being 'cli' is enough to keep the session.
2531
+ const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined;
2532
+ const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined;
2533
+ const entrypoint = headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
2534
+ if (entrypoint && isAutomatedEntrypoint(entrypoint))
2535
+ continue;
2401
2536
  out.push({
2402
2537
  sessionId,
2403
2538
  workingDir,
@@ -2413,7 +2548,13 @@ export function registerSessionRoutes(app, ctx) {
2413
2548
  app.get('/api/history/sessions', async (req) => {
2414
2549
  const query = req.query;
2415
2550
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
2416
- const headBuf = Buffer.alloc(16384);
2551
+ // scanProjectDir tries smallHeadBuf (16KB, the original size) first for every
2552
+ // file and only escalates to headBuf (128KB) when that wasn't enough — see the
2553
+ // comment at the escalation site in scanProjectDir for why unconditional 128KB
2554
+ // reads were too expensive to keep. 128KB matches the existing precedent
2555
+ // elsewhere in this file (line ~1431).
2556
+ const smallHeadBuf = Buffer.alloc(16384);
2557
+ const headBuf = Buffer.alloc(131072);
2417
2558
  // Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
2418
2559
  // must only see history whose decoded workingDir is inside their own case space.
2419
2560
  // Do NOT trust the caller-supplied projectKey — confine on the decoded path.
@@ -2430,7 +2571,7 @@ export function registerSessionRoutes(app, ctx) {
2430
2571
  const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
2431
2572
  const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
2432
2573
  const projPath = join(projectsDir, query.projectKey);
2433
- let all = await scanProjectDir(projPath, query.projectKey, headBuf);
2574
+ let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf);
2434
2575
  // Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
2435
2576
  if (scopeHistory)
2436
2577
  all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
@@ -2443,7 +2584,7 @@ export function registerSessionRoutes(app, ctx) {
2443
2584
  const projectDirs = await fs.readdir(projectsDir);
2444
2585
  for (const projDir of projectDirs) {
2445
2586
  const projPath = join(projectsDir, projDir);
2446
- const list = await scanProjectDir(projPath, projDir, headBuf);
2587
+ const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
2447
2588
  results.push(...list);
2448
2589
  }
2449
2590
  }
@@ -2515,11 +2656,13 @@ export function registerSessionRoutes(app, ctx) {
2515
2656
  const history = [];
2516
2657
  try {
2517
2658
  const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
2518
- const headBuf = Buffer.alloc(16384);
2659
+ // See the sibling allocation above for why there are two sizes.
2660
+ const smallHeadBuf = Buffer.alloc(16384);
2661
+ const headBuf = Buffer.alloc(131072);
2519
2662
  const projectDirs = await fs.readdir(projectsDir);
2520
2663
  for (const projDir of projectDirs) {
2521
2664
  const projPath = join(projectsDir, projDir);
2522
- const list = await scanProjectDir(projPath, projDir, headBuf);
2665
+ const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
2523
2666
  for (const h of list) {
2524
2667
  history.push({
2525
2668
  sessionId: h.sessionId,