mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-12 18:19:33 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa4815b465 |
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user