diff --git a/CLAUDE.md b/CLAUDE.md index fc3ce25..50b0474 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,5 +101,5 @@ The lock file will prevent accidental duplicate starts. ## Custom Rules -if it's a new feature, try to use a test cli unless it would be much easier to just have the user do a manual test (this is usually the case for visual features).. if it doesn't have functionality to do the test then add it. you can also use playwright mcp or curl if either of these would be easier. make sure you have enough logging to debug any issues.. if you create any test files, clean them up when you are done. NEVER TOUCH PORT 4001 or 5173.. if you are having issues with public apis, lookup examples online. NEVER create summary reports! NEVER add attribution comments like "Generated with Claude Code", "Co-Authored-By: Claude", or similar in PR descriptions, issues, commit messages, code comments, or any other artifacts. +if it's a new feature, try to use a test cli unless it would be much easier to just have the user do a manual test (this is usually the case for visual features).. if it doesn't have functionality to do the test then add it. you can also use playwright mcp or curl if either of these would be easier. make sure you have enough logging to debug any issues.. if you create any test files, clean them up when you are done. NEVER TOUCH PORT 4001 or 5173.. if you are having issues with public apis, lookup examples online. NEVER create summary reports! NEVER push to main without explicit user approval. NEVER commit and push without letting the user test/validate changes first. Always ask before committing and before pushing. All changes should go through PRs, not direct pushes to main. NEVER add attribution comments like "Generated with Claude Code", "Co-Authored-By: Claude", or similar in PR descriptions, issues, commit messages, code comments, or any other artifacts. diff --git a/backend/package.json b/backend/package.json index dfdf00e..7be29f2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "cross-env NODE_OPTIONS=--max-old-space-size=2048 tsx watch --watch-path=src src/index.ts", + "dev": "cross-env CLAUDIA_WATCH_MODE=1 NODE_OPTIONS=--max-old-space-size=2048 tsx watch --watch-path=src src/index.ts", "dev:no-watch": "cross-env NODE_OPTIONS=--max-old-space-size=2048 tsx src/index.ts", "build": "tsc", "start": "node dist/index.js", diff --git a/backend/src/claudia-mcp-server.ts b/backend/src/claudia-mcp-server.ts index 0ae9248..db0e94a 100644 --- a/backend/src/claudia-mcp-server.ts +++ b/backend/src/claudia-mcp-server.ts @@ -369,7 +369,7 @@ server.tool( // ============================================================================ // Tool: claudia_create_task // ============================================================================ -const createTaskBaseDescription = `Create a new task in Claudia. The task will be assigned to a Claude Code agent in the current workspace (${WORKSPACE_ID || 'unknown'}). Use this to delegate work to other agents running in parallel.`; +const createTaskBaseDescription = `Create a new task in Claudia. The task will be assigned to a Claude Code agent in the current workspace (${WORKSPACE_ID || 'unknown'}). Use this to delegate work to other agents running in parallel. PREFER this over launching your own internal subagent (the built-in Agent/Task tool) for any delegatable work — Claudia tasks are user-visible, monitorable, resumable, and isolated. Only use your own subagent for a quick throwaway lookup you need inline, or when a Claudia task would clearly give a worse result.`; const createTaskTieringSuffix = ` @@ -380,8 +380,8 @@ You can pass an optional \`complexity\` hint to control the cost of the spawned Be conservative — pick \`low\` when the work is genuinely simple. Omit the parameter to use the workspace's default model.`; -async function handleCreateTask(args: { prompt: string; displayName?: string; complexity?: 'low' | 'medium' | 'high' }) { - const { prompt, displayName, complexity } = args; +async function handleCreateTask(args: { prompt: string; displayName?: string; complexity?: 'low' | 'medium' | 'high'; isolate?: boolean }) { + const { prompt, displayName, complexity, isolate } = args; if (!WORKSPACE_ID) { return { content: [{ @@ -391,13 +391,44 @@ async function handleCreateTask(args: { prompt: string; displayName?: string; co }; } + // If isolate=true, create an isolated worktree via the REST API before spawning the task + let effectiveWorkspaceId = WORKSPACE_ID; + if (isolate) { + try { + log.info('isolate=true: creating worktree before task spawn'); + const { randomBytes } = await import('crypto'); + const shortId = randomBytes(4).toString('hex'); + const branch = `claudia/task-${shortId}`; + const res = await backendFetch( + `/api/worktrees?workspace=${encodeURIComponent(WORKSPACE_ID)}`, + { + method: 'POST', + body: JSON.stringify({ branch, createBranch: true }), + } + ); + if (res.ok) { + const data = await res.json() as { worktreePath?: string; workspace?: { id?: string } }; + const worktreePath = data.workspace?.id ?? data.worktreePath; + if (worktreePath) { + effectiveWorkspaceId = worktreePath; + log.info(`Worktree created: ${worktreePath} (branch=${branch})`); + } + } else { + const err = await res.text(); + log.error(`Failed to create worktree (isolate): ${err}. Falling back to parent workspace.`); + } + } catch (isolateErr) { + log.error('Worktree creation for isolate failed (non-fatal)', isolateErr); + } + } + try { - log.info(`Creating task in workspace: ${WORKSPACE_ID}${complexity ? ` (complexity=${complexity})` : ''}`); + log.info(`Creating task in workspace: ${effectiveWorkspaceId}${complexity ? ` (complexity=${complexity})` : ''}${isolate ? ' (isolated)' : ''}`); log.info(`Prompt: ${prompt.substring(0, 100)}...`); const payload: Record = { prompt, - workspaceId: WORKSPACE_ID, + workspaceId: effectiveWorkspaceId, source: 'mcp', }; if (MODEL_TIERING_ENABLED && complexity) { @@ -427,10 +458,11 @@ async function handleCreateTask(args: { prompt: string; displayName?: string; co taskId: task.id, displayName: displayName || null, state: task.state, - workspace: task.workspaceId, + workspace: effectiveWorkspaceId, + isolated: isolate && effectiveWorkspaceId !== WORKSPACE_ID, prompt: task.prompt?.substring(0, 200), complexity: complexity || null, - message: `Task '${task.id}'${displayName ? ` (${displayName})` : ''} created successfully. It is now running in workspace '${WORKSPACE_ID}'.` + message: `Task '${task.id}'${displayName ? ` (${displayName})` : ''} created successfully${isolate && effectiveWorkspaceId !== WORKSPACE_ID ? ` in isolated worktree '${effectiveWorkspaceId}'` : ` in workspace '${WORKSPACE_ID}'`}.` }, null, 2) }] }; @@ -457,6 +489,14 @@ async function handleCreateTask(args: { prompt: string; displayName?: string; co } } +const isolateParam = { + isolate: z.boolean().optional().describe( + 'When true, creates a new isolated git worktree for this task (branch: claudia/task-). ' + + 'Use this when the task will make file changes that should not conflict with other tasks running in the same workspace. ' + + 'Recommended for parallel feature development or any task that will commit changes.' + ), +}; + if (MODEL_TIERING_ENABLED) { server.tool( 'claudia_create_task', @@ -467,6 +507,7 @@ if (MODEL_TIERING_ENABLED) { complexity: z.enum(['low', 'medium', 'high']).optional().describe( 'Cost/capability tier for the spawned task. Use "low" for trivial work, "medium" for normal coding, "high" for hard reasoning. Omit to use the workspace default model.' ), + ...isolateParam, }, async (args) => handleCreateTask(args) ); @@ -477,6 +518,7 @@ if (MODEL_TIERING_ENABLED) { { prompt: z.string().describe('The prompt/instructions for the new task'), displayName: z.string().optional().describe('Optional short display name for the task in the Claudia sidebar (e.g., "Build API endpoint", "Write tests")'), + ...isolateParam, }, async (args) => handleCreateTask(args) ); diff --git a/backend/src/git-utils.ts b/backend/src/git-utils.ts index b9aaeab..a8d507c 100644 --- a/backend/src/git-utils.ts +++ b/backend/src/git-utils.ts @@ -1,6 +1,6 @@ import { exec, execFile } from 'child_process'; import { promisify } from 'util'; -import { TaskGitState, FileDiff } from '@claudia/shared'; +import { TaskGitState, FileDiff, WorkspacePrInfo } from '@claudia/shared'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -244,6 +244,45 @@ export async function captureGitStateAfter( // Beyond this, the task is considered stale and revert is blocked const MAX_REVERT_COMMITS = 5; +/** + * Detect if a directory is a git-linked worktree (not the main working tree). + * In a linked worktree, `.git` is a FILE; in the main working tree it is a DIRECTORY. + */ +export async function isLinkedWorktree(cwd: string): Promise { + try { + const { lstatSync, existsSync } = await import('fs'); + const { join } = await import('path'); + const gitPath = join(cwd, '.git'); + if (!existsSync(gitPath)) return false; + return lstatSync(gitPath).isFile(); + } catch { + return false; + } +} + +/** + * Get the absolute path to the main working tree from any worktree. + * Uses `git rev-parse --path-format=absolute --git-common-dir` which works in both + * the main working tree and in linked worktrees. + */ +export async function getMainWorktreePath(cwd: string): Promise { + try { + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const { resolve } = await import('path'); + const execFileA = promisify(execFile); + const { stdout } = await execFileA( + 'git', + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd } + ); + // Strip trailing /.git[/] to get the main worktree directory + return resolve(stdout.trim().replace(/[\/\\]\.git[\/\\]?$/, '')); + } catch { + return null; + } +} + /** * Revert changes made by a task * This will: @@ -346,3 +385,72 @@ export async function revertTaskChanges( }; } } + +/** Summarize a gh statusCheckRollup array into a single CI state. */ +function summarizeCi(rollup: unknown): WorkspacePrInfo['ci'] { + if (!Array.isArray(rollup) || rollup.length === 0) return 'none'; + let anyRunning = false; + let anyFailed = false; + let anyTerminal = false; + for (const check of rollup) { + if (!check || typeof check !== 'object') continue; + // CheckRun: { status: QUEUED|IN_PROGRESS|COMPLETED, conclusion: SUCCESS|FAILURE|... } + // StatusContext: { state: SUCCESS|PENDING|FAILURE|ERROR } + const status = String((check as any).status || '').toUpperCase(); + const conclusion = String((check as any).conclusion || '').toUpperCase(); + const state = String((check as any).state || '').toUpperCase(); + + if (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING' || + status === 'WAITING' || status === 'REQUESTED' || state === 'PENDING' || state === 'EXPECTED') { + anyRunning = true; + continue; + } + const outcome = conclusion || state; // COMPLETED uses conclusion; StatusContext uses state + anyTerminal = true; + if (['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(outcome)) { + anyFailed = true; + } + } + if (anyFailed) return 'failed'; + if (anyRunning) return 'running'; + if (anyTerminal) return 'passed'; + return 'none'; +} + +/** + * Look up the GitHub PR for a branch via the `gh` CLI. Returns null when there's + * no PR, `gh` is missing/unauthenticated, or the dir isn't a GitHub repo. + * Never throws — callers can treat null as "no badge". + */ +export async function getPrForBranch(repoPath: string, branch: string): Promise { + if (!branch || !isValidBranchName(branch)) return null; + try { + const { stdout } = await execFileAsync( + 'gh', + ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', + '--json', 'number,title,state,isDraft,url,statusCheckRollup'], + { cwd: repoPath, timeout: 15000 } + ); + const arr = JSON.parse(stdout) as Array<{ + number: number; title: string; state: string; isDraft: boolean; url: string; statusCheckRollup?: unknown; + }>; + if (!Array.isArray(arr) || arr.length === 0) return null; + const pr = arr[0]; + const rawState = String(pr.state || '').toUpperCase(); + const state: WorkspacePrInfo['state'] = pr.isDraft + ? 'draft' + : rawState === 'MERGED' ? 'merged' + : rawState === 'CLOSED' ? 'closed' + : 'open'; + return { + number: pr.number, + title: pr.title || '', + state, + url: pr.url, + ci: summarizeCi(pr.statusCheckRollup), + }; + } catch { + // gh missing/unauth, not a repo, no network — no badge. + return null; + } +} diff --git a/backend/src/server.ts b/backend/src/server.ts index d63d997..f373a0c 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -19,7 +19,8 @@ import { setUserId } from './usage-reporter.js'; import { Task, Workspace, WorkspaceReference, WSMessage, WSMessageType, WSErrorPayload, ChatMessage, SuggestedAction, WaitingInputType, ScheduledTask, PORTS, TaskTokenUsage, UsageDashboardData } from '@claudia/shared'; import { CronScheduler, validateCronExpression, describeCronExpression } from './cron-scheduler.js'; import { validateConfigUpdate, validateWorkspacePath } from './validation.js'; -import { isGitRepo, getDefaultBranch, getCurrentBranch, checkoutBranch } from './git-utils.js'; +import { isGitRepo, getDefaultBranch, getCurrentBranch, checkoutBranch, getPrForBranch } from './git-utils.js'; +import { WorktreeManager } from './worktree-manager.js'; import { LearningsStore } from './learnings-store.js'; import { TunnelManager } from './tunnel-manager.js'; import { getMobilePageHtml } from './mobile-page.js'; @@ -94,6 +95,11 @@ const VALID_WS_MESSAGE_TYPES = new Set([ 'cron:update', 'cron:list', 'cron:run', + 'worktree:list', + 'worktree:create', + 'worktree:remove', + 'worktree:prune', + 'workspace:autoWorktree', ]); // WebSocket message validation @@ -666,6 +672,214 @@ export async function createApp(basePath?: string) { scheduleBatchedBroadcast(); } + // ===== GitHub PR info refresh ===== + // Resolve the GitHub PR for each relevant workspace's branch via `gh`, cache it + // on the workspace store, and broadcast when anything changed. Respects the same + // anti-storm discipline the frontend uses (WorkspacePanel.tsx git-status polling): + // only the workspaces with active tasks are refreshed on the interval; others are + // refreshed lazily via refreshPrInfoFor() on task/worktree events. + let ghAvailable: boolean | null = null; + async function isGhAvailable(): Promise { + if (ghAvailable !== null) return ghAvailable; + try { + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + await promisify(execFile)('gh', ['--version'], { timeout: 5000 }); + ghAvailable = true; + } catch { + ghAvailable = false; + } + return ghAvailable; + } + + // Track which workspaces we've looked up at least once (lazy first-fetch), + // which currently have an in-flight `gh` call, and the last-seen branch per + // workspace (so we only call `gh` when the branch actually changes — the + // local `git branch` check is fast, the `gh pr list` call is slow/networked). + const prInfoSeen = new Set(); + const prInfoInFlight = new Set(); + const lastSeenBranch = new Map(); + + // Resolve (repoPath, branch) for a workspace, then look up its PR. + // If `force` is false (default), skips the expensive `gh` call when the + // branch hasn't changed since the last check. + async function refreshPrInfoFor(workspaceId: string, force = false): Promise { + if (prInfoInFlight.has(workspaceId)) return; + const ws = workspaceStore.getWorkspace(workspaceId); + if (!ws) return; + + // Resolve current branch (fast local git call). + let branch: string | null; + try { + if (ws.worktreeParentId) { + branch = await getCurrentBranch(ws.id) || ws.worktreeBranch || null; + } else { + branch = await getCurrentBranch(ws.id); + } + } catch { + return; + } + + // No badge for the default branch (main/master) — `gh pr list --head main` + // returns unrelated PRs (e.g. from forks) that aren't "this workspace's PR". + if (branch) { + const defaultBranch = await getDefaultBranch(ws.worktreeParentId || ws.id); + if (branch === defaultBranch) { + if (workspaceStore.setPrInfo(workspaceId, null)) { + // Broadcast singular update (not full array) to avoid re-rendering + // the entire workspace list, which kills an in-progress drag. + const updated = workspaceStore.getWorkspace(workspaceId); + if (updated) broadcast({ type: 'workspace:updated' as WSMessageType, payload: { workspace: updated } }); + } + lastSeenBranch.set(workspaceId, branch); + prInfoSeen.add(workspaceId); + return; + } + } + + // Skip the expensive `gh` call if branch hasn't changed and we already + // have a result (unless forced — e.g. initial fetch or CI may have changed). + const prev = lastSeenBranch.get(workspaceId); + const branchChanged = prev !== branch; + if (!branchChanged && !force && prInfoSeen.has(workspaceId)) return; + lastSeenBranch.set(workspaceId, branch); + + if (!(await isGhAvailable())) return; + prInfoInFlight.add(workspaceId); + prInfoSeen.add(workspaceId); + try { + const repoPath = ws.id; + const prInfo = branch ? await getPrForBranch(repoPath, branch) : null; + if (workspaceStore.setPrInfo(workspaceId, prInfo)) { + const updated = workspaceStore.getWorkspace(workspaceId); + if (updated) broadcast({ type: 'workspace:updated' as WSMessageType, payload: { workspace: updated } }); + } + } catch (err) { + logger.debug('refreshPrInfoFor failed', { workspaceId, error: err instanceof Error ? err.message : String(err) }); + } finally { + prInfoInFlight.delete(workspaceId); + } + } + + // Reentrancy guard: if a refresh pass runs long (slow gh/auth), don't let the + // next interval tick start a second concurrent pass. + let prRefreshPassInFlight = false; + + async function refreshActiveWorkspacePrInfo(): Promise { + if (prRefreshPassInFlight) return; + if (!(await isGhAvailable())) return; + prRefreshPassInFlight = true; + try { + const tasks = taskSpawner.getAllTasks(); + // Workspaces with at least one active task → refresh every interval. + const activeWorkspaceIds = new Set( + tasks.filter(t => t.state === 'busy' || t.state === 'starting' || t.state === 'waiting_input') + .map(t => t.workspaceId) + ); + // Workspaces with any task we haven't looked up yet → one-time lazy fetch. + const lazyWorkspaceIds = new Set( + tasks.map(t => t.workspaceId).filter(id => !prInfoSeen.has(id)) + ); + const toRefresh = new Set([...activeWorkspaceIds, ...lazyWorkspaceIds]); + for (const id of toRefresh) { + await refreshPrInfoFor(id, true); // force=true: periodic re-checks CI even if branch same + } + } finally { + prRefreshPassInFlight = false; + } + } + + // Poll on an interval (90s) — only touches active/unseen workspaces, not all. + const PR_INFO_INTERVAL_MS = 90_000; + const prInfoInterval = setInterval(() => { void refreshActiveWorkspacePrInfo(); }, PR_INFO_INTERVAL_MS); + // Kick off an initial pass shortly after startup. + setTimeout(() => { void refreshActiveWorkspacePrInfo(); }, 5_000); + + // ===== Worktree discovery (session attribution) ===== + // A task's Claude session may create a git worktree (raw `git worktree add`) + // and start operating on that branch. The task's PTY cwd stays at the parent + // repo, so we detect this by diffing the repo's worktree list: a branch that + // appears while a task is running in that repo is attributed to that task and + // the task row is annotated with a worktree badge. We do NOT mass-register + // every worktree — repos can have dozens unrelated to Claudia tasks. + let worktreeScanInFlight = false; + // Per-repo baseline of worktree branches observed at first scan. Branches that + // appear after the baseline (while a task runs there) are "new" → attributable. + const repoWorktreeBaseline = new Map>(); + // Repos confirmed to NOT be git repos — skip future scans to avoid error log spam. + const nonGitRepos = new Set(); + + async function discoverWorktrees(): Promise { + if (worktreeScanInFlight) return; + worktreeScanInFlight = true; + try { + const tasks = taskSpawner.getAllTasks(); + // Group tasks by their (non-worktree) repo workspace. + const tasksByRepo = new Map(); + for (const t of tasks) { + const ws = workspaceStore.getWorkspace(t.workspaceId); + if (!ws || ws.worktreeParentId) continue; + if (nonGitRepos.has(t.workspaceId)) continue; // skip known non-repos + if (!tasksByRepo.has(t.workspaceId)) tasksByRepo.set(t.workspaceId, []); + tasksByRepo.get(t.workspaceId)!.push(t); + } + + const manager = new WorktreeManager(); + for (const [repoId, repoTasks] of tasksByRepo) { + let worktrees: Awaited>; + try { + worktrees = await manager.listWorktrees(repoId); + } catch { + nonGitRepos.add(repoId); // remember and stop retrying + continue; + } + const branches = worktrees + .filter(wt => !wt.isMain) + .map(wt => wt.branch.replace(/^refs\/heads\//, '')) + .filter(b => b && !b.startsWith('(')); + + const baseline = repoWorktreeBaseline.get(repoId); + if (!baseline) { + // First scan: record what already exists; don't attribute these. + repoWorktreeBaseline.set(repoId, new Set(branches)); + continue; + } + const newBranches = branches.filter(b => !baseline.has(b)); + if (newBranches.length === 0) continue; + newBranches.forEach(b => baseline.add(b)); + + // Attribute new branch(es) to the most-recently-active task in this repo. + const sorted = [...repoTasks].sort((a, b) => + new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()); + const target = sorted[0]; + if (!target) continue; + const branch = newBranches[newBranches.length - 1]; // latest + const prInfo = await getPrForBranch(repoId, branch); + if (taskSpawner.setSessionWorktree(target.id, branch, prInfo)) { + logger.info('Attributed worktree to task session', { taskId: target.id, branch, repo: repoId }); + } + } + } finally { + worktreeScanInFlight = false; + } + } + + // Periodic sweep + initial pass shortly after startup. + const WORKTREE_SCAN_INTERVAL_MS = 60_000; + const worktreeScanInterval = setInterval(() => { void discoverWorktrees(); }, WORKTREE_SCAN_INTERVAL_MS); + setTimeout(() => { void discoverWorktrees(); }, 6_000); + + // Debounced discovery trigger — multiple tasks going idle in quick succession + // only fire one scan instead of one per task. + let discoverDebounceTimer: ReturnType | null = null; + function debouncedDiscoverWorktrees(): void { + if (discoverDebounceTimer) return; // already scheduled + discoverDebounceTimer = setTimeout(() => { + discoverDebounceTimer = null; + void discoverWorktrees(); + }, 3_000); + } + // ===== Embedded Shell Terminal Management ===== const isWindows = process.platform === 'win32'; const shellProcesses: Map = new Map(); // workspaceId → PTY @@ -754,6 +968,16 @@ export async function createApp(basePath?: string) { console.log(`[Server] taskStateChanged event: task=${task.id} state=${task.state}`); queueTaskStateChange(task); // Batched - deduplicates rapid state changes + // Refresh PR info when a task goes idle (may have switched branches / pushed + // a PR) or becomes busy (new step starting on a potentially different branch). + // Also discover worktrees on idle (Claude may have run `git worktree add`). + if (task.state === 'idle' || task.state === 'busy') { + void refreshPrInfoFor(task.workspaceId); + } + if (task.state === 'idle') { + debouncedDiscoverWorktrees(); + } + // Deliver pending reference notifications when a task becomes idle if (task.state === 'idle') { const internalTask = taskSpawner.getTask(task.id); @@ -1068,7 +1292,7 @@ export async function createApp(basePath?: string) { switch (message.type) { case 'task:create': { // Create a new Claude Code CLI instance - const { prompt, workspaceId, initialCols, initialRows, source, complexity } = payload as { prompt?: string; workspaceId?: string; initialCols?: number; initialRows?: number; source?: string; complexity?: string }; + const { prompt, workspaceId, initialCols, initialRows, source, complexity, isolate } = payload as { prompt?: string; workspaceId?: string; initialCols?: number; initialRows?: number; source?: string; complexity?: string; isolate?: boolean }; if (!prompt || !workspaceId) { logger.error('task:create requires prompt and workspaceId'); sendWSError(ws, 'task:create requires prompt and workspaceId', message.type, 'MISSING_PARAMS'); @@ -1089,7 +1313,7 @@ export async function createApp(basePath?: string) { } // Auto-add workspace if it doesn't exist yet - const validatedPath = workspaceValidation.data!; + let validatedPath = workspaceValidation.data!; if (!workspaceStore.getWorkspace(validatedPath)) { try { const workspace = workspaceStore.addWorkspace(validatedPath); @@ -1101,6 +1325,31 @@ export async function createApp(basePath?: string) { } } + // AUTO-WORKTREE: if workspace has autoWorktree enabled OR isolate flag set, create an isolated worktree + const wsConfig = workspaceStore.getWorkspace(validatedPath); + if ((wsConfig?.autoWorktree || isolate) && !wsConfig?.worktreeParentId) { + // Only auto-worktree on parent workspaces (not on existing worktrees) + try { + const { randomBytes } = await import('crypto'); + const shortId = randomBytes(4).toString('hex'); + const autoBranch = `claudia/task-${shortId}`; + const manager = new WorktreeManager(); + const wt = await manager.createWorktree({ + repoPath: validatedPath, + branch: autoBranch, + createBranch: true, + }); + const wtWorkspace = await workspaceStore.addWorktreeWorkspace(wt.path, validatedPath, autoBranch); + broadcast({ type: 'workspace:created' as WSMessageType, payload: { workspace: wtWorkspace } }); + validatedPath = wt.path; // task runs in the new worktree + logger.info('Auto-created worktree for task', { worktreePath: wt.path, branch: autoBranch }); + } catch (wtErr) { + const wtErrMsg = wtErr instanceof Error ? wtErr.message : String(wtErr); + logger.warn('Auto-worktree creation failed, falling back to parent workspace', { error: wtErrMsg }); + ws.send(JSON.stringify({ type: 'error', payload: { message: `Worktree creation failed, task running in parent workspace: ${wtErrMsg}` } })); + } + } + // Use workspace system prompt if set, otherwise fall back to global rules const workspaceSystemPrompt = workspaceStore.getSystemPrompt(workspaceId); const rules = configStore.getRules(); @@ -1137,6 +1386,8 @@ export async function createApp(basePath?: string) { // Done here (not in the taskCreated event handler) so the source // field is always correct even with concurrent creates. broadcast({ type: 'task:created', payload: { task: newTask, source } }); + // Resolve PR info for this task's workspace (lazy first-fetch). + void refreshPrInfoFor(newTask.workspaceId); // Track which references were injected so we can detect changes on follow-ups const internalTask = taskSpawner.getTask(newTask.id); if (internalTask) { @@ -1221,9 +1472,12 @@ export async function createApp(basePath?: string) { // One-time auto-title instruction injection for existing sessions // (new sessions get this in the orchestration guidance system prompt) const claudiaMcpEnabled = configStore.getClaudioMcpServerEnabled(); - if (claudiaMcpEnabled && !inputTask.titleInstructionInjected) { - inputTask.titleInstructionInjected = true; - const titleInstruction = `[CONTEXT UPDATE: You can update your task title using claudia_rename_task. Call it with your own task ID and a \`displayName\` parameter (string, 3-6 words) describing what you're working on. The parameter is named \`displayName\`, NOT \`title\`. Do NOT rename if the user has manually edited the title (the tool will reject it).] `; + // Inject a title-update reminder on the first follow-up and + // every 5th message after that so Claude keeps the title fresh. + const msgCount = (inputTask.titleInstructionCount || 0) + 1; + inputTask.titleInstructionCount = msgCount; + if (claudiaMcpEnabled && (msgCount === 1 || msgCount % 5 === 0)) { + const titleInstruction = `[CONTEXT UPDATE: You can update your task title using claudia_rename_task. Call it with your own task ID and a \`displayName\` parameter (string, 3-6 words) describing what you're doing NOW. The parameter is named \`displayName\`, NOT \`title\`. Keep the title current — whenever your focus shifts (new topic, new phase, different file), rename yourself so the sidebar reflects your current work. Do NOT rename if the user has manually edited the title (the tool will reject it).] `; const msgContent = filteredInput.endsWith('\r') || filteredInput.endsWith('\n') ? filteredInput.slice(0, -1) : filteredInput; @@ -1345,6 +1599,21 @@ export async function createApp(basePath?: string) { const renamed = taskSpawner.renameTask(taskId, displayName, source || 'user'); if (renamed) { broadcast({ type: 'tasks:updated' as WSMessageType, payload: { tasks: taskSpawner.getAllTasks() } }); + // If this task lives in a worktree workspace, update the workspace displayName + // so the inline group header shows a human-readable label instead of the branch slug. + // worktreeBranch (the git branch) remains unchanged. + if (displayName) { + const task = taskSpawner.getTask(taskId); + if (task) { + const taskWs = workspaceStore.getWorkspaces().find(w => w.id === task.workspaceId); + if (taskWs?.worktreeParentId) { + if (workspaceStore.renameWorkspace(taskWs.id, displayName.substring(0, 60))) { + const updated = workspaceStore.getWorkspace(taskWs.id); + if (updated) broadcast({ type: 'workspace:updated' as WSMessageType, payload: { workspace: updated } }); + } + } + } + } } break; } @@ -1851,6 +2120,10 @@ export async function createApp(basePath?: string) { // Broadcast updated tasks to all clients broadcast({ type: 'tasks:updated', payload: { tasks: taskSpawner.getAllTasks() } }); + // Branch changed (e.g. back to main) — re-resolve PR info so the + // badge updates/clears for the newly checked-out branch. + void refreshPrInfoFor(workspaceId); + // Send result back to requesting client ws.send(JSON.stringify({ type: 'workspace:resetResult', @@ -2139,6 +2412,127 @@ export async function createApp(basePath?: string) { } break; } + + // ── Worktree WS handlers ────────────────────────────── + + case 'worktree:list': { + const { workspaceId } = payload as { workspaceId?: string }; + if (!workspaceId) { + sendWSError(ws, 'worktree:list requires workspaceId', message.type, 'MISSING_PARAMS'); + break; + } + try { + const manager = new WorktreeManager(); + const worktrees = await manager.listWorktrees(workspaceId); + for (const wt of worktrees) { + const tasks = taskSpawner.getAllTasks().filter(t => t.workspaceId === wt.path); + wt.taskCount = tasks.length; + } + ws.send(JSON.stringify({ type: 'worktree:listed', payload: { workspaceId, worktrees } })); + } catch (err) { + sendWSError(ws, err instanceof Error ? err.message : String(err), message.type, 'WORKTREE_LIST_FAILED'); + } + break; + } + + case 'worktree:create': { + const { workspaceId, branch, baseBranch, createBranch = true } = payload as { + workspaceId?: string; + branch?: string; + baseBranch?: string; + createBranch?: boolean; + }; + if (!workspaceId || !branch) { + sendWSError(ws, 'worktree:create requires workspaceId and branch', message.type, 'MISSING_PARAMS'); + break; + } + try { + const manager = new WorktreeManager(); + const result = await manager.createWorktree({ repoPath: workspaceId, branch, baseBranch, createBranch }); + const workspace = await workspaceStore.addWorktreeWorkspace(result.path, workspaceId, branch); + broadcast({ type: 'workspace:created' as WSMessageType, payload: { workspace } }); + ws.send(JSON.stringify({ type: 'worktree:created', payload: { workspace, worktreePath: result.path, branch: result.branch } })); + logger.info('Worktree created via WS', { path: result.path, branch }); + } catch (err) { + sendWSError(ws, err instanceof Error ? err.message : String(err), message.type, 'WORKTREE_CREATE_FAILED'); + } + break; + } + + case 'worktree:remove': { + const { workspaceId, worktreePath, force = false } = payload as { + workspaceId?: string; + worktreePath?: string; + force?: boolean; + }; + if (!workspaceId || !worktreePath) { + sendWSError(ws, 'worktree:remove requires workspaceId and worktreePath', message.type, 'MISSING_PARAMS'); + break; + } + // Safety: check for active tasks + const activeTasks = taskSpawner.getAllTasks().filter( + t => t.workspaceId === worktreePath && ['busy', 'starting', 'waiting_input'].includes(t.state) + ); + if (activeTasks.length > 0 && !force) { + ws.send(JSON.stringify({ + type: 'worktree:error', + payload: { + error: `Cannot remove: ${activeTasks.length} active task(s) still running`, + activeTasks: activeTasks.map(t => t.id), + } + })); + break; + } + try { + const manager = new WorktreeManager(); + await manager.removeWorktree({ repoPath: workspaceId, worktreePath, force }); + workspaceStore.deleteWorkspace(worktreePath); + broadcast({ type: 'workspace:deleted' as WSMessageType, payload: { workspaceId: worktreePath } }); + ws.send(JSON.stringify({ type: 'worktree:removed', payload: { worktreePath } })); + logger.info('Worktree removed via WS', { worktreePath }); + } catch (err) { + sendWSError(ws, err instanceof Error ? err.message : String(err), message.type, 'WORKTREE_REMOVE_FAILED'); + } + break; + } + + case 'worktree:prune': { + const { workspaceId } = payload as { workspaceId?: string }; + if (!workspaceId) { + sendWSError(ws, 'worktree:prune requires workspaceId', message.type, 'MISSING_PARAMS'); + break; + } + try { + const manager = new WorktreeManager(); + const pruned = await manager.pruneWorktrees(workspaceId); + for (const p of pruned) { + if (workspaceStore.getWorkspace(p)) { + workspaceStore.deleteWorkspace(p); + broadcast({ type: 'workspace:deleted' as WSMessageType, payload: { workspaceId: p } }); + } + } + ws.send(JSON.stringify({ type: 'worktree:pruned', payload: { workspaceId, pruned } })); + } catch (err) { + sendWSError(ws, err instanceof Error ? err.message : String(err), message.type, 'WORKTREE_PRUNE_FAILED'); + } + break; + } + + case 'workspace:autoWorktree': { + const { workspaceId, enabled } = payload as { workspaceId?: string; enabled?: boolean }; + if (!workspaceId || typeof enabled !== 'boolean') { + sendWSError(ws, 'workspace:autoWorktree requires workspaceId and enabled', message.type, 'MISSING_PARAMS'); + break; + } + const ok = workspaceStore.setAutoWorktree(workspaceId, enabled); + if (!ok) { + sendWSError(ws, 'Workspace not found', message.type, 'NOT_FOUND'); + break; + } + const updatedWs = workspaceStore.getWorkspace(workspaceId); + broadcast({ type: 'workspace:updated' as WSMessageType, payload: { workspace: updatedWs } }); + break; + } } } catch (err) { logger.error('Error handling message', { @@ -3761,6 +4155,165 @@ export async function createApp(basePath?: string) { } }); + // ── Worktree REST endpoints (query-param routing for Windows path compat) ── + + // GET /api/worktrees?workspace= — list worktrees for a repo + app.get('/api/worktrees', async (req, res) => { + const workspacePath = req.query.workspace as string; + if (!workspacePath) { + return res.status(400).json({ error: 'workspace query parameter is required' }); + } + if (!existsSync(workspacePath)) { + return res.status(404).json({ error: 'Workspace not found' }); + } + try { + const manager = new WorktreeManager(); + const worktrees = await manager.listWorktrees(workspacePath); + // Enrich with Claudia task counts + for (const wt of worktrees) { + const tasks = taskSpawner.getAllTasks().filter(t => t.workspaceId === wt.path); + wt.taskCount = tasks.length; + } + return res.json({ worktrees }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('GET /api/worktrees failed', { error: msg }); + return res.status(500).json({ error: msg }); + } + }); + + // POST /api/worktrees?workspace= — create a worktree + app.post('/api/worktrees', async (req, res) => { + const workspacePath = req.query.workspace as string; + if (!workspacePath) { + return res.status(400).json({ error: 'workspace query parameter is required' }); + } + if (!existsSync(workspacePath)) { + return res.status(404).json({ error: 'Workspace not found' }); + } + const { branch, baseBranch, createBranch = true } = req.body as { + branch?: string; + baseBranch?: string; + createBranch?: boolean; + }; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + try { + const manager = new WorktreeManager(); + const result = await manager.createWorktree({ repoPath: workspacePath, branch, baseBranch, createBranch }); + // Register as workspace and insert after parent + const workspace = await workspaceStore.addWorktreeWorkspace(result.path, workspacePath, branch); + broadcast({ type: 'workspace:created' as WSMessageType, payload: { workspace } }); + logger.info('Worktree created', { path: result.path, branch }); + return res.json({ workspace, worktreePath: result.path, branch: result.branch }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('POST /api/worktrees failed', { error: msg }); + return res.status(400).json({ error: msg }); + } + }); + + // DELETE /api/worktrees?workspace=&worktreePath=&force=true + app.delete('/api/worktrees', async (req, res) => { + const workspacePath = req.query.workspace as string; + const worktreePath = req.query.worktreePath as string; + const force = req.query.force === 'true'; + if (!workspacePath || !worktreePath) { + return res.status(400).json({ error: 'workspace and worktreePath query parameters are required' }); + } + // Safety: don't allow removing the workspace that is the parent + if (worktreePath === workspacePath) { + return res.status(400).json({ error: 'Cannot remove the primary workspace' }); + } + // Safety: check for active tasks + const activeTasks = taskSpawner.getAllTasks().filter( + t => t.workspaceId === worktreePath && ['busy', 'starting', 'waiting_input'].includes(t.state) + ); + if (activeTasks.length > 0 && !force) { + return res.status(409).json({ + error: `Cannot remove: ${activeTasks.length} active task(s) still running in this worktree`, + activeTasks: activeTasks.map(t => t.id), + }); + } + try { + const manager = new WorktreeManager(); + await manager.removeWorktree({ repoPath: workspacePath, worktreePath, force }); + // Remove from workspace store + workspaceStore.deleteWorkspace(worktreePath); + broadcast({ type: 'workspace:deleted' as WSMessageType, payload: { workspaceId: worktreePath } }); + logger.info('Worktree removed', { worktreePath }); + return res.json({ success: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('DELETE /api/worktrees failed', { error: msg }); + return res.status(400).json({ error: msg }); + } + }); + + // POST /api/worktrees/prune?workspace= — prune stale worktrees + app.post('/api/worktrees/prune', async (req, res) => { + const workspacePath = req.query.workspace as string; + if (!workspacePath) { + return res.status(400).json({ error: 'workspace query parameter is required' }); + } + if (!existsSync(workspacePath)) { + return res.status(404).json({ error: 'Workspace not found' }); + } + try { + const manager = new WorktreeManager(); + const pruned = await manager.pruneWorktrees(workspacePath); + // Remove any pruned paths from workspace store + for (const p of pruned) { + if (workspaceStore.getWorkspace(p)) { + workspaceStore.deleteWorkspace(p); + broadcast({ type: 'workspace:deleted' as WSMessageType, payload: { workspaceId: p } }); + } + } + logger.info('Worktrees pruned', { count: pruned.length, pruned }); + return res.json({ pruned }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('POST /api/worktrees/prune failed', { error: msg }); + return res.status(500).json({ error: msg }); + } + }); + + // GET /api/worktrees/branches?workspace= — list local + remote branches for autocomplete + app.get('/api/worktrees/branches', async (req, res) => { + const workspacePath = req.query.workspace as string; + if (!workspacePath) { + return res.status(400).json({ error: 'workspace query parameter is required' }); + } + try { + const manager = new WorktreeManager(); + const [local, remote] = await Promise.all([ + manager.getLocalBranches(workspacePath), + manager.getRemoteBranches(workspacePath), + ]); + return res.json({ local, remote }); + } catch (err) { + return res.json({ local: [], remote: [] }); + } + }); + + // PATCH /api/worktrees/auto?workspace= — toggle autoWorktree on a workspace + app.patch('/api/worktrees/auto', async (req, res) => { + const workspacePath = req.query.workspace as string; + const { enabled } = req.body as { enabled?: boolean }; + if (!workspacePath) { + return res.status(400).json({ error: 'workspace query parameter is required' }); + } + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'enabled (boolean) is required in body' }); + } + const ok = workspaceStore.setAutoWorktree(workspacePath, enabled); + if (!ok) return res.status(404).json({ error: 'Workspace not found' }); + const workspace = workspaceStore.getWorkspace(workspacePath); + broadcast({ type: 'workspace:updated' as WSMessageType, payload: { workspace } }); + return res.json({ success: true, autoWorktree: enabled }); + }); + // Git log endpoint - get commit history app.get('/api/workspaces/git-log', async (req, res) => { const workspacePath = req.query.workspace as string; @@ -5621,20 +6174,24 @@ Guidelines: console.log('[Server] Restart requested via API'); res.json({ status: 'restarting' }); - // Give time for response to be sent + // Two restart mechanisms depending on how the backend was launched: + // - tsx watch mode (CLAUDIA_WATCH_MODE=1): process.exit does NOT make tsx + // relaunch — only a file change does. Touch index.ts to trigger reload. + // - no-watch mode: exit with code 75; the start-script relaunch loop restarts us. + const watchMode = process.env['CLAUDIA_WATCH_MODE'] === '1'; setTimeout(async () => { - // Touch a watched file to trigger tsx watch restart - // This is more reliable than process.exit(0) which tsx may not restart - try { - const { utimes } = await import('fs/promises'); - const restartTriggerFile = join(__dirname, 'index.ts'); - const now = new Date(); - await utimes(restartTriggerFile, now, now); - console.log('[Server] Touched index.ts to trigger tsx watch restart'); - } catch (error) { - console.error('[Server] Failed to touch restart trigger file, falling back to graceful shutdown:', error); - gracefulShutdown('RESTART'); + if (watchMode) { + try { + const { utimes } = await import('fs/promises'); + const now = new Date(); + await utimes(join(__dirname, 'index.ts'), now, now); + console.log('[Server] Touched index.ts to trigger tsx watch restart'); + return; + } catch (error) { + console.error('[Server] Touch failed, falling back to exit-code restart:', error); + } } + gracefulShutdown('RESTART', 75); }, 100); }); @@ -5652,8 +6209,18 @@ Guidelines: }); } - // Graceful shutdown handler - function gracefulShutdown(signal: string): void { + // Graceful shutdown handler. exitCode 75 signals the start-script relaunch + // loop to restart the backend (used by POST /api/server/restart in no-watch mode). + let isShuttingDown = false; + function gracefulShutdown(signal: string, exitCode: number = 0): void { + // Guard against double-shutdown (e.g. restart clicked twice, or Ctrl+C + // during the restart window) — two overlapping sequences would race + // process.exit with different codes. + if (isShuttingDown) { + console.log(`[Server] Shutdown already in progress, ignoring ${signal}`); + return; + } + isShuttingDown = true; console.log(`[Server] Shutting down (${signal}), saving state immediately...`); // CRITICAL: Save all state IMMEDIATELY before anything else. @@ -5668,6 +6235,8 @@ Guidelines: // Clear heartbeat interval clearInterval(heartbeatInterval); + clearInterval(prInfoInterval); + clearInterval(worktreeScanInterval); // Notify all connected clients that the server is reloading broadcast({ type: 'server:reloading' as WSMessageType, payload: {} }); @@ -5684,8 +6253,8 @@ Guidelines: client.close(1001, 'Server reloading'); } - console.log('[Server] Shutdown complete'); - process.exit(0); + console.log(`[Server] Shutdown complete (exit ${exitCode})`); + process.exit(exitCode); }, 500); } diff --git a/backend/src/task-spawner.ts b/backend/src/task-spawner.ts index 972fd79..12c1528 100644 --- a/backend/src/task-spawner.ts +++ b/backend/src/task-spawner.ts @@ -9,7 +9,7 @@ import { execSync } from 'child_process'; import { atomicWriteFileSync } from './utils/atomic-write.js'; import { ConfigStore, ClaudeCodeSwitches } from './config-store.js'; import { captureGitStateBefore, captureGitStateAfter, revertTaskChanges } from './git-utils.js'; -import { sanitizePrompt } from './validation.js'; +import { sanitizePrompt, decodeHtmlEntities } from './validation.js'; import { createLogger } from './logger.js'; import { CodeBackend, BackendTask, createBackend } from './backends/index.js'; import { LearningsStore, LearningSearchResult } from './learnings-store.js'; @@ -96,7 +96,18 @@ function resolveClaudeSpawn(): { command: string; prefixArgs: string[] } { if (!isWindows) return { command: 'claude', prefixArgs: [] }; const appData = process.env['APPDATA']; if (appData) { - const cliPath = join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); + const pkgDir = join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code'); + // Newer claude-code ships a native bin/claude.exe (no cli.js). Spawn it + // directly so multiline args like --system-prompt are passed verbatim. + // The cmd.exe /c claude.cmd fallback re-parses the command line and + // corrupts long/multiline args (e.g. dropping --model after the prompt). + const exePath = join(pkgDir, 'bin', 'claude.exe'); + if (existsSync(exePath)) { + console.log(`[TaskSpawner] Resolved Claude CLI exe via APPDATA: ${exePath}`); + return { command: exePath, prefixArgs: [] }; + } + // Older layout: cli.js run via node. + const cliPath = join(pkgDir, 'cli.js'); if (existsSync(cliPath)) { console.log(`[TaskSpawner] Resolved Claude CLI via APPDATA: ${process.execPath} ${cliPath}`); return { command: process.execPath, prefixArgs: [cliPath] }; @@ -203,6 +214,7 @@ interface InternalTask extends Task { processStartedAt?: Date; // When the current busy/starting state began (for elapsed timer) readyFallbackTimer?: ReturnType; // Fallback timer to send prompt if ready signal is never detected titleInstructionInjected?: boolean; // True if auto-title instruction has been injected into this session + titleInstructionCount?: number; // Number of follow-up messages sent (for periodic title-update reminders) } /** @@ -530,8 +542,12 @@ export class TaskSpawner extends EventEmitter { } } - // Inject the Claudia MCP server if enabled - // Scoped to the task's workspace via CLAUDIA_WORKSPACE_ID env var + // Inject the Claudia MCP server if enabled. + // For per-task configs (workspaceId + taskId), include both env vars. + // For workspace .mcp.json sync (workspaceId only, no taskId), include + // CLAUDIA_WORKSPACE_ID so tools work on resumed sessions where --mcp-config + // doesn't load. CLAUDIA_TASK_ID is omitted but the core tools (list, create, + // status, output) work without it — only self-rename needs the task ID. if (claudiaMcpEnabled) { const mcpServerPath = join(__dirname, 'claudia-mcp-server.ts'); // Use tsx cli directly instead of npx tsx (saves ~25 seconds on Windows). @@ -606,29 +622,11 @@ export class TaskSpawner extends EventEmitter { * @param workspaceIds - List of workspace directory paths to sync. All must exist on disk. */ syncWorkspaceMcpConfigs(workspaceIds: string[]): void { - const result = this.buildMcpConfig(); - if (!result) { - logger.info('No enabled MCP servers, skipping workspace sync'); - return; - } - - const { mcpConfig, enabledMcpServers } = result; - const mcpConfigJson = JSON.stringify({ mcpServers: mcpConfig }, null, 2); - const serverNames = enabledMcpServers.map(s => s.name); - - const settingsContent = { - permissions: { - allow: ['mcp__*'], - deny: [] - }, - enableAllProjectMcpServers: true, - enabledMcpjsonServers: serverNames - }; - // The Claudia project root — skip syncing to our own directory to avoid // triggering tsx watch restarts from file writes in the project tree. const selfRoot = resolve(join(__dirname, '..', '..')); + let syncCount = 0; for (const workspaceId of workspaceIds) { if (!existsSync(workspaceId)) { logger.warn('Workspace does not exist, skipping MCP sync', { workspaceId }); @@ -639,11 +637,29 @@ export class TaskSpawner extends EventEmitter { continue; } + // Build per-workspace config with CLAUDIA_WORKSPACE_ID so the claudia + // MCP server works on resumed sessions (where --mcp-config doesn't load). + // No taskId — per-task --mcp-config supplies that for new sessions. + const result = this.buildMcpConfig(workspaceId); + if (!result) continue; + + const { mcpConfig, enabledMcpServers } = result; + const mcpConfigJson = JSON.stringify({ mcpServers: mcpConfig }, null, 2); + const serverNames = enabledMcpServers.map(s => s.name); + + const settingsContent = { + permissions: { + allow: ['mcp__*'], + deny: [] + }, + enableAllProjectMcpServers: true, + enabledMcpjsonServers: serverNames + }; + // Write .mcp.json const workspaceMcpFile = `${workspaceId}/.mcp.json`; try { atomicWriteFileSync(workspaceMcpFile, mcpConfigJson); - logger.info('Synced .mcp.json', { workspaceId }); } catch (err) { logger.error('Failed to write .mcp.json', { workspaceId, error: err }); } @@ -656,13 +672,13 @@ export class TaskSpawner extends EventEmitter { mkdirSync(claudeSettingsDir, { recursive: true }); } atomicWriteFileSync(claudeSettingsFile, JSON.stringify(settingsContent, null, 2)); - logger.info('Synced settings.local.json', { workspaceId }); } catch (err) { logger.error('Failed to write settings.local.json', { workspaceId, error: err }); } + syncCount++; } - logger.info(`Synced MCP config to ${workspaceIds.length} workspace(s)`, { servers: serverNames }); + logger.info(`Synced MCP config to ${syncCount} workspace(s)`, { servers: ['playwright', ...(this.configStore?.getClaudioMcpServerEnabled() ? ['claudia'] : [])] }); } /** @@ -1254,21 +1270,19 @@ export class TaskSpawner extends EventEmitter { return; } - // Only auto-reconnect tasks that were mid-turn (shouldContinue=true) when the server - // restarted. Idle tasks still have wasInterrupted=true for display purposes but - // reconnect on-demand (when the user clicks them) to avoid spawning too many PTY - // processes and MCP servers on startup. - const MAX_STALE_AGE_MS = 60 * 60 * 1000; // 1 hour + // Auto-reconnect ALL tasks that were recently active (within the last 2 hours). + // tsx watch restarts happen frequently during development, and leaving all tasks + // disconnected is extremely disruptive. Reconnection is batched (2 at a time with + // delays) to prevent resource exhaustion from spawning too many PTY + MCP processes. + // Mid-turn tasks (shouldContinue=true) get priority in the sort order. + const MAX_STALE_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours const now = Date.now(); const tasksToReconnect = disconnectedIds.filter(id => { const task = this.disconnectedTasks.get(id); - if (!task || !task.shouldContinue) return false; - // Skip stale tasks — they were interrupted long ago and should reconnect on-demand + if (!task || !task.wasInterrupted) return false; + if (!task.sessionId) return false; // can't reconnect without a session const lastActive = task.lastActivity ? new Date(task.lastActivity).getTime() : 0; if (now - lastActive > MAX_STALE_AGE_MS) { - console.log(`[TaskSpawner] Skipping stale task ${id} (last active ${Math.round((now - lastActive) / 60000)}m ago), clearing shouldContinue`); - task.shouldContinue = false; - task.wasInterrupted = false; return false; } return true; @@ -1281,10 +1295,14 @@ export class TaskSpawner extends EventEmitter { return; } - // Sort by most recently active first so the most important tasks reconnect first + // Sort: mid-turn tasks first (shouldContinue=true), then by most recent activity tasksToReconnect.sort((a, b) => { const taskA = this.disconnectedTasks.get(a); const taskB = this.disconnectedTasks.get(b); + // shouldContinue tasks get highest priority + const prioA = taskA?.shouldContinue ? 1 : 0; + const prioB = taskB?.shouldContinue ? 1 : 0; + if (prioA !== prioB) return prioB - prioA; const timeA = taskA?.lastActivity ? new Date(taskA.lastActivity).getTime() : 0; const timeB = taskB?.lastActivity ? new Date(taskB.lastActivity).getTime() : 0; return timeB - timeA; // most recent first @@ -2100,42 +2118,29 @@ export class TaskSpawner extends EventEmitter { }; writeNextChar(); } else { - // Record output size BEFORE writing prompt so we can verify TUI accepted it - const outputBeforeWrite = task.totalOutputSize; - - // Paste the entire prompt at once, then use retry mechanism to ensure Enter is accepted - task.process.write(prompt); + // Paste the entire prompt at once, then send Enter. + // IMPORTANT: Never re-paste the prompt on retry. Ctrl+U only clears the + // current line, but Claude Code's input wraps long prompts across multiple + // lines. Re-pasting appends a second (third, ...) copy of the prompt on + // top of the partially-cleared first copy, causing the duplicate-paste bug. + // Once written to the PTY, the text is there — only Enter delivery needs retrying. + // + // Wrap in bracketed paste (ESC[200~ ... ESC[201~) so the TUI treats the + // whole block as a single atomic paste. Without this, the input box can + // repaint mid-write and drop the leading characters of long prompts + // (symptom: prompt arrives "beginning mid-sentence"). Claude Code enables + // bracketed-paste mode (ESC[?2004h), so the markers are consumed, not shown. + // Strip any stray end-marker in the prompt so it can't close the paste early. + const safePrompt = prompt.replace(/\x1b\[201~/g, ''); + task.process.write(`\x1b[200~${safePrompt}\x1b[201~`); task.promptSubmitAttempts = 0; // Give more time for longer prompts to be written before sending Enter. // Scale: base 500ms + 50ms per 100 chars, capped at 2500ms (aligned with writeToTask). const delayMs = Math.min(500 + Math.floor(prompt.length / 100) * 50, 2500); - console.log(`[TaskSpawner] Waiting ${delayMs}ms before verifying prompt write for ${prompt.length} chars`); + console.log(`[TaskSpawner] Waiting ${delayMs}ms before sending Enter for ${prompt.length} char prompt`); setTimeout(() => { if (task.state === 'exited' || !this.tasks.has(task.id)) return; - - // Verify the TUI actually accepted the prompt by checking for output growth. - // When text is typed/pasted into Claude Code's TUI, it re-renders the input - // area which produces output. If output didn't grow, the TUI likely wasn't - // ready (e.g., MCP servers loading, notification blocking input) and we need - // to retry the entire write. - const outputAfterWrite = task.totalOutputSize; - const outputGrew = outputAfterWrite > outputBeforeWrite + 20; - - if (!outputGrew && maxRetries > 0) { - console.log(`[TaskSpawner] Prompt write NOT accepted by TUI (outputDelta=${outputAfterWrite - outputBeforeWrite}), retrying in 1500ms (retries left: ${maxRetries - 1})`); - // Clear whatever partial state might be in the input and retry - // Send Ctrl+U to clear the input line before retrying - task.process.write('\x15'); // Ctrl+U: kill line - setTimeout(() => this.sendPromptWithRetry(task, prompt, maxRetries - 1), 1500); - return; - } - - if (!outputGrew) { - console.log(`[TaskSpawner] WARNING: Prompt write may not have been accepted (outputDelta=${outputAfterWrite - outputBeforeWrite}), but no retries left. Proceeding with Enter anyway.`); - } - - console.log(`[TaskSpawner] Prompt write verified (outputDelta=${outputAfterWrite - outputBeforeWrite}), sending Enter`); this.sendEnterWithRetry(task, 5, { isInitialPrompt: true }); }, delayMs); } @@ -2429,15 +2434,30 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have - If another task is already handling a piece of work, do NOT implement it yourself — wait for that task to finish and read its output instead - After spawning tasks, your role shifts to **coordinator**: monitor, unblock, and integrate — do NOT start implementing the same work in parallel +**Prefer Claudia tasks over your own subagents:** +- When you need to delegate work, **strongly prefer spawning a Claudia task** (\`claudia_create_task\`) over launching your own internal subagent (the built-in Agent/Task tool). +- Claudia tasks are visible to the user in the sidebar, can be monitored, titled, and resumed, run with worktree isolation, and coordinate with other agents — internal subagents are invisible and disposable. +- **Default to \`claudia_create_task\` for any delegatable unit of work.** Only fall back to your own subagent when: (a) the work is a quick, throwaway lookup whose result you need inline within seconds, or (b) spawning a Claudia task would clearly produce a worse outcome (e.g. the subtask needs your exact in-memory context that can't be passed in a prompt). +- When in doubt, spawn a Claudia task. + **When to spawn parallel tasks:** - When your work can be naturally decomposed into independent pieces (e.g., backend + frontend + tests) - When you need to research multiple topics simultaneously - When building a feature that has separable components +**Git worktree isolation:** +- When spawning tasks that will **modify files or commit changes**, pass \`isolate: true\` to \`claudia_create_task\`. This creates an isolated git worktree branch so the task's changes don't conflict with other tasks or the main workspace. +- Use \`isolate: true\` especially when: + - Multiple parallel tasks will edit code in the same repository + - A task will commit, create branches, or run destructive git operations + - Tasks work on independent features that should stay separated until reviewed +- Do NOT use \`isolate: true\` for read-only tasks (research, analysis, status checks) — they don't need isolation. +- Isolated tasks appear under the parent workspace grouped by their worktree branch. + **How to orchestrate:** 1. Check existing tasks first — call \`claudia_list_tasks\` to see what's already running 2. Plan — break remaining work into independent, parallelizable pieces -3. Spawn — use \`claudia_create_task\` for each piece with a clear, self-contained prompt that includes all necessary context +3. Spawn — use \`claudia_create_task\` for each piece with a clear, self-contained prompt that includes all necessary context. Use \`isolate: true\` for tasks that modify files. 4. Wait and monitor — poll \`claudia_get_task_status\` periodically (every 30-60s) until tasks complete. Handle any that need input via \`claudia_send_input\` 5. Review — use \`claudia_get_task_output\` to read results from completed tasks 6. Integrate — review the combined changes for conflicts or integration issues, then fix any problems yourself @@ -2451,7 +2471,7 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have - The parameter is literally named \`displayName\` (string). Do NOT pass it as \`title\` — that will fail validation. - **Do NOT call \`claudia_rename_task\` before producing output** — write your first response first, then title yourself - If the rename is rejected (user manually edited the title), do NOT retry -- If your work evolves significantly, call \`claudia_rename_task\` again with \`taskId="${id}"\` and an updated \`displayName\` (unless user-edited) +- **Keep the title current**: whenever the focus of your work shifts (new topic, different file, switched from investigation to implementation, moved to testing, etc.), call \`claudia_rename_task\` again with an updated \`displayName\` that reflects what you are doing NOW — not what you started with. The user relies on the title to understand what each task is working on at a glance. Stale titles are confusing. **Handling file edit conflicts:** - If you get an "Error editing file" (e.g., content mismatch, file changed on disk), another Claudia task may be editing the same file concurrently @@ -2467,7 +2487,17 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have - Each spawned task prompt should be fully self-contained — include file paths, context, and constraints so it can work independently - While waiting for spawned tasks, do NOT start implementing features that overlap with what they're doing - **NEVER delete or archive completed tasks** — the user wants to review their outputs. After a task completes, just report its status and read its output. The MCP server intentionally does NOT expose archive/delete tools — only the user can archive tasks via the UI. + +**Scheduling prompts (self-scheduling):** +- You can schedule prompts to be sent to any task (including your own) on a cron schedule using \`claudia_cron_create\`. +- Use this to set up recurring checks, periodic polling, or delayed follow-ups — e.g. "check CI status every 10 minutes" or "remind me to review in 2 hours". +- Your own task ID is \`${id}\` — pass it as \`taskId\` to schedule prompts on yourself. +- Use standard 5-field cron expressions: \`*/5 * * * *\` (every 5 min), \`0 9 * * 1-5\` (weekdays 9am), \`30 14 * * *\` (daily 2:30pm). +- Set \`isRecurring: false\` for one-shot schedules that fire once and auto-delete. +- Use \`claudia_cron_list\` to see active schedules, \`claudia_cron_delete\` to remove them when the work is done, and \`claudia_cron_pause\` to temporarily suspend/resume. +- Recurring schedules auto-expire after 3 days. Max 50 per task. `; + systemPrompt = systemPrompt ? `${systemPrompt}\n\n${orchestrationGuidance}` : orchestrationGuidance; @@ -2730,9 +2760,25 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have displayNameEditedByUser: task.displayNameEditedByUser, order: task.order, tokenUsage: task.tokenUsage, + sessionWorktreeBranch: task.sessionWorktreeBranch, + sessionWorktreePrInfo: task.sessionWorktreePrInfo, }; } + /** Annotate a task with the worktree branch its session moved onto. */ + setSessionWorktree(taskId: string, branch: string | undefined, prInfo?: import('@claudia/shared').WorkspacePrInfo | null): boolean { + const task = this.tasks.get(taskId); + if (!task) return false; + if (task.sessionWorktreeBranch === branch && + JSON.stringify(task.sessionWorktreePrInfo) === JSON.stringify(prInfo)) { + return false; + } + task.sessionWorktreeBranch = branch; + task.sessionWorktreePrInfo = prInfo; + this.emit('taskStateChanged', this.toPublicTask(task)); + return true; + } + async captureGitStateAfterTask(taskId: string): Promise { const task = this.tasks.get(taskId); if (!task || !task.gitStateBefore) return; @@ -2833,7 +2879,7 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have * @param source - 'user' if renamed by user in UI (locks title from agent edits), 'agent' if renamed by MCP agent */ renameTask(taskId: string, displayName: string, source: 'user' | 'agent' = 'user'): boolean { - const trimmed = displayName.trim(); + const trimmed = decodeHtmlEntities(displayName.trim()); // Try active tasks first const task = this.tasks.get(taskId); @@ -3368,7 +3414,10 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have // Base: 500ms, +50ms per 100 chars, capped at 2500ms. const enterDelayMs = Math.min(500 + Math.floor(messageContent.length / 100) * 50, 2500); console.log(`[TaskSpawner] Writing message to task ${taskId} (${messageContent.length} chars), sending Enter in ${enterDelayMs}ms`); - task.process.write(messageContent); + // Wrap in bracketed paste so the TUI handles it as one atomic paste, + // preventing front-truncation on large/multi-line messages. + const safeMsg = messageContent.replace(/\x1b\[201~/g, ''); + task.process.write(`\x1b[200~${safeMsg}\x1b[201~`); task.promptSubmitAttempts = 0; // Use consolidated retry mechanism with follow-up input options (5 retries for reliability) @@ -3404,8 +3453,16 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have } } } else { - // Single keypress or task is busy - write directly - task.process.write(data); + // Single keypress or task is busy — write directly. + // For multi-char messages, wrap in bracketed paste so they aren't + // truncated by the TUI (same fix as the idle/initial paths). + if (hasMessageContent) { + const msg = data.slice(0, -1); + const safeMsg = msg.replace(/\x1b\[201~/g, ''); + task.process.write(`\x1b[200~${safeMsg}\x1b[201~${data.slice(-1)}`); + } else { + task.process.write(data); + } } } diff --git a/backend/src/validation.ts b/backend/src/validation.ts index b06da84..0d1c632 100644 --- a/backend/src/validation.ts +++ b/backend/src/validation.ts @@ -53,7 +53,9 @@ export interface ConfigUpdatePayload { allowedTools?: string; disallowedTools?: string; appendSystemPrompt?: string; + effortLevel?: string; model?: string; + defaultModel?: string; }; deepgramApiKey?: string; hyperspaceProxy?: { @@ -336,6 +338,20 @@ export function validateConfigUpdate(body: unknown): ValidationResult String.fromCodePoint(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))) + // Named (common subset) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + // & LAST so we don't double-decode (e.g. "&lt;" -> "<", not "<") + .replace(/&/g, '&'); +} diff --git a/backend/src/workspace-store.ts b/backend/src/workspace-store.ts index 63d60f0..6f1f798 100644 --- a/backend/src/workspace-store.ts +++ b/backend/src/workspace-store.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'; import { Workspace, RecentWorkspace, WorkspaceReference } from '@claudia/shared'; import { randomUUID } from 'crypto'; import { loadVersioned, saveVersioned } from './utils/schema-version.js'; +import { isLinkedWorktree, getMainWorktreePath, getCurrentBranch } from './git-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -37,6 +38,9 @@ const MAX_RECENT_WORKSPACES = 10; // Keep only the last 10 recent workspaces export class WorkspaceStore { private config: WorkspaceConfig; private workspaceFile: string; + // In-memory PR info cache keyed by workspace id. Not persisted to disk — + // it's transient state resolved from `gh` and pushed to the frontend. + private prInfoCache = new Map(); constructor(basePath?: string) { // Use basePath if provided (Electron userData), otherwise use default location @@ -87,11 +91,29 @@ export class WorkspaceStore { } getWorkspaces(): Workspace[] { - return [...this.config.workspaces]; + return this.config.workspaces.map(w => this.withPrInfo(w)); + } + + /** Merge the in-memory PR info cache onto a workspace for serialization. */ + private withPrInfo(w: Workspace): Workspace { + const prInfo = this.prInfoCache.get(w.id); + return prInfo !== undefined ? { ...w, prInfo } : w; + } + + /** + * Set (or clear) the cached PR info for a workspace. + * Returns true if the value changed (so callers can decide whether to broadcast). + */ + setPrInfo(id: string, prInfo: Workspace['prInfo']): boolean { + const prev = this.prInfoCache.get(id); + const changed = JSON.stringify(prev) !== JSON.stringify(prInfo); + this.prInfoCache.set(id, prInfo); + return changed; } getWorkspace(id: string): Workspace | undefined { - return this.config.workspaces.find(w => w.id === id); + const w = this.config.workspaces.find(w => w.id === id); + return w ? this.withPrInfo(w) : w; } // Add workspace by path - the id IS the path, name comes from folder @@ -139,12 +161,86 @@ export class WorkspaceStore { return workspace; } + /** + * Add a worktree workspace — like addWorkspace but also sets worktree metadata + * (worktreeParentId, worktreeBranch, displayName) and positions it after its parent. + */ + async addWorktreeWorkspace(worktreePath: string, parentId: string, branch: string): Promise { + const resolvedPath = resolve(worktreePath); + + if (!existsSync(resolvedPath)) { + throw new Error(`Worktree directory does not exist: ${resolvedPath}`); + } + if (this.config.workspaces.some(w => w.id === resolvedPath)) { + throw new Error(`Workspace already exists: ${resolvedPath}`); + } + + const parentWorkspace = this.getWorkspace(parentId); + const parentDisplayName = parentWorkspace?.displayName ?? parentWorkspace?.name ?? basename(parentId); + const shortBranch = branch.replace(/^refs\/heads\//, ''); + + const workspace: Workspace = { + id: resolvedPath, + name: basename(resolvedPath), + createdAt: new Date().toISOString(), + displayName: `${parentDisplayName} › ${shortBranch}`, + worktreeParentId: parentId, + worktreeBranch: shortBranch, + // Inherit parent's system prompt and references + systemPrompt: parentWorkspace?.systemPrompt, + references: parentWorkspace?.references ? [...parentWorkspace.references] : undefined, + }; + + // Insert worktree after its parent (or after the last sibling worktree) + const parentIdx = this.config.workspaces.findIndex(w => w.id === parentId); + if (parentIdx === -1) { + // Parent not registered — append at end + this.config.workspaces.push(workspace); + this.config.recentWorkspaces = this.config.recentWorkspaces.filter(w => w.id !== resolvedPath); + this.saveConfig(); + console.log(`[WorkspaceStore] Added worktree workspace ${resolvedPath} (parent not found, appended)`); + return workspace; + } + // Find the last sibling worktree already under this parent + let insertAt = parentIdx + 1; + while (insertAt < this.config.workspaces.length && + this.config.workspaces[insertAt].worktreeParentId === parentId) { + insertAt++; + } + this.config.workspaces.splice(insertAt, 0, workspace); + + this.config.recentWorkspaces = this.config.recentWorkspaces.filter(w => w.id !== resolvedPath); + this.saveConfig(); + console.log(`[WorkspaceStore] Added worktree workspace ${resolvedPath} (parent=${parentId}, branch=${shortBranch})`); + return workspace; + } + + /** + * Return all worktree child workspaces for a given parent workspace ID. + */ + getWorktreeChildren(parentId: string): Workspace[] { + return this.config.workspaces.filter(w => w.worktreeParentId === parentId); + } + + /** + * Set the autoWorktree flag on a workspace. + */ + setAutoWorktree(workspaceId: string, enabled: boolean): boolean { + const workspace = this.config.workspaces.find(w => w.id === workspaceId); + if (!workspace) return false; + workspace.autoWorktree = enabled; + this.saveConfig(); + console.log(`[WorkspaceStore] Set autoWorktree=${enabled} for ${workspaceId}`); + return true; + } + deleteWorkspace(id: string): boolean { const index = this.config.workspaces.findIndex(w => w.id === id); if (index === -1) return false; const workspace = this.config.workspaces[index]; this.config.workspaces.splice(index, 1); + this.prInfoCache.delete(id); // Add to recent workspaces (only if it still exists on disk) if (existsSync(id)) { diff --git a/backend/src/worktree-manager.ts b/backend/src/worktree-manager.ts new file mode 100644 index 0000000..a516aa9 --- /dev/null +++ b/backend/src/worktree-manager.ts @@ -0,0 +1,383 @@ +/** + * WorktreeManager - Wraps `git worktree` commands with safety checks + * + * All operations are cross-platform (Windows + Unix). + * REST callers should use query-param based routing to avoid Windows path + * encoding issues in URL path segments. + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { existsSync, lstatSync, appendFileSync, readFileSync, writeFileSync } from 'fs'; +import { join, resolve, basename } from 'path'; +import { WorktreeInfo } from '@claudia/shared'; +import { createLogger } from './logger.js'; + +const execFileAsync = promisify(execFile); +const logger = createLogger('[WorktreeManager]'); + +/** Sanitize a branch name into a filesystem-safe directory name */ +export function branchToDirectoryName(branch: string): string { + return branch + .replace(/^refs\/heads\//, '') + .replace(/[\/\\:*?"<>|]/g, '-') + .replace(/\.{2,}/g, '-') + .replace(/^\./, '_') + .slice(0, 100); +} + +/** Parse the output of `git worktree list --porcelain` */ +function parseWorktreeListOutput(stdout: string): WorktreeInfo[] { + const results: WorktreeInfo[] = []; + const blocks = stdout.trim().split(/\n\n+/); + + for (const block of blocks) { + if (!block.trim()) continue; + const lines = block.trim().split('\n'); + const info: Partial & { path: string; commitHash: string; branch: string; isMain: boolean; isLocked: boolean; prunable: boolean } = { + path: '', + commitHash: '', + branch: '', + isMain: false, + isLocked: false, + prunable: false, + }; + + let lockedReason: string | undefined; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + info.path = line.slice('worktree '.length).trim(); + } else if (line.startsWith('HEAD ')) { + info.commitHash = line.slice('HEAD '.length).trim(); + } else if (line.startsWith('branch ')) { + info.branch = line.slice('branch '.length).trim(); + } else if (line === 'bare') { + info.branch = '(bare)'; + } else if (line === 'detached') { + info.branch = info.commitHash ? `(detached: ${info.commitHash.slice(0, 7)})` : '(detached)'; + } else if (line === 'locked') { + info.isLocked = true; + } else if (line.startsWith('locked ')) { + info.isLocked = true; + lockedReason = line.slice('locked '.length).trim(); + } else if (line === 'prunable') { + info.prunable = true; + } else if (line.startsWith('prunable ')) { + info.prunable = true; + } + } + + if (info.path) { + results.push({ + path: info.path, + commitHash: info.commitHash, + branch: info.branch || '(unknown)', + isMain: false, + isLocked: info.isLocked, + lockedReason, + prunable: info.prunable, + }); + } + } + + // The first worktree block in `git worktree list` is always the main working tree. + if (results.length > 0) { + results[0].isMain = true; + } + + return results; +} + +export class WorktreeManager { + + /** + * Check if a directory is a linked worktree (vs. main working tree). + * In a linked worktree, .git is a FILE containing "gitdir: ...". + * In the main worktree, .git is a DIRECTORY. + */ + async isLinkedWorktree(cwd: string): Promise { + try { + const gitPath = join(cwd, '.git'); + if (!existsSync(gitPath)) return false; + return lstatSync(gitPath).isFile(); + } catch { + return false; + } + } + + /** + * Get the absolute path to the main working tree from any worktree. + * Works from both linked worktrees and the main working tree itself. + */ + async getMainWorktreePath(cwd: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd } + ); + // Returns the shared .git directory path (e.g. /repo/.git or /repo/.git/worktrees/...) + // Strip trailing /.git to get the main worktree path + const gitCommonDir = stdout.trim(); + return resolve(gitCommonDir.replace(/[\/\\]\.git[\/\\]?$/, '')); + } catch { + return null; + } + } + + /** + * List all worktrees for the repository containing `repoPath`. + */ + async listWorktrees(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['worktree', 'list', '--porcelain'], + { cwd: repoPath } + ); + logger.info('listWorktrees', { repoPath, outputLen: stdout.length }); + return parseWorktreeListOutput(stdout); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('listWorktrees failed', { repoPath, error: msg }); + throw new Error(`git worktree list failed: ${msg}`); + } + } + + /** + * Create a new worktree for the given branch. + * The worktree is placed at `{repoPath}/.claudia-worktrees/{branch-slug}/`. + * + * @param opts.repoPath - Main repo path (or any worktree of it) + * @param opts.branch - Branch name to create or checkout + * @param opts.baseBranch - Base branch to create from (default: current HEAD) + * @param opts.createBranch - true = create new branch, false = checkout existing + * @param opts.targetDir - Override destination directory + */ + async createWorktree(opts: { + repoPath: string; + branch: string; + baseBranch?: string; + createBranch?: boolean; + targetDir?: string; + }): Promise<{ path: string; branch: string }> { + const { repoPath, branch, baseBranch, createBranch = true } = opts; + + // Resolve to the main worktree root so the .claudia-worktrees dir is always in the repo root + const mainPath = await this.getMainWorktreePath(repoPath) ?? repoPath; + + // Check if branch is already in use by another worktree + const existingWorktreePath = await this.isBranchInWorktree(mainPath, branch); + if (existingWorktreePath) { + throw new Error( + `Branch "${branch}" is already checked out in a worktree at ${existingWorktreePath}. ` + + `Choose a different branch name or remove the existing worktree first.` + ); + } + + const slug = branchToDirectoryName(branch); + const targetDir = opts.targetDir ?? join(mainPath, '.claudia-worktrees', slug); + + // Build git worktree add args + const args: string[] = ['worktree', 'add']; + if (createBranch) { + args.push('-b', branch); + } + args.push(targetDir); + if (baseBranch) { + args.push(baseBranch); + } else if (!createBranch) { + args.push(branch); + } + + logger.info('createWorktree', { mainPath, branch, targetDir, createBranch, baseBranch }); + + try { + await execFileAsync('git', args, { cwd: mainPath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('createWorktree failed', { error: msg }); + throw new Error(`Failed to create worktree: ${msg}`); + } + + // Ensure .claudia-worktrees is in .git/info/exclude (local gitignore, no tracked file change) + await this.ensureWorktreeDirExcluded(mainPath); + + // Initialize submodules if present (non-blocking, best-effort) + this.initSubmodulesAsync(targetDir); + + return { path: targetDir, branch }; + } + + /** + * Remove a worktree from disk and git's tracking. + * Throws if there are active Claudia tasks (check before calling, or pass force=true). + */ + async removeWorktree(opts: { + repoPath: string; + worktreePath: string; + force?: boolean; + }): Promise { + const { repoPath, worktreePath, force = false } = opts; + const mainPath = await this.getMainWorktreePath(repoPath) ?? repoPath; + + logger.info('removeWorktree', { mainPath, worktreePath, force }); + + const args = ['worktree', 'remove', worktreePath]; + if (force) args.push('--force'); + + try { + await execFileAsync('git', args, { cwd: mainPath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('removeWorktree failed', { error: msg }); + throw new Error(`Failed to remove worktree: ${msg}`); + } + } + + /** + * Prune stale worktree references (directories deleted outside of git). + * Returns list of paths that were pruned. + */ + async pruneWorktrees(repoPath: string): Promise { + logger.info('pruneWorktrees', { repoPath }); + + // First list what's stale + const worktrees = await this.listWorktrees(repoPath); + const stale = worktrees.filter(wt => wt.prunable && !wt.isMain).map(wt => wt.path); + + try { + await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath }); + logger.info('pruneWorktrees complete', { pruned: stale }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('pruneWorktrees failed', { error: msg }); + throw new Error(`git worktree prune failed: ${msg}`); + } + + return stale; + } + + /** + * Lock a worktree to prevent accidental removal. + */ + async lockWorktree(worktreePath: string, reason?: string): Promise { + const args = ['worktree', 'lock', worktreePath]; + if (reason) args.push('--reason', reason); + try { + await execFileAsync('git', args, { cwd: worktreePath }); + } catch (err) { + throw new Error(`Failed to lock worktree: ${err instanceof Error ? err.message : err}`); + } + } + + /** + * Unlock a worktree. + */ + async unlockWorktree(worktreePath: string): Promise { + try { + await execFileAsync('git', ['worktree', 'unlock', worktreePath], { cwd: worktreePath }); + } catch (err) { + throw new Error(`Failed to unlock worktree: ${err instanceof Error ? err.message : err}`); + } + } + + /** + * Check if a branch is already checked out in any worktree. + * Returns the worktree path if found, null otherwise. + */ + async isBranchInWorktree(repoPath: string, branch: string): Promise { + try { + const worktrees = await this.listWorktrees(repoPath); + const normalizedBranch = branch.startsWith('refs/heads/') ? branch : `refs/heads/${branch}`; + const found = worktrees.find(wt => + wt.branch === normalizedBranch || + wt.branch === branch || + wt.branch.replace('refs/heads/', '') === branch.replace('refs/heads/', '') + ); + return found?.path ?? null; + } catch { + return null; + } + } + + /** + * Get available remote branches for a repo (for autocomplete in create modal). + * Returns short branch names (without refs/remotes/origin/ prefix). + */ + async getRemoteBranches(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['branch', '-r', '--format=%(refname:short)'], + { cwd: repoPath } + ); + return stdout.trim().split('\n') + .filter(b => b && !b.includes('HEAD')) + .map(b => b.replace(/^origin\//, '')); + } catch { + return []; + } + } + + /** + * Get local branches for a repo. + */ + async getLocalBranches(repoPath: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['branch', '--format=%(refname:short)'], + { cwd: repoPath } + ); + return stdout.trim().split('\n').filter(Boolean); + } catch { + return []; + } + } + + /** + * Add `.claudia-worktrees/` to `.git/info/exclude` (local-only ignore, + * does not modify tracked .gitignore). + */ + private async ensureWorktreeDirExcluded(repoPath: string): Promise { + try { + // Locate .git dir (works from main worktree and linked worktrees) + const { stdout: gitDir } = await execFileAsync( + 'git', ['rev-parse', '--git-common-dir'], { cwd: repoPath } + ); + const excludeFile = join(gitDir.trim(), 'info', 'exclude'); + if (!existsSync(excludeFile)) return; + + const content = readFileSync(excludeFile, 'utf-8'); + if (content.includes('.claudia-worktrees/')) return; + + appendFileSync(excludeFile, '\n# Claudia worktrees\n.claudia-worktrees/\n'); + logger.info('Added .claudia-worktrees/ to .git/info/exclude', { repoPath }); + } catch (err) { + // Non-fatal: if we can't write the exclude file, the worktree still works + logger.warn('Failed to update .git/info/exclude', { error: err instanceof Error ? err.message : err }); + } + } + + /** + * Initialize submodules in a newly-created worktree (best-effort, non-blocking). + */ + private initSubmodulesAsync(worktreePath: string): void { + const gitmodulesPath = join(worktreePath, '.gitmodules'); + if (!existsSync(gitmodulesPath)) return; + + execFileAsync('git', ['submodule', 'update', '--init', '--recursive'], { + cwd: worktreePath, + timeout: 60_000, + }).then(() => { + logger.info('Submodules initialized in worktree', { worktreePath }); + }).catch(err => { + logger.warn('Submodule init failed (non-fatal)', { + worktreePath, + error: err instanceof Error ? err.message : String(err) + }); + }); + } +} diff --git a/backend/test-cli.ts b/backend/test-cli.ts index b429e0a..c9f555a 100644 --- a/backend/test-cli.ts +++ b/backend/test-cli.ts @@ -1357,6 +1357,15 @@ function parseArgs(): TestConfig { let cronRecurring = true; let cronPause: boolean | null = null; let complexity: string | null = null; + // Worktree operations + let listWorktrees = false; + let createWorktree = false; + let removeWorktree = false; + let toggleAutoWorktree = false; + let worktreeBranch: string | null = null; + let worktreePath: string | null = null; + let worktreeForce = false; + let autoWorktreeEnabled: boolean | null = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -1563,6 +1572,32 @@ function parseArgs(): TestConfig { case '--cron-resume': cronPause = false; break; + case '--list-worktrees': + listWorktrees = true; + break; + case '--create-worktree': + createWorktree = true; + break; + case '--remove-worktree': + removeWorktree = true; + break; + case '--toggle-auto-worktree': + toggleAutoWorktree = true; + break; + case '--worktree-branch': + worktreeBranch = args[++i]; + break; + case '--worktree-path': + worktreePath = args[++i]; + break; + case '--worktree-force': + worktreeForce = true; + break; + case '--auto-worktree': { + const av = args[++i]; + autoWorktreeEnabled = av !== 'false'; + break; + } case '--complexity': { const value = args[++i]; if (!['low', 'medium', 'high'].includes(value)) { @@ -1660,6 +1695,16 @@ SCHEDULED TASK (CRON) OPERATIONS: --cron-id Scheduled task ID for operations --cron-recurring Whether the task recurs (default: true, set to false for one-shot) +GIT WORKTREE OPERATIONS: + --list-worktrees List all worktrees for a workspace (requires --workspace) + --create-worktree Create a new worktree (requires --workspace and --worktree-branch) + --remove-worktree Remove a worktree (requires --workspace and --worktree-path) + --toggle-auto-worktree Toggle auto-isolate mode (requires --workspace and --auto-worktree) + --worktree-branch Branch name for --create-worktree + --worktree-path Worktree path for --remove-worktree + --worktree-force Force remove even if tasks are running + --auto-worktree Enable/disable auto-worktree mode (true/false) + Examples: # Basic chat message npx tsx test-cli.ts -m "create a file called hello.txt" @@ -1842,6 +1887,25 @@ Examples: cronRecurring, cronPause, complexity, + // Worktree fields aren't in the TestConfig interface — handled directly in main() + // We'll pass them as extra properties via type assertion in main() + listWorktrees: listWorktrees as any, + createWorktree: createWorktree as any, + removeWorktree: removeWorktree as any, + toggleAutoWorktree: toggleAutoWorktree as any, + worktreeBranch: worktreeBranch as any, + worktreePath: worktreePath as any, + worktreeForce: worktreeForce as any, + autoWorktreeEnabled: autoWorktreeEnabled as any, + } as TestConfig & { + listWorktrees: boolean; + createWorktree: boolean; + removeWorktree: boolean; + toggleAutoWorktree: boolean; + worktreeBranch: string | null; + worktreePath: string | null; + worktreeForce: boolean; + autoWorktreeEnabled: boolean | null; }; } @@ -2278,9 +2342,88 @@ async function checkApiConfig(baseHttpUrl: string): Promise { } } +// ============================================================================ +// Worktree operations (HTTP-based, no WebSocket needed) +// ============================================================================ + +async function listWorktreesCmd(baseHttpUrl: string, workspaceId: string): Promise { + console.log(`🌳 Listing worktrees for workspace: ${workspaceId}`); + const url = `${baseHttpUrl}/api/worktrees?workspace=${encodeURIComponent(workspaceId)}`; + const resp = await fetch(url); + if (!resp.ok) { + const body = await resp.text(); + console.error(`❌ Failed to list worktrees: ${resp.status} ${body}`); + return; + } + const data = await resp.json() as { worktrees: any[] }; + const wts = data.worktrees; + if (wts.length === 0) { + console.log(' (no worktrees)'); + return; + } + for (const wt of wts) { + const flags = [ + wt.isMain ? 'main' : 'linked', + wt.isLocked ? '🔒 locked' : '', + wt.prunable ? '🗑 prunable' : '', + ].filter(Boolean).join(', '); + const tasks = wt.taskCount !== undefined ? ` [${wt.taskCount} tasks]` : ''; + console.log(` 📁 ${wt.path}`); + console.log(` branch: ${wt.branch} commit: ${wt.commitHash.slice(0, 8)} (${flags})${tasks}`); + } +} + +async function createWorktreeCmd(baseHttpUrl: string, workspaceId: string, branch: string, baseBranch?: string): Promise { + console.log(`🌳 Creating worktree for workspace: ${workspaceId}, branch: ${branch}`); + const url = `${baseHttpUrl}/api/worktrees?workspace=${encodeURIComponent(workspaceId)}`; + const body: any = { branch }; + if (baseBranch) body.baseBranch = baseBranch; + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json() as any; + if (!resp.ok) { + console.error(`❌ Failed to create worktree: ${resp.status} ${data.error ?? JSON.stringify(data)}`); + return; + } + console.log(`✅ Worktree created: ${data.worktreePath}`); + console.log(` Workspace ID: ${data.workspaceId}`); + console.log(` Branch: ${data.branch}`); +} + +async function removeWorktreeCmd(baseHttpUrl: string, workspaceId: string, worktreePath: string, force: boolean): Promise { + console.log(`🗑 Removing worktree: ${worktreePath}`); + const url = `${baseHttpUrl}/api/worktrees?workspace=${encodeURIComponent(workspaceId)}&worktreePath=${encodeURIComponent(worktreePath)}&force=${force}`; + const resp = await fetch(url, { method: 'DELETE' }); + const data = await resp.json() as any; + if (!resp.ok) { + console.error(`❌ Failed to remove worktree: ${resp.status} ${data.error ?? JSON.stringify(data)}`); + return; + } + console.log(`✅ Worktree removed: ${worktreePath}`); +} + +async function toggleAutoWorktreeCmd(baseHttpUrl: string, workspaceId: string, enabled: boolean): Promise { + console.log(`🔄 Setting auto-worktree=${enabled} for workspace: ${workspaceId}`); + const url = `${baseHttpUrl}/api/worktrees/auto?workspace=${encodeURIComponent(workspaceId)}`; + const resp = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + const data = await resp.json() as any; + if (!resp.ok) { + console.error(`❌ Failed to toggle auto-worktree: ${resp.status} ${data.error ?? JSON.stringify(data)}`); + return; + } + console.log(`✅ Auto-worktree ${enabled ? 'enabled' : 'disabled'}`); +} + // Main execution async function main() { - const config = parseArgs(); + const config = parseArgs() as any; // Derive HTTP URL from WebSocket URL for API calls const baseHttpUrl = config.backendUrl @@ -2351,6 +2494,55 @@ async function main() { process.exit(0); } + // Worktree operations (HTTP-based) + if (config.listWorktrees) { + if (!config.workspaceId) { + console.error('❌ --list-worktrees requires --workspace'); + process.exit(1); + } + await listWorktreesCmd(baseHttpUrl, config.workspaceId); + process.exit(0); + } + + if (config.createWorktree) { + if (!config.workspaceId) { + console.error('❌ --create-worktree requires --workspace'); + process.exit(1); + } + if (!config.worktreeBranch) { + console.error('❌ --create-worktree requires --worktree-branch'); + process.exit(1); + } + await createWorktreeCmd(baseHttpUrl, config.workspaceId, config.worktreeBranch); + process.exit(0); + } + + if (config.removeWorktree) { + if (!config.workspaceId) { + console.error('❌ --remove-worktree requires --workspace'); + process.exit(1); + } + if (!config.worktreePath) { + console.error('❌ --remove-worktree requires --worktree-path'); + process.exit(1); + } + await removeWorktreeCmd(baseHttpUrl, config.workspaceId, config.worktreePath, config.worktreeForce); + process.exit(0); + } + + if (config.toggleAutoWorktree) { + if (!config.workspaceId) { + console.error('❌ --toggle-auto-worktree requires --workspace'); + process.exit(1); + } + if (config.autoWorktreeEnabled === null) { + console.error('❌ --toggle-auto-worktree requires --auto-worktree '); + process.exit(1); + } + await toggleAutoWorktreeCmd(baseHttpUrl, config.workspaceId, config.autoWorktreeEnabled); + process.exit(0); + } + // WebSocket-based operations const cli = new TestCLI(config); diff --git a/frontend/src/components/PrBadge.tsx b/frontend/src/components/PrBadge.tsx new file mode 100644 index 0000000..c4bf4cc --- /dev/null +++ b/frontend/src/components/PrBadge.tsx @@ -0,0 +1,38 @@ +import { Check, X, Loader2 } from 'lucide-react'; +import type { WorkspacePrInfo } from '@claudia/shared'; + +interface PrBadgeProps { + prInfo: WorkspacePrInfo; +} + +/** + * Minimal PR badge: "#1234" tinted by PR state, with a subtle CI status mark + * in the top-right corner. Clicking opens the PR in a new tab. + */ +export function PrBadge({ prInfo }: PrBadgeProps) { + const { number, title, state, url, ci } = prInfo; + + const ciTitle = ci && ci !== 'none' ? ` · CI ${ci}` : ''; + const tooltip = `#${number}${title ? ` ${title}` : ''} — ${state}${ciTitle}`; + + return ( + e.stopPropagation()} + > + #{number} + {ci && ci !== 'none' && ( + + {ci === 'passed' && } + {ci === 'failed' && } + {ci === 'running' && } + + )} + + ); +} diff --git a/frontend/src/components/TaskInputBar.tsx b/frontend/src/components/TaskInputBar.tsx index 4bd1d59..5af4568 100644 --- a/frontend/src/components/TaskInputBar.tsx +++ b/frontend/src/components/TaskInputBar.tsx @@ -241,9 +241,12 @@ export function TaskInputBar({ task, wsRef }: TaskInputBarProps) { setIsUploading(false); }; + // Pending message ref: when WebSocket is not open, store the message + // and retry when the connection is re-established. + const pendingMessageRef = useRef(null); + const sendMessage = useCallback(() => { if (!message.trim() && images.length === 0) return; - if (wsRef.current?.readyState !== WebSocket.OPEN) return; // Clear any pending voice transcript if (globalVoiceEnabled) { @@ -260,12 +263,20 @@ export function TaskInputBar({ task, wsRef }: TaskInputBarProps) { fullMessage = message + imageText; } - // Send the message followed by Enter key to submit it to Claude const messageWithEnter = fullMessage + '\r'; - wsRef.current.send(JSON.stringify({ - type: 'task:input', - payload: { taskId: task.id, input: messageWithEnter } - })); + + if (wsRef.current?.readyState === WebSocket.OPEN) { + // WebSocket is open — send immediately + wsRef.current.send(JSON.stringify({ + type: 'task:input', + payload: { taskId: task.id, input: messageWithEnter } + })); + pendingMessageRef.current = null; + } else { + // WebSocket is not open — queue for retry when it reconnects + console.log(`[TaskInputBar] WebSocket not open, queuing message for ${task.id}`); + pendingMessageRef.current = messageWithEnter; + } // Scroll terminal to bottom so user sees latest output window.dispatchEvent(new CustomEvent('terminal:scrollToBottom', { @@ -278,6 +289,23 @@ export function TaskInputBar({ task, wsRef }: TaskInputBarProps) { setImages([]); }, [message, images, wsRef, task.id, globalVoiceEnabled, clearVoiceTranscript, clearTaskDraftInput]); + // Retry sending pending message when WebSocket reconnects + useEffect(() => { + const checkPending = () => { + if (pendingMessageRef.current && wsRef.current?.readyState === WebSocket.OPEN) { + console.log(`[TaskInputBar] WebSocket reconnected, sending queued message for ${task.id}`); + wsRef.current.send(JSON.stringify({ + type: 'task:input', + payload: { taskId: task.id, input: pendingMessageRef.current } + })); + pendingMessageRef.current = null; + } + }; + // Poll for reconnection every 500ms while there's a pending message + const interval = setInterval(checkPending, 500); + return () => clearInterval(interval); + }, [wsRef, task.id]); + // Listen for auto-send event useEffect(() => { const handleAutoSend = (e: CustomEvent<{ inputId: string }>) => { diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index 015464d..51708dc 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -276,6 +276,7 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP let lastSentCols = 0; let lastSentRows = 0; + // Resize output buffer: after sending a resize to the backend, buffer all // incoming PTY output for RESIZE_BUFFER_MS. This gives the PTY time to // process SIGWINCH and start rendering at the new width. Without this, @@ -312,6 +313,7 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP } }; + // Handle resize - sync to backend term.onResize(({ cols, rows }) => { if (initPhase) return; // Skip during init — we send one resize after fit @@ -601,8 +603,10 @@ export function TerminalView({ task, wsRef, workspace, isMobile }: TerminalViewP programmaticScrollRef.current = false; }, 100); }); - } else { - // User was scrolled up - maintain their position + } else if (Number.isInteger(viewport)) { + // User was scrolled up - maintain their position. + // Guard against a non-finite viewportY (xterm's scrollToLine + // throws "This API only accepts integers" on NaN). programmaticScrollRef.current = true; term.scrollToLine(viewport); setTimeout(() => { diff --git a/frontend/src/components/WorkspacePanel.css b/frontend/src/components/WorkspacePanel.css index f4ce518..8c8fb45 100644 --- a/frontend/src/components/WorkspacePanel.css +++ b/frontend/src/components/WorkspacePanel.css @@ -381,12 +381,10 @@ position: relative; } -/* Workspace Dropdown Menu */ +/* Workspace Dropdown Menu — position: fixed so it escapes overflow:auto/hidden + ancestors (workspace-panel-content). Coordinates set inline by the ref callback. */ .workspace-dropdown-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 4px; + position: fixed; min-width: 180px; background: var(--bg-secondary); border: 1px solid var(--border-color); @@ -718,6 +716,62 @@ background: rgba(255, 171, 64, 0.22); } +/* Inline worktree badge on a single-task worktree (icon only, branch in tooltip) */ +.workspace-panel .task-item .task-worktree-badge { + display: inline-flex; + align-items: center; + flex-shrink: 0; + margin-left: 4px; + padding: 1px 4px; + border-radius: 8px; + background: rgba(0, 212, 255, 0.1); + color: var(--accent-secondary); + opacity: 0.8; +} + +/* ===== PR badge (worktree group header + workspace header) ===== */ +.pr-badge { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + margin-left: 6px; + padding: 1px 6px; + border-radius: 8px; + font-size: 0.625rem; + font-weight: 600; + line-height: 1.4; + text-decoration: none; + background: rgba(139, 148, 158, 0.15); + color: #8b949e; + transition: filter 0.15s ease; +} +.pr-badge:hover { filter: brightness(1.25); } + +.pr-badge.draft { background: rgba(139, 148, 158, 0.15); color: #8b949e; } +.pr-badge.open { background: rgba(63, 185, 80, 0.15); color: #3fb950; } +.pr-badge.merged { background: rgba(163, 113, 247, 0.15); color: #a371f7; } +.pr-badge.closed { background: rgba(248, 81, 73, 0.15); color: #f85149; } + +/* Subtle CI status mark in the badge's top-right corner */ +.pr-badge-ci { + position: absolute; + top: -4px; + right: -4px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + padding: 1px; + background: var(--bg-primary, #0d1117); + line-height: 0; +} +.pr-badge-ci.passed { color: #3fb950; } +.pr-badge-ci.failed { color: #f85149; } +.pr-badge-ci.running { color: #d29922; } +.pr-badge-ci-spin { animation: pr-badge-spin 1s linear infinite; } +@keyframes pr-badge-spin { to { transform: rotate(360deg); } } + .workspace-panel .task-item .task-cron-badge .task-cron-count { font-variant-numeric: tabular-nums; line-height: 1; @@ -1675,6 +1729,7 @@ .task-submit-button, .task-image-button, + .task-isolate-button, .task-voice-button { width: 36px; height: 36px; @@ -1902,3 +1957,450 @@ color: var(--text-secondary); } + +/* ── Worktree UI ─────────────────────────────────────────────────────────── */ + +.workspace-icon.worktree-icon { + color: var(--accent-blue, #58a6ff); +} + +.workspace-worktree-count { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 5px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue, #58a6ff); + cursor: pointer; + transition: background 0.15s; + flex-shrink: 0; +} +.workspace-worktree-count:hover { + background: rgba(88, 166, 255, 0.3); +} + +.workspace-auto-worktree-badge { + padding: 1px 5px; + border-radius: 10px; + font-size: 9px; + font-weight: 600; + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green, #3fb950); + flex-shrink: 0; + letter-spacing: 0.02em; +} + +.workspace-dropdown-item.active { + color: var(--accent-blue, #58a6ff); +} + +/* Worktree modal overlay */ +.worktree-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 9000; +} + +.worktree-modal { + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border-color, #30363d); + border-radius: 10px; + width: 420px; + max-width: 95vw; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; +} + +.worktree-modal.worktree-manager { + width: 500px; +} + +.worktree-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color, #30363d); +} + +.worktree-modal-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-primary, #c9d1d9); +} + +.worktree-modal-close { + background: none; + border: none; + color: var(--text-secondary, #8b949e); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; +} +.worktree-modal-close:hover { + background: var(--bg-hover, rgba(255,255,255,0.07)); + color: var(--text-primary, #c9d1d9); +} + +.worktree-modal-body { + padding: 14px 16px; + flex: 1; + overflow-y: auto; + max-height: 400px; +} + +.worktree-modal-subtitle { + font-size: 12px; + color: var(--text-secondary, #8b949e); + margin: 0 0 12px; +} + +.worktree-modal-field { + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.worktree-modal-field label { + font-size: 12px; + color: var(--text-secondary, #8b949e); + display: flex; + align-items: center; + gap: 8px; +} + +.worktree-modal-field .optional { + font-style: italic; + font-size: 11px; +} + +.worktree-modal-input { + background: var(--bg-input, #0d1117); + border: 1px solid var(--border-color, #30363d); + border-radius: 6px; + color: var(--text-primary, #c9d1d9); + font-size: 13px; + padding: 7px 10px; + width: 100%; + box-sizing: border-box; + transition: border-color 0.15s; +} +.worktree-modal-input:focus { + outline: none; + border-color: var(--accent-blue, #58a6ff); +} + +.worktree-modal-error { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + border-radius: 6px; + color: var(--accent-red, #f85149); + font-size: 12px; + margin-top: 8px; +} + +.worktree-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px 14px; + border-top: 1px solid var(--border-color, #30363d); +} + +.worktree-modal-btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + display: inline-flex; + align-items: center; + gap: 5px; + transition: background 0.15s, opacity 0.15s; +} +.worktree-modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.worktree-modal-btn.primary { + background: var(--accent-blue, #58a6ff); + color: #fff; +} +.worktree-modal-btn.primary:hover:not(:disabled) { + background: #79b8ff; +} +.worktree-modal-btn.secondary { + background: var(--bg-hover, rgba(255,255,255,0.07)); + color: var(--text-secondary, #8b949e); + border: 1px solid var(--border-color, #30363d); +} +.worktree-modal-btn.secondary:hover:not(:disabled) { + background: rgba(255,255,255,0.12); +} + +/* Worktree manager rows */ +.worktree-loading { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary, #8b949e); + font-size: 13px; + padding: 8px 0; +} + +.worktree-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 6px; + margin-bottom: 4px; + background: var(--bg-hover, rgba(255,255,255,0.03)); + border: 1px solid var(--border-color, #30363d); +} +.worktree-row.stale { + opacity: 0.6; + border-style: dashed; +} + +.worktree-row-info { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.worktree-row-badge { + padding: 1px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue, #58a6ff); + flex-shrink: 0; + display: flex; + align-items: center; +} +.worktree-row-badge.main { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green, #3fb950); +} + +.worktree-row-branch { + font-size: 12px; + font-family: var(--font-mono, monospace); + color: var(--text-primary, #c9d1d9); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.worktree-row-stale { + font-size: 10px; + color: var(--accent-orange, #d29922); + background: rgba(210, 153, 34, 0.1); + padding: 1px 5px; + border-radius: 8px; + flex-shrink: 0; +} + +.worktree-row-tasks { + font-size: 10px; + color: var(--text-secondary, #8b949e); + flex-shrink: 0; +} + +.worktree-row-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.worktree-row-btn { + padding: 3px 10px; + border-radius: 5px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border-color, #30363d); + background: var(--bg-secondary, #161b22); + color: var(--text-secondary, #8b949e); + transition: background 0.15s; +} +.worktree-row-btn:hover:not(:disabled) { + background: var(--bg-hover, rgba(255,255,255,0.1)); + color: var(--text-primary, #c9d1d9); +} +.worktree-row-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.worktree-row-btn.danger:hover:not(:disabled) { + background: rgba(248, 81, 73, 0.15); + border-color: rgba(248, 81, 73, 0.4); + color: var(--accent-red, #f85149); +} + +.worktree-stale-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: rgba(210, 153, 34, 0.08); + border: 1px solid rgba(210, 153, 34, 0.25); + border-radius: 6px; + color: var(--accent-orange, #d29922); + font-size: 12px; + margin-top: 8px; +} + +.worktree-empty { + font-size: 12px; + color: var(--text-secondary, #8b949e); + font-style: italic; + text-align: center; + padding: 16px 0; +} + +/* Worktree error toast */ +.worktree-error-toast { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + border-radius: 6px; + color: var(--accent-red, #f85149); + font-size: 12px; + margin: 6px 8px; + cursor: pointer; +} + +/* ── Isolate-in-worktree toggle button ─────────────────────────────────────── */ +.task-isolate-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.task-isolate-button:hover { + background: var(--bg-tertiary); + border-color: var(--accent-secondary); + color: var(--accent-secondary); +} + +.task-isolate-button.active { + background: var(--bg-tertiary); + border-color: var(--accent-secondary); + color: var(--accent-secondary); +} + +/* ── Inline worktree group section ──────────────────────────────────────────── */ +.worktree-group { + margin-top: 4px; + border-top: 1px solid rgba(88, 166, 255, 0.12); +} + +.worktree-group-header { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 6px; + cursor: pointer; + color: var(--accent-blue, #58a6ff); + font-size: 11px; + opacity: 0.75; + border-radius: 4px; + transition: opacity 0.15s, background 0.15s; + user-select: none; +} + +.worktree-group-header:hover { + opacity: 1; + background: rgba(88, 166, 255, 0.06); +} + +.worktree-group-branch { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); +} + +.worktree-group-active-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-green, #3fb950); + flex-shrink: 0; +} + +.worktree-group-remove { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + background: transparent; + border: none; + border-radius: 3px; + color: var(--text-muted, #8b949e); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s; +} + +.worktree-group-header:hover .worktree-group-remove { + opacity: 0.6; +} + +.worktree-group-remove:hover { + opacity: 1 !important; + background: rgba(248, 81, 73, 0.15); + color: var(--accent-red, #f85149); +} + +.worktree-group-remove:disabled { + opacity: 0.2 !important; + cursor: not-allowed; +} + +.worktree-group-tasks { + padding-left: 12px; +} diff --git a/frontend/src/components/WorkspacePanel.tsx b/frontend/src/components/WorkspacePanel.tsx index 6af94ee..d735762 100644 --- a/frontend/src/components/WorkspacePanel.tsx +++ b/frontend/src/components/WorkspacePanel.tsx @@ -1,11 +1,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { useTaskStore } from '../stores/taskStore'; -import { Task, Workspace } from '@claudia/shared'; +import { Task, Workspace, WorkspacePrInfo } from '@claudia/shared'; import { Loader2, Circle, ChevronRight, ChevronDown, ChevronLeft, Trash2, FolderOpen, Plus, Briefcase, Send, AlertCircle, StopCircle, Undo2, GripVertical, Archive, RotateCcw, Play, MoreVertical, Terminal, Search, GitBranch, ImagePlus, X, FileText, GripHorizontal, Copy, Pencil, Link2, Check, CheckCircle, FolderPlus, Clipboard, Columns2, Clock, Settings, ArrowDownAZ, ArrowDownUp } from 'lucide-react'; import { getApiBaseUrl } from '../config/api-config'; +import { PrBadge } from './PrBadge'; import { SystemPromptModal } from './SystemPromptModal'; import { ConfirmModal } from './ConfirmModal'; import { ScheduledTasksModal } from './ScheduledTasksModal'; @@ -108,6 +109,9 @@ interface TaskItemProps { onDragStart: (index: number) => void; onDragEnter: (index: number) => void; onDragEnd: () => void; + // When this task is the sole task in a worktree, show an inline worktree + // badge (branch in tooltip) + PR badge instead of a separate group section. + worktreeInfo?: { branch: string; prInfo?: WorkspacePrInfo | null }; } /** Format a time-ago string from a Date/string, e.g. "5s", "2m", "1h", "3d" */ @@ -126,7 +130,7 @@ function formatTimeAgo(date: Date | string): string { return `${days}d`; } -function TaskItem({ task, index, onDeleteTask, onInterruptTask, onArchiveTask, onRevertTask, onSelectTask, onRenameTask, onOpenScheduledTasks, isSelected, isLastSelected, hasActiveQuestion, hasUnreadActivity, isDragging, dragIndex, dragOverIndex, onDragStart, onDragEnter, onDragEnd }: TaskItemProps) { +function TaskItem({ task, index, onDeleteTask, onInterruptTask, onArchiveTask, onRevertTask, onSelectTask, onRenameTask, onOpenScheduledTasks, isSelected, isLastSelected, hasActiveQuestion, hasUnreadActivity, isDragging, dragIndex, dragOverIndex, onDragStart, onDragEnter, onDragEnd, worktreeInfo }: TaskItemProps) { const [stopClicked, setStopClicked] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(''); @@ -211,7 +215,7 @@ function TaskItem({ task, index, onDeleteTask, onInterruptTask, onArchiveTask, o
!isEditing && onSelectTask(task.id)} onDragStart={(e) => { if (isEditing) { e.preventDefault(); return; } @@ -257,6 +261,12 @@ function TaskItem({ task, index, onDeleteTask, onInterruptTask, onArchiveTask, o {scheduledTaskCount} )} + {worktreeInfo && ( + + + + )} + {worktreeInfo?.prInfo && }
{task.lastActivity && ( void; + onCreated: () => void; + onCreateWorktree: (workspaceId: string, branch: string, baseBranch?: string, createBranch?: boolean) => Promise; +} + +function WorktreeCreateModal({ workspace, onClose, onCreated, onCreateWorktree }: WorktreeCreateModalProps) { + const [branchName, setBranchName] = useState(''); + const [baseBranch, setBaseBranch] = useState(''); + const [createNew, setCreateNew] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleCreate = async () => { + if (!branchName.trim()) { setError('Branch name is required'); return; } + setError(null); + setLoading(true); + try { + await onCreateWorktree(workspace.id, branchName.trim(), baseBranch.trim() || undefined, createNew); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+

New Worktree

+ +
+
+

+ Creates an isolated git working directory for {workspace.displayName || workspace.name}. +

+
+ + +
+
+ + setBranchName(e.target.value)} + placeholder={createNew ? 'feature/my-feature' : 'existing-branch-name'} + autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') onClose(); }} + /> +
+ {createNew && ( +
+ + setBaseBranch(e.target.value)} + placeholder="main" + /> +
+ )} + {error &&
{error}
} +
+
+ + +
+
+
+ ); +} + +interface WorktreeManagerPanelProps { + workspace: Workspace; + allWorkspaces: Workspace[]; + onClose: () => void; + onRemoveWorktree?: (workspaceId: string, force?: boolean) => Promise; + onSelectWorkspace?: (workspaceId: string) => void; + onCreateWorktree: () => void; +} + +function WorktreeManagerPanel({ workspace, allWorkspaces, onClose, onRemoveWorktree, onSelectWorkspace, onCreateWorktree }: WorktreeManagerPanelProps) { + const [worktrees, setWorktrees] = useState>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pruning, setPruning] = useState(false); + + const apiBase = getApiBaseUrl(); + const parentId = workspace.worktreeParentId ?? workspace.id; + + const loadWorktrees = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ workspace: parentId }); + const res = await fetch(`${apiBase}/api/worktrees?${params}`); + if (res.ok) { + const data = await res.json(); + setWorktrees(data.worktrees || []); + } else { + const d = await res.json().catch(() => ({})); + setError(d.error || 'Failed to load worktrees'); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [parentId, apiBase]); + + useEffect(() => { loadWorktrees(); }, [loadWorktrees]); + + const handlePruneAll = async () => { + setPruning(true); + try { + const params = new URLSearchParams({ workspace: parentId }); + await fetch(`${apiBase}/api/worktrees/prune?${params}`, { method: 'POST' }); + await loadWorktrees(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPruning(false); + } + }; + + const staleCount = worktrees.filter(wt => wt.prunable && !wt.isMain).length; + const linkedWorktrees = worktrees.filter(wt => !wt.isMain); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+

Worktrees — {workspace.displayName || workspace.name}

+ +
+
+ {loading &&
Loading…
} + {error &&
{error}
} + {!loading && worktrees.map(wt => { + const shortBranch = wt.branch.replace('refs/heads/', ''); + const claudiaWs = allWorkspaces.find(w => w.id === wt.path); + return ( +
+
+ + {wt.isMain ? 'main' : } + + {shortBranch} + {wt.prunable && stale} + {(wt.taskCount ?? 0) > 0 && ( + {wt.taskCount} task{wt.taskCount !== 1 ? 's' : ''} + )} +
+
+ {!wt.isMain && claudiaWs && onSelectWorkspace && ( + + )} + {!wt.isMain && onRemoveWorktree && ( + + )} +
+
+ ); + })} + {!loading && linkedWorktrees.length === 0 && ( +

No worktrees yet. Create one to start isolated development.

+ )} + {staleCount > 0 && ( +
+ {staleCount} stale worktree reference{staleCount !== 1 ? 's' : ''} (directory deleted) + +
+ )} +
+
+ + +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── + +interface WorktreeGroup { + workspace: Workspace; + branch: string; // short branch name + tasks: Task[]; +} + +interface WorktreeGroupSectionProps { + group: WorktreeGroup; + selectedTaskId: string | null; + lastSelectedTaskId: string | null; + waitingInputTaskIds: Set; + unreadTaskIds: Set; + onDeleteTask: (taskId: string) => void; + onInterruptTask: (taskId: string) => void; + onArchiveTask: (taskId: string) => void; + onRevertTask: (taskId: string) => void; + onSelectTask: (taskId: string) => void; + onRenameTask?: (taskId: string, displayName: string) => void; + onOpenScheduledTasks?: (taskId: string) => void; + onRemoveWorktree?: (workspaceId: string, force?: boolean) => Promise; +} + +function WorktreeGroupSection({ group, selectedTaskId, lastSelectedTaskId, waitingInputTaskIds, unreadTaskIds, onDeleteTask, onInterruptTask, onArchiveTask, onRevertTask, onSelectTask, onRenameTask, onOpenScheduledTasks, onRemoveWorktree }: WorktreeGroupSectionProps) { + const [collapsed, setCollapsed] = useState(false); + const [removing, setRemoving] = useState(false); + + const hasActive = group.tasks.some(t => t.state === 'busy' || t.state === 'starting' || t.state === 'waiting_input'); + + const handleRemove = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!onRemoveWorktree) return; + if (!window.confirm(`Remove worktree branch "${group.branch}"? The directory and branch will be deleted.`)) return; + setRemoving(true); + try { + await onRemoveWorktree(group.workspace.id, false); + } catch (err) { + console.error('[WorktreeGroupSection] remove failed:', err); + } finally { + setRemoving(false); + } + }; + + return ( +
+
setCollapsed(c => !c)}> + {collapsed ? : } + + {group.branch} + {group.workspace.prInfo && } + {hasActive && } + {!collapsed && onRemoveWorktree && ( + + )} +
+ {!collapsed && ( +
+ {group.tasks.map((task, idx) => ( + {}} + onDragEnter={() => {}} + onDragEnd={() => {}} + /> + ))} +
+ )} +
+ ); +} + interface WorkspaceSectionProps { workspace: Workspace; tasks: Task[]; + worktreeGroups?: WorktreeGroup[]; // Inline worktree sub-sections waitingInputTaskIds: Set; unreadTaskIds: Set; selectedTaskId: string | null; @@ -363,7 +692,7 @@ interface WorkspaceSectionProps { onPushToGithub: () => void; onSystemPrompt: () => void; onToggleMenu: () => void; - onCreateTask: (prompt: string, initialCols?: number, initialRows?: number) => void; + onCreateTask: (prompt: string, initialCols?: number, initialRows?: number, isolate?: boolean) => void; // Workspace drag handlers onDragStart: (index: number) => void; onDragEnter: (index: number) => void; @@ -382,11 +711,18 @@ interface WorkspaceSectionProps { onResetWorkspace?: () => void; // Scheduled tasks onOpenScheduledTasks?: (taskId: string) => void; + // Worktree handlers + worktreeCount?: number; // Number of linked worktrees for this parent workspace + onCreateWorktree?: (workspaceId: string, branch: string, baseBranch?: string, createBranch?: boolean) => Promise; + onRemoveWorktree?: (workspaceId: string, force?: boolean) => Promise; + onToggleAutoWorktree?: (workspaceId: string, enabled: boolean) => void; + onSelectWorkspace?: (workspaceId: string) => void; // navigate to a different workspace } function WorkspaceSection({ workspace, tasks, + worktreeGroups = [], waitingInputTaskIds, unreadTaskIds, selectedTaskId, @@ -421,10 +757,16 @@ function WorkspaceSection({ onAddCustomReference, onRemoveReference, onResetWorkspace, - onOpenScheduledTasks + onOpenScheduledTasks, + worktreeCount = 0, + onCreateWorktree, + onRemoveWorktree, + onToggleAutoWorktree, + onSelectWorkspace, }: WorkspaceSectionProps) { const isConnected = useTaskStore(s => s.isConnected); const [inputValue, setInputValue] = useState(''); + const [isolate, setIsolate] = useState(!!workspace.autoWorktree); const [isEditingWorkspaceName, setIsEditingWorkspaceName] = useState(false); const [showReferencesSubmenu, setShowReferencesSubmenu] = useState(false); const [submenuPosition, setSubmenuPosition] = useState<{ top: number; left: number } | null>(null); @@ -435,6 +777,10 @@ function WorkspaceSection({ const [showResetConfirm, setShowResetConfirm] = useState(false); const [branchName, setBranchName] = useState(null); + const [showWorktreeModal, setShowWorktreeModal] = useState(false); + const [showWorktreeManager, setShowWorktreeManager] = useState(false); + const [worktreeError, setWorktreeError] = useState(null); + const isWorktree = !!workspace.worktreeParentId; // Reset submenu state when parent menu closes, and cleanup timeout on unmount useEffect(() => { @@ -734,13 +1080,15 @@ function WorkspaceSection({ const panelHeight = mainPanel?.clientHeight || (window.innerHeight - 100); const cols = Math.max(80, Math.floor(panelWidth / 9)); // ~9px per char const rows = Math.max(24, Math.floor(panelHeight / 18)); // ~18px per line - onCreateTask(fullMessage.trim(), cols, rows); + onCreateTask(fullMessage.trim(), cols, rows, isolate); setInputValue(''); + // Reset isolate to workspace default after sending + setIsolate(!!workspace.autoWorktree); // Clear images after sending images.forEach(img => URL.revokeObjectURL(img.previewUrl)); setImages([]); } - }, [inputValue, images, globalVoiceEnabled, clearVoiceTranscript, onCreateTask]); + }, [inputValue, images, isolate, workspace.autoWorktree, globalVoiceEnabled, clearVoiceTranscript, onCreateTask]); // Listen for auto-send event useEffect(() => { @@ -853,7 +1201,10 @@ function WorkspaceSection({
!isEditingWorkspaceName && onToggleExpand()}> {isExpanded ? : } - + {isWorktree + ? + : + } {isEditingWorkspaceName ? ( {branchName} )} + {workspace.prInfo && } {tasks.length > 0 && ( <> {tasks.length} )} + {!isWorktree && worktreeCount > 0 && ( + { + e.stopPropagation(); + setShowWorktreeManager(true); + }} + > + + {worktreeCount} + + )} + {workspace.autoWorktree && !isWorktree && ( + + auto-isolate + + )} {workspace.references && workspace.references.length > 0 && ( { if (el) { - const rect = el.getBoundingClientRect(); - if (rect.bottom > window.innerHeight - 8) { - el.style.top = 'auto'; - el.style.bottom = '100%'; - el.style.marginTop = '0'; - el.style.marginBottom = '4px'; + // Position using the trigger button's viewport coords + // (the menu is position:fixed to escape overflow clipping). + const btn = el.parentElement?.querySelector('.workspace-action-button.menu'); + const btnRect = btn?.getBoundingClientRect(); + if (btnRect) { + const menuH = el.offsetHeight; + const spaceBelow = window.innerHeight - btnRect.bottom - 8; + if (spaceBelow >= menuH) { + el.style.top = `${btnRect.bottom + 4}px`; + el.style.bottom = 'auto'; + } else { + el.style.top = 'auto'; + el.style.bottom = `${window.innerHeight - btnRect.top + 4}px`; + } + el.style.right = `${window.innerWidth - btnRect.right}px`; } } }} @@ -1178,30 +1557,119 @@ function WorkspaceSection({
)} + {/* Worktree actions for parent workspaces */} + {!isWorktree && onCreateWorktree && ( + <> +
+ + {worktreeCount > 0 && ( + + )} + + + )} + {/* Worktree-specific actions — shown when this workspace IS a worktree */} + {isWorktree && ( + <> +
+ {workspace.worktreeParentId && onSelectWorkspace && ( + + )} + {onRemoveWorktree && ( + + )} + + )}
- + {!isWorktree && ( + + )}
)} @@ -1290,6 +1758,16 @@ function WorkspaceSection({ onChange={(e) => handleFileSelect(e.target.files)} style={{ display: 'none' }} /> + {!isWorktree && ( + + )}
- {tasks.length === 0 ? ( + {tasks.length === 0 && worktreeGroups.length === 0 ? (
No tasks yet
) : (
@@ -1330,8 +1808,61 @@ function WorkspaceSection({ onDragStart={handleTaskDragStart} onDragEnter={handleTaskDragEnter} onDragEnd={handleTaskDragEnd} + worktreeInfo={task.sessionWorktreeBranch + ? { branch: task.sessionWorktreeBranch, prInfo: task.sessionWorktreePrInfo } + : undefined} /> ))} + {worktreeGroups.map((group) => ( + // Single-task worktree: render the task inline with a + // worktree badge + PR badge instead of a group section. + // Multi-task worktree: keep the collapsible group. + group.tasks.length === 1 ? ( + {}} + onDragEnter={() => {}} + onDragEnd={() => {}} + worktreeInfo={{ + branch: group.workspace.worktreeBranch || group.branch, + prInfo: group.workspace.prInfo, + }} + /> + ) : ( + + ) + ))}
)} + {/* Worktree Create Modal */} + {showWorktreeModal && onCreateWorktree && ( + { setShowWorktreeModal(false); setWorktreeError(null); }} + onCreated={() => { setShowWorktreeModal(false); setWorktreeError(null); }} + onCreateWorktree={onCreateWorktree} + /> + )} + + {/* Worktree Manager Panel */} + {showWorktreeManager && ( + setShowWorktreeManager(false)} + onRemoveWorktree={onRemoveWorktree} + onSelectWorkspace={onSelectWorkspace} + onCreateWorktree={() => { setShowWorktreeManager(false); setShowWorktreeModal(true); }} + /> + )} + + {/* Worktree error toast */} + {worktreeError && ( +
setWorktreeError(null)}> + + {worktreeError} + +
+ )} + {showResetConfirm && ( s.trim()).filter(Boolean); - const lastSegment = segments.length > 0 ? segments[segments.length - 1] : task.prompt; + const lastSegment = task.displayName || (segments.length > 0 ? segments[segments.length - 1] : task.prompt); // Format date const archivedDate = new Date(task.lastActivity).toLocaleDateString(); @@ -1457,7 +2019,7 @@ interface WorkspacePanelProps { onOpenShell: (workspaceId: string) => void; onPushToGithub: (workspaceId: string) => void; onSetSystemPrompt: (workspaceId: string, systemPrompt: string) => void; - onCreateTask: (prompt: string, workspaceId: string, initialCols?: number, initialRows?: number) => void; + onCreateTask: (prompt: string, workspaceId: string, initialCols?: number, initialRows?: number, isolate?: boolean) => void; onSelectTask: (taskId: string) => void; onRequestArchivedTasks?: () => void; onRestoreArchivedTask?: (taskId: string) => void; @@ -1540,6 +2102,7 @@ export function WorkspacePanel({ const [showWorkspaceManager, setShowWorkspaceManager] = useState(false); // Close menu when clicking outside (capture phase so stopPropagation on child elements doesn't block it) + // or when the panel scrolls (menu is position:fixed and would detach from its trigger). useEffect(() => { if (!openMenuId) return; @@ -1549,9 +2112,15 @@ export function WorkspacePanel({ setOpenMenuId(null); } }; + const handleScroll = () => setOpenMenuId(null); document.addEventListener('mousedown', handleClickOutside, true); - return () => document.removeEventListener('mousedown', handleClickOutside, true); + const scrollContainer = document.querySelector('.workspace-panel-content'); + scrollContainer?.addEventListener('scroll', handleScroll); + return () => { + document.removeEventListener('mousedown', handleClickOutside, true); + scrollContainer?.removeEventListener('scroll', handleScroll); + }; }, [openMenuId]); // Get last modified time for a workspace (most recent task activity). @@ -1571,23 +2140,29 @@ export function WorkspacePanel({ // Sort workspaces based on user preference. 'manual' preserves the // backend-persisted order — drag-drop only has visible effect in this mode. - const sortedWorkspaces = workspaceSortBy === 'manual' - ? workspaces - : [...workspaces].sort((a, b) => { + // Worktree child workspaces are excluded from the top-level list and rendered + // inline within their parent WorkspaceSection instead. + const sortedWorkspaces = (() => { + const parentWorkspaces = workspaces.filter(w => !w.worktreeParentId); + if (workspaceSortBy === 'manual') return parentWorkspaces; + return [...parentWorkspaces].sort((a, b) => { switch (workspaceSortBy) { - case 'alphabetical': + case 'alphabetical': { const nameA = (a.displayName || a.name).toLowerCase(); const nameB = (b.displayName || b.name).toLowerCase(); return nameA.localeCompare(nameB); - case 'last-modified': + } + case 'last-modified': { const timeA = getWorkspaceLastModified(a.id).getTime(); const timeB = getWorkspaceLastModified(b.id).getTime(); return timeB - timeA; + } case 'date-created': default: return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } }); + })(); const handleDragStart = useCallback((index: number) => { setDragIndex(index); @@ -1655,34 +2230,85 @@ export function WorkspacePanel({ // Get task IDs that have active questions const waitingInputTaskIds = new Set(waitingInputNotifications.keys()); - // Group tasks by workspace, sorted by user preference - const getTasksForWorkspace = (workspaceId: string): Task[] => { - const workspaceTasks = Array.from(tasks.values()).filter(t => t.workspaceId === workspaceId); - - return workspaceTasks.sort((a, b) => { - // If both have manual order, sort by order (ascending) - if (a.order !== undefined && b.order !== undefined) { - return a.order - b.order; - } - // If only one has order, it comes first (manual order takes precedence) + // Sort tasks by user preference + const sortTasks = (taskList: Task[]): Task[] => { + return taskList.sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) return a.order - b.order; if (a.order !== undefined) return -1; if (b.order !== undefined) return 1; - - // Neither has manual order, apply user's sort preference switch (taskSortBy) { - case 'last-modified': - // Sort by most recent activity + case 'last-modified': { const timeA = new Date(a.lastActivity || a.createdAt).getTime(); const timeB = new Date(b.lastActivity || b.createdAt).getTime(); - return timeB - timeA; // Most recent first + return timeB - timeA; + } case 'date-created': default: - // Sort by creation time (newest first) return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } }); }; + // Direct tasks for a workspace (excludes tasks in child worktrees) + const getTasksForWorkspace = (workspaceId: string): Task[] => { + return sortTasks(Array.from(tasks.values()).filter(t => t.workspaceId === workspaceId)); + }; + + // Build inline worktree groups for a parent workspace + const getWorktreeGroupsForWorkspace = (parentId: string): WorktreeGroup[] => { + const childWorkspaces = workspaces.filter(w => w.worktreeParentId === parentId); + return childWorkspaces.map(ws => ({ + workspace: ws, + // Show human-readable name if task has been named, otherwise fall back to branch + branch: ws.displayName || ws.worktreeBranch || ws.name, + tasks: sortTasks(Array.from(tasks.values()).filter(t => t.workspaceId === ws.id)), + })).filter(g => g.tasks.length > 0); // only show groups that have tasks + }; + + + // Worktree handlers + const apiBase = getApiBaseUrl(); + + const handleCreateWorktree = useCallback(async (workspaceId: string, branch: string, baseBranch?: string, createBranch = true) => { + const params = new URLSearchParams({ workspace: workspaceId }); + const res = await fetch(`${apiBase}/api/worktrees?${params}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch, baseBranch, createBranch }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d.error || 'Failed to create worktree'); + } + }, [apiBase]); + + const handleRemoveWorktree = useCallback(async (worktreePath: string, force = false) => { + // Find the parent workspace for this worktree + const worktreeWs = workspaces.find(w => w.id === worktreePath); + const parentId = worktreeWs?.worktreeParentId ?? ''; + const params = new URLSearchParams({ workspace: parentId, worktreePath, force: String(force) }); + const res = await fetch(`${apiBase}/api/worktrees?${params}`, { method: 'DELETE' }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d.error || 'Failed to remove worktree'); + } + }, [apiBase, workspaces]); + + const handleToggleAutoWorktree = useCallback((workspaceId: string, enabled: boolean) => { + const params = new URLSearchParams({ workspace: workspaceId }); + fetch(`${apiBase}/api/worktrees/auto?${params}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }).catch(err => console.error('[WorkspacePanel] autoWorktree toggle failed:', err)); + }, [apiBase]); + + const handleSelectWorkspace = useCallback((workspaceId: string) => { + // Expand the target workspace and select its last task (or just expand it) + toggleWorkspaceExpanded(workspaceId); + const lastTask = lastSelectedTaskByWorkspace.get(workspaceId); + if (lastTask) onSelectTask(lastTask); + }, [toggleWorkspaceExpanded, lastSelectedTaskByWorkspace, onSelectTask]); // External drag-and-drop (from OS file explorer) to add workspaces. // Only activates when dataTransfer contains files — internal workspace @@ -1803,7 +2429,7 @@ export function WorkspacePanel({
No archived tasks
) : (
- {archivedTasks.map(task => { + {[...archivedTasks].sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()).map(task => { const ws = workspaces.find(w => w.id === task.workspaceId); // Use display name, workspace name, or fall back to last path segment const wsName = ws?.displayName || ws?.name || (task.workspaceId ? task.workspaceId.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : undefined); @@ -1843,6 +2469,7 @@ export function WorkspacePanel({ key={workspace.id} workspace={workspace} tasks={getTasksForWorkspace(workspace.id)} + worktreeGroups={getWorktreeGroupsForWorkspace(workspace.id)} waitingInputTaskIds={waitingInputTaskIds} unreadTaskIds={unreadTaskIds} selectedTaskId={selectedTaskId} @@ -1865,7 +2492,7 @@ export function WorkspacePanel({ onPushToGithub={() => onPushToGithub(workspace.id)} onSystemPrompt={() => setSystemPromptWorkspace(workspace)} onToggleMenu={() => setOpenMenuId(openMenuId === workspace.id ? null : workspace.id)} - onCreateTask={(prompt, cols, rows) => onCreateTask(prompt, workspace.id, cols, rows)} + onCreateTask={(prompt, cols, rows, isolate) => onCreateTask(prompt, workspace.id, cols, rows, isolate)} onDragStart={handleDragStart} onDragEnter={handleDragEnter} onDragEnd={handleDragEnd} @@ -1888,6 +2515,11 @@ export function WorkspacePanel({ onRemoveReference={onRemoveReference} onResetWorkspace={() => onResetWorkspace?.(workspace.id)} onOpenScheduledTasks={(taskId) => setScheduledTasksForTaskId(taskId)} + worktreeCount={workspaces.filter(w => w.worktreeParentId === workspace.id).length} + onCreateWorktree={handleCreateWorktree} + onRemoveWorktree={handleRemoveWorktree} + onToggleAutoWorktree={handleToggleAutoWorktree} + onSelectWorkspace={handleSelectWorkspace} /> )) )} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 502ec09..99e1483 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -86,6 +86,7 @@ export function useWebSocket() { selectTask, setWorkspaces, addWorkspace, + updateWorkspace, removeWorkspace, setTaskSummary, addChatMessage, @@ -269,9 +270,13 @@ export function useWebSocket() { break; } case 'workspace:updated': { - const payload = message.payload as { workspaces: Workspace[] }; - console.log('[WebSocket] Workspaces updated'); - setWorkspaces(payload.workspaces); + const payload = message.payload as { workspaces?: Workspace[]; workspace?: Workspace }; + if (payload.workspace) { + // Singular update (e.g. autoWorktree toggle) + updateWorkspace(payload.workspace); + } else if (payload.workspaces) { + setWorkspaces(payload.workspaces); + } break; } case 'task:summary': { @@ -573,7 +578,7 @@ export function useWebSocket() { }; wsRef.current = ws; - }, [setConnected, setTasks, addTask, updateTask, deleteTask, selectTask, setWorkspaces, addWorkspace, removeWorkspace, setTaskSummary, addChatMessage, setChatMessages, setChatTyping, setWaitingInput, clearWaitingInput, setArchivedTasks, removeArchivedTask]); + }, [setConnected, setTasks, addTask, updateTask, deleteTask, selectTask, setWorkspaces, addWorkspace, updateWorkspace, removeWorkspace, setTaskSummary, addChatMessage, setChatMessages, setChatTyping, setWaitingInput, clearWaitingInput, setArchivedTasks, removeArchivedTask]); const sendMessage = useCallback((type: string, payload: unknown) => { if (wsRef.current?.readyState === WebSocket.OPEN) { @@ -636,8 +641,8 @@ export function useWebSocket() { }, []); // Task actions - const createTask = useCallback((prompt: string, workspaceId: string, initialCols?: number, initialRows?: number) => { - sendMessage('task:create', { prompt, workspaceId, initialCols, initialRows }); + const createTask = useCallback((prompt: string, workspaceId: string, initialCols?: number, initialRows?: number, isolate?: boolean) => { + sendMessage('task:create', { prompt, workspaceId, initialCols, initialRows, ...(isolate ? { isolate: true } : {}) }); }, [sendMessage]); const selectTaskOnServer = useCallback((taskId: string) => { diff --git a/frontend/src/stores/taskStore.ts b/frontend/src/stores/taskStore.ts index 149f1c2..be58d1a 100644 --- a/frontend/src/stores/taskStore.ts +++ b/frontend/src/stores/taskStore.ts @@ -128,6 +128,7 @@ interface TaskStore { // Workspace actions setWorkspaces: (workspaces: Workspace[]) => void; addWorkspace: (workspace: Workspace) => void; + updateWorkspace: (workspace: Workspace) => void; removeWorkspace: (workspaceId: string) => void; reorderWorkspaces: (fromIndex: number, toIndex: number) => void; toggleWorkspaceExpanded: (workspaceId: string) => void; @@ -537,6 +538,11 @@ export const useTaskStore = create()( }); }, + updateWorkspace: (workspace) => { + const { workspaces } = get(); + set({ workspaces: workspaces.map(w => w.id === workspace.id ? workspace : w) }); + }, + removeWorkspace: (workspaceId) => { const { workspaces, expandedWorkspaces } = get(); const newExpanded = new Set(expandedWorkspaces); diff --git a/shared/src/index.ts b/shared/src/index.ts index 9cf674a..49c3b76 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -45,6 +45,11 @@ export interface Task { displayName?: string; // User-editable display name (shown instead of prompt when set) displayNameEditedByUser?: boolean; // True if the user manually edited the display name (prevents agent auto-title) tokenUsage?: TaskTokenUsage; // Token usage data for this task + // Branch of a git worktree this task's Claude session created/moved onto + // (detected by diffing the repo's worktree list while the task runs). Used to + // annotate the task row with a worktree badge. Undefined = no worktree detected. + sessionWorktreeBranch?: string; + sessionWorktreePrInfo?: WorkspacePrInfo | null; // PR for that branch (if any) } export interface WorkspaceReference { @@ -61,6 +66,35 @@ export interface Workspace { systemPrompt?: string; // Custom system prompt for this workspace displayName?: string; // User-editable display name (shown instead of folder name when set) references?: WorkspaceReference[]; // Referenced workspaces/folders for cross-workspace context + + // Worktree fields (optional — only set for worktree workspaces) + worktreeParentId?: string; // If this workspace IS a worktree, the parent workspace's id (absolute path) + worktreeBranch?: string; // Branch checked out in this worktree + autoWorktree?: boolean; // If true, new tasks auto-create an isolated worktree + + // GitHub PR associated with this workspace's branch (resolved via `gh`, cached server-side). + // null = looked up, no PR found; undefined = not yet looked up. + prInfo?: WorkspacePrInfo | null; +} + +export interface WorkspacePrInfo { + number: number; + title: string; + state: 'draft' | 'open' | 'merged' | 'closed'; + url: string; + ci?: 'passed' | 'failed' | 'running' | 'none'; // statusCheckRollup summary +} + +// Metadata about a single git worktree (from `git worktree list --porcelain`) +export interface WorktreeInfo { + path: string; // Absolute path to the worktree directory + branch: string; // Branch checked out (e.g. "refs/heads/feature/foo" or detached hash) + commitHash: string; // Current HEAD commit hash + isMain: boolean; // True for the primary working tree + isLocked: boolean; // True if the worktree is locked + lockedReason?: string; // Why it's locked (if locked) + prunable: boolean; // True if the directory no longer exists (stale) + taskCount?: number; // Number of active Claudia tasks in this worktree (enriched by server) } export interface RecentWorkspace { @@ -152,6 +186,16 @@ export type WSMessageType = | 'workspace:renamed' | 'workspace:recent:list' | 'workspace:resetResult' + // Worktree management + | 'worktree:list' + | 'worktree:listed' + | 'worktree:create' + | 'worktree:created' + | 'worktree:remove' + | 'worktree:removed' + | 'worktree:prune' + | 'worktree:pruned' + | 'worktree:error' // Task reordering | 'tasks:reordered' // Embedded shell terminals diff --git a/start.ps1 b/start.ps1 index 4385707..8274bc9 100644 --- a/start.ps1 +++ b/start.ps1 @@ -1,4 +1,12 @@ # Claudia - Start Script (PowerShell) +# +# Usage: +# .\start.ps1 # backend runs no-watch (default; stable, no spurious restarts) +# .\start.ps1 -Watch # backend runs tsx watch (auto-reload on backend/src edits) + +param( + [switch]$Watch +) $ErrorActionPreference = "Stop" @@ -71,58 +79,67 @@ $env:CLAUDIA_BACKEND_PORT = $BACKEND_PORT # Increase Node.js memory limit for backend $env:NODE_OPTIONS = "--max-old-space-size=8192" -# Start backend and frontend concurrently -# Using npm.cmd to avoid the PowerShell strict mode bug with npm.ps1 -# Use dev (tsx watch) for auto-reload on file changes, matching start.sh behavior -$backendJob = Start-Job -ScriptBlock { - param($dir, $port, $nodeOpts) - Set-Location $dir - $env:CLAUDIA_BACKEND_PORT = $port - $env:NODE_OPTIONS = $nodeOpts - & npm.cmd run dev -w backend 2>&1 -} -ArgumentList $PSScriptRoot, $BACKEND_PORT, $env:NODE_OPTIONS - -$frontendJob = Start-Job -ScriptBlock { - param($dir) - Set-Location $dir - & npm.cmd run dev -w frontend 2>&1 -} -ArgumentList $PSScriptRoot - -Write-Host "Backend job: $($backendJob.Id) | Frontend job: $($frontendJob.Id)" +# Start backend and frontend as tracked child processes. +# -Watch selects 'dev' (tsx watch, auto-reload) over the default 'dev:no-watch'. +# no-watch is the default because spurious restarts can occur when Claude Code +# tasks edit source files, antivirus scans, or the Windows indexer touch backend/src. +# +# The backend runs in a RELAUNCH LOOP: when it exits with code 75 (RESTART_EXIT_CODE, +# triggered by POST /api/server/restart), we relaunch it. Any other exit code stops +# the loop. This gives a working "restart backend" button without tsx watch. +$backendScript = if ($Watch) { "dev" } else { "dev:no-watch" } +$RESTART_EXIT_CODE = 75 +Write-Host "Backend mode: $backendScript$(if ($Watch) { ' (auto-reload enabled)' } else { '' })" + +$env:CLAUDIA_BACKEND_PORT = $BACKEND_PORT + +# Helper: kill a process and its entire child tree (npm -> node -> tsx -> node). +function Stop-Tree($procId) { + if (-not $procId) { return } + try { & taskkill /PID $procId /T /F 2>$null | Out-Null } catch {} +} + +# Frontend: single long-lived child process (no relaunch loop needed). +$frontendProc = Start-Process -FilePath "npm.cmd" -ArgumentList @("run", "dev", "-w", "frontend") ` + -WorkingDirectory $PSScriptRoot -NoNewWindow -PassThru + +Write-Host "Frontend PID: $($frontendProc.Id)" Write-Host "Press Ctrl+C to stop..." Write-Host "" +$backendProc = $null try { - # Stream output from both jobs while ($true) { - $backendOutput = Receive-Job $backendJob -ErrorAction SilentlyContinue - $frontendOutput = Receive-Job $frontendJob -ErrorAction SilentlyContinue - - if ($backendOutput) { - $backendOutput | ForEach-Object { Write-Host "[backend] $_" } - } - if ($frontendOutput) { - $frontendOutput | ForEach-Object { Write-Host "[frontend] $_" } + # Launch backend and wait for it to exit. + $backendProc = Start-Process -FilePath "npm.cmd" -ArgumentList @("run", $backendScript, "-w", "backend") ` + -WorkingDirectory $PSScriptRoot -NoNewWindow -PassThru + # CRITICAL: cache .Handle BEFORE the process exits, otherwise .ExitCode + # reads $null after WaitForExit() (.NET only retains the code if the handle + # was accessed). Without this the relaunch loop never sees exit code 75. + $null = $backendProc.Handle + Write-Host "Backend PID: $($backendProc.Id) ($backendScript)" + $backendProc.WaitForExit() + $code = $backendProc.ExitCode + + if ($code -eq $RESTART_EXIT_CODE) { + Write-Host "Backend requested restart (exit $code) -- relaunching..." + Start-Sleep -Milliseconds 500 + continue } - # Check if either job has stopped - if ($backendJob.State -eq "Completed" -or $backendJob.State -eq "Failed") { - Write-Host "Backend process exited ($($backendJob.State))" - break + # Frontend died, or backend exited for another reason -- stop. + if ($frontendProc.HasExited) { + Write-Host "Frontend exited -- shutting down." + } else { + Write-Host "Backend exited (code $code) -- shutting down." } - if ($frontendJob.State -eq "Completed" -or $frontendJob.State -eq "Failed") { - Write-Host "Frontend process exited ($($frontendJob.State))" - break - } - - Start-Sleep -Milliseconds 500 + break } } finally { - # Cleanup on exit + # Cleanup on exit -- kill full process trees so ports 4001/5173 are freed. Write-Host "Shutting down..." - Stop-Job $backendJob -ErrorAction SilentlyContinue - Stop-Job $frontendJob -ErrorAction SilentlyContinue - Remove-Job $backendJob -Force -ErrorAction SilentlyContinue - Remove-Job $frontendJob -Force -ErrorAction SilentlyContinue + if ($backendProc) { Stop-Tree $backendProc.Id } + if ($frontendProc) { Stop-Tree $frontendProc.Id } Remove-Item $LOCK_FILE -Force -ErrorAction SilentlyContinue + Write-Host "Stopped." } diff --git a/start.sh b/start.sh index 884f435..1bd6280 100755 --- a/start.sh +++ b/start.sh @@ -1,9 +1,20 @@ #!/bin/bash # Claudia - Start Script +# +# Usage: +# ./start.sh # backend runs no-watch (default; stable, no spurious restarts) +# ./start.sh --watch # backend runs tsx watch (auto-reload on backend/src edits) set -e +WATCH=0 +for arg in "$@"; do + case "$arg" in + --watch|-w) WATCH=1 ;; + esac +done + # ============================================ # PORT CONFIGURATION - Single source of truth # ============================================ @@ -112,8 +123,43 @@ export CLAUDIA_BACKEND_PORT=$BACKEND_PORT # Increase Node.js memory limit for backend (handles many persisted tasks + archived tasks) export NODE_OPTIONS="--max-old-space-size=8192" -# Start backend and frontend -# Backend: tsx watch - auto-reloads on file changes (or use restart button in UI) -# Frontend: Vite HMR auto-reloads on file changes -npm run dev -w backend & npm run dev -w frontend +# Start backend and frontend. +# Backend defaults to no-watch to prevent spurious restarts from file changes +# (e.g., Claude Code tasks editing source files, antivirus). Pass --watch for auto-reload. +# Frontend: Vite HMR auto-reloads on file changes. +# +# The backend runs in a RELAUNCH LOOP: exit code 75 (triggered by POST +# /api/server/restart) relaunches it; any other exit code stops everything. +if [ "$WATCH" -eq 1 ]; then BACKEND_SCRIPT="dev"; else BACKEND_SCRIPT="dev:no-watch"; fi +echo "Backend mode: $BACKEND_SCRIPT" + +# Recursively kill a process and all its descendants (vite spawns esbuild +# grandchildren that `pkill -P` alone would miss). +kill_tree() { + local pid=$1 + for child in $(pgrep -P "$pid" 2>/dev/null); do + kill_tree "$child" + done + kill "$pid" 2>/dev/null +} + +# Frontend as a background child; kill its whole tree on exit. +npm run dev -w frontend & +FRONTEND_PID=$! +trap "rm -f '$LOCK_FILE'; kill_tree $FRONTEND_PID" EXIT INT TERM + +RESTART_EXIT_CODE=75 +while true; do + set +e + npm run "$BACKEND_SCRIPT" -w backend + code=$? + set -e + if [ "$code" -eq "$RESTART_EXIT_CODE" ]; then + echo "Backend requested restart (exit $code) -- relaunching..." + sleep 0.5 + continue + fi + echo "Backend exited (code $code) -- shutting down." + break +done