import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import type { InteractiveShellConfig, SpawnAgent } from "./config.js"; export type SpawnMode = "fresh" | "fork"; export type SpawnMonitorMode = "hands-free" | "dispatch"; export interface SpawnRequest { agent?: SpawnAgent; mode?: SpawnMode; worktree?: boolean; prompt?: string; } export interface ParsedSpawnArgs { request: SpawnRequest; monitorMode?: SpawnMonitorMode; } export interface ResolvedSpawn { agent: SpawnAgent; mode: SpawnMode; command: string; cwd: string; reason: string; worktreePath?: string; } export function parseSpawnArgs(args: string): | { ok: true; parsed: ParsedSpawnArgs } | { ok: false; error: string } { const tokenized = tokenizeSpawnArgs(args); if (!tokenized.ok) { return tokenized; } let agent: SpawnAgent | undefined; let mode: SpawnMode | undefined; let monitorMode: SpawnMonitorMode | undefined; let worktree = false; const promptTokens: string[] = []; for (const token of tokenized.tokens) { if (!token.quoted && token.value === "--worktree") { if (worktree) { return { ok: false, error: "Duplicate flag: --worktree" }; } worktree = true; continue; } if (!token.quoted && (token.value === "--hands-free" || token.value === "--dispatch")) { const nextMonitorMode = token.value === "--hands-free" ? "hands-free" : "dispatch"; if (monitorMode) { return monitorMode === nextMonitorMode ? { ok: false, error: `Duplicate flag: ${token.value}` } : { ok: false, error: "Cannot combine --hands-free and --dispatch." }; } monitorMode = nextMonitorMode; continue; } if (!token.quoted && (token.value === "pi" || token.value === "codex" || token.value === "claude" || token.value === "cursor")) { if (agent) { return { ok: false, error: `Duplicate spawn agent: ${token.value}` }; } agent = token.value; continue; } if (!token.quoted && (token.value === "fresh" || token.value === "fork")) { if (mode) { return { ok: false, error: `Duplicate spawn mode: ${token.value}` }; } mode = token.value; continue; } if (!token.quoted && token.value.startsWith("--")) { return { ok: false, error: `Unknown /spawn argument: ${token.value}` }; } if (!token.quoted) { return { ok: false, error: `Unknown /spawn argument: ${token.value}` }; } promptTokens.push(token.value); } if (promptTokens.length > 1) { return { ok: false, error: "Prompt text must be quoted as a single argument, for example /spawn claude \"review the diffs\" --dispatch.", }; } const prompt = promptTokens[0]; if (prompt !== undefined && !monitorMode) { return { ok: false, error: "Prompt-bearing /spawn requires --hands-free or --dispatch.", }; } if (monitorMode && prompt === undefined) { return { ok: false, error: "Monitored /spawn requires a quoted prompt, for example /spawn claude \"review the diffs\" --dispatch.", }; } return { ok: true, parsed: { request: { agent, mode, worktree: worktree || undefined, prompt }, monitorMode, }, }; } export function resolveSpawn( config: InteractiveShellConfig, cwd: string, request: SpawnRequest | undefined, getSessionFile: () => string | undefined, ): | { ok: true; spawn: ResolvedSpawn } | { ok: false; error: string } { const agent = request?.agent ?? config.spawn.defaultAgent; const mode = request?.mode ?? "fresh"; const worktree = request?.worktree ?? config.spawn.worktree; const prompt = request?.prompt?.trim(); if (request?.prompt !== undefined && !prompt) { return { ok: false, error: "Spawn prompt cannot be empty." }; } if (mode === "fork" && agent !== "pi") { return { ok: false, error: `Cannot fork ${agent}. Fork is only supported for pi sessions.` }; } let sourceSessionFile: string | undefined; if (mode === "fork") { sourceSessionFile = getSessionFile(); if (!sourceSessionFile) { return { ok: false, error: "Cannot fork the current session because it is not persisted (likely --no-session mode)." }; } } let effectiveCwd = cwd; let worktreePath: string | undefined; if (worktree) { const resolvedWorktree = createSpawnWorktree(config, cwd, agent); if (!resolvedWorktree.ok) { return resolvedWorktree; } effectiveCwd = resolvedWorktree.cwd; worktreePath = resolvedWorktree.path; } const executable = config.spawn.commands[agent]; const args = [...config.spawn.defaultArgs[agent]]; let reason = `spawn ${agent} (${mode === "fork" ? "fork current session" : "fresh session"})`; if (sourceSessionFile) { args.push("--fork", sourceSessionFile); } if (prompt) { args.push(prompt); } if (worktreePath) { reason += ` • worktree: ${worktreePath}`; } return { ok: true, spawn: { agent, mode, command: buildShellCommand(executable, args), cwd: effectiveCwd, reason, worktreePath, }, }; } function createSpawnWorktree( config: InteractiveShellConfig, cwd: string, agent: SpawnAgent, ): | { ok: true; cwd: string; path: string } | { ok: false; error: string } { const workingDir = resolve(cwd); const repoRoot = runGit(["-C", workingDir, "rev-parse", "--show-toplevel"], workingDir); if (!repoRoot.ok) { return { ok: false, error: "Cannot create a worktree here because the current directory is not inside a git repository." }; } const baseDir = config.spawn.worktreeBaseDir ? resolve(repoRoot.stdout, config.spawn.worktreeBaseDir) : join(dirname(repoRoot.stdout), `${basename(repoRoot.stdout)}-worktrees`); mkdirSync(baseDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[-:.]/g, "").replace("T", "-").replace("Z", ""); const suffix = Math.random().toString(36).slice(2, 7); const worktreePath = join(baseDir, `${basename(repoRoot.stdout)}-${agent}-${timestamp}-${suffix}`); const addWorktree = runGit(["-C", repoRoot.stdout, "worktree", "add", "--detach", worktreePath, "HEAD"], repoRoot.stdout); if (!addWorktree.ok) { return { ok: false, error: addWorktree.error }; } const relativeCwd = relative(repoRoot.stdout, workingDir); if (relativeCwd.length === 0 || relativeCwd.startsWith("..")) { return { ok: true, cwd: worktreePath, path: worktreePath }; } const nestedCwd = join(worktreePath, relativeCwd); return { ok: true, cwd: existsSync(nestedCwd) ? nestedCwd : worktreePath, path: worktreePath, }; } function runGit(args: string[], cwd: string): | { ok: true; stdout: string } | { ok: false; error: string } { try { return { ok: true, stdout: execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }).trim(), }; } catch (error) { const stderr = error instanceof Error && "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim() : ""; const message = error instanceof Error ? error.message : String(error); return { ok: false, error: stderr ? `${message}\n${stderr}` : message }; } } function buildShellCommand(executable: string, args: string[]): string { return [shellQuoteIfNeeded(executable), ...args.map(shellQuoteIfNeeded)].join(" "); } function shellQuoteIfNeeded(value: string): string { return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : shellQuote(value); } function shellQuote(value: string): string { if (process.platform === "win32") { return `"${value.replace(/"/g, '""')}"`; } return `'${value.replace(/'/g, `'\\''`)}'`; } type ParsedToken = { value: string; quoted: boolean }; function tokenizeSpawnArgs(args: string): | { ok: true; tokens: ParsedToken[] } | { ok: false; error: string } { const tokens: ParsedToken[] = []; let current = ""; let currentQuoted = false; let quote: '"' | "'" | null = null; for (let i = 0; i < args.length; i++) { const char = args[i]; if (!char) continue; if (quote) { if (char === quote) { quote = null; currentQuoted = true; continue; } if (char === "\\" && i + 1 < args.length) { current += args[++i] ?? ""; continue; } current += char; continue; } if (/\s/.test(char)) { if (current.length > 0 || currentQuoted) { tokens.push({ value: current, quoted: currentQuoted }); current = ""; currentQuoted = false; } continue; } if (char === '"' || char === "'") { quote = char; currentQuoted = true; continue; } if (char === "\\" && i + 1 < args.length) { current += args[++i] ?? ""; continue; } current += char; } if (quote) { return { ok: false, error: "Unterminated quote in /spawn arguments." }; } if (current.length > 0 || currentQuoted) { tokens.push({ value: current, quoted: currentQuoted }); } return { ok: true, tokens }; }