From c35a5ce827d87cf4dd32f74bc537dfe2f389b9a9 Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 28 Dec 2025 13:14:01 +0800 Subject: [PATCH] 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 --- cli/src/agent/runners/runAgentSession.ts | 5 +- cli/src/api/apiMachine.ts | 6 +- cli/src/api/rpc/RpcHandlerManager.ts | 6 +- cli/src/api/types.ts | 16 +- cli/src/claude/runClaude.ts | 5 +- cli/src/codex/runCodex.ts | 5 +- cli/src/daemon/controlServer.ts | 10 +- cli/src/daemon/run.ts | 186 +++++++++++++----- cli/src/daemon/worktree.ts | 176 +++++++++++++++++ .../modules/common/registerCommonHandlers.ts | 2 + cli/src/utils/worktreeEnv.ts | 26 +++ server/src/sync/syncEngine.ts | 15 +- server/src/web/routes/machines.ts | 8 +- server/src/web/routes/sessions.ts | 10 +- web/src/api/client.ts | 6 +- web/src/components/NewSession.tsx | 88 ++++++++- web/src/components/SessionHeader.tsx | 2 + web/src/components/SessionList.tsx | 3 + web/src/components/SpawnSession.tsx | 78 +++++++- web/src/hooks/mutations/useSpawnSession.ts | 11 +- web/src/types/api.ts | 10 + 21 files changed, 605 insertions(+), 69 deletions(-) create mode 100644 cli/src/daemon/worktree.ts create mode 100644 cli/src/utils/worktreeEnv.ts diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index dfca2c6e..3b938520 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -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 }); diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 1bb6f285..fad02bfd 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -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) { diff --git a/cli/src/api/rpc/RpcHandlerManager.ts b/cli/src/api/rpc/RpcHandlerManager.ts index 60850835..717e8f5d 100644 --- a/cli/src/api/rpc/RpcHandlerManager.ts +++ b/cli/src/api/rpc/RpcHandlerManager.ts @@ -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) } - diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 68a43073..fced3c0e 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -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 = { diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 3df284f0..86014d3e 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -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 { metadata: initialMachineMetadata }); + const worktreeInfo = readWorktreeEnv(); let metadata: Metadata = { path: workingDirectory, host: os.hostname(), @@ -89,7 +91,8 @@ export async function runClaude(options: StartOptions = {}): Promise { // 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}`); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index a99aabea..43118998 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -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 }); diff --git a/cli/src/daemon/controlServer.ts b/cli/src/daemon/controlServer.ts index e0e05f9b..bf36a3c0 100644 --- a/cli/src/daemon/controlServer.ts +++ b/cli/src/daemon/controlServer.ts @@ -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({ }); }); }); -} \ No newline at end of file +} diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index 1660901e..e5f70e8c 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -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 { 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 | 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 { } } + 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 { // 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 { } }); - // 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 { 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 { // 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((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 { }); }); }); + 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}` diff --git a/cli/src/daemon/worktree.ts b/cli/src/daemon/worktree.ts new file mode 100644 index 00000000..374d4daa --- /dev/null +++ b/cli/src/daemon/worktree.ts @@ -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 { + 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 { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + +async function branchExists(repoRoot: string, branch: string): Promise { + 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 { + 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 { + 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 }; + } +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index c13be033..ccca2d7e 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -124,6 +124,8 @@ export interface SpawnSessionOptions { agent?: 'claude' | 'codex' | 'gemini'; yolo?: boolean; token?: string; + sessionType?: 'simple' | 'worktree'; + worktreeName?: string; } export type SpawnSessionResult = diff --git a/cli/src/utils/worktreeEnv.ts b/cli/src/utils/worktreeEnv.ts new file mode 100644 index 00000000..e674513c --- /dev/null +++ b/cli/src/utils/worktreeEnv.ts @@ -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 + }; +} diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index a3462e3a..6186159d 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -28,7 +28,14 @@ export const MetadataSchema = z.object({ }).optional(), machineId: z.string().optional(), tools: z.array(z.string()).optional(), - flavor: z.string().nullish() + flavor: z.string().nullish(), + worktree: z.object({ + basePath: z.string(), + branch: z.string(), + name: z.string(), + worktreePath: z.string().optional(), + createdAt: z.number().optional() + }).optional() }).passthrough() export type Metadata = z.infer @@ -653,13 +660,15 @@ export class SyncEngine { machineId: string, directory: string, agent: 'claude' | 'codex' | 'gemini' = 'claude', - yolo?: boolean + yolo?: boolean, + sessionType?: 'simple' | 'worktree', + worktreeName?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, yolo } + { type: 'spawn-in-directory', directory, agent, yolo, sessionType, worktreeName } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/server/src/web/routes/machines.ts b/server/src/web/routes/machines.ts index e1b79b93..bed2ba62 100644 --- a/server/src/web/routes/machines.ts +++ b/server/src/web/routes/machines.ts @@ -6,7 +6,9 @@ import type { WebAppEnv } from '../middleware/auth' const spawnBodySchema = z.object({ directory: z.string().min(1), agent: z.enum(['claude', 'codex', 'gemini']).optional(), - yolo: z.boolean().optional() + yolo: z.boolean().optional(), + sessionType: z.enum(['simple', 'worktree']).optional(), + worktreeName: z.string().optional() }) export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Hono { @@ -44,7 +46,9 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho machineId, parsed.data.directory, parsed.data.agent, - parsed.data.yolo + parsed.data.yolo, + parsed.data.sessionType, + parsed.data.worktreeName ) return c.json(result) }) diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 0502194b..fb8dea71 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -9,6 +9,13 @@ type SessionSummaryMetadata = { path: string summary?: { text: string } flavor?: string | null + worktree?: { + basePath: string + branch: string + name: string + worktreePath?: string + createdAt?: number + } } type SessionSummary = { @@ -29,7 +36,8 @@ function toSessionSummary(session: Session): SessionSummary { name: session.metadata.name, path: session.metadata.path, summary: session.metadata.summary ? { text: session.metadata.summary.text } : undefined, - flavor: session.metadata.flavor ?? null + flavor: session.metadata.flavor ?? null, + worktree: session.metadata.worktree } : null const todoProgress = session.todos?.length ? { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d518706d..07db3989 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -231,11 +231,13 @@ export class ApiClient { machineId: string, directory: string, agent?: 'claude' | 'codex' | 'gemini', - yolo?: boolean + yolo?: boolean, + sessionType?: 'simple' | 'worktree', + worktreeName?: string ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, yolo }) + body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName }) }) } } diff --git a/web/src/components/NewSession.tsx b/web/src/components/NewSession.tsx index 10a6f7cb..2ec16595 100644 --- a/web/src/components/NewSession.tsx +++ b/web/src/components/NewSession.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ApiClient } from '@/api/client' import type { Machine } from '@/types/api' import { Button } from '@/components/ui/button' @@ -8,6 +8,7 @@ import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useRecentPaths } from '@/hooks/useRecentPaths' type AgentType = 'claude' | 'codex' | 'gemini' +type SessionType = 'simple' | 'worktree' function getMachineTitle(machine: Machine): string { if (machine.metadata?.displayName) return machine.metadata.displayName @@ -31,7 +32,17 @@ export function NewSession(props: { const [directory, setDirectory] = useState('') const [agent, setAgent] = useState('claude') const [yoloMode, setYoloMode] = useState(false) + const [sessionType, setSessionType] = useState('simple') + const [worktreeName, setWorktreeName] = useState('') const [error, setError] = useState(null) + const worktreeInputRef = useRef(null) + + // Focus worktree input when switching to worktree mode + useEffect(() => { + if (sessionType === 'worktree') { + worktreeInputRef.current?.focus() + } + }, [sessionType]) // Initialize with last used machine or first available useEffect(() => { @@ -84,7 +95,9 @@ export function NewSession(props: { machineId, directory: directory.trim(), agent, - yolo: yoloMode + yolo: yoloMode, + sessionType, + worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined }) if (result.type === 'success') { @@ -170,6 +183,77 @@ export function NewSession(props: { )} + {/* Session Type */} +
+ +
+ {(['simple', 'worktree'] as const).map((type) => ( +
+ {type === 'worktree' ? ( +
+ setSessionType('worktree')} + disabled={isFormDisabled} + className="accent-[var(--app-link)]" + /> +
+
+ {sessionType === 'worktree' ? ( + setWorktreeName(e.target.value)} + disabled={isFormDisabled} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-60" + /> + ) : ( + <> + + + Create a new git worktree next to the repo + + + )} +
+
+
+ ) : ( + + )} +
+ ))} +
+
+ {/* Agent Selector */}
{props.session.metadata?.path ?? props.session.id} + {worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''}
diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index c33fcd69..8fd4ef2b 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -162,6 +162,9 @@ export function SessionList(props: {
❖ {getAgentLabel(s)} model: {getModelLabel(s)} + {s.metadata?.worktree?.branch ? ( + worktree: {s.metadata.worktree.branch} + ) : null} {(() => { const lastSeen = getLastSeenLabel(s) if (!lastSeen) return null diff --git a/web/src/components/SpawnSession.tsx b/web/src/components/SpawnSession.tsx index b17e66c0..96e1f32c 100644 --- a/web/src/components/SpawnSession.tsx +++ b/web/src/components/SpawnSession.tsx @@ -6,6 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { usePlatform } from '@/hooks/usePlatform' import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' +type SessionType = 'simple' | 'worktree' + function getMachineTitle(machine: Machine | null): string { if (!machine) return 'Machine' if (machine.metadata?.displayName) return machine.metadata.displayName @@ -22,6 +24,8 @@ export function SpawnSession(props: { }) { const { haptic } = usePlatform() const [directory, setDirectory] = useState('') + const [sessionType, setSessionType] = useState('simple') + const [worktreeName, setWorktreeName] = useState('') const [error, setError] = useState(null) const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) @@ -33,7 +37,12 @@ export function SpawnSession(props: { setError(null) try { - const result = await spawnSession({ machineId: props.machineId, directory: trimmed }) + const result = await spawnSession({ + machineId: props.machineId, + directory: trimmed, + sessionType, + worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined + }) if (result.type === 'success') { haptic.notification('success') props.onSuccess(result.sessionId) @@ -66,6 +75,73 @@ export function SpawnSession(props: { className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" /> +
+ +
+ {(['simple', 'worktree'] as const).map((type) => ( +
+ {type === 'worktree' ? ( +
+ setSessionType('worktree')} + disabled={isPending} + className="mt-1 accent-[var(--app-link)]" + /> +
+
+ {sessionType === 'worktree' ? ( + setWorktreeName(e.target.value)} + disabled={isPending} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-60" + /> + ) : ( + + )} +
+ + Create a new worktree next to the repo + +
+
+ ) : ( + + )} +
+ ))} +
+
+ {(error ?? spawnError) ? (
{error ?? spawnError} diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index db8fd96f..73d8c7e9 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -8,6 +8,8 @@ type SpawnInput = { directory: string agent?: 'claude' | 'codex' | 'gemini' yolo?: boolean + sessionType?: 'simple' | 'worktree' + worktreeName?: string } export function useSpawnSession(api: ApiClient | null): { @@ -22,7 +24,14 @@ export function useSpawnSession(api: ApiClient | null): { if (!api) { throw new Error('API unavailable') } - return await api.spawnSession(input.machineId, input.directory, input.agent, input.yolo) + return await api.spawnSession( + input.machineId, + input.directory, + input.agent, + input.yolo, + input.sessionType, + input.worktreeName + ) }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 79ef3324..7142b1bd 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,6 +1,14 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null | undefined export type ModelMode = 'default' | 'sonnet' | 'opus' | null | undefined +export type WorktreeMetadata = { + basePath: string + branch: string + name: string + worktreePath?: string + createdAt?: number +} + export type SessionMetadataSummary = { path: string host: string @@ -11,6 +19,7 @@ export type SessionMetadataSummary = { machineId?: string tools?: string[] flavor?: string | null + worktree?: WorktreeMetadata } export type AgentStateRequest = { @@ -63,6 +72,7 @@ export type SessionSummaryMetadata = { path: string summary?: { text: string } flavor?: string | null + worktree?: WorktreeMetadata } export type SessionSummary = {