Merge pull request #203 from shenlvkang-collab/contrib/claude-viewer-session-pin

fix(web): pin the Claude response viewer to the pane's own conversation
This commit is contained in:
Ark0N
2026-08-05 21:44:37 +02:00
committed by GitHub
10 changed files with 339 additions and 82 deletions
+24 -13
View File
@@ -509,6 +509,8 @@ export class Session extends EventEmitter {
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
@@ -535,6 +537,12 @@ export class Session extends EventEmitter {
this._lastActivityAt = this.createdAt;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
// to the launch id even when re-attaching to a mux session whose CLI has
// moved on (a `/clear` before the restart), so this anchor is what lets the
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
@@ -1135,6 +1143,7 @@ export class Session extends EventEmitter {
// recovery can re-attach.
respawnBlocked: this._respawnBlocked || undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -2543,26 +2552,28 @@ export class Session extends EventEmitter {
* ```
*/
write(data: string): void {
this._trackCodexSubmit(data);
this._trackSubmit(data);
if (this.ptyProcess) {
this.ptyProcess.write(data);
}
}
// ── Codex thread tracking ─────────────────────────────────────────────
// When a codex pane last submitted a message (Enter). The response-viewer
// correlates this against ~/.codex/history.jsonl entry timestamps to find
// the thread the pane is ACTUALLY on — the only signal that survives
// /resume, /new and /fork typed inside the codex TUI itself.
private _codexLastSubmitAt = 0;
// ── Conversation tracking ─────────────────────────────────────────────
// When this pane last submitted a message (Enter). The response-viewer
// correlates this against the CLI's own history.jsonl entry timestamps to
// find the conversation the pane is ACTUALLY on — the only signal that
// survives /clear, /resume, /new and /fork typed inside the TUI itself,
// none of which announce themselves on the PTY's stdout.
private _lastSubmitAt = 0;
get codexLastSubmitAt(): number {
return this._codexLastSubmitAt;
/** Wall-clock ms of this pane's last Enter; 0 if it has never submitted. */
get lastSubmitAt(): number {
return this._lastSubmitAt;
}
private _trackCodexSubmit(data: string): void {
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
this._codexLastSubmitAt = Date.now();
private _trackSubmit(data: string): void {
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
}
}
@@ -2619,7 +2630,7 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string): Promise<boolean> {
this._trackCodexSubmit(data);
this._trackSubmit(data);
if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data);
}
+9
View File
@@ -480,6 +480,15 @@ export interface SessionState {
effort?: EffortLevel;
/** Sanitized per-session attachment history. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/**
* Wall-clock ms of this pane's last Enter (Session.lastSubmitAt). Persisted
* because it is the response-viewer's only anchor for re-deriving the pane's
* live conversation after a Codeman restart: `start()` resets
* `claudeSessionId` to the launch id even when re-attaching to a mux session
* whose CLI has since moved on via `/clear`, and the correlation cannot run
* again until the pane's own Enter is known.
*/
lastSubmitAt?: number;
/**
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
+88 -66
View File
@@ -965,80 +965,97 @@ export function registerSessionRoutes(
// ========== Get Last Response (from transcript JSONL) ==========
// Resolves the most recent Claude conversation id for a session's cwd by
// tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
// to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
// that does not rely on project-local hooks (we intentionally don't install
// hooks in arbitrary user repos, see the POST /api/sessions comment).
// How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and
// still be the same submission. Claude appends to history as it accepts the
// prompt, so the true gap is milliseconds — this is slack for a loaded box,
// not a search radius.
const CLAUDE_SUBMIT_MATCH_MS = 10_000;
// history.jsonl grows forever; only the tail can hold entries near a submit.
const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024;
// Resolves the Claude conversation id THIS pane is currently on by matching
// ~/.claude/history.jsonl (which logs every submitted prompt as
// {project, sessionId, timestamp}) against the pane's last Enter. After
// `/clear` Claude keeps writing to a new <uuid>.jsonl, and history.jsonl is
// the only source-of-truth update that does not rely on project-local hooks
// (we intentionally don't install hooks in arbitrary user repos, see the
// POST /api/sessions comment).
//
// Entries from OTHER Codeman sessions in the same cwd are filtered out by
// their known claudeSessionIds so concurrent tabs don't shadow each other,
// as long as each has had its id resolved at least once.
// The pane's own Enter is what makes an entry OURS. `project` alone is not:
// a cwd is shared by every other Codeman tab on it, by tabs long since
// closed, and by any plain `claude` the user runs in their own terminal —
// adopting the newest entry for the cwd pinned the viewer to whichever of
// those conversations was typed in last, so the eye showed a stranger's
// transcript. With no correlated entry we keep the id we have; a viewer one
// turn behind beats a viewer showing someone else's conversation.
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
async function resolveActiveClaudeSessionIdFromHistory(
session: Session,
projectsDir: string
): Promise<string | null> {
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const submitAt = session.lastSubmitAt;
if (!submitAt) return null; // never typed through Codeman — nothing to credit
const cached = claudeHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
// Ids another live pane is already pinned to can never be ours, and every
// pane sharing this cwd competes for the entry we are about to claim —
// including non-Claude panes, since a shell pane can run `claude` too.
const otherClaudeIds = new Set<string>();
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
otherClaudeIds.add(s.claudeSessionId);
}
if (s.id === session.id || s.workingDir !== session.workingDir) continue;
if (s.claudeSessionId) otherClaudeIds.add(s.claudeSessionId);
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
}
let candidateSid: string | null = null;
try {
const content = await fs.readFile(historyPath, 'utf8');
const lines = content.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) continue;
try {
const entry = JSON.parse(line) as { project?: string; sessionId?: string };
if (
entry.project === session.workingDir &&
typeof entry.sessionId === 'string' &&
!otherClaudeIds.has(entry.sessionId)
) {
candidateSid = entry.sessionId;
break;
}
} catch {
// Skip unparseable lines
}
}
} catch {
return null;
}
if (!candidateSid || candidateSid === session.id) return candidateSid;
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const stat = await fs.stat(historyPath).catch(() => null);
if (!stat || stat.size === 0) return null;
const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
if (!tail) return null;
// Safety: only adopt if the candidate's jsonl is more recently written
// than our initial conversation's jsonl. Blocks stale ids inherited from
// a prior Codeman session that happened to share this cwd.
try {
const projectDirs = await fs.readdir(projectsDir);
let best: { sessionId: string; dist: number } | undefined;
for (const line of tail.split('\n')) {
if (!line) continue;
let entry: { project?: string; sessionId?: string; timestamp?: number };
try {
entry = JSON.parse(line) as typeof entry;
} catch {
continue; // the first tail line is usually cut mid-JSON
}
const { sessionId, timestamp } = entry;
if (entry.project !== session.workingDir) continue;
if (typeof sessionId !== 'string' || !sessionId) continue;
if (typeof timestamp !== 'number') continue;
if (otherClaudeIds.has(sessionId)) continue;
const dist = Math.abs(timestamp - submitAt);
if (dist > CLAUDE_SUBMIT_MATCH_MS) continue;
if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist)) continue; // another pane is closer
if (!best || dist <= best.dist) best = { sessionId, dist }; // ties: the newer entry wins
}
if (!best) return null;
// Sanity: the conversation we switch to must exist on disk and must not be
// staler than the one we are leaving. A `/clear` successor never is.
const currentSessionId = session.claudeSessionId || session.id;
if (best.sessionId !== currentSessionId) {
const projectDirs = await fs.readdir(projectsDir).catch(() => null);
if (!projectDirs) return null;
let candidateMtime = 0;
let initialMtime = 0;
let currentMtime = 0;
for (const projDir of projectDirs) {
try {
const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs;
} catch {
/* not in this dir */
}
try {
const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs;
} catch {
/* not in this dir */
}
const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null);
if (candidateStat && candidateStat.mtimeMs > candidateMtime) candidateMtime = candidateStat.mtimeMs;
const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null);
if (currentStat && currentStat.mtimeMs > currentMtime) currentMtime = currentStat.mtimeMs;
}
if (candidateMtime === 0) return null;
if (initialMtime > 0 && candidateMtime <= initialMtime) return null;
} catch {
return null;
if (candidateMtime === 0) return null; // transcript not written yet — retry next poll
if (currentMtime > 0 && candidateMtime < currentMtime) return null;
}
return candidateSid;
claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId });
return best.sessionId;
}
interface ClaudeResponseMessage {
@@ -1236,6 +1253,11 @@ export function registerSessionRoutes(
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId);
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
// emits no completion event, so without this the anchor could still be
// unpersisted when the server restarts — and recovery would fall back to
// the launch conversation.
ctx.persistSessionState(session);
// Docker sessions: keep the case's resume seed following the live conversation.
if (session.docker) {
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
@@ -1309,7 +1331,7 @@ export function registerSessionRoutes(
return { cwd, originator };
}
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
// The pane's last Enter (Session.lastSubmitAt) correlated against
// ~/.codex/history.jsonl, which logs every submitted user message as
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
// is the only signal that survives /resume, /new and /fork typed inside the
@@ -1318,10 +1340,10 @@ export function registerSessionRoutes(
// can't steal the attribution.
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
async function resolveCodexThreadFromHistory(
session: { id: string; codexLastSubmitAt?: number },
session: { id: string; lastSubmitAt?: number },
codexHome: string
): Promise<string | null> {
const submitAt = session.codexLastSubmitAt || 0;
const submitAt = session.lastSubmitAt || 0;
if (!submitAt) return null;
const cached = codexHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.threadId;
@@ -1335,8 +1357,8 @@ export function registerSessionRoutes(
const WINDOW_MS = 15_000;
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
otherSubmits.push(s.codexLastSubmitAt);
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
otherSubmits.push(s.lastSubmitAt);
}
}
@@ -1379,7 +1401,7 @@ export function registerSessionRoutes(
async function findActiveCodexFile(session: {
id: string;
workingDir: string;
codexLastSubmitAt?: number;
lastSubmitAt?: number;
codexConfig?: { resumeSessionId?: string };
}): Promise<string | null> {
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
+4
View File
@@ -2508,6 +2508,10 @@ export class WebServer extends EventEmitter {
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// Remote SSH metadata must round-trip on recovery: without it the
// attach cwd falls back to the (nonexistent-locally) remote path and
// respawn rebuilds a LOCAL command, breaking the pane and silently