Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9a85710
fix: enable Browse button in Add Workspace dialog
May 11, 2026
b17bce8
feat: drag-and-drop folders to add workspaces, fix browse button
May 11, 2026
eb8f83a
fix: prevent tsx watch restarts from MCP sync + browse improvements
May 13, 2026
c5834df
fix: increase history retention to 10MB/5MB-tail, send 2MB to clients
May 13, 2026
25fabae
fix: suppress scrollbar-induced resize oscillation causing garbled text
May 13, 2026
0716881
docs: add git worktree support design plan
May 13, 2026
b769f75
fix: auto-reconnect all recently-active tasks on restart, not just mi…
May 13, 2026
0316c5b
fix: queue messages during WebSocket disconnect, switch to no-watch mode
May 14, 2026
deffe90
Merge remote-tracking branch 'origin/main' into fix/message-queue-on-…
May 14, 2026
0c0a0f1
fix: never re-paste prompt on retry, only retry Enter delivery
May 14, 2026
bc6dbd1
feat: git worktree support -- sidebar grouping, auto-isolate, MCP iso…
May 29, 2026
f601597
feat: worktree UX redesign -- inline grouping, per-task isolate toggl…
May 29, 2026
273357e
fix: configured default model overrides inherited ANTHROPIC_MODEL
Jun 1, 2026
d368bbc
fix: spawn claude.exe directly, decode entity task names, guard NaN s…
Jun 1, 2026
6bb87d0
feat: bias agents toward Claudia tasks over internal subagents
Jun 1, 2026
d1c7a11
feat: PR status badges + reliable backend restart in no-watch mode
Jun 2, 2026
d6ed052
feat: auto-detect worktrees created inside tasks
Jun 2, 2026
07388e5
feat: inline badge for single-task worktrees, group only at 2+
Jun 2, 2026
661e0d3
feat: annotate task with worktree branch its session creates
Jun 3, 2026
6e39b28
fix: deliver initial prompt via bracketed paste to stop front-truncation
Jun 3, 2026
cd14b85
fix: resolve worktree PR by current branch, not stored one
Jun 3, 2026
adef4e3
fix: refresh PR badge when a task goes idle (branch may have changed)
Jun 3, 2026
68cee9a
fix: PR badge updates live when branch changes during active task
Jun 3, 2026
6391b54
fix: workspace context menu no longer clipped by scrollable parent
Jun 3, 2026
6861e23
fix: use bracketed paste for follow-up messages too, not just initial…
Jun 3, 2026
21cc693
fix: archived tasks show most recent first and use display name
Jun 4, 2026
dafdb0b
fix: use bracketed paste for all message writes, including busy tasks
Jun 4, 2026
57328f5
feat: expose cron scheduler to Claude sessions via MCP tools
Jun 4, 2026
8be2cd5
fix: no PR badge on default branch (main/master)
Jun 4, 2026
c2f35aa
fix: exclude claudia MCP server from workspace .mcp.json
Jun 5, 2026
c5a401c
fix: prevent PR badge from capturing workspace drag events
Jun 8, 2026
f857901
fix: PR refresh broadcasts singular workspace update, not full array
Jun 8, 2026
9be0b70
fix: disable draggable on inline worktree tasks to unblock task reorder
Jun 8, 2026
236165f
fix: stronger task rename guidance + periodic title-update reminders
Jun 8, 2026
fb2db6f
fix: include claudia MCP server in workspace .mcp.json with workspace ID
Jun 8, 2026
236fbcb
fix: remove duplicate cron tool registrations that crashed MCP server
Jun 9, 2026
02f5a7c
fix: isMain bug marked all worktrees as main + review fixes
Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,5 @@ The lock file will prevent accidental duplicate starts.
<!-- CODEUI-RULES -->
## 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.
<!-- /CODEUI-RULES -->
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 49 additions & 7 deletions backend/src/claudia-mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `

Expand All @@ -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: [{
Expand All @@ -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<string, unknown> = {
prompt,
workspaceId: WORKSPACE_ID,
workspaceId: effectiveWorkspaceId,
source: 'mcp',
};
if (MODEL_TIERING_ENABLED && complexity) {
Expand Down Expand Up @@ -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)
}]
};
Expand All @@ -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-<id>). ' +
'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',
Expand All @@ -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)
);
Expand All @@ -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)
);
Expand Down
110 changes: 109 additions & 1 deletion backend/src/git-utils.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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<boolean> {
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<string | null> {
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:
Expand Down Expand Up @@ -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<WorkspacePrInfo | null> {
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;
}
}
Loading
Loading