Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions backend/src/__tests__/workspace-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,25 @@ describe('WorkspaceStore', () => {
expect(ws.references?.length).toBe(1);
});

it('resolves the parent to the root repo when spawned from inside a worktree (no nesting)', async () => {
store.addWorkspace(testWorkspace1); // root repo

const wtA = join(testBaseDir, 'wt-parent');
const wtB = join(testBaseDir, 'wt-nested');
mkdirSync(wtA, { recursive: true });
mkdirSync(wtB, { recursive: true });

const a = await store.addWorktreeWorkspace(wtA, testWorkspace1, 'a');
expect(a.worktreeParentId).toBe(testWorkspace1);

// A task running inside wtA spawns another worktree, passing wtA (itself a
// worktree) as the parent. It must re-parent to the ROOT repo, not nest under
// wtA — otherwise the sidebar can't render it.
const b = await store.addWorktreeWorkspace(wtB, wtA, 'b');
expect(b.worktreeParentId).toBe(testWorkspace1);
expect(b.worktreeParentId).not.toBe(wtA);
});

it('should position the worktree directly after its parent', async () => {
store.addWorkspace(testWorkspace1);
store.addWorkspace(testWorkspace2);
Expand Down
6 changes: 5 additions & 1 deletion backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1228,11 +1228,15 @@ export async function createApp(basePath?: string) {
});

// WebSocket connection handling
let wsClientSeq = 0;
wss.on('connection', async (ws: WebSocket, req) => {
// Check for mobile token auth on query string
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const mobileToken = url.searchParams.get('token');
const isMobile = url.searchParams.get('mobile') === '1';
// Stable per-connection id so writes can be attributed to a specific client
// (used to attribute task:input — e.g. to catch a runaway client looping /clear).
const clientId = `${isMobile ? 'mobile' : 'web'}:${req.socket.remoteAddress || 'local'}#${++wsClientSeq}`;

if (isMobile) {
if (!mobileToken || !tunnelManager.validateToken(mobileToken)) {
Expand Down Expand Up @@ -1506,7 +1510,7 @@ export async function createApp(basePath?: string) {
}
}
}
taskSpawner.writeToTask(taskId, filteredInput);
taskSpawner.writeToTask(taskId, filteredInput, clientId);
}
break;
}
Expand Down
151 changes: 96 additions & 55 deletions backend/src/task-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ export class TaskSpawner extends EventEmitter {
private backendType: BackendType = 'claude-code';
/** Track which tasks use which backend (for mixed-backend scenarios during transition) */
private taskBackends: Map<string, BackendType> = new Map();
/** Per-task timestamps of recent context-destroying inputs (/clear, /compact, /reset), used to rate-limit a runaway client */
private recentDestructiveInputs: Map<string, number[]> = new Map();

// Learnings store for RAG-based context injection
private learningsStore: LearningsStore | null = null;
Expand Down Expand Up @@ -2626,13 +2628,14 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
logger.warn('No enabled MCP servers found!');
}

// When skip-permissions is enabled, allow ALL tools so the narrow
// allowlist doesn't reintroduce permission prompts. Otherwise, only
// auto-approve MCP tools (non-MCP tools go through normal permission flow).
// When skip-permissions is enabled, --dangerously-skip-permissions (added
// above) already bypasses ALL permission checks, so no allowlist is needed.
// Do NOT pass `--allowedTools '*'` — current Claude Code rejects '*' as an
// invalid allow rule ("Wildcard tool name '*' is not supported"), which both
// prints a warning and leaves permission prompts active. Only set the narrow
// MCP allowlist in the non-skip case.
const skipPerms = this.configStore?.getSkipPermissions();
if (skipPerms) {
claudeArgs.push('--allowedTools', '*');
} else {
if (!skipPerms) {
claudeArgs.push('--allowedTools', 'mcp__*');
}

Expand Down Expand Up @@ -3438,46 +3441,48 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
}
}

writeToTask(taskId: string, data: string): void {
writeToTask(taskId: string, data: string, source: string = 'internal'): void {
// Safety guard: a runaway client looping a context-destroying slash command
// (/clear, /compact, /reset) can wipe a Claude session. Allow a couple of
// legitimate manual uses, then drop the input once it repeats rapidly to the
// same task within a short window — and log the source so the culprit is named.
const destructiveCmd = data.replace(/[\r\n]+$/, '').trim();
if (/^\/(clear|compact|reset)\b/i.test(destructiveCmd)) {
const now = Date.now();
const WINDOW_MS = 30_000;
const recent = (this.recentDestructiveInputs.get(taskId) || []).filter(t => now - t < WINDOW_MS);
recent.push(now);
this.recentDestructiveInputs.set(taskId, recent);
// Allow only ONE context-destroying command per task per 30s. A real user
// rarely re-clears within seconds; an injected/looping source does. This caps
// the damage to one wipe per window while we identify/close the source.
if (recent.length > 1) {
console.warn(`[TaskSpawner] BLOCKED '${destructiveCmd}' to task ${taskId} — ${recent.length}x in 30s from source=${source} (runaway client — close that client!)`);
logger.warn('Blocked repeated context-destroying input', { taskId, command: destructiveCmd, countIn30s: recent.length, source });
return;
}
console.log(`[TaskSpawner] '${destructiveCmd}' -> task ${taskId} from source=${source} (${recent.length}/2 allowed in 10s window)`);
}

let task = this.tasks.get(taskId);

// If task not found in active tasks, check if it's disconnected and needs reconnecting
if (!task) {
if (this.disconnectedTasks.has(taskId)) {
console.log(`[TaskSpawner] Task ${taskId} is disconnected, auto-reconnecting before write`);
console.log(`[TaskSpawner] Task ${taskId} is disconnectedreconnecting and queueing input for delivery once the resumed session is ready`);
console.log(`[TaskSpawner] Input length: ${data.length}, Data preview: ${JSON.stringify(data.substring(0, 50))}`);
const reconnectedTask = this.reconnectTask(taskId);
if (reconnectedTask) {
task = this.tasks.get(taskId);
if (task) {
task.isActive = true;
this.emit('tasksUpdated');
// Wait for the task to reach idle state before delivering the write.
// A fixed 500ms timeout was too short for large pastes — Claude Code
// may not be ready to accept input yet when the TUI is still initializing.
let delivered = false;
const onStateChanged = (changedTask: Task) => {
if (changedTask.id === taskId && changedTask.state === 'idle' && !delivered) {
delivered = true;
this.removeListener('taskStateChanged', onStateChanged);
console.log(`[TaskSpawner] Task ${taskId} reached idle after reconnect, delivering pending write`);
this.writeToTask(taskId, data);
}
};
this.on('taskStateChanged', onStateChanged);
// Safety fallback: if task never reaches idle within 15s, try anyway
setTimeout(() => {
if (!delivered) {
delivered = true;
this.removeListener('taskStateChanged', onStateChanged);
console.log(`[TaskSpawner] Fallback: delivering pending write after 15s for task ${taskId}`);
this.writeToTask(taskId, data);
}
}, 15000);
return;
}
// Hand the input to reconnectTask so it is delivered through the SAME
// TUI-ready detection + Enter-retry path that delivers a new task's
// initial prompt. reconnectTask creates the task with the message as a
// pendingPrompt and waits for the resumed Claude TUI to actually signal
// readiness before sending it. This is deterministic — the previous
// approach raced a one-shot 'idle' transition that reconnectTask had
// usually already emitted by the time a listener could attach, so the
// first message stalled behind a 15s fallback (or was lost entirely).
const reconnectedTask = this.reconnectTask(taskId, data);
if (!reconnectedTask) {
console.log(`[TaskSpawner] Failed to reconnect task ${taskId} for write`);
}
console.log(`[TaskSpawner] Failed to reconnect task ${taskId} for write`);
} else {
console.log(`[TaskSpawner] Cannot write to task ${taskId}: task not found`);
}
Expand All @@ -3492,9 +3497,16 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
return;
}

// Only log non-trivial writes (messages, not individual keystrokes) to avoid I/O overhead
if (data.length > 1) {
console.log(`[TaskSpawner] Writing to PTY for task ${taskId} (${data.length} chars)`);
// Terminal protocol responses that xterm auto-sends in reply to TUI queries
// (cursor-position reports \x1b[r;cR, device-attributes \x1b[?...c, etc.) are
// pure noise — and a TUI stuck in a cursor-query loop can flood them. They're
// still written normally; we just don't log each one. A real message/keystroke
// never looks like a bare terminal-response escape.
const isTerminalResponse = /^\x1b\[[\d;?]*[A-Za-z~]$/.test(data);
// Only log non-trivial writes (messages, not individual keystrokes or protocol
// responses) to avoid I/O overhead and log spam.
if (data.length > 1 && !isTerminalResponse) {
console.log(`[TaskSpawner] Writing to PTY for task ${taskId} (${data.length} chars) source=${source}`);
}

// Claude Code PTY-based input handling
Expand Down Expand Up @@ -4049,7 +4061,7 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
return [...liveTasks, ...disconnectedTasks];
}

reconnectTask(taskId: string): Task | null {
reconnectTask(taskId: string, pendingInput?: string): Task | null {
// Guard: if the task is already live, return it without spawning a second process
if (this.tasks.has(taskId)) {
const existing = this.tasks.get(taskId)!;
Expand Down Expand Up @@ -4087,6 +4099,16 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
return null;
}

// Guard: the task's working directory must still exist before we spawn a PTY
// into it. Worktree directories can be removed after their PR merges; spawning
// with a non-existent cwd throws ENOENT, which was previously swallowed so the
// user's input vanished with no feedback. Fail the reconnect explicitly instead.
if (!existsSync(persisted.workspaceId)) {
console.error(`[TaskSpawner] Cannot reconnect task ${taskId}: workspace directory no longer exists: ${persisted.workspaceId}`);
logger.error('Reconnect failed: workspace directory missing', { taskId, workspaceId: persisted.workspaceId });
return null;
}

// Determine which backend was used to create this task
// Use persisted backendType, fallback to 'claude-code' for backwards compatibility
const taskBackendType = persisted.backendType || 'claude-code';
Expand Down Expand Up @@ -4148,11 +4170,10 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
claudeArgs.push('--dangerously-skip-permissions');
}

// When skip-permissions is enabled, allow ALL tools to avoid
// the narrow MCP-only allowlist reintroducing permission prompts.
if (skipPermsReconnect) {
claudeArgs.push('--allowedTools', '*');
} else {
// When skip-permissions is enabled, --dangerously-skip-permissions (above)
// bypasses all checks; do NOT pass `--allowedTools '*'` — Claude Code rejects
// '*' as an invalid allow rule, which spams a warning and leaves prompts active.
if (!skipPermsReconnect) {
claudeArgs.push('--allowedTools', 'mcp__*');
}

Expand Down Expand Up @@ -4258,13 +4279,24 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
logger.info(`Task was interrupted, will auto-continue`, { taskId });
}

// A message the user typed into a disconnected task (pendingInput) is delivered
// the same way as a new task's initial prompt: queued as pendingPrompt and sent
// by the ready-detection in setupProcessHandlers (with the fallback timer below)
// once the resumed Claude TUI actually signals it can accept input. Strip a
// trailing newline — sendPromptWithRetry appends Enter itself via sendEnterWithRetry.
const queuedInput = pendingInput ? pendingInput.replace(/[\r\n]+$/, '') : '';
// What to deliver once Claude is ready: a continuation (interrupted mid-turn),
// the user's first message, or nothing (a plain background reconnect).
const deliverOnReady = shouldContinue ? 'continue' : (queuedInput || null);
const needsDelivery = deliverOnReady != null;

const task: InternalTask = {
id: persisted.id,
prompt: persisted.prompt,
workspaceId: persisted.workspaceId,
process: ptyProcess,
state: shouldContinue ? 'starting' : 'idle', // 'starting' if we need to send continuation
processStartedAt: shouldContinue
state: needsDelivery ? 'starting' : 'idle', // 'starting' while we wait to deliver a prompt/message
processStartedAt: needsDelivery
? (persisted.processStartedAt ? new Date(persisted.processStartedAt) : now)
: undefined, // Preserve original start time across reconnect, or use now as fallback
outputHistory: [], // Start empty — resume separator is emitted live but not persisted
Expand All @@ -4273,15 +4305,15 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
lastActivity: now,
createdAt: new Date(persisted.createdAt),
isActive: false,
initialPromptSent: !shouldContinue, // False if we need to send continuation prompt
pendingPrompt: shouldContinue ? 'continue' : null, // Trigger continuation
initialPromptSent: !needsDelivery, // False if we have a prompt/message to deliver on ready
pendingPrompt: deliverOnReady, // Continuation or the user's first message
// Use sessionIdToUse (not persisted.sessionId) — if the session file was missing,
// sessionIdToUse was cleared to null so the PTY handler can capture the new session ID.
sessionId: sessionIdToUse,
lastOutputLength: 0, // Initialize for state polling
totalOutputSize: 0, // Incremental output size tracking
savedBufferCount: 0, // Incremental history saves
hasStartedProcessing: !shouldContinue, // Will be set true when continuation starts
hasStartedProcessing: !needsDelivery, // Will be set true when the queued prompt/message starts
shouldContinue,
continuationSent: false,
displayName: persisted.displayName, // Preserve user-set display name across reconnection
Expand Down Expand Up @@ -4317,11 +4349,20 @@ You are running as an agent inside Claudia, a multi-agent orchestrator. You have
this.pendingResizes.delete(taskId);
}

// Start fallback timer for shouldContinue tasks (same race condition as new tasks)
if (shouldContinue && taskBackendType === 'claude-code') {
// Arm the ready-signal fallback timer whenever we have something to deliver
// (a continuation or the user's first message) — same race window as new tasks.
if (needsDelivery && taskBackendType === 'claude-code') {
this.startReadyFallbackTimer(task);
}

// A user-initiated reconnect (they typed into the task) should stream the
// resumed session's output back immediately so they see their message land and
// the response. Background reconnects (auto-reconnect, sleep/wake, continuation)
// stay inactive until the user selects the task.
if (pendingInput) {
task.isActive = true;
}

this.scheduleSave();

// Start session capture so we detect the new/resumed session file
Expand Down
11 changes: 11 additions & 0 deletions backend/src/workspace-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ export class WorkspaceStore {
throw new Error(`Workspace already exists: ${resolvedPath}`);
}

// Worktrees must nest under a top-level (repo) workspace, never under another
// worktree. When a task running inside a worktree spawns a new worktree, the
// passed parentId is itself a worktree — walk up to the root repo so the sidebar
// can group it. A worktree parented to another worktree renders nowhere in the
// toolbar (the grouping only handles one level under a top-level workspace).
let walk = this.getWorkspace(parentId);
while (walk?.worktreeParentId) {
parentId = walk.worktreeParentId;
walk = this.getWorkspace(parentId);
}

const parentWorkspace = this.getWorkspace(parentId);
const parentDisplayName = parentWorkspace?.displayName ?? parentWorkspace?.name ?? basename(parentId);
const shortBranch = branch.replace(/^refs\/heads\//, '');
Expand Down
Loading