feat(lanes): badge codex lanes and document the lane tool

This commit is contained in:
2026-08-27 10:54:16 +07:00
parent 0d53603d9e
commit 24de68cfe1
5 changed files with 82 additions and 11 deletions
+11 -4
View File
@@ -381,11 +381,18 @@ export default function LaneCard({
</div>
)}
{lane.ci_status && (
{(lane.ci_status || lane.tool === "codex") && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
{lane.ci_status && (
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
)}
{lane.tool === "codex" && (
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
)}
</div>
)}
@@ -69,6 +69,11 @@ export default function LaneStripCard({
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
{lane.tool === "codex" && (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
)}
{detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
@@ -58,6 +58,7 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
cwd: "/work/demo",
branch: "lane/demo",
kind: "adopted",
tool: "claude",
source_repo: null,
pipeline: "default",
session_id: null,
@@ -85,6 +86,17 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
} as Lane;
}
describe("LaneCard lane tool badge", () => {
it("badges a codex lane and leaves a claude lane unbadged", () => {
const { rerender } = render(
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
);
expect(screen.getByText("Codex")).toBeInTheDocument();
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
});
});
describe("LaneCard child worktrees", () => {
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
const onSelectLane = vi.fn();
+26 -7
View File
@@ -135,7 +135,7 @@ POST /api/lanes/ensure
```
```json
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" }
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature", "tool": "codex" }
```
`cwd` is optional — when missing or empty the server falls back to
@@ -147,6 +147,11 @@ optional and, like `title`, applies **only when a lane is actually created**
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
field. An id no template defines returns `400 EBADPIPELINE`.
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`. The tool choice appears as an immutable
badge on the lane card (Codex lanes only).
Idempotent lookup by working directory — the Workspace page opens on a `cwd`,
not on a lane id. Returns the lane whose own `cwd` is that path, or the longest
path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`.
@@ -194,10 +199,15 @@ existing `lane_update` message when the lane becomes `idle` or `failed`.
{
"sourceRepo": "/absolute/path/to/repository",
"title": "Criteria form",
"base": "main"
"base": "main",
"tool": "claude"
}
```
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns `400 EBADTOOL`.
`sourceRepo` must exist, be absolute, and be a git repository. When `base` is
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
@@ -1571,23 +1581,32 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary { found, path } for the `claude` binary
GET /api/run/binary?tool= { found, path } for the `claude` or `codex` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, initialPrompt?, ... }
POST /api/run Start/attach run — Body: { laneId, initialPrompt?, sandbox?, approval?, resumeLast?, ... }
GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
```
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the `claude` command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
**`GET /api/run/binary`:** Probes for the binary (`claude` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
`permissionMode` (`acceptEdits` default, or `default` / `plan` / `bypassPermissions`) is the mode the pane starts in. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it. See [docs/LANES.md § start](LANES.md).
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
For **Claude lanes**, `permissionMode` (`acceptEdits` default, or `default` / `plan` / `bypassPermissions`) is the mode the pane starts in. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it.
For **Codex lanes**, three additional body fields control the spawn behavior:
- **`sandbox`** (boolean, default `false`) — whether to enable Codex's restricted filesystem/network mode (`-s` flag)
- **`approval`** (boolean, default `false`) — whether to prompt for confirmation before tool execution (`-a` flag)
- **`resumeLast`** (boolean, default `false`) — whether to resume the last Codex run (`--last` flag instead of starting new)
See [docs/LANES.md § Lane tool](LANES.md#lane-tool-claude-or-codex) for full details.
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned `claude` processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically.
Every history row carries `lane_id` (the lane whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
---
+28
View File
@@ -45,6 +45,34 @@ The modal has a segmented toggle mirroring the CLI's two modes:
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
## Lane tool: Claude or Codex
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows model, sandbox approval, and reasoning effort toggles instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`.
### How Codex arguments map
The Run Setup panel's model, sandbox, approval, and reasoning-effort toggles map directly onto `codex` command-line arguments:
- **Model** → `codex -m <model>` (e.g. `codex -m claude-opus-5`)
- **Sandbox** → `-s` (enables restricted filesystem/network mode)
- **Approval** → `-a` (prompts for confirmation before tool execution)
- **Reasoning effort** → `-c model_reasoning_effort=<level>` (e.g. `medium`)
A codex lane's run console shows the full command line before execution.
### Limitations of Codex lanes
Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tracking), so the following surfaces remain empty for a Codex lane:
- **Sessions** and **Analytics** tabs — no session history or aggregated statistics
- **Cost** surface — no per-run or per-model pricing data
- **Stage detection** — automatic stage inference from tool events (`stage-detect`) never fires
**These are not bugs; they reflect Codex's architecture.** The stage you declare via `ccam stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
## Destructive lane actions
Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI: