From 6d2e26f1d06073d7c10be9ebeb4fec76d8a530ec Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Tue, 27 Oct 2026 08:28:37 +0100 Subject: [PATCH] chore: enable pi tracing Signed-off-by: Miguel Martinez --- .claude/settings.json | 12 +- .cursor/hooks.json | 10 +- .opencode/plugins/chainloop-trace.ts | 23 ++- .pi/extensions/chainloop-trace.ts | 246 +++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 .pi/extensions/chainloop-trace.ts diff --git a/.claude/settings.json b/.claude/settings.json index ddbdce134..a5c951163 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,7 +13,7 @@ { "hooks": [ { - "command": "chainloop trace hook claude post-tool-use", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook claude post-tool-use'", "type": "command" } ], @@ -24,7 +24,7 @@ { "hooks": [ { - "command": "chainloop trace hook claude post-tool-use", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook claude post-tool-use'", "type": "command" } ], @@ -35,7 +35,7 @@ { "hooks": [ { - "command": "chainloop trace hook claude pre-tool-use", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook claude pre-tool-use'", "type": "command" } ], @@ -46,7 +46,7 @@ { "hooks": [ { - "command": "chainloop trace hook claude session-end", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook claude session-end'", "type": "command" } ] @@ -56,7 +56,7 @@ { "hooks": [ { - "command": "chainloop trace hook claude session-start", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook claude session-start'", "type": "command" } ] @@ -66,7 +66,7 @@ { "hooks": [ { - "command": "chainloop trace hook claude user-prompt-submit", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook claude user-prompt-submit'", "type": "command" } ] diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 7fd0d796a..defd6e51c 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -2,33 +2,33 @@ "hooks": { "afterFileEdit": [ { - "command": "chainloop trace hook cursor after-file-edit", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook cursor after-file-edit'", "timeout": 30 } ], "postToolUse": [ { - "command": "chainloop trace hook cursor post-tool-use", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook cursor post-tool-use'", "matcher": "Shell", "timeout": 30 } ], "preToolUse": [ { - "command": "chainloop trace hook cursor pre-tool-use", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then echo \"{\\\"permission\\\":\\\"allow\\\"}\"; exit 0; fi; exec chainloop trace hook cursor pre-tool-use'", "matcher": "Shell", "timeout": 30 } ], "sessionEnd": [ { - "command": "chainloop trace hook cursor session-end", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook cursor session-end'", "timeout": 30 } ], "sessionStart": [ { - "command": "chainloop trace hook cursor session-start", + "command": "sh -c 'if ! command -v chainloop \u003e/dev/null 2\u003e\u00261; then exit 0; fi; exec chainloop trace hook cursor session-start'", "timeout": 30 } ] diff --git a/.opencode/plugins/chainloop-trace.ts b/.opencode/plugins/chainloop-trace.ts index 71f2a458e..2471f07fb 100644 --- a/.opencode/plugins/chainloop-trace.ts +++ b/.opencode/plugins/chainloop-trace.ts @@ -5,20 +5,29 @@ const commandTools = ["bash","shell"] const skillTool = "skill" // skillDirFrom returns the folder of the skill that a skill tool call loaded: -// the dir field of the result metadata, or else the base directory line of -// the result text. Each OpenCode major gives the result in its own shape, so -// each candidate is tried in turn. +// the dir (1.x) or directory (2) field of the result metadata, or else the +// base directory line of the result text. Each OpenCode major gives the +// result in its own shape, so each candidate is tried in turn. function skillDirFrom(...results: any[]): string { for (const r of results) { - const dir = r?.metadata?.dir - if (typeof dir === "string" && dir) return dir - const text = typeof r === "string" ? r : typeof r?.output === "string" ? r.output : "" - const m = /^Base directory for this skill:\s*(.+)$/m.exec(text) + for (const dir of [r?.metadata?.dir, r?.metadata?.directory]) { + if (typeof dir === "string" && dir) return dir + } + const m = /^Base directory for this skill:\s*(.+)$/m.exec(resultText(r)) if (m) return m[1].trim() } return "" } +// resultText returns the text of a tool result: the result itself, its +// output (1.x), or the text items of its content (2). +function resultText(r: any): string { + if (typeof r === "string") return r + if (typeof r?.output === "string") return r.output + if (Array.isArray(r?.content)) return r.content.map((c: any) => (typeof c?.text === "string" ? c.text : "")).join("\n") + return "" +} + function filePathsFromArgs(args: any): string[] { if (args?.filePath) return [args.filePath] if (args?.path) return [args.path] diff --git a/.pi/extensions/chainloop-trace.ts b/.pi/extensions/chainloop-trace.ts new file mode 100644 index 000000000..d68c59b29 --- /dev/null +++ b/.pi/extensions/chainloop-trace.ts @@ -0,0 +1,246 @@ +// chainloop-trace: managed Pi extension +import { spawn } from "node:child_process" +import { homedir } from "node:os" +import { resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const maxOutputBytes = 64 * 1024 +const hookTimeoutMs = 60 * 1000 +const killGraceMs = 5 * 1000 +const instructionMarker = "chainloop-trace-instruction:" +const reminderMarker = "chainloop-trace-spec-reminder" +const treeMarker = "chainloop-trace-tree-marker" +const fileWritingTools = ["edit","write"] +const commandTools = ["bash"] +const sessionEndReasons = new Set(["quit", "new", "resume", "fork"]) +const unicodeSpaces = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g + +function resolveToolPath(input, cwd) { + let normalized = input.replace(unicodeSpaces, " ") + if (normalized.startsWith("@")) normalized = normalized.slice(1) + if (normalized === "~") return homedir() + if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) { + normalized = resolve(homedir(), normalized.slice(2)) + } + if (/^file:\/\//.test(normalized)) normalized = fileURLToPath(normalized) + return resolve(cwd, normalized) +} + +function sessionID(ctx) { + return ctx.sessionManager.getHeader()?.id ?? ctx.sessionManager.getSessionId() +} + +function hookPayload(ctx, hookEventName) { + const transcriptPath = ctx.sessionManager.getSessionFile() + return { + session_id: sessionID(ctx), + hook_event_name: hookEventName, + cwd: ctx.cwd, + ...(transcriptPath ? { transcript_path: transcriptPath } : {}), + } +} + +function runHook(ctx, event, payload) { + return new Promise((resolveHook) => { + let settled = false + let failed = false + let stdoutBytes = 0 + let stderrBytes = 0 + const stdout = [] + const stderr = [] + let killTimer + let timeoutTimer + let exited = false + let child + + const finish = (response) => { + if (settled) return + settled = true + if (timeoutTimer) clearTimeout(timeoutTimer) + if (killTimer) clearTimeout(killTimer) + resolveHook(response) + } + + const closeStreams = () => { + try { child?.stdin.destroy() } catch {} + try { child?.stdout.destroy() } catch {} + try { child?.stderr.destroy() } catch {} + } + + try { + child = spawn("chainloop", ["trace", "hook", "pi", event], { + cwd: ctx.cwd, + stdio: ["pipe", "pipe", "pipe"], + }) + + const stop = () => { + if (failed) return + failed = true + if (!exited) { + try { child.kill("SIGTERM") } catch {} + } + if (!killTimer) { + killTimer = setTimeout(() => { + if (!exited) { + try { child.kill("SIGKILL") } catch {} + } + closeStreams() + finish() + }, killGraceMs) + } + } + + const capture = (chunks, chunk, current) => { + const remaining = maxOutputBytes - current + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)) + if (chunk.length > remaining) stop() + return Math.min(maxOutputBytes, current + chunk.length) + } + + child.stdout.on("data", (chunk) => { stdoutBytes = capture(stdout, chunk, stdoutBytes) }) + child.stderr.on("data", (chunk) => { stderrBytes = capture(stderr, chunk, stderrBytes) }) + child.on("error", () => { + exited = true + finish() + }) + child.on("exit", () => { exited = true }) + child.on("close", (code) => { + exited = true + if (failed || code !== 0) return finish() + const output = Buffer.concat(stdout).toString("utf8").trim() + if (!output) return finish() + try { + finish(JSON.parse(output)) + } catch { + finish() + } + }) + child.stdin.on("error", () => {}) + child.stdin.end(JSON.stringify(payload)) + timeoutTimer = setTimeout(stop, hookTimeoutMs) + } catch { + finish() + } + }) +} + +function showBanner(ctx, response) { + try { + if (ctx.hasUI && response?.banner) ctx.ui.notify(response.banner, "info") + } catch {} +} + +function showMessage(ctx, response) { + if (!response?.message) return + if (ctx.hasUI) ctx.ui.notify(response.message, "info") + else process.stderr.write(response.message + "\n") +} + +export default function (pi) { + const starts = new Map() + const instructed = new Set() + + const start = async (ctx, hookEventName) => { + const id = sessionID(ctx) + const response = await runHook(ctx, "session-start", hookPayload(ctx, hookEventName)) + if (response?.instruction) starts.set(id, response) + showBanner(ctx, response) + return response + } + + pi.on("session_start", async (event, ctx) => { + try { + await start(ctx, event.type + ":" + event.reason) + } catch {} + }) + + pi.on("session_shutdown", async (event, ctx) => { + if (!sessionEndReasons.has(event.reason)) return + const id = sessionID(ctx) + try { + await runHook(ctx, "session-end", hookPayload(ctx, event.type + ":" + event.reason)) + } catch { + } finally { + starts.delete(id) + instructed.delete(id) + } + }) + + pi.on("session_tree", (event, _ctx) => { + try { + pi.appendEntry(treeMarker, { selected: event.newLeafId }) + } catch {} + }) + + pi.on("before_agent_start", async (event, ctx) => { + try { + const id = sessionID(ctx) + const marker = instructionMarker + id + const persisted = ctx.sessionManager.getEntries().some((entry) => + entry.type === "custom_message" && entry.customType === marker, + ) + const alreadyInstructed = instructed.has(id) || persisted + if (persisted) instructed.add(id) + + const response = starts.get(id) ?? await start(ctx, event.type) + const reminder = await runHook(ctx, "user-prompt-submit", hookPayload(ctx, event.type)) + const content = [] + let includesInstruction = false + + if (!alreadyInstructed && response?.instruction) { + content.push(response.instruction) + includesInstruction = true + } + if (reminder?.instruction) content.push(reminder.instruction) + if (content.length === 0) return + + if (includesInstruction) instructed.add(id) + return { + message: { + customType: includesInstruction ? marker : reminderMarker, + content: content.join("\n\n"), + display: false, + details: { sessionId: id }, + }, + } + } catch { + return + } + }) + + const toolPayload = (ctx, event) => { + if (!fileWritingTools.includes(event.toolName) && !commandTools.includes(event.toolName)) return + const payload = { + ...hookPayload(ctx, event.type), + tool_name: event.toolName, + tool_use_id: event.toolCallId, + ...(event.type === "tool_result" && event.isError ? { tool_failed: true } : {}), + } + if (fileWritingTools.includes(event.toolName)) { + const path = event.input?.path + if (typeof path !== "string" || path.length === 0) return + payload.file_path = resolveToolPath(path, ctx.cwd) + } + if (commandTools.includes(event.toolName) && typeof event.input?.command === "string") { + payload.command = event.input.command + } + return payload + } + + pi.on("tool_call", async (event, ctx) => { + try { + const payload = toolPayload(ctx, event) + if (payload) await runHook(ctx, "pre-tool-use", payload) + } catch {} + return undefined + }) + + pi.on("tool_result", async (event, ctx) => { + try { + const payload = toolPayload(ctx, event) + if (!payload) return + const response = await runHook(ctx, "post-tool-use", payload) + showMessage(ctx, response) + } catch {} + }) +}