From fa4815b465c171c9e55eab0650eb0207f890c510 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 12 Nov 2026 14:39:13 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Agent-CLIs.md | 7 +- docs/wiki/Watching-Agents-Work.md | 6 +- src/web/routes/session-routes.ts | 55 +++++-- test/routes/session-name-routes.test.ts | 31 +++- ...ession-routes-claude-last-response.test.ts | 138 ++++++++++++++++++ 7 files changed, 221 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4d0d0153a..881e5ea56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) -- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects /projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir) +- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so subagent windows, the ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects /projects`). The response viewer and the rename's `/resume` title read a LOCAL session's own folder, then the server's `CLAUDE_CONFIG_DIR`, then `~/.claude` (`claudeConversationDirs()` in session-routes.ts over `claudeProjectsDir()`, as the Edit history panel and the relaunch pin do, #701): a relocated folder is probed bounded first, and a missing one reads as no transcript, never as a reason to look under `~/.claude`. ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir) - **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort ` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts` - **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts` - **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model `, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3f56d6707..87bfa961f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -149,7 +149,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. ### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR -**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts`, the response-viewer routes and Read My Mind capture all hardcode — those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects /projects`), keeping credentials separate while the watchers keep working. +**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts` and Read My Mind capture all hardcode: those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects /projects`), keeping credentials separate while the watchers keep working. The response viewer is no longer one of them (#701): `claudeConversationDirs()` in `session-routes.ts` gives `lastResponseFor` (the viewer, `codeman agent read`, the Discord bridge's last message) and the rename's `/resume` title the folder the session's claude really writes to, `/projects` and `/history.jsonl`, resolved by `claudeProjectsDir()` like the relaunch pin and the Edit history panel: the session's own override, then the server's `CLAUDE_CONFIG_DIR` (a pane inherits it through tmux), then `~/.claude`. ⚠️ Only a LOCAL session reads its override: in a Docker case or on a remote host the value names a folder over there. ⚠️ A relocated folder is a user-chosen path, so it is probed bounded first (#516), and anything but a directory reads as "no transcript": never fall back to `~/.claude`, where a file of the same name is another account's conversation. The default folder is not probed, so that path is unchanged. Tests: `test/routes/session-routes-claude-last-response.test.ts` (relocated folder block), `test/routes/session-name-routes.test.ts`. ⚠️ **As of the custom-model endpoint feature, `CLAUDE_CONFIG_DIR` is ALSO admin-only in multi-user mode**, which is a change to the above rather than a restatement of it. It joined claude's `privilegedEnvKeys` (`stock.ts`) alongside `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, because the rule that every traffic-redirecting var that feature can inject must be listed there is worth keeping literally true. `privilegedEnvKeys` has exactly one consumer, `ownerClampedEnvKeys()` in `src/session-env-clamp.ts`, which feeds the generic `envOverrides` clamp on `POST /api/sessions`, `POST /api/quick-start` and reboot-restore. So for a non-granted owner two things now follow: the key cannot be set through `envOverrides` at all, and an ALREADY-PERSISTED one is stripped on reboot-restore, which silently moves that session back to the default Claude account. That second consequence is the one to watch, since it turns a working per-client setup into a wrong-account one across a host reboot with no error anywhere. `session-env-clamp.ts`'s own fileoverview used to state the opposite invariant (that claude's privileged keys are the five `ANTHROPIC_*` names, so a persisted record cannot carry a clamped key) and was corrected when this landed; a claude clamp test now pins the behaviour next to the deepseek and omp ones. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index cb2d0b687..a9c5ed960 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -110,9 +110,10 @@ The defaults you will care about, all under **App Settings**: **Separate Claude accounts per session.** Set `CLAUDE_CONFIG_DIR` in a session's environment overrides to point it at a different Claude config directory, which is how you run one session on a client's subscription and another on your own. One caveat: a relocated config -directory writes transcripts outside `~/.claude/projects`, which blinds the response viewer, -subagent windows, ultracode panel, and Read My Mind for that session. Symlink `projects` -back into the shared tree to keep them working: +directory writes transcripts outside `~/.claude/projects`, which blinds subagent windows, +the ultracode panel and Read My Mind for that session (the response viewer and the Edit +history panel read the relocated directory). Symlink `projects` back into the shared tree +to keep them working: ```bash ln -s ~/.claude/projects /projects diff --git a/docs/wiki/Watching-Agents-Work.md b/docs/wiki/Watching-Agents-Work.md index f43022841..c855fe92c 100644 --- a/docs/wiki/Watching-Agents-Work.md +++ b/docs/wiki/Watching-Agents-Work.md @@ -108,9 +108,9 @@ are not reading. - **A session pointed at a relocated Claude config directory goes blind here.** Transcripts written outside `~/.claude/projects` are invisible to the watchers, so subagent windows, - the ultracode panel, the response viewer, and Read My Mind all stop working for that - session. Symlink `projects` back into the shared tree to fix it. See - [Agent CLIs](Agent-CLIs). + the ultracode panel and Read My Mind stop working for that session. Symlink `projects` + back into the shared tree to fix it. The response viewer reads the session's own + directory and keeps working. See [Agent CLIs](Agent-CLIs). - **Closing a window does not cancel the agent.** Nothing on this page controls agents; it observes them. - **Windows are opt-in for ultracode, automatic for subagents.** diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d229e3dc7..505527d92 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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({ maxSize: 1024 }); async function resolveActiveClaudeSessionIdFromHistory( session: Session, - projectsDir: string + projectsDir: string, + historyPath: string ): Promise { // 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: `/projects` and + * `/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 /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) { diff --git a/test/routes/session-name-routes.test.ts b/test/routes/session-name-routes.test.ts index 5bdc53e71..4410604e4 100644 --- a/test/routes/session-name-routes.test.ts +++ b/test/routes/session-name-routes.test.ts @@ -12,7 +12,7 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -131,6 +131,35 @@ describe('PUT /api/sessions/:id/name', () => { expect(transcriptRows()).toHaveLength(before + 1); }); + it("finds the transcript in the session's relocated Claude config folder when no hook has named it (#701)", async () => { + // Before the first hook after a restart the watcher has no path, and the scan used to + // look under ~/.claude alone, so a session on another account kept its old /resume title. + const configDir = join(transcriptDir, 'claude-clients', 'acme'); + const projectDir = join(configDir, 'projects', '-tmp'); + mkdirSync(projectDir, { recursive: true }); + const relocatedPath = join(projectDir, `${session.id}.jsonl`); + writeFileSync(relocatedPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })}\n`); + const hookPath = harness.ctx.getTranscriptPath; + (harness.ctx as Record).getTranscriptPath = () => null; + Object.defineProperty(session, 'claudeConfigDir', { configurable: true, value: () => configDir }); + try { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${session.id}/name`, + payload: { name: 'client work' }, + }); + expect(res.statusCode).toBe(200); + const rows = readFileSync(relocatedPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + expect(rows.at(-1)).toEqual({ type: 'custom-title', customTitle: 'client work', sessionId: session.id }); + } finally { + delete (session as unknown as Record).claudeConfigDir; + (harness.ctx as Record).getTranscriptPath = hookPath; + } + }); + it('writes no title row for a docker session, whose transcript lives in the container', async () => { const before = transcriptRows().length; Object.defineProperty(session, 'docker', { configurable: true, get: () => ({ caseName: 'c' }) }); diff --git a/test/routes/session-routes-claude-last-response.test.ts b/test/routes/session-routes-claude-last-response.test.ts index 0b1d77cda..fcf522531 100644 --- a/test/routes/session-routes-claude-last-response.test.ts +++ b/test/routes/session-routes-claude-last-response.test.ts @@ -598,3 +598,141 @@ describe('GET /api/sessions/:id/last-response (claude conversation pinning)', () expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation'); }); }); + +describe('GET /api/sessions/:id/last-response (relocated Claude config folder, #701)', () => { + let harness: LocalHarness; + let testHome: string; + let relocated: string; + let previousHome: string | undefined; + let previousConfigDir: string | undefined; + const WORKDIR = '/workspace'; + const NOW = 1_770_000_000_000; + + beforeEach(async () => { + testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-relocated-')); + relocated = join(testHome, 'claude-clients', 'acme'); + previousHome = process.env.HOME; + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.HOME = testHome; + delete process.env.CLAUDE_CONFIG_DIR; + harness = await createEnvelopeHarness(); + }); + + afterEach(async () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + rmSync(testHome, { recursive: true, force: true }); + await harness.app.close(); + }); + + /** A one-answer transcript under `/projects`, stamped at `mtimeMs`. */ + function writeTranscript(configDir: string, conversationId: string, text: string, mtimeMs = NOW - 60_000): void { + const projectDir = join(configDir, 'projects', '-workspace'); + mkdirSync(projectDir, { recursive: true }); + const path = join(projectDir, `${conversationId}.jsonl`); + writeFileSync( + path, + JSON.stringify({ + type: 'assistant', + timestamp: new Date(mtimeMs).toISOString(), + message: { content: [{ type: 'text', text }] }, + }) + ); + utimesSync(path, mtimeMs / 1000, mtimeMs / 1000); + } + + function writeHistory(configDir: string, sessionId: string, timestamp: number): void { + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'history.jsonl'), + JSON.stringify({ display: 'prompt', project: WORKDIR, sessionId, timestamp }) + ); + } + + /** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */ + function addPane(conversationId: string, opts: { configDir?: string; lastSubmitAt?: number; docker?: boolean } = {}) { + const base = harness.ctx._session; + const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & { + claudeSessionId: string; + lastSubmitAt: number; + claudeSessionIdIsFirstHand: boolean; + claudeConfigDirOverride: string | undefined; + adoptClaudeSessionId: ReturnType; + }; + Object.assign(pane, base, { + id: 'pane-1', + mode: 'claude', + workingDir: WORKDIR, + docker: opts.docker ? { containerName: 'codeman-case-acme' } : undefined, + }); + pane.claudeSessionId = conversationId; + pane.lastSubmitAt = opts.lastSubmitAt ?? 0; + pane.claudeSessionIdIsFirstHand = false; + pane.claudeConfigDirOverride = opts.configDir; + pane.adoptClaudeSessionId = vi.fn((newId: string) => { + pane.claudeSessionId = newId; + }); + harness.ctx.sessions.set(pane.id, pane); + return pane; + } + + async function getLastResponse(query = '') { + const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/pane-1/last-response${query}` }); + return JSON.parse(response.body).data as { text: string; messages?: unknown[] }; + } + + it("reads the answer from the session's own config folder", async () => { + addPane('conversation-a', { configDir: relocated }); + writeTranscript(relocated, 'conversation-a', 'the answer in the relocated folder'); + + expect((await getLastResponse()).text).toBe('the answer in the relocated folder'); + expect((await getLastResponse('?context=full')).messages).toHaveLength(1); + }); + + it("reads the server's own CLAUDE_CONFIG_DIR for a session without an override", async () => { + process.env.CLAUDE_CONFIG_DIR = relocated; + addPane('conversation-a'); + writeTranscript(relocated, 'conversation-a', 'the answer in the server folder'); + + expect((await getLastResponse()).text).toBe('the answer in the server folder'); + }); + + it('never shows a conversation of the same id from ~/.claude instead', async () => { + const home = join(testHome, '.claude'); + writeTranscript(home, 'conversation-a', 'an answer from another account'); + + // The relocated folder has conversations, but not this one. + addPane('conversation-a', { configDir: relocated }); + writeTranscript(relocated, 'another-conversation', 'not this pane'); + expect(await getLastResponse('?context=turn')).toEqual({ text: '', timestamp: '', messages: [] }); + + // And a relocated folder that is not there at all. + addPane('conversation-a', { configDir: join(testHome, 'not-created-yet') }); + expect(await getLastResponse()).toEqual({ text: '', timestamp: '' }); + }); + + it("follows /clear through the relocated folder's history, not the one under ~/.claude", async () => { + const pane = addPane('before-clear', { configDir: relocated, lastSubmitAt: NOW }); + writeTranscript(relocated, 'before-clear', 'answer before clear', NOW - 60_000); + writeTranscript(relocated, 'after-clear', 'answer after clear', NOW + 500); + writeHistory(relocated, 'after-clear', NOW + 120); + // The default account typed in the same folder at the same moment. + const home = join(testHome, '.claude'); + writeTranscript(home, 'default-account', 'an answer from the default account', NOW + 500); + writeHistory(home, 'default-account', NOW + 100); + + expect((await getLastResponse()).text).toBe('answer after clear'); + expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear'); + }); + + it("keeps a Docker session off its override, which names a folder inside the container", async () => { + const home = join(testHome, '.claude'); + writeTranscript(home, 'conversation-a', 'what the host folder holds'); + writeTranscript(relocated, 'conversation-a', 'a host folder that only shares the path'); + addPane('conversation-a', { configDir: relocated, docker: true }); + + expect((await getLastResponse()).text).toBe('what the host folder holds'); + }); +});