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
67 changes: 33 additions & 34 deletions src/lib/provider-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,55 +210,30 @@ 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<string, string> = {};

// 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';
mkdirSync(dir, { recursive: true });
const ts = Date.now().toString(36);
const promptFile = join(dir, `${params.role || 'agent'}-${ts}.md`);
Comment on lines 224 to 227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function creates temporary files in /tmp/genie-prompts but doesn't seem to have a mechanism to clean them up. Over time, this could lead to a large number of files accumulating in the /tmp directory.

Consider implementing a cleanup strategy. This could be done, for example, by registering a cleanup function on process exit, or by using a library like tmp which can manage temporary files and directories automatically.


// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function mutates the params.extraArgs array by calling splice. While this is documented in the JSDoc ("consuming it so it isn't duplicated later"), functions with side effects on their inputs can be harder to reason about and reuse safely. Consider making this function pure by having it return the unconsumed arguments instead of modifying the input array directly. This would make the data flow more explicit.

}
}
Expand All @@ -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<string, string> = {};

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));
}
Expand Down
74 changes: 40 additions & 34 deletions src/term-commands/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,42 +496,37 @@ async function resolveSpawnTeamWindow(team: string | undefined, cwd: string): Pr
}
}

async function launchTmuxSpawn(ctx: SpawnCtx): Promise<void> {
/**
* 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<void> {
const { execSync } = require('node:child_process');
const session = 'genie';
let layoutTarget = `${session}:${teamWindow?.windowName ?? ''}`;
if (!teamWindow) {
Expand All @@ -543,16 +538,28 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise<void> {
} catch {
/* best-effort */
}
}

async function launchTmuxSpawn(ctx: SpawnCtx): Promise<void> {
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,
Expand All @@ -566,7 +573,6 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise<void> {
lastSessionId: workerEntry.claudeSessionId,
});

// Register pane with the shared OTel relay.
if (ctx.otelRelayActive && paneId !== '%0') {
registerOtelRelayPane(ctx.workerId, paneId, ctx.agentName, ctx.spawnColor);
}
Expand Down
Loading