Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
313
extensions/pi-interactive-shell/spawn.ts
Normal file
313
extensions/pi-interactive-shell/spawn.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user