mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
+24
-13
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user