feat(lanes): let claude and codex lanes coexist at the same cwd

Repo/Adopt mode (POST /api/lanes/ensure) treated lanes.cwd as globally
unique, so adopting a path already driven by one CLI silently ignored a
different tool choice. Uniqueness moves to the composite (cwd, tool):
one directory can now back a claude lane and a codex lane side by side.

- server/db.js: drop the column-level UNIQUE on cwd, migrate existing
  DBs via table rebuild, add idx_lanes_cwd_tool.
- resolveLaneByCwd gains an optional tool filter; hook ingestion always
  pins to "claude" since Codex has no hook mechanism here, so a hook
  event never touches a codex lane sharing the path.
- POST /ensure resolves the existing owner by (cwd, tool) instead of
  cwd alone, so a different tool creates a sibling lane.
- LaneConsolePane's cwd-only lane lookups now also match tool, so a
  shared cwd doesn't attach a run to the wrong sibling lane.

Worktree mode is unaffected (already allocates a unique directory per
lane). docs/LANES.md and CLAUDE.md updated to describe the new
composite uniqueness.
This commit is contained in:
2026-08-27 13:06:08 +07:00
parent f8fba0a51d
commit ccf2fdb8eb
9 changed files with 530 additions and 17 deletions
+16 -6
View File
@@ -217,15 +217,22 @@ export function LaneConsolePane({
// args.laneId — RunSetup always supplies this pane's laneId (a
// required prop), which would otherwise silently start a run in the
// wrong lane whenever the user types a cwd different from the one
// this pane currently shows.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
// this pane currently shows. A cwd can now back both a claude lane
// and a codex lane, so match this pane's own tool too (laneTool,
// which already defaults to "claude" when no lane is selected —
// the same default RunSetup renders) — otherwise the wrong sibling
// lane could be picked.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd && l.tool === laneTool);
let targetLaneId: number;
if (ownedLane) {
targetLaneId = ownedLane.id;
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
const ensureResult = await api.lanes.ensure({
cwd: effectiveCwd,
tool: laneTool,
});
targetLaneId = ensureResult.lane.id;
onLaneIdChange(ensureResult.lane.id);
onLaneCreated(ensureResult.lane);
@@ -287,7 +294,7 @@ export function LaneConsolePane({
setBusy(null);
}
},
[busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
);
const onResumeFromHistory = useCallback(
@@ -301,10 +308,13 @@ export function LaneConsolePane({
if (item.cwd) {
const effectiveCwd = item.cwd;
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
// A cwd can now back both a claude lane and a codex lane — match
// this history item's own tool, the ground truth for which lane it
// belongs to, rather than an arbitrary lane at that cwd.
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
if (!targetLaneId) {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool });
targetLaneId = ensureResult.lane.id;
onLaneCreated(ensureResult.lane);
}