Files
ECC/.pi/extensions/index.ts
T
haelyraandDavidHLP f2bcc00d69 fix(pi): prevent recursive compiled OMP hook execution
Forward-port #2911 for #2909 and exercise the real adapter lifecycle with a recorded process boundary, including unavailable Node and invalid overrides.

Co-authored-by: DavidHLP <lysf15520112973@163.com>
2026-09-07 16:26:10 -04:00

662 lines
20 KiB
TypeScript

/**
* ECC adapter for the Pi coding agent.
*
* This is the ONLY adapter logic ECC ships for Pi. ECC's canonical assets stay
* the single source of truth: `skills/` and `commands/` are mounted directly by
* the `pi` manifest in the repo's root `package.json`. Nothing is copied or
* generated under `.pi/`.
*
* What this file adapts:
* - Pi lifecycle events -> ECC's existing hook runner (`run-with-flags.js`),
* so ECC hook profiles and disable flags keep working under Pi.
* - ECC's SessionStart `additionalContext` payload -> Pi's system prompt.
* - A `/ecc-doctor` command for install diagnostics.
*
* Design constraints (see .pi/README.md):
* - Hooks resolve relative to THIS file, never `process.cwd()`, so a global
* `pi install` works from any project directory.
* - Hooks execute via `execFile(hookRuntime, [...])` with no shell, so paths
* containing spaces or shell metacharacters are safe. The hook runtime is
* selected separately because compiled OMP may report `process.release.name`
* as `node` while `process.execPath` points back to `omp`; Bun is detected
* separately via `process.versions.bun`.
* - Hook failures are isolated: a broken, missing, slow, or misconfigured hook
* degrades to a warning and never terminates the Pi session.
*/
import { execFile } from "node:child_process"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { resolveHookRuntime } from "./hook-runtime.js"
/**
* Minimal structural types mirroring `@earendil-works/pi-coding-agent`.
*
* Declared locally on purpose: Pi loads extensions through jiti, which strips
* types without type-checking, so importing the package would add a dependency
* and a lockfile entry that buy nothing at runtime. Field names and signatures
* match the upstream `ExtensionAPI` / `ExtensionContext` declarations; install
* the package as a devDependency if you want editor-level checking.
*/
interface PiUiContext {
notify(message: string, type?: "info" | "warning" | "error"): void
}
interface PiSessionManager {
getSessionId(): string
getSessionFile(): string | undefined
}
interface ExtensionContext {
ui: PiUiContext
cwd: string
sessionManager: PiSessionManager
}
interface SessionStartEvent {
reason: "startup" | "reload" | "new" | "resume" | "fork"
}
interface SessionShutdownEvent {
reason: "quit" | "reload" | "new" | "resume" | "fork"
}
interface BeforeAgentStartEvent {
systemPrompt: string
}
interface BeforeAgentStartResult {
systemPrompt?: string
}
interface ExtensionAPI {
on(
event: "session_start",
handler: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void> | void
): void
on(
event: "session_shutdown",
handler: (event: SessionShutdownEvent, ctx: ExtensionContext) => Promise<void> | void
): void
on(
event: "before_agent_start",
handler: (
event: BeforeAgentStartEvent,
ctx: ExtensionContext
) => Promise<BeforeAgentStartResult | void> | BeforeAgentStartResult | void
): void
registerCommand(
name: string,
options: {
description?: string
handler: (args: string, ctx: ExtensionContext) => Promise<void>
}
): void
sendMessage(
message: { customType: string; content: string; display: boolean; details?: unknown },
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }
): void
}
/**
* ECC package root. This file lives at `<root>/.pi/extensions/index.ts`, so the
* root is two levels up. Pi loads extensions via jiti in CommonJS mode, which
* is why `__dirname` is the correct primitive here rather than
* `import.meta.url` (verified against Pi 0.84.1).
*/
const ECC_ROOT = path.resolve(__dirname, "..", "..")
/** ECC's universal hook runner. It applies hook-profile and disable flags. */
const HOOK_RUNNER = path.join(ECC_ROOT, "scripts", "hooks", "run-with-flags.js")
const HOOK_TIMEOUT_MS = 30_000
const MAX_HOOK_OUTPUT_BYTES = 1024 * 1024
/**
* ECC rules injected into Pi's system prompt, read from the canonical
* `rules/common/` directory at runtime. Nothing is copied or generated.
*
* Excluded on purpose: `agents.md`, `hooks.md`, and `performance.md`. Those
* describe Claude Code primitives Pi does not have (Task/TodoWrite delegation,
* Claude hook event types, thinking-budget toggles), so injecting them would
* instruct the model to use tools that are not there.
*/
const PORTABLE_RULE_FILES = [
"coding-style.md",
"testing.md",
"security.md",
"git-workflow.md",
"patterns.md",
"development-workflow.md",
"code-review.md",
] as const
/** Upper bound on injected rule text, so a large edit cannot flood the prompt. */
const MAX_RULES_BYTES = 32 * 1024
/** Values ECC treats as "off" across its existing environment switches. */
const DISABLED_VALUES = new Set(["0", "false", "off", "none", "disabled"])
/**
* Optional Pi companion packages. ECC works without every one of these; they
* are reported by `/ecc-doctor` so users can see which extras are available.
*/
const COMPANION_PACKAGES = [
"pi-subagents",
"@juicesharp/rpiv-ask-user-question",
"@juicesharp/rpiv-todo",
] as const
interface HookSpec {
/** ECC hook id, used for profile gating and disable flags. */
id: string
/** Hook script path relative to the ECC package root. */
script: string
/** Hook profiles the hook participates in. */
profiles: string
}
/** Mirrors the SessionStart wiring in `hooks/hooks.json`. */
const SESSION_START_HOOK: HookSpec = {
id: "session:start",
script: "scripts/hooks/session-start.js",
profiles: "minimal,standard,strict",
}
/** Mirrors the SessionEnd wiring in `hooks/hooks.json`. */
const SESSION_END_HOOK: HookSpec = {
id: "session:end:marker",
script: "scripts/hooks/session-end-marker.js",
profiles: "minimal,standard,strict",
}
interface HookResult {
stdout: string
failure?: string
}
/**
* Run an ECC hook through ECC's own runner.
*
* Never rejects: an invalid runtime override, a missing runner, a non-zero exit,
* a timeout, or a spawn error all resolve to a `failure` string that the caller
* surfaces as a warning.
*/
function runEccHook(
spec: HookSpec,
payload: unknown,
env: NodeJS.ProcessEnv,
cwd: string
): Promise<HookResult> {
return new Promise(resolve => {
if (!fs.existsSync(HOOK_RUNNER)) {
resolve({ stdout: "", failure: `hook runner not found at ${HOOK_RUNNER}` })
return
}
let hookRuntime: string
try {
hookRuntime = resolveHookRuntime()
} catch (error) {
resolve({
stdout: "",
failure: `${spec.id}: ${(error as Error).message}`,
})
return
}
const child = execFile(
hookRuntime,
[HOOK_RUNNER, spec.id, spec.script, spec.profiles],
{
// Hooks inspect the user's project, so they run there. Only the script
// path is package-relative, and the runner resolves that from
// CLAUDE_PLUGIN_ROOT rather than from the working directory.
cwd,
env,
timeout: HOOK_TIMEOUT_MS,
maxBuffer: MAX_HOOK_OUTPUT_BYTES,
encoding: "utf8",
},
(error, stdout) => {
const text = typeof stdout === "string" ? stdout : ""
if (error) {
resolve({ stdout: text, failure: `${spec.id}: ${error.message}` })
return
}
resolve({ stdout: text })
}
)
child.on("error", error => {
resolve({ stdout: "", failure: `${spec.id}: ${error.message}` })
})
// stdin.end() writes asynchronously. A hook that exits, short-circuits, or
// is killed by the timeout before reading the payload makes the write fail
// with EPIPE, which Node reports as an `error` event rather than a throw.
// Without this listener that event is unhandled and would take the Pi
// session down, breaking the isolation guarantee documented above.
child.stdin?.on("error", error => {
resolve({ stdout: "", failure: `${spec.id}: could not write hook payload (${error.message})` })
})
try {
child.stdin?.end(JSON.stringify(payload))
} catch (error) {
resolve({
stdout: "",
failure: `${spec.id}: could not write hook payload (${(error as Error).message})`,
})
}
})
}
/**
* Working directory for hook execution: the user's project. Falls back to the
* ECC package root if Pi reports a directory that no longer exists, so a stale
* cwd degrades to a working hook rather than a spawn failure.
*/
function resolveHookCwd(ctx: ExtensionContext): string {
try {
if (ctx.cwd && fs.existsSync(ctx.cwd)) {
return ctx.cwd
}
} catch {
// Fall through to the package root.
}
return ECC_ROOT
}
function readSessionId(ctx: ExtensionContext): string | undefined {
try {
return ctx.sessionManager.getSessionId() || undefined
} catch {
return undefined
}
}
/**
* Build the environment ECC hooks expect.
*
* `CLAUDE_PLUGIN_ROOT` / `ECC_PLUGIN_ROOT` are how every ECC hook locates the
* package; setting them from `ECC_ROOT` is what makes a global install resolve
* correctly instead of probing the user's project. The `CLAUDE_*` session vars
* are the names ECC's shared hook scripts already read across harnesses.
*/
function buildHookEnv(ctx: ExtensionContext): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
CLAUDE_PLUGIN_ROOT: ECC_ROOT,
ECC_PLUGIN_ROOT: ECC_ROOT,
CLAUDE_PROJECT_DIR: ctx.cwd,
}
const sessionId = readSessionId(ctx)
if (sessionId) {
env.CLAUDE_SESSION_ID = sessionId
}
return env
}
/**
* Map Pi's session reason onto the `source` values ECC's SessionStart hook
* understands. Pi's `new` and `reload` have no Claude Code equivalent, so they
* report as a fresh startup.
*/
function mapSessionSource(reason: SessionStartEvent["reason"]): string {
switch (reason) {
case "resume":
case "fork":
return "resume"
default:
return "startup"
}
}
/**
* Extract `hookSpecificOutput.additionalContext` from a hook's stdout.
*
* ECC hooks emit a JSON envelope, but the runner passes stdin straight through
* when a hook is disabled by profile, so non-JSON stdout is expected and must
* not be treated as an error.
*/
function extractAdditionalContext(stdout: string): string | undefined {
const trimmed = stdout.trim()
if (!trimmed.startsWith("{")) {
return undefined
}
try {
const parsed = JSON.parse(trimmed) as {
hookSpecificOutput?: { additionalContext?: unknown }
}
const context = parsed.hookSpecificOutput?.additionalContext
return typeof context === "string" && context.trim() ? context : undefined
} catch {
return undefined
}
}
function isDisabledByEnv(value: string | undefined): boolean {
return typeof value === "string" && DISABLED_VALUES.has(value.trim().toLowerCase())
}
/** Memoized so the rule files are read once per session, not once per turn. */
let cachedRules: string | null | undefined
/**
* How many of `PORTABLE_RULE_FILES` actually made it into `cachedRules`.
*
* Kept alongside the cache because `loadPortableRules` silently drops files it
* cannot read, files that are empty, and every file past the size cap — so the
* allowlist length would overstate a partial install in `/ecc-doctor`, which is
* the one place a user looks to find exactly that.
*/
let cachedRuleFileCount = 0
/**
* ECC's portable engineering rules, concatenated from the canonical
* `rules/common/` directory of the installed package.
*
* Returns null when disabled via `ECC_PI_RULES` or when no rule file could be
* read, so a partial install degrades to "no rules" instead of failing.
*/
function loadPortableRules(): string | null {
if (cachedRules !== undefined) {
return cachedRules
}
if (isDisabledByEnv(process.env.ECC_PI_RULES)) {
cachedRules = null
cachedRuleFileCount = 0
return cachedRules
}
const sections: string[] = []
let total = 0
for (const file of PORTABLE_RULE_FILES) {
let text: string
try {
text = fs.readFileSync(path.join(ECC_ROOT, "rules", "common", file), "utf8").trim()
} catch {
continue
}
if (!text) {
continue
}
if (total + text.length > MAX_RULES_BYTES) {
break
}
total += text.length
sections.push(text)
}
cachedRules = sections.length > 0 ? sections.join("\n\n---\n\n") : null
cachedRuleFileCount = sections.length
return cachedRules
}
/**
* Pi's config directory, honoring the documented `PI_CODING_AGENT_DIR` override.
*/
function resolvePiConfigDir(): string {
const override = process.env.PI_CODING_AGENT_DIR
if (override && override.trim()) {
return override.trim()
}
return path.join(os.homedir(), ".pi", "agent")
}
/**
* Package names Pi currently has installed, read from the same `packages`
* lists Pi itself uses: the user config directory plus the project-local
* `.pi/settings.json`.
*
* `require.resolve` cannot answer this. Pi installs packages under its own
* config directory (`<config>/npm`, `<config>/git`), which is not on Node's
* module resolution path from this file, so resolving would report every
* companion as missing no matter what the user has installed.
*/
function listInstalledPiPackages(projectDir: string): Set<string> {
const names = new Set<string>()
const settingsFiles = [
path.join(resolvePiConfigDir(), "settings.json"),
path.join(projectDir, ".pi", "settings.json"),
]
for (const file of settingsFiles) {
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { packages?: unknown }
if (!Array.isArray(parsed.packages)) {
continue
}
for (const entry of parsed.packages) {
const name = normalizePiPackageName(entry)
if (name) {
names.add(name)
}
}
} catch {
// Missing or unreadable settings are simply "nothing installed here".
}
}
return names
}
/**
* Reduce a `packages` entry to a bare package name.
*
* An entry is either the source string itself or an object carrying that
* string under `source` alongside resource filters (`{ source: "npm:x",
* skills: [] }`). Pi accepts both forms, and a filtered package is just as
* installed as a plain one, so both must resolve to the same name.
*
* Sources look like `npm:pi-subagents`, `npm:@scope/name@1.2.3`, a git source,
* or a filesystem path. Only npm sources carry a comparable package name.
*/
function normalizePiPackageName(entry: unknown): string | undefined {
const source = entry && typeof entry === "object" ? (entry as { source?: unknown }).source : entry
if (typeof source !== "string" || !source.startsWith("npm:")) {
return undefined
}
const spec = source.slice("npm:".length)
// Strip a trailing @version without breaking the leading @ of a scoped name.
const versionAt = spec.lastIndexOf("@")
return versionAt > 0 ? spec.slice(0, versionAt) : spec
}
function countDirectories(dir: string): number {
try {
return fs.readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isDirectory()).length
} catch {
return 0
}
}
function countMarkdownFiles(dir: string): number {
try {
return fs.readdirSync(dir).filter(name => name.endsWith(".md")).length
} catch {
return 0
}
}
function readEccVersion(): string {
try {
const manifest = JSON.parse(fs.readFileSync(path.join(ECC_ROOT, "package.json"), "utf8")) as {
version?: string
}
return manifest.version || "unknown"
} catch {
return "unknown"
}
}
function describeRulesStatus(): string {
if (isDisabledByEnv(process.env.ECC_PI_RULES)) {
return "disabled via ECC_PI_RULES"
}
const rules = loadPortableRules()
if (!rules) {
return `NOT FOUND (${path.join(ECC_ROOT, "rules", "common")})`
}
const skipped = PORTABLE_RULE_FILES.length - cachedRuleFileCount
const shortfall = skipped > 0 ? ` (${skipped} unreadable, empty, or past the size cap)` : ""
return `${cachedRuleFileCount}/${PORTABLE_RULE_FILES.length} rule file(s), ${rules.length} chars, from rules/common/${shortfall}`
}
function buildDoctorReport(ctx: ExtensionContext): string {
const skillsDir = path.join(ECC_ROOT, "skills")
const commandsDir = path.join(ECC_ROOT, "commands")
const skillCount = countDirectories(skillsDir)
const commandCount = countMarkdownFiles(commandsDir)
const lines = [
"ECC adapter for Pi",
"",
` ECC version: ${readEccVersion()}`,
` Package root: ${ECC_ROOT}`,
` Project cwd: ${ctx.cwd}`,
"",
"Canonical resources",
` skills/ ${skillCount > 0 ? `${skillCount} skill(s)` : "NOT FOUND"} (${skillsDir})`,
` commands/ ${commandCount > 0 ? `${commandCount} command(s)` : "NOT FOUND"} (${commandsDir})`,
"",
"Engineering rules (injected into the system prompt)",
` ${describeRulesStatus()}`,
"",
"Hook runner",
` ${fs.existsSync(HOOK_RUNNER) ? "found" : "NOT FOUND"} (${HOOK_RUNNER})`,
` profile: ${process.env.ECC_HOOK_PROFILE || "standard (default)"}`,
` disabled: ${process.env.ECC_DISABLED_HOOKS || "none"}`,
"",
"Optional companion packages (from Pi's installed package list)",
]
const installed = listInstalledPiPackages(ctx.cwd)
for (const name of COMPANION_PACKAGES) {
const present = installed.has(name)
lines.push(` ${present ? "installed " : "not installed"} ${name}`)
if (!present) {
lines.push(` install with: pi install npm:${name}`)
}
}
lines.push(
"",
"Companion packages are optional; ECC skills, commands, and session hooks",
"work without them. See .pi/README.md for what each one unlocks.",
"Detection reads Pi's `packages` list, so a companion vendored some other",
"way may work while reporting as not installed."
)
return lines.join("\n")
}
export default function (pi: ExtensionAPI): void {
/**
* ECC's SessionStart hook returns context for the model, but Pi has no
* equivalent of Claude Code's `additionalContext` field. It is held here and
* folded into the system prompt on the next agent start, which is the
* documented Pi injection point that does not fabricate a user turn.
*/
let pendingContext: string | undefined
pi.on("session_start", async (event, ctx) => {
const payload = {
hook_event_name: "SessionStart",
source: mapSessionSource(event.reason),
cwd: ctx.cwd,
session_id: readSessionId(ctx),
}
// Drop any context captured by an earlier session start that has not been
// injected yet. Pi can start a new session (/new, /resume, /fork) before
// `before_agent_start` consumes the previous value, and replaying context
// built for a different session would describe the wrong project state.
pendingContext = undefined
const result = await runEccHook(
SESSION_START_HOOK,
payload,
buildHookEnv(ctx),
resolveHookCwd(ctx)
)
if (result.failure) {
ctx.ui.notify(`ECC session-start hook skipped (${result.failure})`, "warning")
return
}
pendingContext = extractAdditionalContext(result.stdout)
})
pi.on("before_agent_start", event => {
const additions: string[] = []
// Rules describe standing engineering policy, so they are re-applied on
// every turn. The session context is a one-shot handoff and is consumed.
const rules = loadPortableRules()
if (rules) {
additions.push(`<ecc-engineering-rules>\n${rules}\n</ecc-engineering-rules>`)
}
if (pendingContext) {
additions.push(`<ecc-session-context>\n${pendingContext}\n</ecc-session-context>`)
pendingContext = undefined
}
if (additions.length === 0) {
return
}
return { systemPrompt: [event.systemPrompt, ...additions].join("\n\n") }
})
pi.on("session_shutdown", async (event, ctx) => {
const payload = {
hook_event_name: "SessionEnd",
reason: event.reason,
cwd: ctx.cwd,
session_id: readSessionId(ctx),
}
const result = await runEccHook(
SESSION_END_HOOK,
payload,
buildHookEnv(ctx),
resolveHookCwd(ctx)
)
if (result.failure) {
ctx.ui.notify(`ECC session-end hook skipped (${result.failure})`, "warning")
}
})
pi.registerCommand("ecc-doctor", {
description: "Report ECC adapter status: package root, canonical resources, hooks, companions",
handler: async (_args, ctx) => {
pi.sendMessage(
{
customType: "ecc-doctor",
content: buildDoctorReport(ctx),
display: true,
},
{ deliverAs: "nextTurn" }
)
},
})
}