Files
pi-config/extensions/pi-interactive-shell/spawn.ts

314 lines
8.6 KiB
TypeScript

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 };
}