mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(response-viewer): a session on a relocated Claude config folder gets its answers back (#701)
The viewer looked for a Claude transcript under ~/.claude/projects only, and for the conversation after a /clear in ~/.claude/history.jsonl only. A session started with its own CLAUDE_CONFIG_DIR (a second account, #255), or any session on a server that exports one, writes both elsewhere, so the eye button showed "No response yet" or a terminal dump, and codeman agent read and the Discord bridge's last message came back empty. This is the third part of the report in issue 701; the other two are a design choice and a feature request. lastResponseFor() now asks claudeConversationDirs() for the folder the session's claude writes to: its own override, then the server's, then ~/.claude, through claudeProjectsDir(), which the relaunch pin and the Edit history panel already use. The rename's /resume title takes the same lookup when no hook has named the transcript yet. - Local sessions only. In a Docker case or on a remote host the override names a folder over there, and the same path on this host is someone else's. - A relocated folder is a path somebody chose, so it is probed bounded first (the rule from issue 516). Anything but a directory reads as no transcript, never as a reason to look under ~/.claude, where a file of the same name is another account's conversation. - The default folder is not probed, so that path is as before. Not changed: the resume pre-validation at session create and the host-wide history scans still read the default folder. Tests: five route cases for the viewer (own folder, the server's, no fallback, /clear through the relocated history, Docker left alone) and one for the rename. Each part of the change fails one of them when reverted. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
a26be7c98f
commit
fa4815b465
7 files changed
+221
-20
No files matched your search
@@ -216,6 +216,7 @@ import {
|
||||
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
|
||||
import { claudeProjectsDir } from '../../utils/claude-transcript.js';
|
||||
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
@@ -2859,7 +2860,8 @@ export function registerSessionRoutes(
|
||||
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
|
||||
async function resolveActiveClaudeSessionIdFromHistory(
|
||||
session: Session,
|
||||
projectsDir: string
|
||||
projectsDir: string,
|
||||
historyPath: string
|
||||
): Promise<string | null> {
|
||||
// A pane whose conversation id came from its OWN hook needs no correlation:
|
||||
// $CODEMAN_SESSION_ID (the pane's env) -> data.session_id (the CLI's own
|
||||
@@ -2886,7 +2888,6 @@ export function registerSessionRoutes(
|
||||
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -3090,6 +3091,32 @@ export function registerSessionRoutes(
|
||||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where THIS session's claude keeps its conversations: `<config dir>/projects` and
|
||||
* `<config dir>/history.jsonl`. The session's own `CLAUDE_CONFIG_DIR` override (#255) comes
|
||||
* first, then the server's (a pane inherits it through tmux), then `~/.claude`: the order
|
||||
* `claudeProjectsDir()` gives the relaunch pin and the Edit history panel. Looking under
|
||||
* `~/.claude` alone showed "No response yet" for every session on a relocated folder (#701).
|
||||
*
|
||||
* ⚠️ A relocated folder is a path somebody chose, and it can sit on a mount that stopped
|
||||
* answering, so it is probed bounded first (#516, without `pastCap`: agents poll this).
|
||||
* Anything but a directory answers null, which callers read as "no transcript", never as a
|
||||
* reason to look under `~/.claude` instead: a file of the same name there is another
|
||||
* conversation. The default folder is not probed, so that path is unchanged.
|
||||
*
|
||||
* ⚠️ Local sessions only read their own override: in a Docker case or on a remote host the
|
||||
* value names a folder over there, and the same path on this host is somebody else's.
|
||||
*/
|
||||
async function claudeConversationDirs(
|
||||
session: Session
|
||||
): Promise<{ projectsDir: string; historyPath: string } | null> {
|
||||
const local = !session.remote && !session.docker;
|
||||
const projectsDir = claudeProjectsDir(local ? session.claudeConfigDir() : undefined);
|
||||
const relocated = projectsDir !== join(homedir(), '.claude', 'projects');
|
||||
if (relocated && (await probePathKind(projectsDir)) !== 'directory') return null;
|
||||
return { projectsDir, historyPath: join(dirname(projectsDir), 'history.jsonl') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a rename into the conversation's `/resume` title (claude-session-title.ts).
|
||||
* Local Claude-format transcripts only: a remote pane's transcript lives on the remote host and a
|
||||
@@ -3105,11 +3132,13 @@ export function registerSessionRoutes(
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const hookPath = ctx.getTranscriptPath(session.id);
|
||||
const dirs = hookPath ? null : await claudeConversationDirs(session);
|
||||
const transcript = hookPath
|
||||
? { sessionId: basename(hookPath, '.jsonl'), path: hookPath }
|
||||
: await findClaudeTranscript(projectsDir, session.claudeSessionId || session.id, session.id);
|
||||
: dirs
|
||||
? await findClaudeTranscript(dirs.projectsDir, session.claudeSessionId || session.id, session.id)
|
||||
: null;
|
||||
if (transcript) await appendClaudeCustomTitle(transcript.path, transcript.sessionId, session.name);
|
||||
} catch (err) {
|
||||
console.warn(`[Session] Could not carry rename into the Claude transcript for ${session.id}:`, err);
|
||||
@@ -3264,13 +3293,21 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = context === 'full' || context === 'turn';
|
||||
|
||||
// Scan <config dir>/projects/*/ for the transcript file: the session's own Claude config
|
||||
// folder when it was relocated, else the server's, else ~/.claude.
|
||||
const dirs = await claudeConversationDirs(session);
|
||||
if (!dirs) return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
const { projectsDir, historyPath } = dirs;
|
||||
|
||||
// Adopt the current conversation id if the user ran `/clear` — Claude CLI's
|
||||
// interactive PTY emits no JSON on stdout, so without this lookup the
|
||||
// stored id stays pinned to the pre-/clear transcript.
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir, historyPath);
|
||||
if (activeId && activeId !== session.claudeSessionId) {
|
||||
session.adoptClaudeSessionId(activeId);
|
||||
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
|
||||
@@ -3286,10 +3323,6 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = context === 'full' || context === 'turn';
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
|
||||
Reference in new issue
Block a user