mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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<typeof MetadataSchema>
|
||||
@@ -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<string, unknown>
|
||||
|
||||
@@ -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<WebAppEnv> {
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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<SpawnResponse> {
|
||||
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ directory, agent, yolo })
|
||||
body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AgentType>('claude')
|
||||
const [yoloMode, setYoloMode] = useState(false)
|
||||
const [sessionType, setSessionType] = useState<SessionType>('simple')
|
||||
const [worktreeName, setWorktreeName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const worktreeInputRef = useRef<HTMLInputElement>(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: {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Type */}
|
||||
<div className="flex flex-col gap-1.5 px-3 py-3">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
Session type
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{(['simple', 'worktree'] as const).map((type) => (
|
||||
<div key={type} className="flex flex-col gap-2">
|
||||
{type === 'worktree' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="session-type-worktree"
|
||||
type="radio"
|
||||
name="sessionType"
|
||||
value="worktree"
|
||||
checked={sessionType === 'worktree'}
|
||||
onChange={() => setSessionType('worktree')}
|
||||
disabled={isFormDisabled}
|
||||
className="accent-[var(--app-link)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="min-h-[34px] flex items-center">
|
||||
{sessionType === 'worktree' ? (
|
||||
<input
|
||||
ref={worktreeInputRef}
|
||||
type="text"
|
||||
placeholder="Branch name (optional)"
|
||||
value={worktreeName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label
|
||||
htmlFor="session-type-worktree"
|
||||
className="text-sm capitalize cursor-pointer"
|
||||
>
|
||||
Worktree
|
||||
</label>
|
||||
<span className="ml-2 text-xs text-[var(--app-hint)]">
|
||||
Create a new git worktree next to the repo
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 cursor-pointer min-h-[34px]">
|
||||
<input
|
||||
id="session-type-simple"
|
||||
type="radio"
|
||||
name="sessionType"
|
||||
value="simple"
|
||||
checked={sessionType === 'simple'}
|
||||
onChange={() => setSessionType('simple')}
|
||||
disabled={isFormDisabled}
|
||||
className="accent-[var(--app-link)]"
|
||||
/>
|
||||
<span className="text-sm capitalize">Simple</span>
|
||||
<span className="text-xs text-[var(--app-hint)]">
|
||||
Use the selected directory as-is
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Selector */}
|
||||
<div className="flex flex-col gap-1.5 px-3 py-3">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
|
||||
@@ -42,6 +42,7 @@ export function SessionHeader(props: {
|
||||
onViewFiles?: () => void
|
||||
}) {
|
||||
const title = useMemo(() => getSessionTitle(props.session), [props.session])
|
||||
const worktreeBranch = props.session.metadata?.worktree?.branch
|
||||
|
||||
// In Telegram, don't render header (Telegram provides its own)
|
||||
if (isTelegramApp()) {
|
||||
@@ -79,6 +80,7 @@ export function SessionHeader(props: {
|
||||
</div>
|
||||
<div className="text-xs text-[var(--app-hint)] truncate">
|
||||
{props.session.metadata?.path ?? props.session.id}
|
||||
{worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -162,6 +162,9 @@ export function SessionList(props: {
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
|
||||
<span>❖ {getAgentLabel(s)}</span>
|
||||
<span>model: {getModelLabel(s)}</span>
|
||||
{s.metadata?.worktree?.branch ? (
|
||||
<span>worktree: {s.metadata.worktree.branch}</span>
|
||||
) : null}
|
||||
{(() => {
|
||||
const lastSeen = getLastSeenLabel(s)
|
||||
if (!lastSeen) return null
|
||||
|
||||
@@ -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<SessionType>('simple')
|
||||
const [worktreeName, setWorktreeName] = useState('')
|
||||
const [error, setError] = useState<string | null>(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)]"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
Session type
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
{(['simple', 'worktree'] as const).map((type) => (
|
||||
<div key={type} className="flex flex-col gap-2">
|
||||
{type === 'worktree' ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
id="session-type-worktree"
|
||||
type="radio"
|
||||
name="sessionType"
|
||||
value="worktree"
|
||||
checked={sessionType === 'worktree'}
|
||||
onChange={() => setSessionType('worktree')}
|
||||
disabled={isPending}
|
||||
className="mt-1 accent-[var(--app-link)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="min-h-[34px] flex items-center">
|
||||
{sessionType === 'worktree' ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="feature-x (default 1228-xxxx)"
|
||||
value={worktreeName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="session-type-worktree"
|
||||
className="capitalize cursor-pointer"
|
||||
>
|
||||
Worktree
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<span className={`block text-xs text-[var(--app-hint)] ${sessionType === 'worktree' ? 'invisible' : ''}`}>
|
||||
Create a new worktree next to the repo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 cursor-pointer min-h-[34px]">
|
||||
<input
|
||||
id="session-type-simple"
|
||||
type="radio"
|
||||
name="sessionType"
|
||||
value="simple"
|
||||
checked={sessionType === 'simple'}
|
||||
onChange={() => setSessionType('simple')}
|
||||
disabled={isPending}
|
||||
className="accent-[var(--app-link)]"
|
||||
/>
|
||||
<span className="capitalize">Simple</span>
|
||||
<span className="text-xs text-[var(--app-hint)]">
|
||||
Use the selected directory as-is
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(error ?? spawnError) ? (
|
||||
<div className="text-sm text-red-600">
|
||||
{error ?? spawnError}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user