From 3952963bada0f6cba50634cc68628b22ba03f991 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 14 Oct 2026 05:16:10 +0200 Subject: [PATCH] docs(worktrees): API reference, wiki page and the invariants behind them Documents the worktree endpoints and quick-start's worktree field in docs/api-reference.md, adds a Git Worktrees page to the wiki (sidebar, Home index, a pointer from Core Concepts where sessions sharing one case folder is explained), and records the rules that keep the feature safe in docs/architecture-invariants.md with a short CLAUDE.md pointer: only the managed .claude/worktrees path is ever touched, git never runs over a Docker workspace, removal is never forced and branch delete is -d only, and sessions start through the existing create paths. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 + docs/api-reference.md | 36 ++++++++++++++++++ docs/architecture-invariants.md | 14 +++++++ docs/wiki/Core-Concepts.md | 3 +- docs/wiki/Git-Worktrees.md | 66 +++++++++++++++++++++++++++++++++ docs/wiki/HTTP-API.md | 2 +- docs/wiki/Home.md | 1 + docs/wiki/_Sidebar.md | 1 + 8 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 docs/wiki/Git-Worktrees.md diff --git a/CLAUDE.md b/CLAUDE.md index adb38400..5b362aca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) +**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `/.claude/worktrees/` on `worktree-`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) + **Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) **Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview//...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index db7ac4d2..aa4d5cc8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1011,6 +1011,42 @@ Both routes accept two optional query parameters, which the UI sends from its pe Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running. +## Git worktrees of a case + +A local case whose folder is the top of a git repository can keep **worktrees** at `/.claude/worktrees/`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree ` uses (branch `worktree-`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`. + +`GET /api/cases/:name/worktrees` lists `git worktree list` for the case's repository: + +```json +{ + "caseName": "myrepo", + "repoRoot": "/home/me/codeman-cases/myrepo", + "worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees", + "truncated": false, + "worktrees": [ + { "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false, + "locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] }, + { "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login", + "main": false, "isCase": false, "managed": true, "name": "fix-login", + "sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] } + ] +} +``` + +`managed` is `true` only for `/.claude/worktrees/`: those are the ones Codeman starts sessions in and removes; any other worktree of the repository is listed for information. `sessionCount` counts live sessions of every owner running in that worktree (a session belongs to the deepest worktree holding its folder), `sessions` lists the ones the caller may see. `lockReason`/`prunableReason` appear when git reports them. + +`POST /api/cases/:name/worktrees` with `{ name, branch?, base? }` creates one (`201`, `{ worktree: { name, path, branch, base, head, excludeAdded, warnings } }`): + +- `name`: 1 to 64 characters of `A-Z a-z 0-9 . _ -`, starting with a letter or digit, no `..`, not ending in `.` or `.lock`. `400` otherwise. +- `branch`: the NEW branch, default `worktree-`. Letters, digits, `.`, `_`, `-`, `/`, then `git check-ref-format --branch` must accept it unchanged. An existing branch is `409 ALREADY_EXISTS` (never reset), and so is a name whose folder already exists. +- `base`: where the branch starts, default `HEAD` (the case's current commit): a branch, tag or commit, resolved before anything is written (`400` when it is not a commit, which includes an empty repository). +- `excludeAdded` lists the lines appended to the repository's local `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git did not already ignore them: `/.claude/worktrees/`, so the main checkout does not show the worktrees as untracked, and `/.claude/settings.local.json`, the hooks file Codeman writes for every claude session, which would otherwise make `git worktree remove` refuse. +- A symlinked `.claude` or `.claude/worktrees` directory is refused (`400`): a repository cannot redirect the worktree outside itself. + +`DELETE /api/cases/:name/worktrees/:worktree[?deleteBranch=true]` removes a managed worktree with `git worktree remove` **without `--force`**: git refuses a worktree with modified or untracked files (or a locked one), and that refusal is the answer, `409 CONFLICT` with git's own words in `error`. A live session at or inside the worktree is `409` as well. With `deleteBranch=true` the branch is deleted with `git branch -d`, never `-D`, so an unmerged branch is kept: the response is still `200`, with `branchDeleted: false` and git's reason in `branchError`. Response: `{ removed: { name, path, branch, branchDeleted, branchError? } }`. A folder git does not list as a worktree of this repository, or a symlink in its place, is `404`/`400`. + +**Starting a session in a worktree** goes through the normal create paths: `POST /api/quick-start` takes `worktree: ""` next to `caseName` and runs the session (and the hooks, the agent skill and `modelOverride`) in the worktree folder; the response adds `worktree` and `workingDir`. It is refused for remote and Docker cases (`400`), for a worktree that is not a registered, managed one (`404`), and for a case that does not exist (`404`: a worktree launch never scaffolds a case). `POST /api/sessions` takes the worktree path as `workingDir` like any other folder. + ## CLI management Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf..0a81b119 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -448,6 +448,20 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`. +### Git worktrees of a case + +**Git worktrees** (case picker → **Git worktrees…**, the phone case sheet's **Worktrees**, Close Session's **Kill & remove worktree**): a LOCAL case whose folder is the top of a git repository keeps worktrees at `/.claude/worktrees/` on a new branch (default `worktree-`), so parallel agents each get their own checkout instead of sharing the case folder. That is Claude Code's own `--worktree` layout (measured in 2.1.296: same path, branch `worktree-`, names of `[A-Za-z0-9._-]`, at most 64), and Codeman's names are a strict subset of Claude's. Core in `src/git-worktrees.ts` (pure name/branch gates, `worktree list --porcelain` parsing, exclude text; IO behind an injectable runner); routes in `routes/worktree-routes.ts` (`GET`/`POST /api/cases/:name/worktrees`, `DELETE /api/cases/:name/worktrees/:worktree`). + +- ⚠️ **Only `/.claude/worktrees/` is ever created or removed.** Every component is `lstat`ed (a symlinked `.claude`, `worktrees` or worktree folder is refused, never followed) and the real path must equal the lexical one, so a repository cannot redirect a create or remove outside itself; removal also requires git to list the folder as a worktree of THIS repository. A worktree anywhere else is listed read-only (`managed: false`). +- ⚠️ **git never runs for a case at or inside a Docker case workspace** (or whose common git dir is there), and Docker and remote cases are refused with a 400 before any git, the same rule as the git status indicator: `git worktree add` runs the repository's post-checkout hook and checkout filters ON THE HOST. That is acceptable for a local case, where the user's agent already runs code. +- ⚠️ **Removal is `git worktree remove` WITHOUT `--force`**, refused (409) while any live session (any owner) runs at or inside the worktree, and git's own refusal (modified or untracked files, a lock) is the answer, shown through `textContent`. The optional branch delete is `git branch -d`, never `-D`: an unmerged branch survives and the response says why (`branchError`). +- ⚠️ **The main checkout must not see the worktrees.** When `git check-ignore` says they are not ignored, `/.claude/worktrees/` is appended to the repository's LOCAL `/info/exclude` (never a tracked `.gitignore`), and so is `/.claude/settings.local.json`: Codeman writes its hooks there for every claude session, and an untracked copy in a worktree would make `git worktree remove` refuse forever (Claude Code covers that file with the global excludes file, which a machine may not have). A symlinked `info` dir or `exclude` file is reported, never written through. +- **Names are gated before git**: charset and length (Zod and `worktreeNameError()`), then `git check-ref-format --branch`, whose output must EQUAL the input (`--branch` expands `@{-1}`, which the charset gate already refuses). The base is a resolved commit (`rev-parse --verify ^{commit}`), passed after `--` with `--no-track`; an existing branch is refused (`-b`, never `-B`, which would reset it). Create and remove are serialized per repository. +- **Sessions start through the EXISTING create paths**, never a new spawn path: `POST /api/quick-start` with `worktree` (resolved with the same guards; hooks, the agent skill, the model override and the session all target the worktree, and a case that does not exist is a 404, never a scaffold), or `POST /api/sessions` with the worktree as `workingDir` (what the Run button uses for claude and shell). The frontend launches through `run({ worktree })` with a temporary `_runMode` swap, tabs named `w-`. +- The unified session list names a live session's worktree from its folder layout (`worktreeFromPath`), so a codex or shell session gets the worktree pill before (or without) a transcript record. + +Tests: `test/git-worktrees.test.ts` (pure half, then REAL git: create, exclude, symlink guards, dirty/live/locked refusals, `branch -d`), `test/routes/worktree-routes.test.ts` (real repository through the routes and quick-start, Docker/remote/ownership refusals), `test/worktree-ui.test.ts`, `test/worktree-modal-i18n.test.ts`. + ### Ultracode and workflow-run visualization **Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 7f1a6404..45d9bb3a 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -46,7 +46,8 @@ case. Each has a stable id, and that id is what the API, the wait primitives, an event use. Several sessions can share one case. That is the normal way to parallelize: three workers -in the same repo, three tabs, one case. +in the same repo, three tabs, one case. They then also share one folder; when the workers +edit and commit in parallel, give each its own checkout with [Git Worktrees](Git-Worktrees). A session carries state the case does not: diff --git a/docs/wiki/Git-Worktrees.md b/docs/wiki/Git-Worktrees.md new file mode 100644 index 00000000..563aed9b --- /dev/null +++ b/docs/wiki/Git-Worktrees.md @@ -0,0 +1,66 @@ +# Git Worktrees + +Sessions in one case share its folder. That is fine for one agent, but three agents in the +same checkout edit the same files, overwrite each other's builds, and commit each other's +half-finished work. A **git worktree** gives each of them a checkout of its own: same +repository, same history, a separate folder on a separate branch. + +Codeman creates worktrees for a case whose folder is a git repository, starts sessions in +them, and removes them again when the work is done. + +## Where they live + +`/.claude/worktrees/`, on a new branch `worktree-`. That is exactly where +Claude Code's own `claude --worktree ` puts them, so a worktree made in Codeman is one +Claude Code recognizes, and the other way round. Codeman's Recent Sessions and Session Manager +lists mark a session that runs in one with a `⑂ ` pill. + +The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/` +(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session) to the +repository's local `.git/info/exclude` when git does not ignore them already. Your tracked +`.gitignore` is never touched. + +## Creating one and starting a session in it + +1. Pick the case in the toolbar's case picker. +2. Open the picker again and choose **Git worktrees…** (on a phone: the case sheet's + **Worktrees** button). +3. Under **New worktree**, type a name (letters, digits, `.`, `_`, `-`, up to 64 characters). +4. Pick the CLI under **Start sessions with** (Claude Code, Codex, a shell, any installed run + mode). +5. **Create & start session**. + +The worktree is created from the case's current commit, and the session opens in it as a tab +named `w1-`. Under **Branch and start point** you can name the branch yourself or start +from another branch, tag or commit. An existing branch is never reused or reset: pick another +name. + +**Open** on a worktree's row starts another session in it, with the CLI chosen above. + +## Removing one + +Two ways, and both use `git worktree remove` **without** `--force`: + +- **Remove…** on the worktree's row in the dialog. +- Closing a session that runs in a worktree: the Close Session dialog offers **Kill & remove + worktree**, which kills the session and then removes its worktree. + +git refuses to remove a worktree that still holds uncommitted or untracked files, and Codeman +shows that refusal as git worded it and leaves everything in place. Commit, stash or delete the +work first, then remove again. A worktree a live session still runs in is refused too. + +**Also delete its branch** deletes the branch with `git branch -d`, which keeps a branch whose +commits are not merged yet. The worktree is still removed, and the message says the branch was +kept. + +## What is not supported + +- **Docker cases and remote (SSH) cases.** A container can write to its workspace, and creating + a worktree runs the repository's hooks and checkout filters on the Codeman machine, so git is + never run there. A remote case's repository is on another machine. +- A case folder that is not the top of a repository (a subfolder of one), a bare repository, or + an empty repository with no commit yet. +- Worktrees outside `.claude/worktrees/` (made by hand with `git worktree add`) are listed, but + Codeman neither starts sessions in them from the dialog nor removes them. + +The HTTP endpoints are in the [HTTP API](HTTP-API) reference (`/api/cases/:name/worktrees`). diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index b4fb3cae..cbcda8fc 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain: | ------------------- | -------- | --------------------------------------------------- | | System | 56 | Status, settings, digest, updates, tunnel. | | Sessions | 34 | Create, input, terminal, wait, last response, kill. | -| Cases | 34 | Create, link, clone, remote and docker cases. | +| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. | | Files | 17 | Preview, edit, raw, attachments, path picker. | | Orchestrator | 10 | Plans and phases. | | Ralph | 9 | Loop control and configuration. | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7be48771..8a82a08c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000 | [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? | | [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? | | [Working With Files](Working-With-Files) | How do I read, edit, and attach files? | +| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? | | [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? | | [Mobile Guide](Mobile-Guide) | How well does this work on a phone? | | [Keyboard Shortcuts](Keyboard-Shortcuts) | What can I drive from the keyboard? | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 73662db4..c6e67b87 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -15,6 +15,7 @@ - [Agent CLIs](Agent-CLIs) - [Custom Model Endpoints](Custom-Model-Endpoints) - [Working With Files](Working-With-Files) +- [Git Worktrees](Git-Worktrees) - [Input And Voice](Input-And-Voice) - [Mobile Guide](Mobile-Guide) - [Keyboard Shortcuts](Keyboard-Shortcuts)