diff --git a/src/lib/provider-adapters.ts b/src/lib/provider-adapters.ts index 95c244170..5a69adf58 100644 --- a/src/lib/provider-adapters.ts +++ b/src/lib/provider-adapters.ts @@ -210,36 +210,15 @@ function appendNativeTeamFlags( if (nt.permissionMode) parts.push('--permission-mode', escapeShellArg(nt.permissionMode)); } -export function buildClaudeCommand(params: SpawnParams): LaunchCommand { - preflightCheck('claude'); - - const parts: string[] = ['claude', '--dangerously-skip-permissions']; - const env: Record = {}; - - // Always set GENIE_AGENT_NAME and GENIE_TEAM, even for non-native spawns - if (params.role) { - env.GENIE_AGENT_NAME = params.role; - } - if (params.team) { - env.GENIE_TEAM = params.team; - } - - if (params.nativeTeam?.enabled) { - appendNativeTeamFlags(parts, env, params.nativeTeam, params); - } - - if (params.resume) { - parts.push('--resume', escapeShellArg(params.resume)); - } else if (params.sessionId) { - parts.push('--session-id', escapeShellArg(params.sessionId)); - } - - if (params.role) parts.push('--agent', escapeShellArg(params.role)); - - if (params.model) parts.push('--model', escapeShellArg(params.model)); - +/** + * Resolve system prompt flags for the Claude command. + * + * When both an inline systemPrompt and a systemPromptFile exist, merges + * them into a single temp file. Also merges any --append-system-prompt-file + * found in extraArgs (consuming it so it isn't duplicated later). + */ +function appendSystemPromptFlags(parts: string[], params: SpawnParams): void { if (params.systemPrompt) { - // Write built-in prompt to temp file — avoids shell escaping of complex content const { mkdirSync, writeFileSync, readFileSync } = require('node:fs'); const { join } = require('node:path'); const dir = '/tmp/genie-prompts'; @@ -247,18 +226,14 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand { const ts = Date.now().toString(36); const promptFile = join(dir, `${params.role || 'agent'}-${ts}.md`); - // If there is also a systemPromptFile (user agent), merge both let content = params.systemPrompt; if (params.systemPromptFile) { content = `${readFileSync(params.systemPromptFile, 'utf-8')}\n\n${content}`; } - - // If extraArgs has --append-system-prompt-file, merge that too if (params.extraArgs) { const fileIdx = params.extraArgs.indexOf('--append-system-prompt-file'); if (fileIdx !== -1 && params.extraArgs[fileIdx + 1]) { content = `${content}\n\n${readFileSync(params.extraArgs[fileIdx + 1], 'utf-8')}`; - // Remove the extra arg since we merged it params.extraArgs.splice(fileIdx, 2); } } @@ -270,12 +245,36 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand { const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file'; parts.push(flag, escapeShellArg(params.systemPromptFile)); } +} + +export function buildClaudeCommand(params: SpawnParams): LaunchCommand { + preflightCheck('claude'); + + const parts: string[] = ['claude', '--dangerously-skip-permissions']; + const env: Record = {}; + + if (params.role) env.GENIE_AGENT_NAME = params.role; + if (params.team) env.GENIE_TEAM = params.team; + + if (params.nativeTeam?.enabled) { + appendNativeTeamFlags(parts, env, params.nativeTeam, params); + } + + if (params.resume) { + parts.push('--resume', escapeShellArg(params.resume)); + } else if (params.sessionId) { + parts.push('--session-id', escapeShellArg(params.sessionId)); + } + + if (params.role) parts.push('--agent', escapeShellArg(params.role)); + if (params.model) parts.push('--model', escapeShellArg(params.model)); + + appendSystemPromptFlags(parts, params); if (params.extraArgs) { for (const arg of params.extraArgs) parts.push(escapeShellArg(arg)); } - // Positional [prompt] arg must be last — becomes the first user message if (params.initialPrompt) { parts.push(escapeShellArg(params.initialPrompt)); } diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index 261701f03..8727ae3b3 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -496,42 +496,37 @@ async function resolveSpawnTeamWindow(team: string | undefined, cwd: string): Pr } } -async function launchTmuxSpawn(ctx: SpawnCtx): Promise { +/** + * Create a tmux pane for the worker. + * + * First agent in a newly created team window reuses the blank pane via send-keys. + * Subsequent agents split-window into the same team window. + */ +function createTmuxPane(ctx: SpawnCtx, teamWindow: TeamWindowInfo | null): string { const { execSync } = require('node:child_process'); - // When spawning into the current session (no explicit --team), skip team window - // resolution and split the current window directly. - const teamWindow = ctx.spawnIntoCurrentWindow ? null : await resolveSpawnTeamWindow(ctx.validated.team, ctx.cwd); - const splitTarget = teamWindow ? `-t '${teamWindow.windowId}'` : ''; - - let paneId: string; - try { - // When first agent spawns into a newly created team window, use send-keys - // to run the command in the existing (blank) pane — no split-window needed. - // Only split-window for 2nd+ agent in the same window. - if (teamWindow?.created) { - // Get the existing pane ID from the newly created window - paneId = execSync(`tmux list-panes -t '${teamWindow.windowId}' -F '#{pane_id}'`, { encoding: 'utf-8' }) - .trim() - .split('\n')[0]; - // cd into the working directory and run the command - if (ctx.cwd) { - execSync(`tmux send-keys -t '${paneId}' 'cd ${ctx.cwd.replace(/'/g, "'\\''")}' Enter`, { encoding: 'utf-8' }); - } - execSync(`tmux send-keys -t '${paneId}' '${ctx.fullCommand.replace(/'/g, "'\\''")}' Enter`, { - encoding: 'utf-8', - }); - } else { - const cwdFlag = ctx.cwd ? `-c '${ctx.cwd}'` : ''; - const splitCmd = `tmux split-window -d ${splitTarget} ${cwdFlag} -P -F '#{pane_id}' ${ctx.fullCommand}`; - paneId = execSync(splitCmd, { encoding: 'utf-8' }).trim(); + if (teamWindow?.created) { + const paneId = execSync(`tmux list-panes -t '${teamWindow.windowId}' -F '#{pane_id}'`, { encoding: 'utf-8' }) + .trim() + .split('\n')[0]; + if (ctx.cwd) { + execSync(`tmux send-keys -t '${paneId}' 'cd ${ctx.cwd.replace(/'/g, "'\\''")}' Enter`, { encoding: 'utf-8' }); } - } catch (err) { - console.error(`Failed to create tmux pane: ${err instanceof Error ? err.message : 'unknown error'}`); - process.exit(1); + execSync(`tmux send-keys -t '${paneId}' '${ctx.fullCommand.replace(/'/g, "'\\''")}' Enter`, { + encoding: 'utf-8', + }); + return paneId; } - // Apply layout to team window (or fallback to first window in session) + const splitTarget = teamWindow ? `-t '${teamWindow.windowId}'` : ''; + const cwdFlag = ctx.cwd ? `-c '${ctx.cwd}'` : ''; + const splitCmd = `tmux split-window -d ${splitTarget} ${cwdFlag} -P -F '#{pane_id}' ${ctx.fullCommand}`; + return execSync(splitCmd, { encoding: 'utf-8' }).trim(); +} + +/** Apply mosaic layout to the team window (or first window in session as fallback). */ +async function applySpawnLayout(ctx: SpawnCtx, teamWindow: TeamWindowInfo | null): Promise { + const { execSync } = require('node:child_process'); const session = 'genie'; let layoutTarget = `${session}:${teamWindow?.windowName ?? ''}`; if (!teamWindow) { @@ -543,16 +538,28 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise { } catch { /* best-effort */ } +} + +async function launchTmuxSpawn(ctx: SpawnCtx): Promise { + const teamWindow = ctx.spawnIntoCurrentWindow ? null : await resolveSpawnTeamWindow(ctx.validated.team, ctx.cwd); + + let paneId: string; + try { + paneId = createTmuxPane(ctx, teamWindow); + } catch (err) { + console.error(`Failed to create tmux pane: ${err instanceof Error ? err.message : 'unknown error'}`); + process.exit(1); + } + + await applySpawnLayout(ctx, teamWindow); const workerEntry = await registerSpawnWorker(ctx, paneId, teamWindow); await notifySpawnJoin(ctx, paneId); - // Apply agent color to tmux pane border (focus-driven) if (ctx.spawnColor && paneId !== 'inline') { await tmux.applyPaneColor(paneId, ctx.spawnColor, teamWindow?.windowId); } - // Save spawn template for auto-respawn on message delivery await registry.saveTemplate({ id: ctx.validated.role ?? ctx.workerId, provider: ctx.validated.provider, @@ -566,7 +573,6 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise { lastSessionId: workerEntry.claudeSessionId, }); - // Register pane with the shared OTel relay. if (ctx.otelRelayActive && paneId !== '%0') { registerOtelRelayPane(ctx.workerId, paneId, ctx.agentName, ctx.spawnColor); }