/** * pi-cmd-expand * * Expand inline command and file-reference syntax in prompts and * project context files (AGENTS.md / CLAUDE.md) before the agent * sees them. Two pairs of forms are supported, all Claude * Code-compatible: * * !`ls -la` -- inline shell command * !```sh -- fenced shell command (multi-line) * ls -la * ``` * @`path/to/file` -- inline file reference (one file) * @``` -- fenced file reference (one or more files) * path/to/file_1 * path/to/file_2 * ``` * * Both commands and file references resolve against the agent's * current working directory (ctx.cwd), never against the directory * of the prompt or context file. * * Every match is wrapped in a semantic tag so the LLM can tell what * was generated and where it came from: * * !`ls -la` → AUTHORS\nLICENSE\nREADME.md * !```sh\nls -la\n``` → … * @`./README.md` → … * @```\na.md\nb.md\n``` → …\n\n… * * Tag attributes carry provenance and outcome metadata * (`source`, `lang`, `path`, `status`, `exit-code`, `error`, * `inline-size`, `total-size`). Single-line content uses inline * tag wrapping; multi-line content gets the open/close tags on * their own lines for readability. * * - For user input: hooks the `message_end` event and transforms the * text in place. Works for both directly-typed `!`cmd`` / `` @`path` `` * and for the same syntax that lives inside a prompt template / skill * body (the body lands in the user message *after* template expansion, * so a pre-expansion `input` hook never sees it). * * Expansion runs once per unique file content (cached by path + mtime) * so repeat turns do not re-execute the same shell commands or * re-read the same referenced files. * * Command execution is delegated to pi's built-in * `createLocalBashOperations()`, so we inherit the same shell * resolution (Windows Git Bash / WSL / Unix bash / sh), output * sanitization (ANSI strip, control-char filter, CR normalization), * process-tree kill on timeout, and detached-child tracking that the * rest of pi uses. This keeps behaviour aligned with pi's built-in * `bash` tool. * * File reading uses plain `fs.readFile(path, "utf-8")`. A loaded file * is recursively expanded the same way the top-level text is: any * `` !`cmd` `` / `!```sh\ncmd\n``` ` / `` @`path` `` / `@```\npaths\n``` ` * inside the loaded content is also resolved, up to a recursion-depth * cap (10). Cycles are short-circuited with a per-chain visited set. * * Truncation is also delegated to pi's `truncateTail`: when a command's * output or a file's expanded content exceeds its cap, we keep the * last N bytes and persist the full content to a temp file. The temp * file path is surfaced inline (so the LLM can read it) inside the * `` / `` wrap. */ import { randomBytes } from "node:crypto"; import { existsSync, promises as fs, readFileSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { CONFIG_DIR_NAME, createLocalBashOperations, type ExtensionAPI, formatSize, getAgentDir, truncateTail, } from "@earendil-works/pi-coding-agent"; const DEFAULT_TIMEOUT_MS = 5_000; /** Soft cap on inline command output. Beyond this we tail-truncate and * persist the full output to a temp file. */ const DEFAULT_MAX_OUTPUT_BYTES = 2_000; /** Soft cap on inline file content. Same truncation policy as * commands; full content is persisted to a temp file above this. * Defaults larger than commands because source files tend to be * larger than typical command output. */ const DEFAULT_MAX_FILE_BYTES = 10_000; /** Hard cap on how deeply `@` references may nest. With this cap, * even a chain of `a → b → c → …` references has a bounded worst * case; the visited-set additionally short-circuits actual cycles * long before the cap is reached. */ const MAX_RECURSION_DEPTH = 10; /** Maximum characters in the `source` attribute of a `` tag. * Long commands are truncated to `first 17 chars + "..."` so the * attribute stays at or below this limit. Only the first line of * a fenced body is used; the rest of the body is still executed. */ const SOURCE_MAX_CHARS = 20; /** File name used for both global and project-local config files. * Global: `${getAgentDir()}/${CONFIG_FILE_NAME}` * Project: `${cwd}/${CONFIG_DIR_NAME}/${CONFIG_FILE_NAME}` * Project entries override global entries on a per-key basis * (shallow merge). */ const CONFIG_FILE_NAME = "pi-cmd-expand.json"; /** Resolved runtime configuration. The two `enable*` toggles control * whether `` and `` expansion runs at all; turning them * off leaves the original `!`...`` / `` @`...` `` syntax in the * text the LLM sees, untouched. Defaults preserve the historical * behaviour (everything expanded). */ interface PiCmdExpandConfig { enableCmd: boolean; enableFile: boolean; } const DEFAULT_CONFIG: PiCmdExpandConfig = { enableCmd: true, enableFile: true, }; /** Module-level cache of the most recently loaded config. The * `session_start` handler refreshes it from disk; the expansion * functions read from it directly. Treated as immutable between * `session_start` calls — mid-session edits to the config file * take effect after the next `/reload`. */ let currentConfig: PiCmdExpandConfig = { ...DEFAULT_CONFIG }; /** Type-check a raw `unknown` config object and return only the * recognised keys with valid types / values. Unknown keys are * silently ignored (lenient schema). Numeric / boolean coercion * errors do not throw — invalid entries just don't make it into * the output, so the caller sees a clean partial override that * defaults can fill the gaps in. */ function parseConfig(raw: unknown): Partial { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return {}; } const obj = raw as Record; const out: Partial = {}; if (typeof obj.enableCmd === "boolean") out.enableCmd = obj.enableCmd; if (typeof obj.enableFile === "boolean") out.enableFile = obj.enableFile; return out; } /** Load the effective config for `cwd`. Reads the global file at * `${getAgentDir()}/${CONFIG_FILE_NAME}` first, then merges in * the project file at `${cwd}/${CONFIG_DIR_NAME}/${CONFIG_FILE_NAME}` * (project wins on a per-key basis). Missing files are no-ops. * Parse / IO failures are logged but never throw — a broken * config falls back to defaults instead of aborting the session. */ function loadConfigFromDisk(cwd: string): PiCmdExpandConfig { let merged: Partial = {}; const globalPath = join(getAgentDir(), CONFIG_FILE_NAME); if (existsSync(globalPath)) { try { const raw = JSON.parse(readFileSync(globalPath, "utf-8")); merged = { ...merged, ...parseConfig(raw) }; } catch (err) { console.error( `[pi-cmd-expand] failed to load global config at ${globalPath}: ${err}`, ); } } const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME); if (existsSync(projectPath)) { try { const raw = JSON.parse(readFileSync(projectPath, "utf-8")); merged = { ...merged, ...parseConfig(raw) }; } catch (err) { console.error( `[pi-cmd-expand] failed to load project config at ${projectPath}: ${err}`, ); } } return { ...DEFAULT_CONFIG, ...merged }; } const bashOps = createLocalBashOperations(); interface CommandMatch { start: number; end: number; kind: "command"; command: string; /** Language tag from the fenced form (`!```sh\n...\n``` `). Empty * string for inline commands (`!`cmd``). */ lang: string; } interface FileMatch { start: number; end: number; kind: "file"; /** One entry for inline, one-or-more for fenced. Blank lines * inside fenced bodies are skipped. */ paths: string[]; /** True for `@```...``` `, false for `` @`path` ``. Currently only * controls output labelling, but the inline/fenced distinction * may grow more interesting in the future. */ fenced: boolean; } type Match = CommandMatch | FileMatch; /** What a single `!cmd` invocation produces. Captured separately from * the wrapper so the splice loop can decide on attributes (status, * exit-code, truncation sizes) before emitting the final tag. */ interface CommandOutcome { /** Body to splice in. For failed commands, includes the * `[command failed: …]` marker; for truncated commands, includes * the `[Output truncated … Full output: ]` footer. */ output: string; /** Process exit code. `null` means execution itself failed * (timeout, cwd missing, etc.); `0` is success; non-zero is a * failing exit. */ exitCode: number | null; /** Whether the output was tail-truncated. */ truncated: boolean; totalBytes?: number; inlineBytes?: number; } /** What loading a single file path produces. */ interface FileOutcome { /** Body to splice in. For failed reads, the `[file failed: …]` * marker; for circular references, `[file skipped: circular * reference: …]`; for truncated files, the truncated content * + `[Output truncated …]` footer. */ output: string; /** Outcome classification. */ status: "ok" | "failed" | "circular" | "truncated"; /** Only present when `status === "failed"`. */ error?: string; /** Only present when `status === "truncated"`. */ totalBytes?: number; inlineBytes?: number; } /** Find all `!`cmd`` / `!```sh\ncmd\n``` ` / `` @`path` `` / * `@```\npaths\n``` ` occurrences in `text`. Within each character * class (`!` or `@`), fenced takes priority over inline; the two * character classes do not interfere with each other (an inline * `!` inside a fenced `@` block, or vice-versa, is just text). */ function findAllMatches(text: string): Match[] { const matches: Match[] = []; /** Fenced ranges of either class. Inline matches (of either class) * inside any fenced range are skipped — the fenced body is opaque * to further inline expansion. */ const fencedRanges: Array<[number, number]> = []; // Fenced command: !```lang\ncmd\n``` (lang is optional) const cmdFencedRe = /!```([a-zA-Z0-9_+-]*)\n([\s\S]*?)\n```/g; for (const m of text.matchAll(cmdFencedRe)) { const start = m.index ?? 0; const end = start + m[0].length; matches.push({ start, end, kind: "command", command: m[2], lang: m[1], }); fencedRanges.push([start, end]); } // Fenced file reference: @```lang\npaths\n``` const fileFencedRe = /@```([a-zA-Z0-9_+-]*)\n([\s\S]*?)\n```/g; for (const m of text.matchAll(fileFencedRe)) { const start = m.index ?? 0; const end = start + m[0].length; matches.push({ start, end, kind: "file", paths: m[2].split("\n"), fenced: true, }); fencedRanges.push([start, end]); } // Inline command: !`cmd` const cmdInlineRe = /!`([^`\n]+)`/g; for (const m of text.matchAll(cmdInlineRe)) { const start = m.index ?? 0; const end = start + m[0].length; if (fencedRanges.some(([s, e]) => start >= s && end <= e)) continue; matches.push({ start, end, kind: "command", command: m[1], lang: "" }); } // Inline file reference: @`path` (no newlines, no nested backticks). // The `(? start >= s && end <= e)) continue; matches.push({ start, end, kind: "file", paths: [m[1]], fenced: false }); } matches.sort((a, b) => a.start - b.start); return matches; } /** * Map an error thrown by pi's `bashOps.exec` to the same textual * detail the previous `child_process.exec` wrapper produced. Keeps * downstream `[command failed: ...]` lines byte-compatible with the * pre-refactor output. */ function describeExecError(err: unknown, timeoutMs: number): string { if (!(err instanceof Error)) return String(err) || "unknown error"; // `bashOps.exec` throws `Error("timeout:")` when the // configured timeout elapses. Surface that in milliseconds so the // caller sees the same unit it configured. if (err.message.startsWith("timeout:")) { const secs = Number(err.message.slice("timeout:".length)); if (Number.isFinite(secs)) return `timed out after ${Math.round(secs * 1000)}ms`; return `timed out after ${timeoutMs}ms`; } // `bashOps.exec` also throws when the working directory does not // exist. Preserve the original message verbatim. if (err.message.startsWith("Working directory does not exist:")) { return err.message; } return err.message || "unknown error"; } /** Format a Node-style FS error as a one-line `[file failed: …]` body. */ function describeFileError(err: unknown): string { if (!(err instanceof Error)) return String(err) || "unknown error"; const code = (err as NodeJS.ErrnoException).code; if (typeof code === "string" && code) { return `${code}: ${err.message}`; } return err.message || "unknown error"; } /** Persist `fullOutput` to a temp file and return its absolute path. */ async function writeFullOutput(fullOutput: string): Promise { const path = join( tmpdir(), `pi-cmd-expand-${randomBytes(8).toString("hex")}.log`, ); await writeFile(path, fullOutput, "utf-8"); return path; } /** Choose single vs double quotes for an attribute value, preferring * double quotes and falling back to single quotes only if the value * contains a `"`. (Both quotes in one value would still break, but * that combination is exotic enough to ignore.) */ function quoteAttr(value: string): string { return value.includes('"') ? `'${value}'` : `"${value}"`; } /** Truncate the `source` attribute for `` tags so it stays at or * below `SOURCE_MAX_CHARS`. For multi-line bodies (fenced commands), * only the first line is used as the source — the full body is still * executed, the source attribute is just a provenance hint. */ function truncateSource(source: string): string { const firstLine = source.split("\n")[0]; if (firstLine.length <= SOURCE_MAX_CHARS) return firstLine; return firstLine.slice(0, SOURCE_MAX_CHARS - 3) + "..."; } /** * Wrap `content` with `` / ``. Single-line * content (after stripping a trailing newline) is wrapped inline — * tags sit on the same line as the content. Multi-line content is * wrapped across three lines: open tag, content, close tag. The * trailing newline is always stripped from the body so we don't * produce a stray `\n` right before the close tag. */ function wrapWithTag(tagName: string, attrs: string, content: string): string { const body = content.replace(/\n$/, ""); const lineCount = body === "" ? 0 : body.split("\n").length; if (lineCount <= 1) { return `<${tagName}${attrs}>${body}`; } return `<${tagName}${attrs}>\n${body}\n`; } /** Build the `` wrapper for a `CommandOutcome`. */ function formatCmdWrap( outcome: CommandOutcome, command: string, lang: string, ): string { const attrs: string[] = []; attrs.push(` source=${quoteAttr(truncateSource(command))}`); if (lang) attrs.push(` lang=${quoteAttr(lang)}`); if (outcome.exitCode !== null && outcome.exitCode !== 0) { attrs.push( ` status="failed" exit-code=${quoteAttr(String(outcome.exitCode))}`, ); } else if (outcome.truncated) { attrs.push( ` status="truncated" inline-size=${quoteAttr(String(outcome.inlineBytes))} total-size=${quoteAttr(String(outcome.totalBytes))}`, ); } return wrapWithTag("cmd", attrs.join(""), outcome.output); } /** Build the `` wrapper for a `FileOutcome`. */ function formatFileWrap(outcome: FileOutcome, path: string): string { const attrs: string[] = []; attrs.push(` path=${quoteAttr(path)}`); if (outcome.status === "failed") { attrs.push(` status="failed" error=${quoteAttr(outcome.error ?? "")}`); } else if (outcome.status === "circular") { attrs.push(` status="circular"`); } else if (outcome.status === "truncated") { attrs.push( ` status="truncated" inline-size=${quoteAttr(String(outcome.inlineBytes))} total-size=${quoteAttr(String(outcome.totalBytes))}`, ); } return wrapWithTag("file", attrs.join(""), outcome.output); } /** * Execute `command` in `cwd` and return a `CommandOutcome` with the * combined stdout/stderr (trailing newline stripped), an `exitCode`, * and any truncation metadata. Never throws — execution failures * are captured as `exitCode: null` with an inline `[command failed: …]` * marker in `output`. * * Streams data through pi's `bashOps.exec` (which is the same backend * pi's built-in `bash` tool uses) so we get cross-platform shell * resolution, ANSI/binary sanitization, and process-tree cleanup for * free. */ async function runCommand( command: string, cwd: string, timeoutMs: number, ): Promise { const chunks: Buffer[] = []; let exitCode: number | null = 0; // `bashOps.exec` accepts `timeout` in seconds and refuses values // <= 0. We always have a positive `timeoutMs` from `DEFAULT_TIMEOUT_MS`, // so a ceiling division by 1000 is safe. const timeoutSecs = Math.max(1, Math.ceil(timeoutMs / 1000)); try { const result = await bashOps.exec(command, cwd, { onData: (data) => chunks.push(data), timeout: timeoutSecs, }); exitCode = result.exitCode; } catch (err) { const partial = Buffer.concat(chunks).toString("utf-8").replace(/\n$/, ""); const parts: string[] = []; if (partial) parts.push(partial); parts.push(`[command failed: ${describeExecError(err, timeoutMs)}]`); // Failures get inlined verbatim — they're already short and // truncation would only obscure the diagnostic. return { output: parts.join("\n"), exitCode: null, truncated: false }; } const raw = Buffer.concat(chunks).toString("utf-8").replace(/\n$/, ""); // Non-zero exit: surface the output plus a failure note inline. // No truncation — the failure detail (incl. partial stdout/stderr) // is what the LLM needs to diagnose the problem. if (exitCode !== null && exitCode !== 0) { const parts: string[] = []; if (raw) parts.push(raw); parts.push(`[command failed: exit code ${exitCode}]`); return { output: parts.join("\n"), exitCode, truncated: false }; } // Success path: tail-truncate if the output is over the soft cap. const truncation = truncateTail(raw, { maxBytes: DEFAULT_MAX_OUTPUT_BYTES }); if (!truncation.truncated) { return { output: truncation.content, exitCode: 0, truncated: false, }; } const fullOutputPath = await writeFullOutput(raw); const footer = `\n\n[Output truncated to last ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full output: ${fullOutputPath}]`; return { output: truncation.content + footer, exitCode: 0, truncated: true, inlineBytes: truncation.outputBytes, totalBytes: truncation.totalBytes, }; } /** Resolve `relPath` against `cwd`; absolute paths are used verbatim. */ function resolveFilePath(relPath: string, cwd: string): string { return isAbsolute(relPath) ? relPath : resolve(cwd, relPath); } /** * Read `relPath` (resolved against `cwd`) and return a `FileOutcome` * with the raw UTF-8 content on success, or a `[file failed: …]` * marker on error (never throws). The caller checks `status` to * decide whether to recurse into the content for further `@`/`!` * expansion. */ async function loadFileRaw(relPath: string, cwd: string): Promise { const absPath = resolveFilePath(relPath, cwd); try { const content = await fs.readFile(absPath, "utf-8"); return { output: content, status: "ok" }; } catch (err) { const errorMsg = describeFileError(err); return { output: `[file failed: ${errorMsg}]`, status: "failed", error: errorMsg, }; } } /** * Load each path in a `FileMatch`, recursively expand its content * (so nested `!`/`@` patterns inside the loaded file are resolved * and themselves wrapped in their own `` / `` tags), * apply the per-file truncation cap, and format each path into a * `` wrapper. * * Reads in parallel (`Promise.all`) since file I/O is independent. * Each parallel task uses its own copy of `visited` so a file shared * across tasks loads once per task — preventing races on the shared * set while still giving us cycle detection inside a single chain. */ async function expandFileReferences( match: FileMatch, cwd: string, timeoutMs: number, maxFileBytes: number, depth: number, visited: Set, ): Promise { const tasks = match.paths.map(async (rawPath) => { const p = rawPath.trim(); if (!p) return null; const absPath = resolveFilePath(p, cwd); if (visited.has(absPath)) { // Circular reference: emit a clear inline marker instead of // recursing (which would loop until the depth cap kills it). const outcome: FileOutcome = { output: `[file skipped: circular reference: ${p}]`, status: "circular", }; return formatFileWrap(outcome, p); } const rawOutcome = await loadFileRaw(p, cwd); let outcome = rawOutcome; // Only recurse into successfully-read files. An error or // circular outcome must not be parsed for further `@` refs. if (outcome.status === "ok") { const newVisited = new Set(visited); newVisited.add(absPath); const expanded = await expandText( outcome.output, cwd, timeoutMs, maxFileBytes, depth + 1, newVisited, ); const truncation = truncateTail(expanded, { maxBytes: maxFileBytes }); if (!truncation.truncated) { outcome = { output: truncation.content, status: "ok" }; } else { const fullOutputPath = await writeFullOutput(expanded); const footer = `\n\n[Output truncated to last ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full output: ${fullOutputPath}]`; outcome = { output: truncation.content + footer, status: "truncated", inlineBytes: truncation.outputBytes, totalBytes: truncation.totalBytes, }; } } return formatFileWrap(outcome, p); }); const blocks = (await Promise.all(tasks)).filter( (b): b is string => b !== null, ); return blocks.join("\n\n"); } /** * Replace every `!`cmd`` / `!```sh\ncmd\n``` ` / `` @`path` `` / * `@```\npaths\n``` ` occurrence in `text` with the corresponding * `` / `` wrap. Walks matches from the end so earlier * byte offsets stay valid as we splice. Recurses into loaded file * content (via the `depth` / `visited` parameters) so `@` references * can chain. */ async function expandText( text: string, cwd: string, timeoutMs: number, maxFileBytes: number, depth = 0, visited: Set = new Set(), ): Promise { // Hard cap on how deep `@` expansion may nest. Beyond this, the // text is returned with whatever `@` references it still contains // unexpanded — the LLM sees the unresolved syntax and can decide // what to do (e.g., use `read`). if (depth >= MAX_RECURSION_DEPTH) return text; // Per-kind gating from config: when `enableCmd` is `false`, // `` !`cmd` `` / `!```sh` blocks are left as literal text; same // for `enableFile` and `` @`path` ``. The filter applies at every // recursion level, so a disabled form stays disabled even inside // a loaded file. const allMatches = findAllMatches(text); const matches = allMatches.filter((m) => { if (m.kind === "command" && !currentConfig.enableCmd) return false; if (m.kind === "file" && !currentConfig.enableFile) return false; return true; }); if (matches.length === 0) return text; let out = text; for (let i = matches.length - 1; i >= 0; i--) { const m = matches[i]; let replacement: string; if (m.kind === "command") { const outcome = await runCommand(m.command, cwd, timeoutMs); replacement = formatCmdWrap(outcome, m.command, m.lang); } else { replacement = await expandFileReferences( m, cwd, timeoutMs, maxFileBytes, depth, visited, ); } out = out.slice(0, m.start) + replacement + out.slice(m.end); } return out; } /** Per-file expansion cache keyed by absolute path. */ const expansionCache = new Map< string, { mtimeMs: number; size: number; expanded: string } >(); async function expandFile( filePath: string, original: string, cwd: string, timeoutMs: number, maxFileBytes: number, ): Promise { let mtimeMs = 0; let size = original.length; try { const stat = await fs.stat(filePath); mtimeMs = stat.mtimeMs; size = stat.size; } catch { // File may have been deleted between discovery and expansion. // Fall through with a synthetic key so we still cache the result // for the duration of this session. } const cached = expansionCache.get(filePath); if (cached && cached.mtimeMs === mtimeMs && cached.size === size) { return cached.expanded; } const expanded = await expandText(original, cwd, timeoutMs, maxFileBytes); expansionCache.set(filePath, { mtimeMs, size, expanded }); return expanded; } /** Escape a literal string for embedding into a RegExp. */ function reEscape(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Extract the concatenated text from a message's `content` field. * The `message_end` event always passes content as a `ContentBlock[]` * (see agent-session.js:865 where the user message is built), but we * also accept a bare string in case a future pi version passes one. * Mirrors `contentText` from `@earendil-works/pi-ai` but inlined to * avoid adding a transitive dep just for one 4-line helper. */ function extractUserText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter( (block): block is { type: string; text?: string } => typeof block === "object" && block !== null && (block as { type?: unknown }).type === "text", ) .map((block) => block.text ?? "") .join("\n"); } /** * Substitute the expanded content of a context file into the system prompt. * The project context is rendered as: * \n{content}\n * so we locate that exact block and swap its body. */ function substituteInSystemPrompt( systemPrompt: string, filePath: string, originalContent: string, expandedContent: string, ): string { const pathEsc = reEscape(filePath); const re = new RegExp( `(]*>\\n)([\\s\\S]*?)(\\n)`, ); return systemPrompt.replace(re, (_match, head, _body, tail) => { // Only replace if the body still matches what we expect. If a previous // hook already mutated it, leave it alone. return _body === originalContent ? head + expandedContent + tail : _match; }); } export default function cmdExpandExtension(pi: ExtensionAPI) { const timeoutMs = DEFAULT_TIMEOUT_MS; const maxFileBytes = DEFAULT_MAX_FILE_BYTES; // We deliberately do NOT register a `user_bash` handler. When the // user types `!cmd` at the start of a line, pi's interactive mode // intercepts it as a raw bash invocation and runs it natively: // - `!ls` → pi runs `ls` in bash, shows the listing. // - `` !`ls` `` → pi runs `` `ls` `` in bash, which bash itself // expands via command substitution, so the user // still sees the same listing. // Hooking `user_bash` here would double-handle the command and // break pi's own `!!` (excludeFromContext) / cancellation / live // streaming semantics. So we let pi own this path. // --- 0) Config load --------------------------------------------- // Refresh `currentConfig` from disk on every session start. This // fires once at startup and again on `/reload`, so users who edit // `pi-cmd-expand.json` between sessions (or while editing during // a session and then `/reload`-ing) see their toggles take effect. // Mid-session edits without `/reload` do NOT take effect until the // next session — same constraint as preset.ts applies to its own // config file. pi.on("session_start", async (_event, ctx) => { currentConfig = loadConfigFromDisk(ctx.cwd); }); // --- 1) `!cmd` / `@path` in a typed message OR inside a prompt // template / skill body ------------------------------------ // The `input` event fires before skill/template expansion, so it only // ever sees the raw `/template-name` text for template-sourced input // (no `!` or `@` in it, can't help). The `message_end` event fires // after template expansion with the final user message, so it sees // the `!`...`` / `` @`path` `` whether the user typed it directly or // it came from a template body, and rewrites the user message in // place before the LLM call. // // TUI trade-off: pi renders the user message into the chat history // in `message_start` and does not re-render on `message_end` for the // user role, so the on-screen text in the chat will show the raw // `!`...`` / `` @`path` `` form even though the LLM sees the // expanded output. That is a pi design gap, not something this // extension can paper over. pi.on("message_end", async (event, ctx) => { if (event.message.role !== "user") return; const text = extractUserText(event.message.content); if (!text) return; // Fast path: skip expansion when no enabled trigger character // is present. `findAllMatches` would return the same `[]` and // `expandText` would short-circuit anyway, but checking here // avoids the per-message function-call overhead for plain prose. const wantBang = currentConfig.enableCmd && text.includes("!"); const wantAt = currentConfig.enableFile && text.includes("@"); if (!wantBang && !wantAt) return; const expanded = await expandText(text, ctx.cwd, timeoutMs, maxFileBytes); if (expanded === text) return; return { message: { ...event.message, content: [{ type: "text", text: expanded }], }, }; }); // --- 2) Project context files (AGENTS.md / CLAUDE.md) ---------------- // `before_agent_start` is the ONLY hook in pi that exposes the // fully-assembled system prompt AND the structured `contextFiles` // list (with `path` and `content`). No other event carries either: // `agent_start` / `agent_end` / `context` / `message_end` / // `turn_end` are all message- or lifecycle-scoped, not // system-prompt-scoped. So if you want `!`cmd`` / `` @`path` `` to // expand inside `AGENTS.md` / `CLAUDE.md`, this hook is not optional. pi.on("before_agent_start", async (event, ctx) => { const contextFiles = event.systemPromptOptions?.contextFiles; if (!contextFiles || contextFiles.length === 0) return; let systemPrompt = event.systemPrompt; let changed = false; for (const file of contextFiles) { // Same fast-path optimization as the `message_end` handler: // skip files whose content has no enabled trigger character. const wantBang = currentConfig.enableCmd && file.content.includes("!"); const wantAt = currentConfig.enableFile && file.content.includes("@"); if (!wantBang && !wantAt) continue; const expanded = await expandFile( file.path, file.content, ctx.cwd, timeoutMs, maxFileBytes, ); if (expanded === file.content) continue; const next = substituteInSystemPrompt( systemPrompt, file.path, file.content, expanded, ); if (next !== systemPrompt) { systemPrompt = next; changed = true; } } if (changed) { return { systemPrompt }; } }); }