Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 428 additions & 0 deletions docs/design/2026-07-19-webshell-worktree-sessions.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,8 @@ interface SessionEntry {
/** Immutable creator attribution, persisted in the transcript when present. */
sourceType?: string;
sourceId?: string;
/** Worktree isolation metadata, when created with worktree param. */
worktree?: { slug: string; path: string; branch: string };
channel: AcpChannel;
connection: ClientSideConnection;
/** Per-session event bus drives `GET /session/:id/events`. */
Expand Down Expand Up @@ -1668,6 +1670,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
hasTurnError: entry.turnError !== undefined,
...(entry.turnError !== undefined ? { turnError: entry.turnError } : {}),
pendingInteractions: [...entry.pendingInteractions.values()],
...(entry.worktree ? { worktree: entry.worktree } : {}),
};
};
// Pending + resolved permission state lives in
Expand Down Expand Up @@ -2223,6 +2226,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
parentSessionId?: string,
sourceType?: string,
sourceId?: string,
worktree?: { slug: string; path: string; branch: string },
): Promise<BridgeSession> {
// Get-or-create the daemon's single channel, then call
// `connection.newSession()` on it. Sessions share the child's
Expand Down Expand Up @@ -2327,7 +2331,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
newSessionResp.sessionId,
boundWorkspace,
undefined,
{ parentSessionId, sourceType, sourceId },
{ parentSessionId, sourceType, sourceId, worktree },
);
initializedSessionId = entry.sessionId;
sessionRegistered = true;
Expand Down Expand Up @@ -2518,6 +2522,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
...(entry.parentSessionId
? { parentSessionPersisted: parentSessionPersisted === true }
: {}),
...(entry.worktree ? { worktree: entry.worktree } : {}),
};
} finally {
ci.sessionSpawnsInFlight = Math.max(0, ci.sessionSpawnsInFlight - 1);
Expand Down Expand Up @@ -3462,6 +3467,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
parentSessionId?: string;
sourceType?: string;
sourceId?: string;
worktree?: { slug: string; path: string; branch: string };
} = {},
): SessionEntry => {
const entry: SessionEntry = {
Expand All @@ -3473,6 +3479,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
: {}),
...(options.sourceType ? { sourceType: options.sourceType } : {}),
...(options.sourceId !== undefined ? { sourceId: options.sourceId } : {}),
...(options.worktree ? { worktree: options.worktree } : {}),
channel: ci.channel,
connection: ci.connection,
events,
Expand Down Expand Up @@ -4651,6 +4658,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
req.parentSessionId,
source.sourceType,
source.sourceId,
req.worktree,
);
// Track in-flight spawns regardless of scope. Under `single`
// this also serves the coalescing path above (a parallel
Expand Down
6 changes: 6 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export interface BridgeSpawnRequest {
/** Optional source-specific identifier. Valid only with `sourceType`. */
sourceId?: string;
approvalMode?: ApprovalMode;
/** Worktree isolation metadata, set by the daemon route before spawn. */
worktree?: { slug: string; path: string; branch: string };
}

export interface BridgeSession {
Expand Down Expand Up @@ -126,6 +128,8 @@ export interface BridgeSession {
sourceId?: string;
/** True iff the source metadata was durably written to the transcript. */
sourcePersisted?: boolean;
/** Present when the session was created with worktree isolation. */
worktree?: { slug: string; path: string; branch: string };
}

export interface BridgeRestoreSessionRequest {
Expand Down Expand Up @@ -406,6 +410,8 @@ export interface BridgeSessionSummary {
groupId?: string | null;
/** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */
color?: SessionGroupPresetColor | null;
/** Present when the session was created with worktree isolation. */
worktree?: { slug: string; path: string; branch: string };
}

/**
Expand Down
158 changes: 158 additions & 0 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
APPROVAL_MODES,
BTW_MAX_INPUT_LENGTH,
GROUP_COLOR_OPTIONS,
GitWorktreeService,
SessionService,
SessionOrganizationError,
SESSION_TRANSCRIPT_MAX_LIMIT,
Expand All @@ -20,6 +21,8 @@ import {
SessionTranscriptSnapshotUnavailableError,
addDaemonRequestAttribute,
runWithoutDebugLogSession,
writeWorktreeSessionMarker,
writeWorktreeSession,
type ApprovalMode,
type SessionGroupColor,
type SessionGroupPresetColor,
Expand Down Expand Up @@ -1195,6 +1198,84 @@ export function registerSessionRoutes(
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;

// ── Worktree isolation ──────────────────────────────────────────
// When `worktree` is present, create a git worktree before spawning
// and relocate the session into it immediately after. The workspace
// runtime resolution still uses the main workspace cwd; only the
// child process's effective working directory changes.
let worktreeMeta:
| { slug: string; path: string; branch: string }
| undefined;
const rawWorktree = body['worktree'];
if (rawWorktree !== undefined && rawWorktree !== null) {
if (typeof rawWorktree !== 'object' || Array.isArray(rawWorktree)) {
res.status(400).json({
error:
'`worktree` must be an object (e.g. `{}` or `{"slug":"my-task"}`)',
code: 'invalid_worktree',
});
return;
}
const wtReq = rawWorktree as Record<string, unknown>;
let wtService: GitWorktreeService;
try {
wtService = new GitWorktreeService(workspaceCwd);
} catch {
Comment thread
wenshao marked this conversation as resolved.
res.status(500).json({
error: 'Failed to initialize worktree service',
code: 'worktree_init_failed',
});
return;
}
if (!(await wtService.isGitRepository())) {
res.status(400).json({
error: 'Worktree isolation requires a git repository',
code: 'worktree_not_git_repo',
});
return;
}
const rawSlug = wtReq['slug'];
let slug: string;
if (rawSlug === undefined || rawSlug === null) {
slug = GitWorktreeService.generateAutoSlug();
Comment thread
wenshao marked this conversation as resolved.
} else if (typeof rawSlug !== 'string' || rawSlug.length === 0) {
res.status(400).json({
error: '`worktree.slug` must be a non-empty string when provided',
code: 'worktree_invalid_slug',
});
return;
} else {
slug = rawSlug;
}
const slugError = GitWorktreeService.validateUserWorktreeSlug(slug);
if (slugError) {
res
.status(400)
.json({ error: slugError, code: 'worktree_invalid_slug' });
return;
}
const baseBranch = await wtService
.getCurrentBranch()
.catch(() => undefined);
const wtResult = await wtService.createUserWorktree(slug, baseBranch);
if (!wtResult.success || !wtResult.worktree) {
Comment thread
wenshao marked this conversation as resolved.
res.status(500).json({
error: wtResult.error ?? 'Failed to create worktree',
code: 'worktree_create_failed',
});
return;
}
worktreeMeta = {
slug,
path: wtResult.worktree.path,
branch: wtResult.worktree.branch,
};
// Worktree sessions must be independent — never coalesce onto an
// existing single-scope session that lives in the main checkout.
sessionScope = 'thread';
}

try {
const session = await runtime.bridge.spawnOrAttach({
workspaceCwd,
Expand All @@ -1206,6 +1287,7 @@ export function registerSessionRoutes(
? { sourceType: source.sourceType }
: {}),
...(source.sourceId !== undefined ? { sourceId: source.sourceId } : {}),
...(worktreeMeta ? { worktree: worktreeMeta } : {}),
});
// Client may have disconnected during the 1–3s spawn window. If
// so, the response can't be delivered. The session is otherwise
Expand Down Expand Up @@ -1260,6 +1342,12 @@ export function registerSessionRoutes(
await new SessionService(runtime.workspaceCwd).removeSession(
session.sessionId,
);
// Clean up the worktree if one was created for this session.
if (worktreeMeta) {
Comment thread
wenshao marked this conversation as resolved.
await new GitWorktreeService(workspaceCwd)
.removeUserWorktree(worktreeMeta.slug, { deleteBranch: true })
.catch(() => {});
Comment thread
wenshao marked this conversation as resolved.
}
}
} catch {
// Best-effort cleanup; channel.exited will eventually reap.
Expand All @@ -1282,8 +1370,78 @@ export function registerSessionRoutes(
}
return;
}

// Relocate the freshly spawned session into its worktree. The
// cd chains onto the session's promptQueue, so it completes
// before any subsequent prompt is processed.
if (worktreeMeta) {
try {
await runtime.bridge.changeSessionCwd(session.sessionId, {
path: worktreeMeta.path,
});
Comment thread
wenshao marked this conversation as resolved.
await writeWorktreeSessionMarker(
worktreeMeta.path,
session.sessionId,
).catch(() => {});
// Write the worktree sidecar so the session list can restore
// worktree metadata after a daemon restart.
await writeWorktreeSession(
new SessionService(workspaceCwd).getWorktreeSessionPath(
session.sessionId,
),
{
slug: worktreeMeta.slug,
worktreePath: worktreeMeta.path,
worktreeBranch: worktreeMeta.branch,
originalCwd: workspaceCwd,
originalBranch: '',
originalHeadCommit: '',
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
},
).catch(() => {});
} catch (cdErr) {
// cd failed — relocation is transactional: kill the session,
// remove the worktree, and return an error. Leaving the session
Comment thread
wenshao marked this conversation as resolved.
// alive with stale worktree metadata in the bridge entry would
// make GET /session/:id/status claim isolation the session
// doesn't have.
if (daemonLog) {
daemonLog.warn('worktree cd failed, rolling back', {
sessionId: session.sessionId,
error: cdErr instanceof Error ? cdErr.message : String(cdErr),
});
}
const killed = await runtime.bridge
.killSession(session.sessionId, { requireZeroAttaches: true })
Comment thread
wenshao marked this conversation as resolved.
.catch(() => false);
if (killed) {
await new SessionService(workspaceCwd)
.removeSession(session.sessionId)
.catch(() => {});
}
// cd failed so the session never entered the worktree — the
// worktree is unused regardless of whether the session was
// killed or another client keeps it alive in the main checkout.
await new GitWorktreeService(workspaceCwd)
Comment thread
wenshao marked this conversation as resolved.
.removeUserWorktree(worktreeMeta.slug, { deleteBranch: true })
.catch(() => {});
Comment thread
wenshao marked this conversation as resolved.
Comment thread
wenshao marked this conversation as resolved.
res.status(500).json({
error: 'Failed to relocate session into worktree',
code: 'worktree_relocate_failed',
});
return;
}
}

res.status(200).json(session);
} catch (err) {
// Roll back the worktree if spawn failed — otherwise the directory
// and branch are orphaned (the agent-* stale cleanup won't collect
Comment thread
wenshao marked this conversation as resolved.
// user-named worktrees).
if (worktreeMeta) {
await new GitWorktreeService(workspaceCwd)
.removeUserWorktree(worktreeMeta.slug, { deleteBranch: true })
.catch(() => {});
}
sendBridgeError(res, err, { route: 'POST /session' });
}
});
Expand Down
53 changes: 49 additions & 4 deletions packages/cli/src/serve/routes/workspace-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
*/

import type { Application, Request, Response } from 'express';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getGitWorkingTreeStatus } from '@qwen-code/qwen-code-core';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import type { SendBridgeError } from '../server/error-response.js';
import type { WorkspaceGitState } from '../workspace-git-state.js';
Expand Down Expand Up @@ -59,12 +62,54 @@ export function registerWorkspaceQualifiedGitRoutes(
const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res);
if (!runtime) return;
const route = 'GET /workspaces/:workspace/git';
// Optional ?cwd= override for worktree sessions whose working directory
// differs from the workspace root. Canonicalize both paths with realpath
// to prevent symlink escape, then validate containment.
const rawCwd = req.query['cwd'];
let gitCwd = runtime.workspaceCwd;
if (typeof rawCwd === 'string' && rawCwd.length > 0) {
Comment thread
wenshao marked this conversation as resolved.
try {
const resolved = fs.realpathSync(path.resolve(rawCwd));
const root = fs.realpathSync(runtime.workspaceCwd);
const rel = path.relative(root, resolved);
Comment thread
wenshao marked this conversation as resolved.
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
gitCwd = resolved;
}
} catch {
// Path doesn't exist or can't be resolved — use workspace root.
}
}
try {
res
.status(200)
.json(
await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge),
if (gitCwd !== runtime.workspaceCwd) {
// Worktree cwd: call getGitWorkingTreeStatus directly to avoid
// creating a watcher entry in WorkspaceGitState (which would leak
// one fs watcher per worktree path, never disposed).
const status = await getGitWorkingTreeStatus(gitCwd).catch(() => null);
Comment thread
wenshao marked this conversation as resolved.
res.status(200).json(
status
? {
v: 2,
workspaceCwd: gitCwd,
branch: status.branch ?? null,
detached: status.detached,
Comment thread
wenshao marked this conversation as resolved.
staged: status.staged,
unstaged: status.unstaged,
untracked: status.untracked,
conflicted: status.conflicted,
hasUpstream: status.hasUpstream,
ahead: status.ahead,
behind: status.behind,
stashCount: status.stashCount,
...(status.operation ? { operation: status.operation } : {}),
computedAt: Date.now(),
}
: { v: 2, workspaceCwd: gitCwd, branch: null },
);
} else {
res
.status(200)
.json(await deps.gitState.getStatus(gitCwd, runtime.bridge));
}
} catch (err) {
deps.sendBridgeError(res, err, { route });
}
Expand Down
Loading
Loading