feat(workspace): a session in its own worktree, Claude's way

Desktop-parity phase 6, following Claude Desktop:
- the workspace home's Worktree checkbox starts the pane with
  `claude --worktree=<name>`, so Claude Code makes, populates
  (.worktreeinclude, WorktreeCreate hook) and cleans up
  <repo>/.claude/worktrees/<name>; AgentHub only excludes
  /.claude/worktrees/ at the repo root (Claude does not)
- server/lib/session-worktree.js resolves a session's worktree from the
  session row only (owned by the workspace, registered by git worktree
  list); every lane git route takes ?session= and answers ENOWORKTREE
  instead of falling back to the main folder
- the session view's Changes panel works in the opened run's worktree
  (dashboard_runs.worktree, GitTarget in the client api)
- Resume/Fork start in the session's worktree; a gone one is ENOWORKTREE
- Archive of a worktree session removes the worktree after asking:
  git worktree remove without --force, git branch -d (unmerged work keeps
  its branch), a lock held by a live pid refused, a stale one cleared
- listWorktrees reads `locked <reason>` locks
- tests scrub GIT_* env so the pre-commit suite cannot act on this repo
This commit is contained in:
2026-10-02 13:22:29 +07:00
parent a6bd33bcff
commit 5084334d81
35 changed files with 1041 additions and 119 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -78,6 +78,8 @@ export interface CheckboxProps {
* (bypass permissions, purge data, force). */
tone?: "accent" | "danger" | "warning";
disabled?: boolean;
/** Tooltip (the app turns every `title` into its themed bubble). */
title?: string;
}
const TONE = {
@@ -99,6 +101,7 @@ export function Checkbox({
labelClassName,
tone = "accent",
disabled = false,
title,
}: CheckboxProps) {
return (
<button
@@ -106,6 +109,7 @@ export function Checkbox({
role="checkbox"
aria-checked={checked}
disabled={disabled}
title={title}
onClick={() => onChange(!checked)}
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
>
@@ -53,6 +53,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
list: vi.fn(async () => ({ lanes, counts: {} })),
update: vi.fn(async () => ({ lane: lanes[0] })),
action: vi.fn(async () => ({ ok: true })),
removeSessionWorktree: vi.fn(async () => ({
removed: true,
branch: "x",
branchKept: false,
})),
routerList: vi.fn(async () => ({ routers: [] })),
},
sessions: {
@@ -158,6 +163,50 @@ describe("WorkspaceTree", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
it("archiving a worktree session removes its worktree first, after asking", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
],
total: 1,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
expect(await screen.findByTestId("workspace-session-worktree-s-wt")).toBeInTheDocument();
await user.click(screen.getByTestId("workspace-session-archive-s-wt"));
expect(api.sessions.patch).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toHaveTextContent(".claude/worktrees/ah-1-x");
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
await waitFor(() =>
expect(api.sessions.patch).toHaveBeenCalledWith("s-wt", { archived: true })
);
expect(api.lanes.removeSessionWorktree).toHaveBeenCalledWith(1, "s-wt");
});
it("keeps a worktree session when git refuses to remove its worktree", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
{ ...session("s-wt", "In a worktree"), cwd: "/r/.claude/worktrees/ah-1-x" } as Session,
],
total: 1,
limit: 20,
offset: 0,
}));
vi.mocked(api.lanes.removeSessionWorktree).mockRejectedValueOnce(
Object.assign(new Error("contains modified or untracked files"), { code: "EGIT" })
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await user.click(await screen.findByTestId("workspace-session-archive-s-wt"));
await user.click(screen.getByRole("button", { name: "Archive and remove" }));
expect(await screen.findByRole("alert")).toHaveTextContent("modified or untracked");
expect(api.sessions.patch).not.toHaveBeenCalled();
});
it("dots a session waiting for the user amber and a failed one red", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
@@ -59,7 +59,7 @@ import {
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
@@ -422,11 +422,16 @@ function ChangeRow({
export function LaneChangesPanel({
laneId,
session,
onNewLane,
full = false,
tab = "changes",
}: {
laneId: number;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
session?: string;
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
* on its own (no git header, no mode switcher), `changes` the other four.
* At full width both show the explorer sidebar beside the changes. */
@@ -440,7 +445,9 @@ export function LaneChangesPanel({
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId);
const [git, refreshGit] = useLaneGitFacts(laneId, session);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
@@ -544,7 +551,7 @@ export function LaneChangesPanel({
const seq = ++loadSeq.current;
setLoading(true);
try {
const result = await api.lanes.changes(laneId, {
const result = await api.lanes.changes(target, {
range,
...(range === "commit" && sha ? { sha } : {}),
...(mode === "file" && openFile ? { path: openFile, context } : {}),
@@ -591,7 +598,7 @@ export function LaneChangesPanel({
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.gitLog(target, { limit: 50 })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
@@ -603,7 +610,7 @@ export function LaneChangesPanel({
const wanted = full || (mode === "browse" && !query.trim());
if (!wanted || treeRootLoaded) return;
void api.lanes
.gitTree(laneId, "")
.gitTree(target, "")
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` itself is the cache this fills — a dep on it would loop.
@@ -619,7 +626,7 @@ export function LaneChangesPanel({
});
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
@@ -632,7 +639,7 @@ export function LaneChangesPanel({
const lane = laneId;
setTreeBusy(true);
try {
const { levels } = await api.lanes.gitTreeAll(lane);
const { levels } = await api.lanes.gitTreeAll(target);
// A lane switch mid-read must not land lane A's tree in lane B.
if (laneNow.current !== lane) return;
setTreeChildren(levels);
@@ -655,7 +662,7 @@ export function LaneChangesPanel({
let live = true;
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.gitLs(target, q)
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
@@ -670,7 +677,7 @@ export function LaneChangesPanel({
useEffect(() => {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then(
forLane(laneId, (r) =>
setText(
@@ -775,7 +782,7 @@ export function LaneChangesPanel({
setBusy(true);
setError(null);
try {
const r = await api.lanes.gitSaveFile(lane, text.path, draft, text.sha);
const r = await api.lanes.gitSaveFile(target, text.path, draft, text.sha);
if (laneNow.current !== lane) return;
if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
@@ -803,7 +810,7 @@ export function LaneChangesPanel({
setError(null);
try {
const paths = Array.isArray(path) ? path : [path];
await api.lanes.gitAction(lane, action, { paths, ...(confirm ? { confirm } : {}) });
await api.lanes.gitAction(target, action, { paths, ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
@@ -824,7 +831,7 @@ export function LaneChangesPanel({
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, body);
await api.lanes.gitAction(target, action, body);
if (laneNow.current !== lane) return;
setArmedAbort(false);
refreshGit();
@@ -849,7 +856,7 @@ export function LaneChangesPanel({
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
await api.lanes.gitAction(target, action, {
path,
hunk: change.hunk.header,
block: change.block,
@@ -916,7 +923,7 @@ export function LaneChangesPanel({
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
refreshGit();
@@ -1055,7 +1062,13 @@ export function LaneChangesPanel({
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{!filesTab && (
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
)}
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
@@ -1430,6 +1443,7 @@ export function LaneChangesPanel({
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
<LaneConflictView
laneId={laneId}
session={session}
path={openFile}
onResolved={() => {
refreshGit();
@@ -15,7 +15,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { api } from "../../lib/api";
import { api, gitTarget } from "../../lib/api";
import {
applyChoices,
parseConflicts,
@@ -37,11 +37,14 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
export function LaneConflictView({
laneId,
session,
path,
onResolved,
onEdit,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
@@ -62,7 +65,7 @@ export function LaneConflictView({
setChoices([]);
setError(null);
api.lanes
.gitFile(laneId, path, "work")
.gitFile(gitTarget(laneId, session), path, "work")
.then((r) => {
if (!live) return;
setFile(
@@ -75,7 +78,7 @@ export function LaneConflictView({
return () => {
live = false;
};
}, [laneId, path]);
}, [laneId, session, path]);
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
@@ -98,11 +101,16 @@ export function LaneConflictView({
const save = () =>
run(async () => {
if (!parts || !file?.sha) return;
await api.lanes.gitSaveFile(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
await api.lanes.gitSaveFile(
gitTarget(laneId, session),
path,
applyChoices(parts, choices).join("\n"),
file.sha
);
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
});
const takeAll = (side: "ours" | "theirs") =>
run(() => api.lanes.gitAction(laneId, "resolve", { path, side }));
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
const choose = (i: number, c: ConflictChoice | undefined) =>
setChoices((prev) => {
+6 -3
View File
@@ -36,7 +36,7 @@ import {
Trash2,
Upload,
} from "lucide-react";
import { api, type GitAuthNeeded } from "../../lib/api";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
type Action =
@@ -89,12 +89,15 @@ function Btn({
export function LaneGitBar({
laneId,
session,
git,
onChanged,
onNewLane,
lead,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -157,7 +160,7 @@ export function LaneGitBar({
setBusy(action);
setError(null);
try {
const res = await api.lanes.gitAction(lane, action, body || {});
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
@@ -208,7 +211,7 @@ export function LaneGitBar({
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
+12 -4
View File
@@ -15,7 +15,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api";
import { api, gitTarget } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
@@ -29,7 +29,11 @@ const GIT_REFRESH_MS = 30_000;
* into the polled lane list. Absent facts are not an error state: a lane may point at a
* plain directory, and it renders with no git header.
*/
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
@@ -46,7 +50,7 @@ export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => voi
let alive = true;
const read = () => {
api.lanes
.git(laneId)
.git(gitTarget(laneId, session))
.then((f) => {
if (alive) setFacts(keepIfSame(f));
})
@@ -62,7 +66,7 @@ export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => voi
alive = false;
clearInterval(timer);
};
}, [laneId, nonce]);
}, [laneId, session, nonce]);
return [facts, () => setNonce((n) => n + 1)];
}
@@ -202,11 +206,14 @@ function LaneGitFilesPanel({
export function LaneGitHeader({
laneId,
session,
git,
onChanged,
onNewLane,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -275,6 +282,7 @@ export function LaneGitHeader({
>
<LaneGitBar
laneId={laneId}
session={session}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
@@ -19,11 +19,11 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { GitFork, History, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import type { Lane, Session } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { liveRunFor } from "./WorkspaceSessionList";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** The workspace of this tool whose folder holds `cwd` most deeply — the
* same path-boundary rule the server binds hook sessions with. */
@@ -41,7 +41,7 @@ export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | nul
export function SessionResumeButton({
session,
}: {
session: { id: string; cwd: string | null; tool?: string };
session: { id: string; cwd: string | null; tool?: string; lane_id?: number | null };
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -65,11 +65,21 @@ export function SessionResumeButton({
// Claude only: a Codex session id is not what
// `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
// The workspace that owns it, else the one whose folder holds it.
const lane =
tool !== "claude" || !session.cwd
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
const resumable = !!session.cwd && same(session.cwd, lane.cwd);
// Its own worktree counts too: the server resumes it there (`claude
// --worktree` sessions), and refuses one whose worktree is gone.
const resumable =
!!session.cwd &&
(same(session.cwd, lane.cwd) ||
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
if (!run && !resumable) return null;
const open = (runId: string) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
+31 -1
View File
@@ -12,6 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -19,6 +20,7 @@ import {
ArchiveRestore,
FileText,
Folder,
GitBranch,
MoreHorizontal,
Pencil,
Play,
@@ -41,7 +43,13 @@ import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { liveRunFor, SessionFold, sessionDot, useSessionPage } from "./WorkspaceSessionList";
import {
liveRunFor,
SessionFold,
sessionDot,
sessionWorktreeName,
useSessionPage,
} from "./WorkspaceSessionList";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -96,6 +104,9 @@ function HomeBody({
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel]);
const [mode, setMode] = useState<PermissionMode>("auto");
// Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -219,6 +230,7 @@ function HomeBody({
prompt: prompt.trim(),
...(model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -344,6 +356,15 @@ function HomeBody({
/>
</div>
)}
{claude && project?.root && (
<Checkbox
checked={inWorktree}
onChange={setInWorktree}
label={t("home.worktree")}
title={t("home.worktreeHint")}
className="ml-1"
/>
)}
<button
type="button"
data-testid="workspace-home-start"
@@ -382,6 +403,15 @@ function HomeBody({
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
{sessionWorktreeName(lane, s) && (
<span
title={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
className="inline-flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
>
<GitBranch className="h-3 w-3" />
{sessionWorktreeName(lane, s)}
</span>
)}
{!!s.cost && s.cost > 0 && (
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{fmtCost(s.cost)}
@@ -16,12 +16,13 @@
*/
import { useEffect, useState, type ReactNode } from "react";
import { Archive } from "lucide-react";
import { Archive, GitBranch } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
export const SESSION_LIMIT = 20;
@@ -38,6 +39,14 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
);
}
/** The name of the git worktree a session ran in (`claude --worktree` puts it
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
const strip = (p: string) => p.replace(/\/+$/, "");
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
}
/** A session row's dot: waiting for the user, failed, held by a live pane, or
* at rest. */
export function sessionDot(session: Session, live: boolean): string {
@@ -178,6 +187,10 @@ export function WorkspaceSessionList({
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
// A worktree session asked to be archived: Claude Desktop removes its
// worktree too, so that is confirmed first.
const [archiving, setArchiving] = useState<Session | null>(null);
const [busy, setBusy] = useState(false);
// The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state — re-read then, not on a timer.
@@ -205,6 +218,44 @@ export function WorkspaceSessionList({
}
};
// Archive: a worktree session with no live pane loses its worktree first
// (never forced — git refuses unsaved work, which then stops the archive);
// one Claude already cleaned up is just archived.
const archive = (s: Session) =>
sessionWorktreeName(lane, s) && !runFor(s)
? setArchiving(s)
: void patch(s, { archived: true });
const archiveWithWorktree = async (s: Session) => {
setBusy(true);
setError(null);
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
});
await patch(s, { archived: true });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setArchiving(null);
}
};
const confirmArchive = (
<ConfirmModal
open={archiving !== null}
title={t("tree.archiveWorktreeTitle")}
message={t("tree.archiveWorktreeBody", {
name: archiving ? sessionWorktreeName(lane, archiving) : "",
})}
confirmLabel={t("tree.archiveWorktreeConfirm")}
cancelLabel={t("home.cancel")}
destructive
busy={busy}
onConfirm={() => archiving && void archiveWithWorktree(archiving)}
onCancel={() => setArchiving(null)}
/>
);
if (hidden) return null;
const failure = error && (
<p role="alert" className="pl-7 text-[11px] text-status-danger">
@@ -216,6 +267,7 @@ export function WorkspaceSessionList({
<>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
{failure}
{confirmArchive}
</>
);
}
@@ -269,9 +321,11 @@ export function WorkspaceSessionList({
setDraft(s.name || "");
setRenaming(s.id);
}}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}\n${
run ? t("tree.splitHint") : t("tree.transcriptHint")
}`}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
@@ -283,11 +337,18 @@ export function WorkspaceSessionList({
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
/>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{sessionWorktreeName(lane, s) && (
<GitBranch
data-testid={`workspace-session-worktree-${s.id}`}
aria-label={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
</button>
<button
type="button"
data-testid={`workspace-session-archive-${s.id}`}
onClick={() => void patch(s, { archived: true })}
onClick={() => archive(s)}
title={t("tree.archiveSession")}
aria-label={t("tree.archiveSession")}
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
@@ -299,6 +360,7 @@ export function WorkspaceSessionList({
})}
</ul>
{failure}
{confirmArchive}
</>
);
}
@@ -19,6 +19,7 @@ import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: {
lanes: {
git: vi.fn(),
@@ -14,6 +14,7 @@ import { LaneConflictView } from "../LaneConflictView";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
}));
@@ -17,6 +17,7 @@ import { api } from "../../../lib/api";
import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
@@ -13,6 +13,7 @@ import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
@@ -43,7 +43,12 @@ function Where() {
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderButton(session: { id: string; cwd: string | null; tool?: string }) {
function renderButton(session: {
id: string;
cwd: string | null;
tool?: string;
lane_id?: number;
}) {
return render(
<MemoryRouter initialEntries={["/sessions/x"]}>
<Routes>
@@ -136,6 +141,17 @@ describe("SessionResumeButton", () => {
});
});
it("resumes a session in its own worktree through the workspace that owns it", async () => {
const user = userEvent.setup();
renderButton({ id: "s-wt", cwd: "/r/.claude/worktrees/ah-3-x", tool: "claude", lane_id: 3 });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
resumeSessionId: "s-wt",
});
});
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
@@ -193,6 +193,21 @@ describe("WorkspaceHome", () => {
);
});
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.click(await screen.findByRole("checkbox", { name: "Worktree" }));
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
prompt: "try it",
permissionMode: "auto",
worktree: true,
});
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
+6
View File
@@ -351,6 +351,10 @@
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
"tree.renameSession": "Rename session",
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
"tree.archiveWorktreeTitle": "Archive and remove its worktree?",
"tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.",
"tree.archiveWorktreeConfirm": "Archive and remove",
"tree.archived_one": "{{count}} archived session",
"tree.archived_other": "{{count}} archived sessions",
"tree.unarchive": "Unarchive",
@@ -361,6 +365,8 @@
"home.worktreeCount_other": "{{count}} worktrees",
"home.placeholder": "How can I help you today?",
"home.start": "Start",
"home.worktree": "Worktree",
"home.worktreeHint": "Run this session in its own git worktree — <repo>/.claude/worktrees/, a branch of its own, made and cleaned up by Claude Code",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
"home.recents": "Recents",
"home.noRecents": "No sessions yet — start one above.",
+6
View File
@@ -351,6 +351,10 @@
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
"tree.renameSession": "Đổi tên session",
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
"tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.",
"tree.archiveWorktreeConfirm": "Lưu trữ và gỡ",
"tree.archived_one": "{{count}} session đã lưu trữ",
"tree.archived_other": "{{count}} session đã lưu trữ",
"tree.unarchive": "Bỏ lưu trữ",
@@ -361,6 +365,8 @@
"home.worktreeCount_other": "{{count}} worktree",
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
"home.start": "Bắt đầu",
"home.worktree": "Worktree",
"home.worktreeHint": "Chạy session này trong worktree git riêng — <repo>/.claude/worktrees/, branch riêng, do Claude Code tạo và dọn",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
"home.recents": "Gần đây",
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
@@ -0,0 +1,39 @@
/**
* @file Pins where a workspace git call goes: the workspace's own routes, or —
* for a session's worktree target — the same routes with `?session=`, which
* the server resolves to that session's worktree.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { api, gitTarget } from "../api";
afterEach(() => {
vi.unstubAllGlobals();
});
function stubFetch() {
const spy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => "application/json" },
json: async () => ({}),
});
vi.stubGlobal("fetch", spy);
return spy;
}
describe("git targets", () => {
it("adds session= for a worktree target and nothing for the workspace", async () => {
const spy = stubFetch();
await api.lanes.changes(3, { range: "work" });
await api.lanes.changes(gitTarget(3, "s-1"), { range: "work" });
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
await api.lanes.git(gitTarget(3, null));
const urls = spy.mock.calls.map((c) => String(c[0]));
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
});
});
+52 -23
View File
@@ -565,6 +565,27 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
* JSON-encoding, and error-throwing behavior documented on that helper — the
* per-method docs below focus on the specific route, params, and response shape.
*/
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`) — the server
* resolves the tree from the session row (`?session=`), never from a path.
*/
export type GitTarget = number | { lane: number; session?: string };
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const params = new URLSearchParams(q);
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** The target for `laneId`, in `session`'s worktree when one is given. */
export function gitTarget(laneId: number, session?: string | null): GitTarget {
return session ? { lane: laneId, session } : laneId;
}
export const api = {
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
@@ -2542,7 +2563,7 @@ export const api = {
* @returns {@link LaneGitFacts} — `available: false` when the cwd is not a
* readable git repository, which is a normal state rather than an error.
*/
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
git: (id: GitTarget) => request<LaneGitFacts>(gitUrl(id, "")),
/**
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
* the Changes panel asks for.
@@ -2553,7 +2574,7 @@ export const api = {
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
*/
changes: (
id: number,
id: GitTarget,
opts: {
range?: LaneChangeRange;
path?: string;
@@ -2568,20 +2589,18 @@ export const api = {
if (opts.full) q.set("full", "1");
if (opts.sha) q.set("sha", opts.sha);
if (opts.context != null) q.set("context", String(opts.context));
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
return request<LaneChanges>(gitUrl(id, "/changes", q));
},
/**
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
gitLog: (id: GitTarget, opts: { limit?: number; file?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
return request<{ available: boolean; commits: LaneCommit[] }>(
`/lanes/${id}/git/log?${q.toString()}`
);
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
@@ -2589,17 +2608,15 @@ export const api = {
* @param path Repo-relative path.
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
*/
gitFile: (id: number, path: string, rev = "work") =>
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
gitFile: (id: GitTarget, path: string, rev = "work") =>
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
* disk since answers 409 ESTALE rather than being overwritten.
*/
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(`/lanes/${id}/git/file`, {
gitSaveFile: (id: GitTarget, path: string, content: string, baseSha: string) =>
request<LaneFileContent>(gitUrl(id, "/file"), {
method: "PUT",
body: JSON.stringify({ path, content, baseSha }),
}),
@@ -2609,9 +2626,9 @@ export const api = {
* @param id The lane id.
* @param q The substring typed into the quick-open box.
*/
gitLs: (id: number, q: string) =>
gitLs: (id: GitTarget, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
gitUrl(id, "/ls", new URLSearchParams({ q }))
),
/**
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
@@ -2622,31 +2639,41 @@ export const api = {
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
*/
gitTreeAll: (id: number) =>
gitTreeAll: (id: GitTarget) =>
request<{
available: boolean;
levels: Record<string, LaneTreeEntry[]>;
truncated: boolean;
}>(`/lanes/${id}/git/tree-all`),
gitTree: (id: number, path = "") =>
}>(gitUrl(id, "/tree-all")),
gitTree: (id: GitTarget, path = "") =>
request<{
available: boolean;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
}>(gitUrl(id, "/tree", new URLSearchParams({ path }))),
/**
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
* the branch switcher. The repo comes from the lane row, never the query.
* @param id The lane id.
*/
gitBranches: (id: number) =>
/**
* POST /api/lanes/:id/session-worktree/remove — Claude Desktop's archive of a
* worktree session: removes the session's own worktree (never forced; git
* refuses a dirty tree) and its branch when merged (`branchKept` otherwise).
*/
removeSessionWorktree: (id: number, session: string) =>
request<{ removed: true; branch: string | null; branchKept: boolean }>(
`/lanes/${id}/session-worktree/remove`,
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
),
gitBranches: (id: GitTarget) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(`/lanes/${id}/git/branches`),
}>(gitUrl(id, "/branches")),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
@@ -2661,7 +2688,7 @@ export const api = {
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
id: number,
id: GitTarget,
action:
| "fetch"
| "pull"
@@ -2702,7 +2729,7 @@ export const api = {
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
`/lanes/${id}/git/${action}`,
gitUrl(id, `/${action}`),
{ method: "POST", body: JSON.stringify(body) }
),
/**
@@ -3916,6 +3943,8 @@ export interface RunHandle {
resumeSessionId: string | null;
/** Claude Code session id this run created/resumed, once known. */
sessionId: string | null;
/** The pane works in a git worktree of its own (`claude --worktree`). */
worktree?: boolean;
/** ISO timestamp the tmux session was created. */
startedAt: string | null;
/** Initial prompt preview (first 500 chars), null when not provided. */
+9
View File
@@ -493,6 +493,11 @@ export function Workspace() {
});
}, []);
// The session the opened run holds, when that run works in a worktree of
// its own (`claude --worktree`): the Changes panel shows that tree.
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
// A pane the layout does not show yet (`&pane=1` on one pane) widens the
// split so the run lands where it was asked for — once per request, so the
// user can still go back to one pane afterwards.
@@ -1065,7 +1070,11 @@ export function Workspace() {
</button>
</div>
<LaneChangesPanel
// A new tree is a new panel: no read for the previous one
// can land in it.
key={`${currentLane.id}:${gitSession ?? ""}`}
laneId={currentLane.id}
session={gitSession}
tab={panelTab}
full={changesSize === "full"}
// A sibling worktree forks from the lane's SOURCE repo when
@@ -722,6 +722,34 @@ describe("Workspace — home and session view", () => {
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation();
vi.mocked(api.run.list).mockResolvedValue({
runs: [],
items: [
{
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
sessionId: "s-wt",
worktree: true,
},
],
} as never);
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, session: "s-wt" }));
vi.mocked(api.run.list).mockImplementation(original!);
localStorage.removeItem("agenthub.workspace.changesOpen");
});
it("widens one pane to two for &pane=1, so the run opens beside the first", async () => {
localStorage.removeItem("agenthub.workspace.splitView");
render(
+28
View File
@@ -272,6 +272,7 @@ All fields are optional. The tool (CLI invoked) comes from the workspace itself,
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex workspaces specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude workspaces, `resumeSessionId` re-attaches a Claude session by id.
- **`forkSession: true`** (with `resumeSessionId`, Claude workspaces only) — resume into a NEW session id (`claude --resume <id> --fork-session`), leaving the original untouched; the run records no session until the fork's first hook. Without `resumeSessionId` it is `400 EBADSESSION`, on any other tool `400 EUNSUPPORTED`.
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
@@ -576,6 +577,33 @@ comes from the workspace row, never from the query (that is what `GET
/api/lanes/branches` is for, which takes a candidate path for a workspace that does
not exist yet).
#### A session's own worktree (`?session=`)
Every `/api/lanes/:id/git…` route above and below takes `?session=<id>`: the
read or write then works in that session's worktree instead of the
workspace's folder. The tree is resolved from the SESSION ROW
(`server/lib/session-worktree.js`): the session must be owned by this
workspace (`sessions.lane_id`), its `cwd` must not be the workspace's own
folder, and `git worktree list` of the workspace's repo must register it —
otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
`GET /git` also answers `worktree: {path, branch}`.
#### Remove a session's worktree
```http
POST /api/lanes/:id/session-worktree/remove
```
Body `{"session": "<id>", "confirm": true}` (same-origin). Claude Desktop's
archive of a worktree session: `git worktree remove <path>` WITHOUT force
(git refuses modified or untracked files: `409 EGIT` with git's message), then
`git branch -d` of its branch (`-d`, never `-D`: a branch with commits merged
nowhere is kept, `branchKept: true`). A lock held by a live process is `409
ELOCKED`; a stale one (the pid in Claude's `claude session <name> (pid …)`
reason is gone) is cleared first. A live pane still holding the session is
`409 ELIVE`; no `confirm` is `400 ECONFIRM`; a session with no worktree here
`400 ENOWORKTREE`. Answers `{removed: true, branch, branchKept}`.
#### Act on a workspace's working copy
```http
+1
View File
@@ -394,6 +394,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the home's prompt box has a **Worktree** checkbox (Claude workspaces in a git repo), and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
+46 -12
View File
@@ -1,8 +1,9 @@
# Desktop parity — what the workspace still lacks next to Claude Desktop / Codex app
_Status: written 2026-10-02. Phases 1–5 and 7 implemented 2026-10-02
(uncommitted); phase 6 waits on decision D4. Phases run in order, each one change-set on `main`.
Comes BEFORE `docs/plans/multi-user-server/` — that plan's owner column hangs
_Status: written 2026-10-02. Phases 1–5 and 7 implemented and committed
(a6bd33b); phase 6 implemented 2026-10-02 the Claude way (D4). Phases run in order, each one change-set on `main`.
Comes BEFORE the multi-user server plan (now in the AgentHub-Server repo,
https://git.smartgift.io.vn/Smartgift-AI/AgentHub-Server) — that plan's owner column hangs
off the session ownership built in phase 1 here._
## Where we stand
@@ -36,7 +37,7 @@ archived, and give a session — not a project — its own worktree.
| D1 | Sessions in the folder that the workspace did not start | **Not listed** (user's call, 2026-10-02): a workspace shows only its own sessions; the rest stay on the Sessions page. The fold and `?unowned=` / `PATCH {lane_id}` built first were removed again. |
| D2 | What "belongs to a workspace" means | `sessions.lane_id` = the lane whose pane held the session (stamped from the hook's `run_id`), or one the user added by hand. First writer wins; only the user moves it. |
| D3 | Old history | Not guessed. Only the sessions `dashboard_runs` still names are backfilled; everything older is not listed in any workspace. |
| D4 | Per-session worktree (phase 6) | A **child workspace**: a worktree lane with `parent_lane_id`, listed under its parent in the sidebar. Reuses every lane rule (git surface, destroy guard) instead of giving a session its own cwd. Confirm with the user before phase 6. |
| D4 | Per-session worktree (phase 6) | **Claude's model** (user's call, 2026-10-02): the pane runs `claude --worktree=<name>`, so Claude Code itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. The session stays the workspace's own (no child workspace). Archive removes a CLEAN worktree, like Claude Desktop. |
| D5 | Tool chosen per session instead of per workspace | **Out of scope here.** `lanes.tool` stays immutable. Revisit with the user after phase 6. |
| D6 | Environments (SSH/cloud), Routines/Automations, cross-session messaging | **Not built.** AgentHub runs locally and does not orchestrate (`CLAUDE.md`). |
@@ -121,15 +122,48 @@ Ctrl/Cmd+click on a live session row → `/workspace?lane=<id>&run=<runId>&pane=
`Workspace.tsx` switches a 1-pane layout to 2 and hands `attachRequest` to that
pane instead of pane 0. A plain click is unchanged.
### Phase 6 — worktree per session (needs D4 confirmed)
### Phase 6 — worktree per session, Claude's way
- `lanes.parent_lane_id INTEGER` (nullable).
- Home prompt box: a **Worktree** toggle (git repos only). Run → `POST
/api/lanes/worktree` with a generated branch + `parent_lane_id`, then start the
prompt in the child's slot 0.
- Sidebar: a child lane is listed under its parent's sessions, not as a
top-level row. Archive on it = the existing Remove (record only — the
worktree stays the user's, per the remove rule) plus a hint to delete it.
Verified 2026-10-02 on Claude Code 2.1.286: `claude -w probe1` makes
`<repo>/.claude/worktrees/probe1` on `worktree-probe1` (from origin/HEAD, or
`worktree.baseRef`), locks it with `claude session probe1 (pid …)`, and its
hooks report that worktree as `cwd`. It does NOT exclude `.claude/worktrees/`,
so the main repo shows `?? .claude/`.
- **Start:** the home prompt box gets a **Worktree** checkbox (Claude
workspaces in a git repo). `POST /api/lanes/:id/start {slot, worktree: true}`
→ `spawnRun` puts `--worktree=<name>` in the argv (`=` form: `-w [name]`
takes an optional value and would eat the prompt as the name); the name is
server-made (`ah-<lane>-<base36 time>`), never from the request. Claude only
(`EUNSUPPORTED` elsewhere). `.claude/worktrees/` goes into the common git
dir's `info/exclude` at the repo root (`excludeFromGit`).
`dashboard_runs.worktree` = 1 marks the run.
- **Which worktree a session is in:** `server/lib/session-worktree.js`
`sessionWorktree(lane, sessionId)` → `{path, branch, locked}` only when the
session is OWNED by the lane, its `cwd` is not the lane's own folder, and
`git worktree list` of the lane's repo registers that path. Nothing else
turns a request into a path.
- **Git panel:** every `/:id/git*` route takes `?session=<id>`; with it the
route works in that session's worktree, and answers `400 ENOWORKTREE`
instead of falling back to the main folder (a commit must never land in the
wrong tree). The session view passes the session of the attached run when
that run is a worktree run.
- **Resume / Fork** of a worktree session start the pane in its worktree;
one whose worktree is gone is refused (`ENOWORKTREE`) — Claude finds a
session by the folder it ran in.
- **Sidebar / Recents:** a worktree session shows a branch icon and its
branch in the tooltip.
- **Archive = remove the worktree** (Claude Desktop): for a worktree session
with no live pane the archive button asks first, then
`POST /api/lanes/:id/session-worktree/remove {session, confirm: true}` runs
`git worktree remove <path>` WITHOUT force (git refuses a dirty tree) and
`git branch -d` (refuses unmerged work — the branch is then kept and
reported); a lock held by a live process is refused (`ELOCKED`), a stale
one (dead pid) unlocked first. Only after that succeeds is the session
archived. The workspace's own folder and any worktree no session of this
workspace ran in are never touched.
- **Not built:** auto-archive on PR merge (no PR integration), a branch prefix
setting (the CLI has none), worktrees for Codex.
### Phase 7 — diff comment to prompt (optional)
+93
View File
@@ -1841,3 +1841,96 @@ describe("/api/lanes tool tests", () => {
await request("DELETE", `/api/lanes/${codexId}`);
});
});
describe("a session's own worktree (claude --worktree)", () => {
const { execFileSync } = require("child_process");
const tmux = require("../lib/tmux");
const { db } = require("../db");
// Inside a git hook GIT_DIR / GIT_INDEX_FILE name the repo being committed.
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let repo, wtDir, laneId;
before(async () => {
repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-session-wt-")));
gitIn(repo, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(repo, "a.txt"), "a\n");
gitIn(repo, "add", ".");
gitIn(repo, "commit", "-qm", "init");
gitIn(repo, "worktree", "add", "-q", "-b", "worktree-x", ".claude/worktrees/x");
wtDir = path.join(repo, ".claude", "worktrees", "x");
laneId = (await request("POST", "/api/lanes", { cwd: repo })).body.lane.id;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES ('wt-sess-1', 'w', 'completed', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
).run(wtDir, laneId);
});
after(() => fs.rmSync(repo, { recursive: true, force: true }));
it("reads and writes git in the session's worktree, never falling back", async () => {
fs.writeFileSync(path.join(wtDir, "only-in-wt.txt"), "w\n");
const inWt = await request("GET", `/api/lanes/${laneId}/git/changes?session=wt-sess-1`);
assert.ok(inWt.body.files.some((f) => f.path === "only-in-wt.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?session=wt-sess-1`);
assert.equal(facts.body.branch, "worktree-x");
assert.equal(facts.body.worktree.branch, "worktree-x");
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
assert.ok(!main.body.files.some((f) => f.path === "only-in-wt.txt"));
const bad = await request("POST", `/api/lanes/${laneId}/git/stage?session=nope`, {
paths: ["only-in-wt.txt"],
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "ENOWORKTREE");
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?session=wt-sess-1`, {
paths: ["only-in-wt.txt"],
});
assert.equal(ok.status, 200);
assert.match(gitIn(wtDir, "diff", "--cached", "--name-only"), /only-in-wt\.txt/);
});
it("resumes a worktree session in its worktree", async () => {
let cwd = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") cwd = args[args.indexOf("-c") + 1];
return "";
});
try {
const r = await request("POST", `/api/lanes/${laneId}/start`, {
slot: 2,
resumeSessionId: "wt-sess-1",
});
assert.equal(r.status, 200);
assert.equal(cwd, wtDir);
} finally {
tmux.__reset();
}
});
it("removes it only when asked, and only when git agrees it is clean", async () => {
const url = `/api/lanes/${laneId}/session-worktree/remove`;
let r = await request("POST", url, { session: "wt-sess-1" });
assert.equal(r.body.error.code, "ECONFIRM");
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
assert.equal(r.status, 409); // a staged file: git refuses
assert.ok(fs.existsSync(wtDir));
gitIn(wtDir, "reset", "-q");
fs.rmSync(path.join(wtDir, "only-in-wt.txt"));
r = await request("POST", url, { session: "wt-sess-1", confirm: true });
assert.equal(r.status, 200);
assert.deepEqual(r.body, { removed: true, branch: "worktree-x", branchKept: false });
assert.equal(fs.existsSync(wtDir), false);
// Its worktree is gone: nothing to resume it in.
r = await request("POST", `/api/lanes/${laneId}/start`, {
slot: 3,
resumeSessionId: "wt-sess-1",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENOWORKTREE");
});
});
+37
View File
@@ -230,6 +230,43 @@ describe("pty-run", () => {
);
});
it("spawnRun starts a Claude session in its own worktree, Claude's way", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
const repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pty-wt-")));
// Inside a git hook GIT_DIR names the repo being committed; drop it.
const env = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
require("node:child_process").execFileSync("git", ["init", "-q", repo], { env });
try {
pty.spawnRun({ laneId: 41, cwd: repo, worktree: true, initialPrompt: "fix it" });
// `=` form: `-w [name]` takes an optional value and would eat the prompt.
const flag = newSessionArgv.find((a) => a.startsWith("--worktree="));
assert.match(flag, /^--worktree=ah-41-[a-z0-9]+$/);
assert.equal(newSessionArgv.at(-1), "fix it");
// Claude does not exclude its worktrees; the main repo would list them.
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
assert.ok(exclude.split("\n").includes("/.claude/worktrees/"), exclude);
const dashboardRuns = require("../lib/dashboard-runs");
assert.equal(dashboardRuns.getRun("agenthub-lane-41").worktree, 1);
assert.throws(
() => pty.spawnRun({ laneId: 42, cwd: repo, tool: "codex", worktree: true }),
(e) => e.code === "EUNSUPPORTED"
);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
+137
View File
@@ -0,0 +1,137 @@
/**
* @file Tests for a session's own worktree (`server/lib/session-worktree.js`):
* it is resolved only for a session the workspace OWNS whose cwd git registers
* as a worktree of the workspace's repo, and removing it never forces — git
* refuses a dirty tree, `branch -d` keeps unmerged work, a lock held by a live
* process is refused and a stale one cleared first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "session-wt-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
const REPO = path.join(ROOT, "repo");
// A git hook (the pre-commit suite) exports GIT_DIR / GIT_INDEX_FILE for the
// repo being committed; inherited, every git call below would act on THAT repo.
const ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
const git = (...args) =>
execFileSync("git", ["-C", REPO, ...args], {
encoding: "utf8",
env: {
...ENV,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
},
});
let db;
let wt;
let lane;
function addWorktree(name) {
git("worktree", "add", "-q", "-b", `worktree-${name}`, `.claude/worktrees/${name}`);
return path.join(REPO, ".claude", "worktrees", name);
}
function session(id, cwd, laneId) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) VALUES (?, ?, 'active', ?, ?, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')"
).run(id, id, cwd, laneId);
}
before(() => {
fs.mkdirSync(REPO);
git("init", "-q", "-b", "main");
fs.writeFileSync(path.join(REPO, "a.txt"), "a\n");
git("add", ".");
git("commit", "-qm", "init");
({ db } = require("../db"));
wt = require("../lib/session-worktree");
const id = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES (?, 'claude')")
.run(REPO).lastInsertRowid;
lane = db.prepare("SELECT * FROM lanes WHERE id = ?").get(id);
});
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("sessionWorktree", () => {
it("resolves an owned session's registered worktree", async () => {
const dir = addWorktree("one");
session("s-one", dir, lane.id);
const found = await wt.sessionWorktree(lane, "s-one");
assert.equal(found.path, dir);
assert.equal(found.branch, "worktree-one");
});
it("refuses a session it does not own, its own folder, and an unregistered path", async () => {
const dir = addWorktree("two");
session("s-other", dir, lane.id + 100);
session("s-root", REPO, lane.id);
fs.mkdirSync(path.join(REPO, "plain"));
session("s-plain", path.join(REPO, "plain"), lane.id);
for (const id of ["s-other", "s-root", "s-plain", "s-missing"]) {
await assert.rejects(wt.sessionWorktree(lane, id), (e) => e.code === "ENOWORKTREE", id);
}
});
});
describe("removeSessionWorktree", () => {
it("asks for confirmation", async () => {
await assert.rejects(wt.removeSessionWorktree(lane, "s-one", {}), (e) => e.code === "ECONFIRM");
});
it("removes a clean worktree and its merged branch", async () => {
const out = await wt.removeSessionWorktree(lane, "s-one", { confirm: true });
assert.deepEqual(out, { removed: true, branch: "worktree-one", branchKept: false });
assert.equal(fs.existsSync(path.join(REPO, ".claude/worktrees/one")), false);
assert.equal(git("branch", "--list", "worktree-one").trim(), "");
});
it("refuses a dirty worktree and leaves it as it was", async () => {
const dir = addWorktree("dirty");
session("s-dirty", dir, lane.id);
fs.writeFileSync(path.join(dir, "a.txt"), "changed\n");
await assert.rejects(wt.removeSessionWorktree(lane, "s-dirty", { confirm: true }));
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "changed\n");
});
it("keeps a branch that holds unmerged commits", async () => {
const dir = addWorktree("work");
session("s-work", dir, lane.id);
fs.writeFileSync(path.join(dir, "b.txt"), "b\n");
execFileSync("git", ["-C", dir, "add", "."], { env: ENV });
execFileSync(
"git",
["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "b"],
{ env: ENV }
);
const out = await wt.removeSessionWorktree(lane, "s-work", { confirm: true });
assert.equal(out.branchKept, true);
assert.equal(git("branch", "--list", "worktree-work").trim(), "worktree-work");
});
it("refuses a lock held by a live process and clears a stale one", async () => {
const dir = addWorktree("locked");
session("s-locked", dir, lane.id);
git("worktree", "lock", "--reason", `claude session locked (pid ${process.pid} start 1)`, dir);
await assert.rejects(
wt.removeSessionWorktree(lane, "s-locked", { confirm: true }),
(e) => e.code === "ELOCKED"
);
git("worktree", "unlock", dir);
// A pid that cannot be alive: past the kernel's pid_max.
git("worktree", "lock", "--reason", "claude session locked (pid 2147483646 start 1)", dir);
const out = await wt.removeSessionWorktree(lane, "s-locked", { confirm: true });
assert.equal(out.removed, true);
});
});
+9
View File
@@ -575,6 +575,15 @@ try {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run();
}
// Migrate: add `worktree` to dashboard_runs — 1 when the run's pane works in a
// git worktree of its own (`claude --worktree=<name>`, or a resume of such a
// session), so the session view points its git panel there.
try {
db.prepare("SELECT worktree FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN worktree INTEGER NOT NULL DEFAULT 0").run();
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence —
// these are additive columns separate from `stage` (the declared stage), so
// `stage`'s meaning is untouched and turning the feature off loses nothing.
+4 -3
View File
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
INSERT OR REPLACE INTO dashboard_runs (
id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
lane_id, tool
lane_id, tool, worktree
) VALUES (
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
@lane_id, @tool
@lane_id, @tool, @worktree
)
`);
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
resume_session_id, prompt_preview, status, exit_code,
started_at, ended_at, lane_id, tool`;
started_at, ended_at, lane_id, tool, worktree`;
const listStmt = db.prepare(`
SELECT ${RUN_COLUMNS}
@@ -114,6 +114,7 @@ function recordRun(handle) {
ended_at: endedAt,
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
worktree: handle.worktree ? 1 : 0,
});
} catch (err) {
// Best-effort by design (a live run must never fail because sqlite did),
+22 -1
View File
@@ -58,4 +58,25 @@ function excludeFromGit(laneDir, relPaths) {
}
}
module.exports = { excludeFromGit };
/**
* The same, anchored at the REPOSITORY root rather than `dir` — for paths a
* tool creates at the root whatever subdirectory it was started in (Claude
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`.
*/
function excludeFromRepoRoot(dir, relPaths) {
try {
const env = { ...process.env };
for (const name of REPO_VARS) delete env[name];
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: dir,
env,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (root) excludeFromGit(root, relPaths);
} catch {
/* not a repo */
}
}
module.exports = { excludeFromGit, excludeFromRepoRoot };
+24
View File
@@ -107,6 +107,7 @@ function buildClaudeArgv({
effort,
resumeSessionId,
forkSession = false,
worktreeName = null,
initialPrompt,
addDirs = [],
systemPromptFile = null,
@@ -117,6 +118,11 @@ function buildClaudeArgv({
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
// the prompt as the worktree's name.
if (worktreeName) argv.push(`--worktree=${worktreeName}`);
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
@@ -228,6 +234,10 @@ function buildArgv(args, entry) {
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.forkSession] resume `resumeSessionId` into a new
* session id (`claude --fork-session`); Claude only.
* @param {boolean} [args.worktree] start a NEW Claude session in its own git
* worktree (`claude --worktree=<name>`, name made here); Claude only.
* @param {boolean} [args.inWorktree] `cwd` is a session's worktree (a resume
* into it, resolved by the caller from the session row) — recorded only.
* @param {boolean} [args.resumeLast]
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
@@ -248,6 +258,8 @@ function spawnRun(args) {
approval,
resumeSessionId,
forkSession,
worktree,
inWorktree,
resumeLast,
bypass,
initialPrompt,
@@ -300,6 +312,9 @@ function spawnRun(args) {
) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
if (worktree === true && entry.launcher !== "claude") {
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
}
if (forkSession === true) {
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
if (entry.launcher !== "claude") {
@@ -324,6 +339,11 @@ function spawnRun(args) {
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
// Claude Code does not exclude the worktrees it makes, so the main repo
// would list `.claude/` as untracked; the root's info/exclude hides them.
if (worktree === true) {
require("./git-exclude").excludeFromRepoRoot(cwd, [".claude/worktrees/"]);
}
// The workspace's own folders, context files and instructions — read from
// its rows here, the one place both spawn paths pass through, never from a
// request (server/lib/lane-sources.js).
@@ -344,6 +364,7 @@ function spawnRun(args) {
approval,
resumeSessionId,
forkSession: forkSession === true,
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
resumeLast,
bypass: bypass === true,
initialPrompt,
@@ -372,6 +393,7 @@ function spawnRun(args) {
permissionMode: permissionMode || "auto",
effort: effort || null,
resumeSessionId: resumeSessionId || null,
worktree: worktree === true || inWorktree === true,
prompt: initialPrompt || "",
status: "running",
startedAt,
@@ -488,6 +510,8 @@ function publicRun(id) {
effort: row?.effort || null,
resumeSessionId: row?.resume_session_id || null,
sessionId: row?.session_id || null,
// The pane works in a git worktree of its own (`claude --worktree`).
worktree: !!row?.worktree,
startedAt: row?.started_at || null,
promptPreview: row?.prompt_preview || null,
};
+92
View File
@@ -0,0 +1,92 @@
/**
* @file The git worktree a workspace session runs in — Claude Code's own
* `claude --worktree=<name>` (`<repo>/.claude/worktrees/<name>` on branch
* `worktree-<name>`), started from a workspace pane. A session's worktree is
* resolved ONLY for a session the workspace owns (`sessions.lane_id`) whose
* recorded cwd is not the workspace's own folder and is registered by
* `git worktree list` of the workspace's repo: that is the whole authorization
* for a git route, a resume or a removal to work there, and nothing in a
* request is ever read as a path. Removal is Claude Desktop's "archive a
* worktree session" and never forces: `git worktree remove` refuses a dirty
* tree, `git branch -d` keeps unmerged work, a lock held by a live process is
* refused and only a stale one (dead pid) is cleared.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { db } = require("../db");
const { git, listWorktrees } = require("./worktree");
const getSession = db.prepare("SELECT id, cwd, lane_id FROM sessions WHERE id = ?");
function makeErr(code, message) {
return Object.assign(new Error(message), { code });
}
const real = (p) => {
try {
return fs.realpathSync(p);
} catch {
return null;
}
};
/**
* @returns {Promise<{path: string, branch: string|null, locked: boolean, lockReason: string|null}>}
* @throws ENOWORKTREE when the session is not this workspace's or not in a worktree of its repo.
*/
async function sessionWorktree(lane, sessionId) {
const none = () => makeErr("ENOWORKTREE", "that session has no worktree in this workspace");
const s = typeof sessionId === "string" && sessionId ? getSession.get(sessionId) : null;
if (!s || s.lane_id !== lane.id || typeof s.cwd !== "string" || !s.cwd) throw none();
const dir = real(s.cwd);
if (!dir || dir === real(lane.cwd)) throw none();
const found = (await listWorktrees(lane.cwd).catch(() => [])).find((w) => real(w.path) === dir);
if (!found) throw none();
return {
path: dir,
branch: found.branch || null,
locked: !!found.locked,
lockReason: found.lockReason || null,
};
}
/** A lock git can name a holder for (`… (pid N …)`) whose process is gone. */
function staleLock(reason) {
const pid = Number(/\bpid (\d+)\b/.exec(reason || "")?.[1]);
if (!pid) return false; // no holder to check: treat it as held
try {
process.kill(pid, 0);
return false;
} catch (err) {
return err.code === "ESRCH";
}
}
/**
* Removes a session's worktree and, when git agrees it is merged, its branch.
* @returns {Promise<{removed: true, branch: string|null, branchKept: boolean}>}
*/
async function removeSessionWorktree(lane, sessionId, { confirm } = {}) {
if (confirm !== true) throw makeErr("ECONFIRM", "removing a worktree needs confirm: true");
const wt = await sessionWorktree(lane, sessionId);
if (wt.locked) {
if (!staleLock(wt.lockReason)) {
throw makeErr("ELOCKED", "the worktree is locked by a running session — exit it first");
}
await git(lane.cwd, ["worktree", "unlock", wt.path]);
}
// No --force: git refuses modified or untracked files, which is the point.
await git(lane.cwd, ["worktree", "remove", wt.path]);
let branchKept = false;
if (wt.branch) {
// -d, never -D: a branch with commits merged nowhere stays.
branchKept = await git(lane.cwd, ["branch", "-d", "--", wt.branch]).then(
() => false,
() => true
);
}
return { removed: true, branch: wt.branch, branchKept };
}
module.exports = { sessionWorktree, removeSessionWorktree };
+5 -2
View File
@@ -192,7 +192,7 @@ function slugify(text) {
* Records are separated by blank lines, with keys like:
* - worktree <path>
* - branch refs/heads/<name>
* - locked (optional, bare line)
* - locked [<reason>] (optional)
*/
async function listWorktrees(sourceRepo) {
const result = await git(sourceRepo, ["worktree", "list", "--porcelain"]);
@@ -215,8 +215,11 @@ async function listWorktrees(sourceRepo) {
const branchPath = line.slice("branch ".length);
// Strip refs/heads/ prefix
current.branch = branchPath.replace(/^refs\/heads\//, "");
} else if (line === "locked") {
} else if (line === "locked" || line.startsWith("locked ")) {
// `locked <reason>` when one was given (Claude Code locks its session
// worktrees with `claude session <name> (pid …)`).
current.locked = true;
if (line.length > "locked".length) current.lockReason = line.slice("locked ".length);
}
}
+137 -36
View File
@@ -14,6 +14,7 @@ const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const aiTools = require("../lib/ai-tools");
const {
listPipelines,
@@ -834,11 +835,14 @@ router.get("/:id/integrations/:name", (req, res) => {
* registration after it: the suite stayed green.
*/
router.get("/:id/git", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const t = await gitTargetOr404(req, res);
if (!t) return;
try {
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
res.json({
available: true,
...(await gitFacts(t.cwd)),
...(t.worktree ? { worktree: t.worktree } : {}),
});
} catch {
res.json({ available: false });
}
@@ -860,13 +864,14 @@ router.get("/:id/git", async (req, res) => {
* GET /:id/git. See server/lib/git-diff.js for the caps.
*/
router.get("/:id/git/changes", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
const full = req.query.full === "1" || req.query.full === "true";
try {
const changes = await collectChanges(lane.cwd, {
const changes = await collectChanges(cwd, {
range,
base: lane.base_branch,
sha: typeof req.query.sha === "string" ? req.query.sha : null,
@@ -891,10 +896,11 @@ router.get("/:id/git/changes", async (req, res) => {
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
*/
router.get("/:id/git/log", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const commits = await listCommits(lane.cwd, {
const commits = await listCommits(cwd, {
limit: req.query.limit,
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
});
@@ -915,11 +921,12 @@ router.get("/:id/git/log", async (req, res) => {
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
*/
router.get("/:id/git/file", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const file = await readFileAt(
lane.cwd,
cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
@@ -942,12 +949,13 @@ router.get("/:id/git/file", async (req, res) => {
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
*/
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
const body = req.body || {};
try {
await writeLaneFile(lane.cwd, body.path, body.content, body.baseSha);
res.json({ available: true, ...(await readFileAt(lane.cwd, body.path, "work")) });
await writeLaneFile(cwd, body.path, body.content, body.baseSha);
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
} catch (err) {
const status =
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
@@ -958,10 +966,11 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
/** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
try {
res.json({ available: true, ...(await listPaths(lane.cwd, { q: req.query.q })) });
res.json({ available: true, ...(await listPaths(cwd, { q: req.query.q })) });
} catch {
res.json({ available: false, paths: [], truncated: false, total: 0 });
}
@@ -969,10 +978,11 @@ router.get("/:id/git/ls", async (req, res) => {
/** Every level of the tracked tree in one read, for "expand all folders". */
router.get("/:id/git/tree-all", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { cwd } = t;
try {
res.json({ available: true, ...(await listTreeAll(lane.cwd)) });
res.json({ available: true, ...(await listTreeAll(cwd)) });
} catch {
res.json({ available: false, levels: {}, truncated: false });
}
@@ -980,11 +990,12 @@ router.get("/:id/git/tree-all", async (req, res) => {
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
router.get("/:id/git/tree", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const subdir = typeof req.query.path === "string" ? req.query.path : "";
res.json({ available: true, ...(await listTree(lane.cwd, { subdir })) });
res.json({ available: true, ...(await listTree(cwd, { subdir })) });
} catch (err) {
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
@@ -1000,11 +1011,12 @@ router.get("/:id/git/tree", async (req, res) => {
* so no path ever comes from the request.
*/
router.get("/:id/git/branches", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
const { branches, current } = await listBranches(lane.cwd);
const worktrees = await listWorktrees(lane.cwd).catch(() => []);
const { branches, current } = await listBranches(cwd);
const worktrees = await listWorktrees(cwd).catch(() => []);
res.json({ available: true, branches, current, worktrees });
} catch {
res.json({ available: false, branches: [], current: null, worktrees: [] });
@@ -1022,19 +1034,20 @@ router.get("/:id/git/branches", async (req, res) => {
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const t = await gitTargetOr404(req, res);
if (!t) return;
const { lane, cwd } = t;
try {
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
// cwd — or a root lane and a subdirectory lane of one repo — share one
// index, and their writes would otherwise race on index.lock. The git dir
// is per worktree, so sibling worktree lanes still run in parallel; a
// cwd that is not a repo falls back to its real path.
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"])
.then((r) => r.stdout.trim())
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
.catch(() => fs.promises.realpath(cwd).catch(() => cwd));
const result = await withLaneLock(`git:${gitDir}`, () =>
runGitAction(lane.cwd, req.params.action, req.body || {})
runGitAction(cwd, req.params.action, req.body || {})
);
res.json(result);
} catch (err) {
@@ -1090,6 +1103,46 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
}
});
/**
* Claude Desktop's "archive a worktree session": remove the git worktree a
* session of this workspace ran in (`claude --worktree`), then its branch when
* git agrees it is merged. The session is named, never a path; the tree comes
* from `sessionWorktree`. Refused while a live pane holds the session
* (`ELIVE`), and every refusal git itself makes — a dirty tree, an unmerged
* branch (kept and reported) — stands: nothing here forces.
*/
router.post("/:id/session-worktree/remove", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const sessionId = typeof req.body?.session === "string" ? req.body.session : "";
const held = runs
.listRuns()
.some((r) => r.laneId === lane.id && r.status === "running" && r.sessionId === sessionId);
if (held) {
return res.status(409).json({
error: { code: "ELIVE", message: "a pane is still running that session — exit it first" },
});
}
try {
res.json(await removeSessionWorktree(lane, sessionId, { confirm: req.body?.confirm }));
} catch (err) {
// git ran and refused (a dirty tree): the repository answering, a 409.
const refused = !!err.git;
const status = ["ECONFIRM", "ENOWORKTREE"].includes(err.code)
? 400
: err.code === "ELOCKED" || refused
? 409
: 500;
const detail = refused ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: refused ? "EGIT" : err.code || "EGIT",
message: detail ? `${err.message}\n${detail}` : err.message,
},
});
}
});
/**
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
@@ -1422,6 +1475,28 @@ function gitErrorCode(err) {
}
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
/**
* Which tree a git route works in: the workspace's own folder, or — with
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
* `sessionWorktree` from the session ROW (owned by this workspace, registered
* by `git worktree list`), never from a path in the request. An unresolvable
* session is a 400, not a silent fall back to the main folder: a commit or a
* discard must never land in the wrong tree.
*/
async function gitTargetOr404(req, res) {
const lane = laneOr404(req, res);
if (!lane) return null;
const session = req.query.session;
if (session === undefined) return { lane, cwd: lane.cwd, worktree: null };
try {
const wt = await sessionWorktree(lane, String(session));
return { lane, cwd: wt.path, worktree: { path: wt.path, branch: wt.branch } };
} catch (err) {
res.status(400).json({ error: { code: err.code, message: err.message } });
return null;
}
}
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
if (!lane) {
@@ -1752,6 +1827,29 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
});
}
// A session that ran in a worktree of its own resumes (or forks) THERE:
// Claude finds a session by the folder it ran in. Resolved from the
// session row, before the lock; anything else starts as it always did.
let resumeWorktree = null;
if (typeof body.resumeSessionId === "string" && body.resumeSessionId) {
resumeWorktree = await sessionWorktree(lane, body.resumeSessionId).catch(() => null);
const row = resumeWorktree
? null
: db
.prepare("SELECT cwd, lane_id FROM sessions WHERE id = ?")
.get(body.resumeSessionId);
// One of this workspace's worktree sessions whose worktree is gone
// (Claude removed it on exit): there is no folder to resume it in.
if (
row &&
row.lane_id === lane.id &&
/\/\.claude\/worktrees\/[^/]+\/?$/.test(row.cwd || "")
) {
return res.status(400).json({
error: { code: "ENOWORKTREE", message: "that session's worktree no longer exists" },
});
}
}
// Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into. Stop the first run before starting a
@@ -1774,7 +1872,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
laneId: current.id,
slot,
initialPrompt: body.prompt || "",
cwd: current.cwd,
cwd: resumeWorktree ? resumeWorktree.path : current.cwd,
inWorktree: !!resumeWorktree,
// A NEW session in a worktree of its own (`claude --worktree`).
worktree: body.worktree === true && !body.resumeSessionId,
// The tool is the LANE's fact, never the request's: a client that
// asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe.