// orchestrate-command.ts — the /orchestrate command the PACKAGE owns. // // One source of truth for the orchestrator procedure: prompts/orchestrate.md, // shipped in the package's files[] and loaded HERE at runtime. Both hosts // register the command themselves (pi: pi.registerCommand; opencode TUI: a // keymap-layer command with slashName "orchestrate") and inject THIS text — // no per-host projection of the procedure, no sync, no drift check. // // The second half of this module is the CONFLICT WARNING (AUTOPILOT-32 D4): // a leftover projection of the OLD procedure sitting in a host's command dir // is exactly how a superseded procedure keeps running silently. At load each // host asks for ONE report naming every competing definition on disk AND // which one actually wins. // // PRECEDENCE IS EMPIRICAL, NOT DOCUMENTED — measured 2026-09-10 by // registering the plugin command while a same-named file existed and // observing which one ran. IT DIFFERS PER HOST: // // pi — the EXTENSION command WINS over $PI_CODING_AGENT_DIR/prompts/ // orchestrate.md. Probe: `pi --mode rpc` + {"type":"prompt", // "message":"/orchestrate"} with both present ran the extension // handler (marker file written, no LLM turn); with the extension // removed the same keystroke expanded the template into a user // message instead. get_commands lists BOTH under the plain name // (numeric :1/:2 suffixes are only minted when two EXTENSIONS // collide), so the list alone does not reveal the winner. // opencode — the FILE WINS. Probe: the real TUI driven through a pty with // both $OPENCODE_CONFIG_DIR/command/orchestrate.md and a plugin // slashName "orchestrate"; the palette showed both entries and // submitting /orchestrate rendered the FILE's body — the // plugin's run() never fired. With the file removed, the same // keystrokes fired run(). So on opencode a stale projection // SHADOWS the packaged procedure and must be removed, not // merely tolerated. import { existsSync, readFileSync, realpathSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { homedir } from "node:os"; // --------------------------------------------------------------------------- // The packaged procedure // --------------------------------------------------------------------------- /** The packaged procedure file: /prompts/orchestrate.md. This module * lives at src/framework/, so the package root is two levels up. * * package.json ships this file in `files[]` but deliberately does NOT list * `prompts` in its `pi` manifest: registering it as a pi prompt TEMPLATE * would make the package define /orchestrate twice (its own extension * command plus a template of the same name), which is the very conflict * detectOrchestrateConflict() exists to report. */ export function orchestratePromptPath(): string { return join(__dirname, "..", "..", "prompts", "orchestrate.md"); } /** Is `path` a file of THIS package? The hosts enumerate their LIVE commands * to find competing /orchestrate definitions, and the plugin's own command is * in that list — it must be excluded by IDENTITY (this package's files), not * by kind. Excluding every `source === "extension"` entry instead would hide * the one competitor whose precedence is genuinely unknown: a SECOND * extension defining /orchestrate (which is also the only case where pi mints * the `orchestrate:1` / `orchestrate:2` names). Never throws. */ export function isPackagePath(path: string): boolean { const canonical = (p: string): string => { try { return realpathSync(p); } catch { return resolve(p); } }; try { const root = canonical(join(__dirname, "..", "..")); const target = canonical(path); return target === root || target.startsWith(root + sep); } catch { return false; } } /** Strip a leading YAML frontmatter block. The hosts inject the PROCEDURE, not * the file's command metadata (pi rendered frontmatter as prompt text when a * template carried it; opencode never showed it at all). */ export function stripFrontmatter(content: string): string { const m = content.match(/^---\n[\s\S]*?\n---\n?/); return m ? content.slice(m[0].length).replace(/^\n+/, "") : content; } /** The procedure text both hosts inject, or null when the package is broken. * NEVER throws — a missing/unreadable prompt must not take down the host; the * caller reports the failure through its own UI. */ export function orchestrateProcedure(): string | null { try { const text = stripFrontmatter(readFileSync(orchestratePromptPath(), "utf8")).trim(); return text.length ? text : null; } catch { return null; } } // --------------------------------------------------------------------------- // Conflict detection (D4) // --------------------------------------------------------------------------- export type OrchestrateHost = "pi" | "opencode"; export interface CompetingDefinition { /** Absolute path of the competing orchestrate command definition. */ path: string; /** The tool that loads this file (named so the user knows where to look). */ owner: string; /** Does the RUNNING host load this file? Only a live one can shadow (or be * shadowed by) the plugin's command; the rest are named so a stale copy in * ANOTHER tool is visible rather than silent. */ liveInHost: boolean; } export interface OrchestrateConflict { competing: CompetingDefinition[]; /** What actually runs when the user types /orchestrate in THIS host. */ winner: string; /** The ONE warning line-set the host emits (already formatted). */ message: string; } export interface ConflictScanOptions { host: OrchestrateHost; /** The running host's own config dir ($PI_CODING_AGENT_DIR / $OPENCODE_CONFIG_DIR). */ hostConfigDir?: string; env?: NodeJS.ProcessEnv; home?: string; /** Injectable for tests. */ exists?: (path: string) => boolean; /** Definitions the host enumerated LIVE from its own runtime (pi's * pi.getCommands() reports every registered /orchestrate with its * sourceInfo.path). Merged with the on-disk scan, de-duplicated by path. */ live?: CompetingDefinition[]; /** The store this session resolved, and whether any profile DECLARED it. * An undeclared store means the resolution fell back — and a fallback can * land on ANOTHER profile's LIVE queue (observed 2026-09-10: an opencode * session with no declared state dir resolved to the live personal store * and fixture writes overwrote real items). The competing-copy warning is * where a user is already being told "which one is live", so it says this * too. */ store?: { resolved: string; declaredBy: string | null }; } /** Every place an orchestrate command definition is known to live, other than * the package itself. Order = report order (host-loaded first, then the * other tools' copies). */ function candidateDefinitions(opts: ConflictScanOptions): CompetingDefinition[] { const env = opts.env ?? process.env; // $HOME first: bun's os.homedir() ignores it, and the hermetic host tests // scope the scan by setting HOME to a scratch dir. const home = opts.home ?? env.HOME ?? homedir(); const piDir = opts.host === "pi" ? opts.hostConfigDir ?? env.PI_CODING_AGENT_DIR : env.PI_CODING_AGENT_DIR; const ocDir = opts.host === "opencode" ? opts.hostConfigDir ?? env.OPENCODE_CONFIG_DIR : env.OPENCODE_CONFIG_DIR; const out: CompetingDefinition[] = []; if (piDir) { out.push({ path: join(piDir, "prompts", "orchestrate.md"), owner: "pi prompt template", liveInHost: opts.host === "pi" }); } if (ocDir) { out.push({ path: join(ocDir, "command", "orchestrate.md"), owner: "opencode command file", liveInHost: opts.host === "opencode" }); } // Neither host loads these — they belong to other tools. Named anyway: a // stale procedure running in Claude Code is invisible from here otherwise. out.push({ path: join(home, ".config/agents/commands/orchestrate.md"), owner: "canonical copy (feeds the Claude Code projection)", liveInHost: false }); out.push({ path: join(home, ".claude/commands/orchestrate.md"), owner: "Claude Code command", liveInHost: false }); return out; } /** Scan for competing orchestrate definitions and say which one WINS. Returns * null when the packaged command is the only one. NEVER throws. */ export function detectOrchestrateConflict(opts: ConflictScanOptions): OrchestrateConflict | null { let competing: CompetingDefinition[] = []; try { const exists = opts.exists ?? existsSync; const seen = new Set(); // The packaged file can never compete with itself: a host that enumerates // it live (an install that also exposes prompts/ as templates) would // otherwise be reported as a conflict with the command it IS. seen.add(orchestratePromptPath()); for (const c of [...candidateDefinitions(opts), ...(opts.live ?? [])]) { if (seen.has(c.path)) continue; // A live-enumerated definition is present by construction; an on-disk // candidate has to be there. const fromLive = (opts.live ?? []).some((l) => l.path === c.path); if (!fromLive && !exists(c.path)) continue; seen.add(c.path); competing.push(c); } } catch { return null; // a broken scan must never cost the host its command } if (!competing.length) return null; const packaged = orchestratePromptPath(); // EMPIRICAL precedence (see the module header): pi runs the plugin command, // opencode runs the file. const shadow = opts.host === "opencode" ? competing.find((c) => c.liveInHost) : undefined; const winner = shadow ? `${shadow.path} (${shadow.owner}) — it SHADOWS the packaged procedure` : `the plugin's packaged procedure (${packaged})`; const lines: string[] = []; lines.push(`/orchestrate is defined in ${competing.length + 1} places. WINNER in this host (${opts.host}): ${winner}.`); lines.push(` - ${packaged} — packaged procedure (this plugin)${shadow ? " — SHADOWED, does not run" : " — RUNS"}`); for (const c of competing) { const note = !c.liveInHost ? "not loaded by this host" : c === shadow ? "loaded by this host — WINS" : "loaded by this host — loses to the plugin command"; lines.push(` - ${c.path} — ${c.owner} (${note})`); } lines.push("Delete the stale copies so the packaged procedure is the only one that can run."); if (opts.store) { lines.push( opts.store.declaredBy ? `State dir: ${opts.store.resolved} (declared by ${opts.store.declaredBy}).` : `State dir: ${opts.store.resolved} — NOTHING DECLARES IT, this is a fallback. It may be another profile's LIVE queue; declare "stateDir" in this profile's autopilot.host.json before any write.`, ); } return { competing, winner, message: lines.join("\n") }; }