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.
- package/README.md +10 -10
- package/README.zh-CN.md +8 -8
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +3 -0
- package/dist/docker-hosts.js.map +1 -1
- package/dist/services/unified-session-service.d.ts.map +1 -1
- package/dist/services/unified-session-service.js +13 -2
- package/dist/services/unified-session-service.js.map +1 -1
- package/dist/session.d.ts +8 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +22 -13
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.js +1 -1
- package/dist/types/session.d.ts +9 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/utils/claude-cli-resolver.d.ts +1 -1
- package/dist/utils/claude-cli-resolver.js +1 -1
- package/dist/utils/cloudflared-resolver.d.ts +1 -1
- package/dist/utils/cloudflared-resolver.js +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/{app.166af5f1.js → app.ce412e69.js} +5 -5
- package/dist/web/public/app.ce412e69.js.br +0 -0
- package/dist/web/public/app.ce412e69.js.gz +0 -0
- package/dist/web/public/constants.e7175b95.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/i18n.0fbea500.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +9 -5
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.9936227d.js.gz +0 -0
- package/dist/web/public/mobile-handlers.bc2b8c1d.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +6 -0
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.32cacde5.css.gz +0 -0
- package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.1d4203e0.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.1356a4f4.js.gz +0 -0
- package/dist/web/public/{settings-ui.ed9cdb3f.js → settings-ui.01a1ae7d.js} +1 -1
- package/dist/web/public/settings-ui.01a1ae7d.js.br +0 -0
- package/dist/web/public/settings-ui.01a1ae7d.js.gz +0 -0
- package/dist/web/public/styles.50cd52bd.css +1 -0
- package/dist/web/public/styles.50cd52bd.css.br +0 -0
- package/dist/web/public/styles.50cd52bd.css.gz +0 -0
- package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.3ac6f8c2.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +229 -86
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/schemas.js +1 -1
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +4 -0
- package/dist/web/server.js.map +1 -1
- package/package.json +2 -1
- package/dist/web/public/app.166af5f1.js.br +0 -0
- package/dist/web/public/app.166af5f1.js.gz +0 -0
- package/dist/web/public/settings-ui.ed9cdb3f.js.br +0 -0
- package/dist/web/public/settings-ui.ed9cdb3f.js.gz +0 -0
- package/dist/web/public/styles.2e8dc8b7.css +0 -1
- package/dist/web/public/styles.2e8dc8b7.css.br +0 -0
- 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
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
|
|
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
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
847
|
+
if (!best)
|
|
809
848
|
return null;
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
857
|
+
let currentMtime = 0;
|
|
820
858
|
for (const projDir of projectDirs) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
1112
|
-
otherSubmits.push(s.
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2383
|
-
|
|
2384
|
-
if (!firstPrompt && fileStat.size >
|
|
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 >
|
|
2395
|
-
// then contains the whole transcript — scan it for the last match instead.
|
|
2396
|
-
if (!tail && fileStat.size >
|
|
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
|
-
|
|
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
|
-
|
|
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,
|