Compare commits

..
Author SHA1 Message Date
Codeman maintainer fa4815b465 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>
2026-11-12 14:39:13 +01:00
7 changed files with 221 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -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 `<case>/.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 <configDir>/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 `<case>/.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 <configDir>/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 <level>` 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 `<case>/.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 <id>`, 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).
+1 -1
View File
@@ -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 <configDir>/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 <configDir>/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, `<config dir>/projects` and `<config dir>/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.
+4 -3
View File
@@ -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 <configDir>/projects
+3 -3
View File
@@ -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.**
+44 -11
View File
@@ -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) {
+30 -1
View File
@@ -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<string, unknown>).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<string, unknown>);
expect(rows.at(-1)).toEqual({ type: 'custom-title', customTitle: 'client work', sessionId: session.id });
} finally {
delete (session as unknown as Record<string, unknown>).claudeConfigDir;
(harness.ctx as Record<string, unknown>).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' }) });
@@ -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 `<configDir>/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<typeof vi.fn>;
};
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');
});
});