feat: add git worktree session support with improved UI

Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection.

Backend:
- Add worktree creation/removal utilities with branch management
- Track worktree metadata (basePath, branch, name, path) in session metadata
- Automatic cleanup of worktrees when sessions fail or exit
- Enhanced error handling with stderr tail logging

UI improvements:
- Redesign session type toggle with improved alignment and spacing
- Move worktree description inline with label for cleaner layout
- Add branch name input field that appears when worktree mode selected
- Auto-focus on worktree input when switching modes
- Reduce gap between radio options from gap-3 to gap-1.5
- Update descriptive text and placeholders for clarity

Integration:
- Thread worktree parameters through API client, RPC handlers, and daemon
- Add worktreeEnv utility to read worktree info from environment
- Update session spawning to support both simple and worktree modes
This commit is contained in:
weishu
2025-12-28 13:17:58 +08:00
parent 0eab45c49c
commit c35a5ce827
21 changed files with 605 additions and 69 deletions
+4 -1
View File
@@ -20,6 +20,7 @@ import { initialMachineMetadata } from '@/daemon/run';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import { readWorktreeEnv } from '@/utils/worktreeEnv';
function emitReadyIfIdle(props: {
queueSize: () => number;
@@ -56,6 +57,7 @@ export async function runAgentSession(opts: {
controlledByUser: false
};
const worktreeInfo = readWorktreeEnv();
const metadata: Metadata = {
path: process.cwd(),
host: os.hostname(),
@@ -71,7 +73,8 @@ export async function runAgentSession(opts: {
startedBy: opts.startedBy || 'terminal',
lifecycleState: 'running',
lifecycleStateSince: Date.now(),
flavor: opts.agentType
flavor: opts.agentType,
worktree: worktreeInfo ?? undefined
};
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
+4 -2
View File
@@ -70,7 +70,7 @@ export class ApiMachineClient {
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => {
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, yolo, token } = params || {}
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, yolo, token, sessionType, worktreeName } = params || {}
if (!directory) {
throw new Error('Directory is required')
@@ -83,7 +83,9 @@ export class ApiMachineClient {
approvedNewDirectoryCreation,
agent,
yolo,
token
token,
sessionType,
worktreeName
})
switch (result.type) {
+4 -2
View File
@@ -51,7 +51,10 @@ export class RpcHandlerManager {
const result = await handler(params as any)
return JSON.stringify(result)
} catch (error) {
this.logger('[RPC] [ERROR] Error handling request', { error })
const details = error instanceof Error
? { message: error.message, stack: error.stack }
: { error: String(error) }
this.logger('[RPC] [ERROR] Error handling request', details)
return JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error'
})
@@ -91,4 +94,3 @@ export class RpcHandlerManager {
export function createRpcHandlerManager(config: RpcHandlerConfig): RpcHandlerManager {
return new RpcHandlerManager(config)
}
+15 -1
View File
@@ -41,6 +41,13 @@ export type Metadata = {
archivedBy?: string
archiveReason?: string
flavor?: string
worktree?: {
basePath: string
branch: string
name: string
worktreePath?: string
createdAt?: number
}
}
export const MetadataSchema = z.object({
@@ -69,7 +76,14 @@ export const MetadataSchema = z.object({
lifecycleStateSince: z.number().optional(),
archivedBy: z.string().optional(),
archiveReason: z.string().optional(),
flavor: z.string().optional()
flavor: z.string().optional(),
worktree: z.object({
basePath: z.string(),
branch: z.string(),
name: z.string(),
worktreePath: z.string().optional(),
createdAt: z.number().optional()
}).optional()
}).passthrough()
export type AgentState = {
+4 -1
View File
@@ -24,6 +24,7 @@ import { registerKillSessionHandler } from './registerKillSessionHandler';
import { runtimePath } from '../projectPath';
import { resolve } from 'node:path';
import type { Session } from './session';
import { readWorktreeEnv } from '@/utils/worktreeEnv';
export interface StartOptions {
model?: string
@@ -73,6 +74,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
metadata: initialMachineMetadata
});
const worktreeInfo = readWorktreeEnv();
let metadata: Metadata = {
path: workingDirectory,
host: os.hostname(),
@@ -89,7 +91,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
// Initialize lifecycle state
lifecycleState: 'running',
lifecycleStateSince: Date.now(),
flavor: 'claude'
flavor: 'claude',
worktree: worktreeInfo ?? undefined
};
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
logger.debug(`Session created: ${response.id}`);
+4 -1
View File
@@ -18,6 +18,7 @@ import packageJson from '../../package.json';
import { runtimePath } from '@/projectPath';
import type { CodexSession } from './session';
import { parseCodexCliOverrides } from './utils/codexCliOverrides';
import { readWorktreeEnv } from '@/utils/worktreeEnv';
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
@@ -50,6 +51,7 @@ export async function runCodex(opts: {
controlledByUser: false
};
const worktreeInfo = readWorktreeEnv();
const metadata: Metadata = {
path: workingDirectory,
host: os.hostname(),
@@ -65,7 +67,8 @@ export async function runCodex(opts: {
startedBy,
lifecycleState: 'running',
lifecycleStateSince: Date.now(),
flavor: 'codex'
flavor: 'codex',
worktree: worktreeInfo ?? undefined
};
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
+6 -4
View File
@@ -108,7 +108,9 @@ export function startDaemonControlServer({
schema: {
body: z.object({
directory: z.string(),
sessionId: z.string().optional()
sessionId: z.string().optional(),
sessionType: z.enum(['simple', 'worktree']).optional(),
worktreeName: z.string().optional()
}),
response: {
200: z.object({
@@ -129,10 +131,10 @@ export function startDaemonControlServer({
}
}
}, async (request, reply) => {
const { directory, sessionId } = request.body;
const { directory, sessionId, sessionType, worktreeName } = request.body;
logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || 'new'}`);
const result = await spawnSession({ directory, sessionId });
const result = await spawnSession({ directory, sessionId, sessionType, worktreeName });
switch (result.type) {
case 'success':
@@ -208,4 +210,4 @@ export function startDaemonControlServer({
});
});
});
}
}
+141 -45
View File
@@ -16,6 +16,7 @@ import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } fro
import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient';
import { startDaemonControlServer } from './controlServer';
import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree';
import { join } from 'path';
import { runtimePath } from '@/projectPath';
@@ -188,51 +189,113 @@ export async function startDaemon(): Promise<void> {
const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options;
const agent = options.agent ?? 'claude';
const yolo = options.yolo === true;
const sessionType = options.sessionType ?? 'simple';
const worktreeName = options.worktreeName;
let directoryCreated = false;
let spawnDirectory = directory;
let worktreeInfo: WorktreeInfo | null = null;
let happyProcess: ReturnType<typeof spawnHappyCLI> | null = null;
try {
await fs.access(directory);
logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
} catch (error) {
logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
// Check if directory creation is approved
if (!approvedNewDirectoryCreation) {
logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
return {
type: 'requestToApproveDirectoryCreation',
directory
};
}
if (sessionType === 'simple') {
try {
await fs.mkdir(directory, { recursive: true });
logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
directoryCreated = true;
} catch (mkdirError: any) {
let errorMessage = `Unable to create directory at '${directory}'. `;
await fs.access(directory);
logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
} catch (error) {
logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
// Provide more helpful error messages based on the error code
if (mkdirError.code === 'EACCES') {
errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
} else if (mkdirError.code === 'ENOTDIR') {
errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
} else if (mkdirError.code === 'ENOSPC') {
errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
} else if (mkdirError.code === 'EROFS') {
errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
} else {
errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
// Check if directory creation is approved
if (!approvedNewDirectoryCreation) {
logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
return {
type: 'requestToApproveDirectoryCreation',
directory
};
}
logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
try {
await fs.mkdir(directory, { recursive: true });
logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
directoryCreated = true;
} catch (mkdirError: any) {
let errorMessage = `Unable to create directory at '${directory}'. `;
// Provide more helpful error messages based on the error code
if (mkdirError.code === 'EACCES') {
errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
} else if (mkdirError.code === 'ENOTDIR') {
errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
} else if (mkdirError.code === 'ENOSPC') {
errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
} else if (mkdirError.code === 'EROFS') {
errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
} else {
errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
}
logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
return {
type: 'error',
errorMessage
};
}
}
} else {
try {
await fs.access(directory);
logger.debug(`[DAEMON RUN] Worktree base directory exists: ${directory}`);
} catch (error) {
logger.debug(`[DAEMON RUN] Worktree base directory missing: ${directory}`);
return {
type: 'error',
errorMessage
errorMessage: `Worktree sessions require an existing Git repository. Directory not found: ${directory}`
};
}
}
if (sessionType === 'worktree') {
const worktreeResult = await createWorktree({
basePath: directory,
nameHint: worktreeName
});
if (!worktreeResult.ok) {
logger.debug(`[DAEMON RUN] Worktree creation failed: ${worktreeResult.error}`);
return {
type: 'error',
errorMessage: worktreeResult.error
};
}
worktreeInfo = worktreeResult.info;
spawnDirectory = worktreeInfo.worktreePath;
logger.debug(`[DAEMON RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`);
}
const cleanupWorktree = async () => {
if (!worktreeInfo) {
return;
}
const result = await removeWorktree({
repoRoot: worktreeInfo.basePath,
worktreePath: worktreeInfo.worktreePath
});
if (!result.ok) {
logger.debug(`[DAEMON RUN] Failed to remove worktree ${worktreeInfo.worktreePath}: ${result.error}`);
}
};
const maybeCleanupWorktree = async (reason: string) => {
if (!worktreeInfo) {
return;
}
const pid = happyProcess?.pid;
if (pid && isProcessAlive(pid)) {
logger.debug(`[DAEMON RUN] Skipping worktree cleanup after ${reason}; child still running`, {
pid,
worktreePath: worktreeInfo.worktreePath
});
return;
}
await cleanupWorktree();
};
try {
// Resolve authentication token if provided
@@ -257,6 +320,17 @@ export async function startDaemon(): Promise<void> {
}
}
if (worktreeInfo) {
extraEnv = {
...extraEnv,
HAPI_WORKTREE_BASE_PATH: worktreeInfo.basePath,
HAPI_WORKTREE_BRANCH: worktreeInfo.branch,
HAPI_WORKTREE_NAME: worktreeInfo.name,
HAPI_WORKTREE_PATH: worktreeInfo.worktreePath,
HAPI_WORKTREE_CREATED_AT: String(worktreeInfo.createdAt)
};
}
// Construct arguments for the CLI
const agentCommand = agent === 'codex'
? 'codex'
@@ -274,8 +348,26 @@ export async function startDaemon(): Promise<void> {
// TODO: In future, sessionId could be used with --resume to continue existing sessions
// For now, we ignore it - each spawn creates a new session
const happyProcess = spawnHappyCLI(args, {
cwd: directory,
const MAX_TAIL_CHARS = 4000;
let stderrTail = '';
const appendTail = (current: string, chunk: Buffer | string): string => {
const text = chunk.toString();
if (!text) {
return current;
}
const combined = current + text;
return combined.length > MAX_TAIL_CHARS ? combined.slice(-MAX_TAIL_CHARS) : combined;
};
const logStderrTail = () => {
const trimmed = stderrTail.trim();
if (!trimmed) {
return;
}
logger.debug('[DAEMON RUN] Child stderr tail', trimmed);
};
happyProcess = spawnHappyCLI(args, {
cwd: spawnDirectory,
detached: true, // Sessions stay alive when daemon stops
stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging
env: {
@@ -284,18 +376,13 @@ export async function startDaemon(): Promise<void> {
}
});
// Log output for debugging
if (process.env.DEBUG) {
happyProcess.stdout?.on('data', (data) => {
logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`);
});
happyProcess.stderr?.on('data', (data) => {
logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`);
});
}
happyProcess.stderr?.on('data', (data) => {
stderrTail = appendTail(stderrTail, data);
});
if (!happyProcess.pid) {
logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned');
await maybeCleanupWorktree('no-pid');
return {
type: 'error',
errorMessage: 'Failed to spawn HAPI process - no PID returned'
@@ -316,6 +403,9 @@ export async function startDaemon(): Promise<void> {
happyProcess.on('exit', (code, signal) => {
logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
if (code !== 0 || signal) {
logStderrTail();
}
if (happyProcess.pid) {
onChildExited(happyProcess.pid);
}
@@ -331,11 +421,12 @@ export async function startDaemon(): Promise<void> {
// Wait for webhook to populate session with happySessionId
logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
return new Promise((resolve) => {
const spawnResult = await new Promise<SpawnSessionResult>((resolve) => {
// Set timeout for webhook
const timeout = setTimeout(() => {
pidToAwaiter.delete(happyProcess.pid!);
logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
logStderrTail();
resolve({
type: 'error',
errorMessage: `Session webhook timeout for PID ${happyProcess.pid}`
@@ -354,9 +445,14 @@ export async function startDaemon(): Promise<void> {
});
});
});
if (spawnResult.type !== 'success') {
await maybeCleanupWorktree('spawn-error');
}
return spawnResult;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.debug('[DAEMON RUN] Failed to spawn session:', error);
await maybeCleanupWorktree('exception');
return {
type: 'error',
errorMessage: `Failed to spawn session: ${errorMessage}`
+176
View File
@@ -0,0 +1,176 @@
import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { access, mkdir } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export type WorktreeInfo = {
basePath: string;
worktreePath: string;
branch: string;
name: string;
createdAt: number;
};
type WorktreeResult =
| { ok: true; info: WorktreeInfo }
| { ok: false; error: string };
export type RemoveWorktreeResult =
| { ok: true }
| { ok: false; error: string };
const MAX_ATTEMPTS = 5;
async function runGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
try {
const result = await execFileAsync('git', args, { cwd });
return {
stdout: result.stdout ? result.stdout.toString() : '',
stderr: result.stderr ? result.stderr.toString() : ''
};
} catch (error) {
const execError = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
const stderr = execError.stderr ? execError.stderr.toString() : '';
const stdout = execError.stdout ? execError.stdout.toString() : '';
const message = stderr.trim() || stdout.trim() || execError.message || 'Git command failed';
throw new Error(message);
}
}
async function resolveRepoRoot(basePath: string): Promise<string> {
const result = await runGit(['rev-parse', '--show-toplevel'], basePath);
const root = result.stdout.trim();
if (!root) {
throw new Error('Unable to resolve Git repository root.');
}
return root;
}
function toSlug(value: string): string {
const cleaned = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned;
}
function formatDatePrefix(date: Date = new Date()): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}${day}`;
}
function normalizeNameHint(nameHint?: string): string | null {
if (!nameHint) {
return null;
}
const trimmed = nameHint.trim();
if (!trimmed) {
return null;
}
const slug = toSlug(trimmed);
return slug ? slug : null;
}
function makeDefaultBaseName(): string {
const suffix = randomBytes(2).toString('hex');
return `${formatDatePrefix()}-${suffix}`;
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
async function branchExists(repoRoot: string, branch: string): Promise<boolean> {
try {
await runGit(['show-ref', '--verify', `refs/heads/${branch}`], repoRoot);
return true;
} catch {
return false;
}
}
export async function createWorktree(options: {
basePath: string;
nameHint?: string;
}): Promise<WorktreeResult> {
const { basePath, nameHint } = options;
let repoRoot: string;
try {
repoRoot = await resolveRepoRoot(basePath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
error: `Path is not a Git repository: ${message}`
};
}
const repoParent = dirname(repoRoot);
const repoName = basename(repoRoot);
const repoWorktreesRoot = join(repoParent, `${repoName}-worktrees`);
await mkdir(repoWorktreesRoot, { recursive: true });
const baseName = normalizeNameHint(nameHint) ?? makeDefaultBaseName();
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
const name = attempt === 0 ? baseName : `${baseName}-${randomBytes(2).toString('hex')}`;
const branch = `hapi-${name}`;
const worktreePath = join(repoWorktreesRoot, name);
if (await pathExists(worktreePath)) {
continue;
}
if (await branchExists(repoRoot, branch)) {
continue;
}
try {
await runGit(['worktree', 'add', '-b', branch, worktreePath], repoRoot);
return {
ok: true,
info: {
basePath: repoRoot,
worktreePath,
branch,
name,
createdAt: Date.now()
}
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
error: `Failed to create worktree: ${message}`
};
}
}
return {
ok: false,
error: 'Failed to create worktree after multiple attempts. Try again.'
};
}
export async function removeWorktree(options: {
repoRoot: string;
worktreePath: string;
}): Promise<RemoveWorktreeResult> {
try {
await runGit(['worktree', 'remove', '--force', options.worktreePath], options.repoRoot);
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { ok: false, error: message };
}
}
@@ -124,6 +124,8 @@ export interface SpawnSessionOptions {
agent?: 'claude' | 'codex' | 'gemini';
yolo?: boolean;
token?: string;
sessionType?: 'simple' | 'worktree';
worktreeName?: string;
}
export type SpawnSessionResult =
+26
View File
@@ -0,0 +1,26 @@
import type { WorktreeInfo } from '@/daemon/worktree';
export function readWorktreeEnv(): WorktreeInfo | null {
const basePath = process.env.HAPI_WORKTREE_BASE_PATH?.trim();
const branch = process.env.HAPI_WORKTREE_BRANCH?.trim();
const name = process.env.HAPI_WORKTREE_NAME?.trim();
const worktreePath = process.env.HAPI_WORKTREE_PATH?.trim();
const createdAtRaw = process.env.HAPI_WORKTREE_CREATED_AT?.trim();
if (!basePath || !branch || !name || !worktreePath || !createdAtRaw) {
return null;
}
const createdAt = Number(createdAtRaw);
if (!Number.isFinite(createdAt)) {
return null;
}
return {
basePath,
branch,
name,
worktreePath,
createdAt
};
}