mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a93c871f | ||
|
|
46b85fa6d0 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Files panel: when a session's agent moves into a git worktree part way through (Claude Code's EnterWorktree), the panel now lists that worktree instead of the main checkout, follows the agent back out, and shows a strip that names the worktree with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder as before.
|
||||
@@ -21,9 +21,7 @@ permissions:
|
||||
jobs:
|
||||
browser:
|
||||
name: Playwright browser suite
|
||||
# Pinned, not ubuntu-latest: that moved to 26.04 on 2026-11-05 and the locked Playwright 1.60.0
|
||||
# refuses to install there (support starts at 1.61.0). Unpin when Playwright is bumped.
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
|
||||
@@ -185,7 +185,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
|
||||
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path, which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
|
||||
|
||||
@@ -238,6 +238,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ Only a folder strictly below `workingDir` is ever an answer, which is the whole security argument: the walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
|
||||
|
||||
@@ -638,7 +638,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
|
||||
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, a path, which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
|
||||
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
|
||||
|
||||
|
||||
@@ -535,19 +535,13 @@ id, model and injected key NAMES are persisted, the values are re-derived
|
||||
from the endpoint store on recovery, and the pane keeps running against the
|
||||
endpoint in between because tmux retains its environment.
|
||||
|
||||
A value the session set itself under an injected key comes back when the
|
||||
selection goes, on a clear or a switch to another endpoint. That is what
|
||||
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
|
||||
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
|
||||
|
||||
⚠️ Across a Codeman restart only the home directory comes back. The
|
||||
selection overwrote the tmux `setenv` that held it, so its record in
|
||||
`state.json` keeps that one path (`ownEnv`), and a non-granted owner loses it
|
||||
at recovery like any other privileged override. Any other own value can be a
|
||||
credential and stays in memory: a session that set its own `GEMINI_API_KEY`,
|
||||
picked an endpoint and then outlived a restart falls back to the server's key
|
||||
on clear. A selection saved by an older build has no such record, so its first
|
||||
clear still falls back to the server's default.
|
||||
⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one
|
||||
of the names claude's selection injects — so a session that ALSO had
|
||||
`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the
|
||||
per-client-account case) loses that override on clear too, and silently
|
||||
falls back to the server's default Claude account. If you route a session
|
||||
to a specific account this way, re-apply the override after clearing a
|
||||
custom-model selection from it.
|
||||
|
||||
**New sessions always default back to the harness's native backend.** A
|
||||
custom-endpoint selection is a per-session choice, never a sticky global
|
||||
|
||||
@@ -39,6 +39,19 @@ name.
|
||||
|
||||
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
|
||||
|
||||
## When an agent moves into a worktree
|
||||
|
||||
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
|
||||
and the session's tab stays on the case. While the session's pane is in a worktree inside the
|
||||
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
|
||||
editing. A strip under the panel's title names the worktree, and its **Session folder** button
|
||||
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
|
||||
in and back out on its own while it is open.
|
||||
|
||||
A worktree outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
|
||||
only serves files inside the session's folder. Open such a worktree as a case of its own with
|
||||
Add Case > Link Existing.
|
||||
|
||||
## Removing one
|
||||
|
||||
Two ways, and both use `git worktree remove` **without** `--force`:
|
||||
|
||||
@@ -6,8 +6,9 @@ until you get home.
|
||||
|
||||
## The File Viewer
|
||||
|
||||
A panel that browses the active session's working directory. Its header button is on by
|
||||
default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
||||
A panel that browses the active session's working directory, or the git worktree its agent
|
||||
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
|
||||
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
||||
|
||||
It renders what it can:
|
||||
|
||||
|
||||
Generated
+10
-28
@@ -63,7 +63,7 @@
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.64.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.4.0",
|
||||
@@ -9736,50 +9736,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@
|
||||
"exceljs": "4.4.0",
|
||||
"fflate": "0.8.3",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.64.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.4.0",
|
||||
|
||||
@@ -301,8 +301,6 @@ export interface AppliedCustomModel {
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
/** The env key among `envKeys` that moves the CLI's home, see `CustomModelBookkeeping`. */
|
||||
homeEnvVar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,16 +360,9 @@ export function applyCustomModelInjection(
|
||||
envKeys: Object.keys(envOverrides),
|
||||
configDir,
|
||||
launchModel: injection.launchModel,
|
||||
homeEnvVar: injection.configDirVar,
|
||||
};
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
|
||||
return {
|
||||
envOverrides,
|
||||
envKeys: Object.keys(envOverrides),
|
||||
configDir,
|
||||
launchModel: injection.launchModel,
|
||||
homeEnvVar: injection.dirEnvVar,
|
||||
};
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Which linked git worktree a session's pane is in.
|
||||
*
|
||||
* Claude Code's worktree feature moves the running `claude` process into
|
||||
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
|
||||
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
|
||||
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
|
||||
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
|
||||
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
|
||||
* answers the narrower question: is the pane inside a linked worktree, and which one.
|
||||
*
|
||||
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
|
||||
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
|
||||
* changes, and a file read starts no process, no repository config and no filter.
|
||||
*
|
||||
* Only a folder at or below `workingDir` is ever an answer, and that is the whole security
|
||||
* argument: the file routes already serve everything under `workingDir`, so following a pane
|
||||
* there widens nothing, and a planted `.git` file can only move where the listing STARTS inside
|
||||
* that same folder. A worktree outside it (`git worktree add ../feature`) stays unfollowed, since
|
||||
* listing it would need a second confinement root. Nothing here is a confinement decision: the
|
||||
* routes keep judging every path against `workingDir`.
|
||||
*
|
||||
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
|
||||
* `unknown` answer stops the walk rather than being read as "no `.git` here".
|
||||
*
|
||||
* @module live-worktree
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
/** A linked worktree a session's pane is in. */
|
||||
export interface LiveWorktree {
|
||||
/** The worktree folder's name, for display. */
|
||||
name: string;
|
||||
/** Absolute path, spelled like the session's `workingDir`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** More than any real `.git` file. It is read only to recognise the first line. */
|
||||
const MAX_DOT_GIT_FILE_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Whether the text of a `.git` file names a linked worktree's git dir
|
||||
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
|
||||
* The path may be relative: `git worktree add --relative-paths` writes it that way.
|
||||
*/
|
||||
export function isLinkedWorktreeGitFile(text: string): boolean {
|
||||
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
if (!gitDir) return false;
|
||||
const segments = gitDir.split('/').filter(Boolean);
|
||||
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
|
||||
}
|
||||
|
||||
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
|
||||
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
|
||||
const dotGit = join(dir, '.git');
|
||||
const kind = await probePathKind(dotGit);
|
||||
if (kind === 'absent') return 'none';
|
||||
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
|
||||
if (kind !== 'file') return 'stop';
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
// lstat, so a `.git` that is a symlink or a FIFO is refused before anything opens it:
|
||||
// opening a FIFO waits for a writer and would hold a threadpool worker for good.
|
||||
if (!(await fs.lstat(dotGit)).isFile()) return 'stop';
|
||||
handle = await fs.open(dotGit, 'r');
|
||||
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
|
||||
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
|
||||
} catch {
|
||||
return 'stop';
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
|
||||
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
|
||||
* read. `workingDir` itself is never an answer: a session started in a worktree is already
|
||||
* listing it. Never throws.
|
||||
*/
|
||||
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
|
||||
if (!cwd?.startsWith('/')) return undefined;
|
||||
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
|
||||
const under = root === '/' ? '/' : `${root}/`;
|
||||
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
|
||||
const found = await classifyDotGit(dir);
|
||||
if (found === 'worktree') return { name: basename(dir), path: dir };
|
||||
if (found === 'stop') return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
* re-resolves the grant at the moment it builds a session, for the same reason.
|
||||
*
|
||||
* @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
|
||||
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes, web/server
|
||||
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
|
||||
*
|
||||
* @module session-env-clamp
|
||||
*/
|
||||
|
||||
+28
-45
@@ -73,6 +73,7 @@ import type { GitFetchStatus } from './git-fetch.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import type { LiveWorktree } from './live-worktree.js';
|
||||
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -766,6 +767,8 @@ export class Session extends EventEmitter {
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _paneCwd: string | undefined;
|
||||
/** See {@link liveWorktree}. */
|
||||
private _liveWorktree: LiveWorktree | undefined;
|
||||
/**
|
||||
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
|
||||
* process cwd, always the resolved path) can be put back into `workingDir`'s
|
||||
@@ -1576,6 +1579,22 @@ export class Session extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
|
||||
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
|
||||
* never a confinement root, like `paneCwd`: it is always a folder the file routes already serve.
|
||||
*/
|
||||
get liveWorktree(): LiveWorktree | undefined {
|
||||
return this._liveWorktree;
|
||||
}
|
||||
|
||||
/** Record the result of a worktree lookup. Returns whether it CHANGED, so the caller broadcasts only then. */
|
||||
setLiveWorktree(next: LiveWorktree | undefined): boolean {
|
||||
if (next?.path === this._liveWorktree?.path) return false;
|
||||
this._liveWorktree = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `workingDir` without a trailing separator (the root stays `/`). */
|
||||
private _workspaceSpelling(): string {
|
||||
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
|
||||
@@ -1994,36 +2013,21 @@ export class Session extends EventEmitter {
|
||||
|
||||
/**
|
||||
* The full selection incl. bookkeeping, for state.json ONLY (`__customModel`, the
|
||||
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no injected env
|
||||
* values, so nothing secret lands on disk; recovery re-derives them from the endpoint store.
|
||||
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no env values,
|
||||
* so nothing secret lands on disk; recovery re-derives them from the endpoint store.
|
||||
* Without this a Codeman restart left the pane on the custom endpoint (tmux keeps
|
||||
* its `setenv`s) while `customModel` came back undefined, so the state was wrong and
|
||||
* clearing had nothing to unset. Must NOT be included in any API-bound serializer.
|
||||
*
|
||||
* The one value it does carry is the session's own home directory (`ownEnv`, narrowed to
|
||||
* `homeEnvVar`), a path like `configDir` beside it: the selection overwrote the tmux
|
||||
* `setenv` that held it, so without this a clear after a restart would lose it. Any other
|
||||
* entry of `ownEnv` may be a credential and stays in memory.
|
||||
*/
|
||||
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
|
||||
if (!this._customModel) return undefined;
|
||||
const { ownEnv, ...selection } = this._customModel;
|
||||
const { homeEnvVar } = selection;
|
||||
const ownHome = homeEnvVar ? ownEnv?.[homeEnvVar] : undefined;
|
||||
return {
|
||||
...selection,
|
||||
envKeys: [...selection.envKeys],
|
||||
...(homeEnvVar && ownHome ? { ownEnv: { [homeEnvVar]: ownHome } } : {}),
|
||||
};
|
||||
return this._customModel ? { ...this._customModel, envKeys: [...this._customModel.envKeys] } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this session's custom-model selection and merge the endpoint's injected env
|
||||
* vars into `_envOverrides` — first UNDOING whatever the previous selection injected
|
||||
* (removing exactly those env keys, and putting back a value the session had set under
|
||||
* one itself), so switching endpoints, or clearing back to the harness's native cloud
|
||||
* default, never leaves a stale key behind and never loses the session's own `DSH_HOME`
|
||||
* or `CLAUDE_CONFIG_DIR`. Synchronous and
|
||||
* (removing exactly those env keys), so switching endpoints, or clearing back to the
|
||||
* harness's native cloud default, never leaves a stale key behind. Synchronous and
|
||||
* side-effect-free beyond mutating state, matching `setNice`/`setColor` above — this
|
||||
* class does no file IO, so it reports the PREVIOUS `configDir` (if any) for the
|
||||
* caller to clean up on disk (custom-model-injection.ts's configDir kind).
|
||||
@@ -2033,12 +2037,7 @@ export class Session extends EventEmitter {
|
||||
* `_buildRespawnPaneOptions().unsetEnvKeys`): the tmux session inherits every
|
||||
* `setenv` into `respawn-pane`, so dropping them from the map alone would relaunch
|
||||
* the CLI still pointed at the old endpoint — and for the `configDir` kinds, at a
|
||||
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted. A key put back stays out
|
||||
* of that queue: `applyEnvOverrides()` writes its value over the injected one.
|
||||
*
|
||||
* `next.ownEnv` is for recovery, which states the session's own values itself: after a
|
||||
* restart `_envOverrides` holds what the previous run injected (`CLAUDE_CONFIG_DIR` is
|
||||
* persisted), and reading those as the session's own would pin it to a deleted directory.
|
||||
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted.
|
||||
*/
|
||||
setCustomModel(
|
||||
next: CustomModelBookkeeping | undefined,
|
||||
@@ -2047,30 +2046,13 @@ export class Session extends EventEmitter {
|
||||
const previousConfigDir = this._customModel?.configDir;
|
||||
const removedEnvKeys: string[] = [];
|
||||
if (this._customModel) {
|
||||
const previousOwnEnv = this._customModel.ownEnv ?? {};
|
||||
for (const key of this._customModel.envKeys) {
|
||||
if (this._envOverrides) delete this._envOverrides[key];
|
||||
removedEnvKeys.push(key);
|
||||
const own = previousOwnEnv[key];
|
||||
if (own) {
|
||||
this._envOverrides = { ...(this._envOverrides ?? {}), [key]: own };
|
||||
this._pendingEnvUnsets.delete(key);
|
||||
} else {
|
||||
this._pendingEnvUnsets.add(key);
|
||||
}
|
||||
this._pendingEnvUnsets.add(key);
|
||||
}
|
||||
}
|
||||
// Read after the undo, so a switch between endpoints carries the own values across. An
|
||||
// empty value is skipped: `applyEnvOverrides()` never set one, so it has nothing to
|
||||
// restore, and the injected key still has to be unset.
|
||||
const ownEnv: Record<string, string> = { ...next?.ownEnv };
|
||||
if (!next?.ownEnv) {
|
||||
for (const key of Object.keys(envOverrides ?? {})) {
|
||||
const own = this._envOverrides?.[key];
|
||||
if (own) ownEnv[key] = own;
|
||||
}
|
||||
}
|
||||
this._customModel = next ? { ...next, envKeys: [...next.envKeys], ownEnv } : undefined;
|
||||
this._customModel = next ? { ...next, envKeys: [...next.envKeys] } : undefined;
|
||||
if (envOverrides && Object.keys(envOverrides).length > 0) {
|
||||
this._envOverrides = { ...(this._envOverrides ?? {}), ...envOverrides };
|
||||
// A key the new selection sets again does not need an unset (applyEnvOverrides
|
||||
@@ -2337,6 +2319,7 @@ export class Session extends EventEmitter {
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
liveWorktree: this._liveWorktree,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
|
||||
+10
-13
@@ -34,6 +34,7 @@
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
import type { GitFetchStatus } from '../git-fetch.js';
|
||||
import type { LiveWorktree } from '../live-worktree.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -702,19 +703,6 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||
configDir?: string;
|
||||
/** Value forced onto the CLI's `model` launch param (pi/omp `custom/<id>`, grok's block name). */
|
||||
launchModel?: string;
|
||||
/**
|
||||
* The variable that moves the CLI's home, when the recipe redirects one (`DSH_HOME`,
|
||||
* `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`). Its value is a path, which is what
|
||||
* lets {@link CustomModelBookkeeping.ownEnv} keep that one entry on disk.
|
||||
*/
|
||||
homeEnvVar?: string;
|
||||
/**
|
||||
* What the session itself had set under keys the selection replaced, put back when the
|
||||
* selection goes. In memory this can hold a credential (a session's own `GEMINI_API_KEY`),
|
||||
* so `Session.getCustomModelForPersist()` writes `homeEnvVar`'s entry and nothing else:
|
||||
* tmux keeps only the injected value, so state.json is the one copy left after a restart.
|
||||
*/
|
||||
ownEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -860,6 +848,15 @@ export interface SessionState {
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
|
||||
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
|
||||
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
|
||||
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
|
||||
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
|
||||
* only starts its listing here.
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
|
||||
@@ -2458,6 +2458,8 @@ class CodemanApp {
|
||||
}
|
||||
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
|
||||
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
|
||||
// The agent moved into or out of a git worktree while the Files panel is open (#765).
|
||||
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
|
||||
// Update parentSessionName for any subagents belonging to this session
|
||||
// (fixes stale name display after session rename)
|
||||
this.updateSubagentParentNames(session.id);
|
||||
|
||||
@@ -1133,6 +1133,9 @@
|
||||
'Filter by date range': '按日期范围筛选',
|
||||
'Filter by session status': '按会话状态筛选',
|
||||
'Filter files...': '筛选文件…',
|
||||
Worktree: '工作树',
|
||||
'Session folder': '会话文件夹',
|
||||
'Back to worktree': '返回工作树',
|
||||
'Any status': '任意状态',
|
||||
'Any time': '任意时间',
|
||||
'Last hour': '最近一小时',
|
||||
|
||||
@@ -617,6 +617,11 @@
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-browser-root" id="fileBrowserRoot" hidden>
|
||||
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
|
||||
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
|
||||
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()"></button>
|
||||
</div>
|
||||
<div class="file-browser-search">
|
||||
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
|
||||
</div>
|
||||
|
||||
@@ -3282,6 +3282,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
matches: [],
|
||||
deferredDirectoryTarget: null,
|
||||
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
|
||||
// The person asked for the session folder while the agent is in a worktree (#765).
|
||||
sessionRootPinned: false,
|
||||
};
|
||||
}
|
||||
return this._fileBrowserState;
|
||||
@@ -3304,6 +3306,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3314,6 +3317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
|
||||
const panel = this.$?.('fileBrowserPanel');
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
@@ -3346,6 +3350,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3356,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
if (treeEl) treeEl.innerHTML = '';
|
||||
const statusEl = this.$?.('fileBrowserStatus');
|
||||
@@ -3448,6 +3454,60 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
this.renderFileBrowserTree(normalState.sessionId);
|
||||
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||
this._renderFileBrowserRoot(normalState.data);
|
||||
},
|
||||
|
||||
// The strip under the panel header, present only while the session's pane is in a linked
|
||||
// worktree (#765): it says which of the two folders is listed and switches between them.
|
||||
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
|
||||
// than the session's state a moment later.
|
||||
_renderFileBrowserRoot(data) {
|
||||
const strip = this.$?.('fileBrowserRoot');
|
||||
if (!strip) return;
|
||||
const worktree = data?.worktree;
|
||||
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
|
||||
strip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const inWorktree = data.root === worktree.path;
|
||||
const label = this.$?.('fileBrowserRootLabel');
|
||||
const name = this.$?.('fileBrowserRootName');
|
||||
const button = this.$?.('fileBrowserRootSwitch');
|
||||
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
|
||||
if (name) name.textContent = inWorktree ? worktree.name : '';
|
||||
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
|
||||
strip.title = typeof data.root === 'string' ? data.root : '';
|
||||
strip.hidden = false;
|
||||
},
|
||||
|
||||
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
|
||||
// a worktree, so the panel always asks unless the person chose the session folder.
|
||||
_fileBrowserRootParam() {
|
||||
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
|
||||
},
|
||||
|
||||
toggleFileBrowserRoot() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
state.sessionRootPinned = !state.sessionRootPinned;
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Called on every session update of the active session. Compares with what the listing on
|
||||
// screen reported rather than with the previous update, so a load already in flight when the
|
||||
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
|
||||
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
|
||||
_followFileBrowserWorktree(session) {
|
||||
const state = this._fileBrowserState;
|
||||
if (!state || state.ownerSessionId !== session.id) return;
|
||||
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
|
||||
const normal = state.normalState;
|
||||
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
|
||||
const live = session.liveWorktree?.path ?? null;
|
||||
const listed = normal.data?.worktree?.path ?? null;
|
||||
if (live === listed) return;
|
||||
// The pin was about the worktree the agent has just left.
|
||||
state.sessionRootPinned = false;
|
||||
this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
_validateFileBrowserTreeEnvelope(result) {
|
||||
@@ -3552,14 +3612,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async toggleFileBrowserHidden() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
|
||||
// root), then re-run the search in the box against the new tree.
|
||||
async _reloadFileBrowserKeepingQuery() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
@@ -3633,7 +3699,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
const result = await res.json();
|
||||
@@ -3820,6 +3886,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showHidden: this.fileBrowserShowHidden === true,
|
||||
rawInput,
|
||||
query,
|
||||
rootParam: this._fileBrowserRootParam(),
|
||||
timer: null,
|
||||
};
|
||||
state.view = 'search-pending';
|
||||
@@ -3834,7 +3901,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
request.timer = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const result = await res.json();
|
||||
|
||||
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-browser-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-root[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser-root-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-browser-root-switch {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-browser-root-switch:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-search {
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -1595,12 +1595,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
|
||||
const { depth, showHidden, q, root } = req.query as {
|
||||
depth?: string;
|
||||
showHidden?: string;
|
||||
q?: string;
|
||||
root?: string;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
const workingDir = session.workingDir;
|
||||
// `root=live` starts the walk at the linked worktree the pane is in (#765). That folder is
|
||||
// inside `workingDir` (`live-worktree.ts`), and every `path` below stays relative to
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
const liveWorktree = session.liveWorktree;
|
||||
const walkRoot =
|
||||
root === 'live' && liveWorktree && (await probePathKind(liveWorktree.path, { pastCap: true })) === 'directory'
|
||||
? liveWorktree.path
|
||||
: workingDir;
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
@@ -1676,17 +1691,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
const listedPath = relative(walkRoot, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
}
|
||||
// Always recurse, even when this directory does not match.
|
||||
await searchDirectory(fullPath, currentDepth + 1);
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
@@ -1705,12 +1721,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
};
|
||||
|
||||
await searchDirectory(workingDir, 1);
|
||||
await searchDirectory(walkRoot, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1791,12 +1808,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
};
|
||||
|
||||
const tree = await scanDirectory(workingDir, 1);
|
||||
const tree = await scanDirectory(walkRoot, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
|
||||
@@ -1689,7 +1689,6 @@ export function registerSessionRoutes(
|
||||
envKeys: applied.envKeys,
|
||||
configDir: applied.configDir,
|
||||
launchModel: applied.launchModel,
|
||||
homeEnvVar: applied.homeEnvVar,
|
||||
},
|
||||
applied.envOverrides
|
||||
);
|
||||
@@ -4468,12 +4467,12 @@ export function registerSessionRoutes(
|
||||
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
|
||||
// a lighter version of them, since this is the same server-side authority reached a
|
||||
// different way, not a separate, less-checked path.
|
||||
//
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in): setCustomModel()
|
||||
// merges these over what the Session constructor took, which is how it learns which of
|
||||
// the caller's values the selection replaced. Handing the constructor the merged set
|
||||
// would hide them, and giving setCustomModel() that set would put
|
||||
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
|
||||
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
|
||||
// setCustomModel() bookkeeping must be given below. The Session constructor already
|
||||
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
|
||||
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
|
||||
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
|
||||
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
|
||||
let qsCustomModelLaunchModel: string | undefined;
|
||||
let qsCustomModelSessionId: string | undefined;
|
||||
@@ -4486,7 +4485,6 @@ export function registerSessionRoutes(
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
homeEnvVar?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (customModel) {
|
||||
@@ -4590,6 +4588,7 @@ export function registerSessionRoutes(
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
qsCustomModelBookkeeping = {
|
||||
@@ -4599,7 +4598,6 @@ export function registerSessionRoutes(
|
||||
envKeys: cmApplied.envKeys,
|
||||
configDir: cmApplied.configDir,
|
||||
launchModel: cmApplied.launchModel,
|
||||
homeEnvVar: cmApplied.homeEnvVar,
|
||||
};
|
||||
|
||||
// Actually kick off llama-swap's load now — see the dedicated apply route's own
|
||||
@@ -4632,7 +4630,7 @@ export function registerSessionRoutes(
|
||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
remote,
|
||||
@@ -4661,10 +4659,10 @@ export function registerSessionRoutes(
|
||||
const session = new Session(qsSessionOptions);
|
||||
|
||||
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
||||
// clear/switch calls, and merges the injected env, which the launch below reads along
|
||||
// with the launch-model config set above (piConfig/grokConfig/ompConfig.model). Nothing
|
||||
// has started yet, so this is never a restart: setCustomModel() is synchronous state,
|
||||
// no tmux IO of its own (see its own doc comment in session.ts).
|
||||
// clear/switch calls — the actual env vars and launch-model config are already part of
|
||||
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
|
||||
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
|
||||
// tmux IO of its own (see its own doc comment in session.ts).
|
||||
if (qsCustomModelBookkeeping) {
|
||||
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
||||
}
|
||||
|
||||
+21
-13
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { findLiveWorktree } from '../live-worktree.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
|
||||
@@ -104,7 +105,6 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { clampEnvOverridesForOwner } from '../session-env-clamp.js';
|
||||
import {
|
||||
applyWorkspaceHooks,
|
||||
pruneAgentSessionPreambles,
|
||||
@@ -1533,10 +1533,12 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
try {
|
||||
// Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
|
||||
// already fired once) and the session-env real-home lookup: the first apply already
|
||||
// linked this directory from the session's own home, and `linkRealHomeEntry()` keeps
|
||||
// what is there. Only a directory someone removed gets its links again, from the
|
||||
// SERVER's `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
// already fired once) and the session-env real-home lookup: the injected VALUES are
|
||||
// never persisted, so after a real restart the session can no longer carry its
|
||||
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
|
||||
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
|
||||
// apply in-process. Real-home resolution therefore falls back to the SERVER's
|
||||
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
|
||||
return applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
@@ -1558,25 +1560,18 @@ export class WebServer extends EventEmitter {
|
||||
* while the new recipe writes one, and clearing it later could then never delete the
|
||||
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
|
||||
* still carry `setenv`s from both, and a clear has to unset every one.
|
||||
*
|
||||
* `ownEnv` is always passed, empty for a record saved before it existed: `_envOverrides`
|
||||
* holds what the previous run injected, and only this record says what was the session's
|
||||
* own. It meets the owner's grant again, like every other env override rebuilt from disk.
|
||||
*/
|
||||
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
|
||||
const applied = await this._rebuildCustomModelEnv(session, saved);
|
||||
if (!applied) {
|
||||
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
|
||||
// is what a later clear needs.
|
||||
session.setCustomModel({ ...saved, ownEnv });
|
||||
session.setCustomModel(saved);
|
||||
return;
|
||||
}
|
||||
session.setCustomModel(
|
||||
{
|
||||
...saved,
|
||||
ownEnv,
|
||||
homeEnvVar: applied.homeEnvVar ?? saved.homeEnvVar,
|
||||
// Prefer the current recipe's dir; keep the saved one when the recipe writes none,
|
||||
// so a clear can still delete a directory an older recipe created.
|
||||
configDir: applied.configDir ?? saved.configDir,
|
||||
@@ -2973,6 +2968,7 @@ export class WebServer extends EventEmitter {
|
||||
// `undefined` here means an authoritative read listed no single directory
|
||||
// for the session (split into several panes, or gone from the socket).
|
||||
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
|
||||
if (paneCwdChanged) void this.refreshLiveWorktree(session);
|
||||
if (session.setPaneExit(getPaneExit(muxName))) {
|
||||
this.persistSessionState(session);
|
||||
} else if (!paneCwdChanged) {
|
||||
@@ -2983,6 +2979,18 @@ export class WebServer extends EventEmitter {
|
||||
this.closeCleanlyExitedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which linked worktree a session's pane moved into (#765). In the background, since
|
||||
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
|
||||
* A reading newer than the one this lookup started from owns the answer.
|
||||
*/
|
||||
private async refreshLiveWorktree(session: Session): Promise<void> {
|
||||
const cwd = session.paneCwd;
|
||||
const worktree = await findLiveWorktree(session.workingDir, cwd);
|
||||
if (session.paneCwd !== cwd) return;
|
||||
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
|
||||
* that clearing could never delete.
|
||||
*
|
||||
* Real-home resolution takes no session lookup on this path: the first apply's links are
|
||||
* still in the generated directory, and one someone removed links from the server's
|
||||
* `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s
|
||||
* comment. A session's own home comes back on clear from `ownEnv`, the last block here.
|
||||
* Real-home resolution is NOT recovered from the session's env overrides any more:
|
||||
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
|
||||
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
|
||||
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
|
||||
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
|
||||
*
|
||||
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
|
||||
* everything from the endpoint store and the Session it is handed), so it is driven off
|
||||
@@ -98,91 +99,4 @@ describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', ()
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual(saved);
|
||||
});
|
||||
|
||||
describe("a session's own value across a restart", () => {
|
||||
const OWN_HOME = '/home/u/my-dsh';
|
||||
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
|
||||
});
|
||||
|
||||
/** What a previous run left in state.json for a deepseek session on its own home. */
|
||||
function savedByPreviousRun(): CustomModelBookkeeping {
|
||||
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
|
||||
configs.push(previous.id);
|
||||
previous.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
configDir: customModelConfigDir(previous.id),
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
},
|
||||
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
|
||||
);
|
||||
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
|
||||
}
|
||||
|
||||
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, savedByPreviousRun());
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
||||
});
|
||||
|
||||
it('keeps it when the endpoint is gone', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), []);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
|
||||
await restoreCustomModel(session, savedByPreviousRun());
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
||||
});
|
||||
|
||||
it('never reads what the previous run injected as the session own value', async () => {
|
||||
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
|
||||
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
|
||||
// stays pinned to a directory the clear just deleted.
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
|
||||
});
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, {
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
||||
});
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
|
||||
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
|
||||
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
|
||||
// this record must not be a way around it.
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const saved = savedByPreviousRun();
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, saved);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @fileoverview Files panel following the agent into a git worktree (#765).
|
||||
*
|
||||
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
|
||||
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
|
||||
* why the follow compares with what the listing reported rather than with the previous update.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
|
||||
|
||||
function element() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPanel() {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route: the session's worktree, listed when the request asks for it.
|
||||
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.worktree;
|
||||
const data = {
|
||||
root: live ? server.worktree!.path : '/repo',
|
||||
...(server.worktree && { worktree: server.worktree }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
}
|
||||
|
||||
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
|
||||
hidden: elements.fileBrowserRoot.hidden,
|
||||
label: elements.fileBrowserRootLabel.textContent,
|
||||
name: elements.fileBrowserRootName.textContent,
|
||||
button: elements.fileBrowserRootSwitch.textContent,
|
||||
title: elements.fileBrowserRoot.title,
|
||||
});
|
||||
|
||||
describe('Files panel in a git worktree', () => {
|
||||
it('asks for the live root by default', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests[0]).toContain('root=live');
|
||||
});
|
||||
|
||||
it('shows no strip for a session in no worktree', async () => {
|
||||
const { app, elements } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('says which worktree is listed, and offers the session folder', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Worktree',
|
||||
name: 'probe',
|
||||
button: 'Session folder',
|
||||
title: WORKTREE.path,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to the session folder and back', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[1]).not.toContain('root=live');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Session folder',
|
||||
name: '',
|
||||
button: 'Back to worktree',
|
||||
title: '/repo',
|
||||
});
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[2]).toContain('root=live');
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('keeps the query in the box across the switch, and searches the new root', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._fileBrowserState.filter = 'main';
|
||||
elements.fileBrowserSearch.value = 'main';
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
|
||||
|
||||
const search = server.requests.find((url) => url.includes('q=main'))!;
|
||||
expect(search).not.toContain('root=live');
|
||||
});
|
||||
|
||||
it('follows the agent into a worktree while the panel is open', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
|
||||
server.worktree = WORKTREE;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
server.worktree = null;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1' });
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('settles: updates that agree with the listing reload nothing', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves another session and a closed panel alone', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
|
||||
elements.fileBrowserPanel.classList.toggle('visible', false);
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops the pin and the strip on a session switch', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
app._activateFileBrowserSession('sess-2');
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired up in the page', () => {
|
||||
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
|
||||
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
|
||||
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
|
||||
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
|
||||
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
|
||||
*
|
||||
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
|
||||
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
|
||||
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
|
||||
* file. Runs against a real filesystem, and one case against a worktree real git made.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
|
||||
|
||||
describe('isLinkedWorktreeGitFile', () => {
|
||||
it('accepts what git writes for a linked worktree, absolute or relative', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLiveWorktree', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds the worktree from its own root and from below it', async () => {
|
||||
const expected = { name: 'probe', path: worktree };
|
||||
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
|
||||
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
|
||||
});
|
||||
|
||||
it('answers the same for a workingDir spelled with a trailing slash', async () => {
|
||||
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('does not treat the session folder as a worktree of itself', async () => {
|
||||
writeFileSync(join(worktree, 'marker'), '');
|
||||
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
|
||||
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
|
||||
mkdirSync(join(repo, 'src'));
|
||||
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never answers outside the session folder', async () => {
|
||||
const outside = join(base, 'repo-feature');
|
||||
mkdirSync(outside);
|
||||
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
|
||||
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
|
||||
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops at a submodule and at a repository of its own', async () => {
|
||||
const submodule = join(repo, 'lib');
|
||||
mkdirSync(submodule);
|
||||
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
|
||||
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
|
||||
|
||||
const nested = join(worktree, 'vendored');
|
||||
mkdirSync(join(nested, '.git'), { recursive: true });
|
||||
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a .git that is not a regular file', async () => {
|
||||
const linked = join(repo, 'linked');
|
||||
mkdirSync(linked);
|
||||
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
|
||||
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
|
||||
|
||||
// A FIFO would block open() for good: the test would time out rather than fail.
|
||||
const piped = join(repo, 'piped');
|
||||
mkdirSync(piped);
|
||||
execFileSync('mkfifo', [join(piped, '.git')]);
|
||||
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers nothing without an absolute reading', async () => {
|
||||
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '')).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognises a worktree real git made', async () => {
|
||||
const real = join(base, 'real');
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
|
||||
cwd: real,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
mkdirSync(real);
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'init');
|
||||
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
|
||||
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
|
||||
name: 'made',
|
||||
path: join(real, '.claude', 'worktrees', 'made'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,11 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session, rebasePath } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
||||
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
|
||||
expect(session.toState().workingDir).toBe('/work/proj');
|
||||
});
|
||||
|
||||
describe('liveWorktree (#765)', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const inRepo = () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
const broadcast = vi.fn();
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
broadcast;
|
||||
mux.getPaneExit = () => undefined;
|
||||
return { mux, session, web, broadcast };
|
||||
};
|
||||
|
||||
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
|
||||
const { mux, session, web, broadcast } = inRepo();
|
||||
let dir = worktree;
|
||||
mux.getPaneCurrentPath = () => dir;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
||||
// What the browser receives, and what the file routes read: never the confinement root.
|
||||
const state = (
|
||||
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
||||
).getSessionStateWithRespawn(session);
|
||||
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
const broadcasts = broadcast.mock.calls.length;
|
||||
dir = repo;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
||||
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
|
||||
});
|
||||
|
||||
it('does nothing for a pane that has not moved', async () => {
|
||||
const { mux, session } = inRepo();
|
||||
mux.getPaneCurrentPath = () => repo;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
|
||||
mux.emit('paneExitsUpdated');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops an answer for a directory the pane has already left', async () => {
|
||||
const { web, session } = inRepo();
|
||||
session.setPaneCwd(worktree);
|
||||
const refresh = (
|
||||
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
|
||||
).refreshLiveWorktree.call(web, session);
|
||||
session.setPaneCwd(repo);
|
||||
await refresh;
|
||||
expect(session.liveWorktree).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
||||
const { mux, sessions } = build();
|
||||
const session = localMuxSession();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
|
||||
*
|
||||
* The listing starts at the worktree the pane is in, and every `path` stays relative to
|
||||
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
|
||||
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const PREFIX = '.claude/worktrees/probe';
|
||||
|
||||
describe('Files panel listing of a live worktree', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
const list = async (query = '') => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data;
|
||||
};
|
||||
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
|
||||
worktree = join(repo, PREFIX);
|
||||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src'), { recursive: true });
|
||||
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
|
||||
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: repo,
|
||||
liveWorktree: { name: 'probe', path: worktree },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists the worktree with paths relative to the session folder', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
|
||||
const data = await list('&root=live&showHidden=false');
|
||||
expect(data.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
|
||||
const data = await list();
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
});
|
||||
|
||||
it('answers exactly as before for a session in no worktree', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: undefined });
|
||||
const data = await list('&root=live');
|
||||
expect(data).toEqual(await list());
|
||||
expect(data.root).toBe(repo);
|
||||
expect('worktree' in data).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
|
||||
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('serves a listed path through the unchanged preview route', async () => {
|
||||
const path = `${PREFIX}/src/worktree-only.ts`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
expect(res.json().success).toBe(true);
|
||||
expect(res.json().data.content).toBe('worktree');
|
||||
});
|
||||
});
|
||||
@@ -92,35 +92,6 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
|
||||
safeRmHomeTree(CASES_DIR);
|
||||
});
|
||||
|
||||
it("learns the caller's own value under an injected key, so a clear can put it back", async () => {
|
||||
const own = join(getDataDir(), 'client-claude');
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-own-home',
|
||||
mode: 'claude',
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: own },
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
const isolatedDir = customModelConfigDir(sessionId);
|
||||
// It launches on the isolated directory, with the caller's value set aside rather
|
||||
// than recorded as the injected one.
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(isolatedDir);
|
||||
expect(session.getCustomModelForPersist()?.ownEnv).toEqual({ CLAUDE_CONFIG_DIR: own });
|
||||
|
||||
const clear = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/custom-model`,
|
||||
payload: { clear: true },
|
||||
});
|
||||
expect(clear.statusCode).toBe(200);
|
||||
expect(session.customModel).toBeUndefined();
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBe(own);
|
||||
expect(existsSync(isolatedDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('launches a claude session already pointed at the endpoint — no restart at all', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-claude',
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
* gated on that transcript existing, so these tests write one.
|
||||
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||
* that selects it, so the file was ignored.
|
||||
* 4. A value the session set itself under an injected key was overwritten, then deleted
|
||||
* with the injected one on clear: an own `DSH_HOME` fell back to the server's tree and
|
||||
* an own `CLAUDE_CONFIG_DIR` silently moved the session to the default Claude account.
|
||||
*
|
||||
* Drives a real `Session` against the in-memory tmux layer vitest substitutes,
|
||||
* spying on `respawnPane` to read the options the relaunch would get.
|
||||
@@ -256,104 +253,3 @@ describe('Codex custom endpoint model overrides', () => {
|
||||
expect(respawn.mock.calls[0][0].codexConfig).toEqual({ model: 'native-model', reasoningEffort: 'high' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a value the session set itself comes back with the selection gone', () => {
|
||||
const DSH_SELECTION = {
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
configDir: '/isolated/dsh',
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
};
|
||||
const DSH_ENV = { DSH_HOME: '/isolated/dsh', CODEMAN_CUSTOM_MODEL_API_KEY: 'k' };
|
||||
|
||||
it('puts an own DSH_HOME back on clear and sets it rather than unsetting it', async () => {
|
||||
const { session, respawn } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
expect(session.deepSeekHomeOverride).toBe('/home/u/my-dsh');
|
||||
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.envOverrides).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
||||
expect(options.unsetEnvKeys).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
|
||||
});
|
||||
|
||||
it('carries it across a switch between endpoints', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '/home/u/my-dsh' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
session.setCustomModel({ ...DSH_SELECTION, endpointId: 'other' }, DSH_ENV);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/isolated/dsh');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe('/home/u/my-dsh');
|
||||
});
|
||||
|
||||
it('keeps a claude session on its own account, the #255 override', async () => {
|
||||
const { session, respawn } = liveSession('claude', {
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: '/home/u/.claude-client', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: [...CLAUDE_KEYS, 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
||||
configDir: '/isolated/claude',
|
||||
homeEnvVar: 'CLAUDE_CONFIG_DIR',
|
||||
},
|
||||
{ ...claudeEnv, CLAUDE_CONFIG_DIR: '/isolated/claude', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' }
|
||||
);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.envOverrides).toEqual({
|
||||
CLAUDE_CONFIG_DIR: '/home/u/.claude-client',
|
||||
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '100000',
|
||||
});
|
||||
expect(options.unsetEnvKeys).toEqual(CLAUDE_KEYS);
|
||||
});
|
||||
|
||||
it('still unsets a key whose own value was empty, since tmux never held that value', () => {
|
||||
const { session } = liveSession('deepseek', { envOverrides: { DSH_HOME: '' } });
|
||||
session.setCustomModel(DSH_SELECTION, DSH_ENV);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes the own home to state.json and no other own value', () => {
|
||||
const { session } = liveSession('gemini', {
|
||||
envOverrides: { GEMINI_API_KEY: 'own-secret', DSH_HOME: '/home/u/my-dsh' },
|
||||
});
|
||||
session.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'q',
|
||||
envKeys: ['GEMINI_API_KEY', 'DSH_HOME'],
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
},
|
||||
{ GEMINI_API_KEY: 'endpoint-key', DSH_HOME: '/isolated/dsh' }
|
||||
);
|
||||
|
||||
const persisted = session.getCustomModelForPersist();
|
||||
expect(persisted?.ownEnv).toEqual({ DSH_HOME: '/home/u/my-dsh' });
|
||||
expect(JSON.stringify(persisted)).not.toContain('own-secret');
|
||||
// The secret is still put back: it just never leaves memory.
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('GEMINI_API_KEY')).toBe('own-secret');
|
||||
});
|
||||
|
||||
it('persists nothing extra for a recipe with no home variable', () => {
|
||||
const { session } = liveSession('gemini', { envOverrides: { GEMINI_API_KEY: 'own-secret' } });
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] },
|
||||
{ GEMINI_API_KEY: 'endpoint-key' }
|
||||
);
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual({ endpointId: 'ep', modelId: 'q', envKeys: ['GEMINI_API_KEY'] });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user