Skip to content

Commit f21df2a

Browse files
TeigenZhangTeigen
andauthored
fix: eye icon follows /clear to the new Claude conversation (#76)
Interactive Claude CLI never emits session_id on stdout, so the Session's _claudeSessionId stayed pinned to the pre-/clear jsonl and the last-response viewer kept showing the old conversation. Two complementary update paths: - Session.adoptClaudeSessionId() — public setter mirroring the existing no-op-if-same guard. Called from POST /api/hook-event when Claude Code hooks carry data.session_id (works once hooks are configured). - /api/sessions/:id/last-response now resolves the active id from ~/.claude/history.jsonl before reading the transcript. This is the only source-of-truth that does not require hooks, and we intentionally don't write hooks into arbitrary user repos. History scan filters out sessionIds held by other Codeman sessions in the same cwd, and validates via jsonl mtime to avoid inheriting a dead prior session's id. Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
1 parent e549e15 commit f21df2a

3 files changed

Lines changed: 104 additions & 3 deletions

File tree

src/session.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,15 @@ export class Session extends EventEmitter {
484484
return this._claudeSessionId;
485485
}
486486

487+
// Adopt a Claude conversation ID observed from an external source (e.g. hook
488+
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
489+
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
490+
// that conveys a post-/clear conversation switch.
491+
adoptClaudeSessionId(newId: string): void {
492+
if (!newId || newId === this._claudeSessionId) return;
493+
this._claudeSessionId = newId;
494+
}
495+
487496
/** The tmux session name, if the session is running inside a mux */
488497
get muxName(): string | null {
489498
return this._muxSession?.muxName ?? null;

src/web/routes/hook-event-routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ export function registerHookEventRoutes(
4343
}
4444
}
4545

46+
// Sync Claude's current conversation id. Interactive PTY mode never emits
47+
// `session_id` on stdout, so hooks are the only reliable way to learn that
48+
// the user ran `/clear` (which spins up a new conversation jsonl).
49+
if (data && typeof data.session_id === 'string' && data.session_id) {
50+
const session = ctx.sessions.get(sessionId);
51+
session?.adoptClaudeSessionId(data.session_id);
52+
}
53+
4654
// Sanitize forwarded data: only include known safe fields, limit size
4755
const safeData = sanitizeHookData(data);
4856
ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });

src/web/routes/session-routes.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,15 +595,99 @@ export function registerSessionRoutes(
595595

596596
// ========== Get Last Response (from transcript JSONL) ==========
597597

598+
// Resolves the most recent Claude conversation id for a session's cwd by
599+
// tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
600+
// to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
601+
// that does not rely on project-local hooks (we intentionally don't install
602+
// hooks in arbitrary user repos, see the POST /api/sessions comment).
603+
//
604+
// Entries from OTHER Codeman sessions in the same cwd are filtered out by
605+
// their known claudeSessionIds so concurrent tabs don't shadow each other,
606+
// as long as each has had its id resolved at least once.
607+
async function resolveActiveClaudeSessionIdFromHistory(
608+
session: Session,
609+
projectsDir: string
610+
): Promise<string | null> {
611+
const historyPath = join(homedir(), '.claude', 'history.jsonl');
612+
const otherClaudeIds = new Set<string>();
613+
for (const s of ctx.sessions.values()) {
614+
if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
615+
otherClaudeIds.add(s.claudeSessionId);
616+
}
617+
}
618+
619+
let candidateSid: string | null = null;
620+
try {
621+
const content = await fs.readFile(historyPath, 'utf8');
622+
const lines = content.split('\n');
623+
for (let i = lines.length - 1; i >= 0; i--) {
624+
const line = lines[i];
625+
if (!line) continue;
626+
try {
627+
const entry = JSON.parse(line) as { project?: string; sessionId?: string };
628+
if (
629+
entry.project === session.workingDir &&
630+
typeof entry.sessionId === 'string' &&
631+
!otherClaudeIds.has(entry.sessionId)
632+
) {
633+
candidateSid = entry.sessionId;
634+
break;
635+
}
636+
} catch {
637+
// Skip unparseable lines
638+
}
639+
}
640+
} catch {
641+
return null;
642+
}
643+
if (!candidateSid || candidateSid === session.id) return candidateSid;
644+
645+
// Safety: only adopt if the candidate's jsonl is more recently written
646+
// than our initial conversation's jsonl. Blocks stale ids inherited from
647+
// a prior Codeman session that happened to share this cwd.
648+
try {
649+
const projectDirs = await fs.readdir(projectsDir);
650+
let candidateMtime = 0;
651+
let initialMtime = 0;
652+
for (const projDir of projectDirs) {
653+
try {
654+
const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
655+
if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs;
656+
} catch {
657+
/* not in this dir */
658+
}
659+
try {
660+
const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
661+
if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs;
662+
} catch {
663+
/* not in this dir */
664+
}
665+
}
666+
if (candidateMtime === 0) return null;
667+
if (initialMtime > 0 && candidateMtime <= initialMtime) return null;
668+
} catch {
669+
return null;
670+
}
671+
return candidateSid;
672+
}
673+
598674
app.get('/api/sessions/:id/last-response', async (req) => {
599675
const { id } = req.params as { id: string };
600676
const session = findSessionOrFail(ctx, id);
601677

602-
// The Claude conversation ID (used as JSONL filename)
603-
const claudeSessionId = session.claudeSessionId || session.id;
604-
605678
// Scan ~/.claude/projects/*/ for the transcript file
606679
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
680+
681+
// Adopt the current conversation id if the user ran `/clear` — Claude CLI's
682+
// interactive PTY emits no JSON on stdout, so without this lookup the
683+
// stored id stays pinned to the pre-/clear transcript.
684+
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
685+
if (activeId && activeId !== session.claudeSessionId) {
686+
session.adoptClaudeSessionId(activeId);
687+
}
688+
689+
// The Claude conversation ID (used as JSONL filename)
690+
const claudeSessionId = session.claudeSessionId || session.id;
607691
let transcriptText = '';
608692
let transcriptTimestamp = '';
609693

0 commit comments

Comments
 (0)