diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f4a43ad60..4c80e4f12 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed idle prompt-cache warming rebuilding expired caches when its timer or an extension decision is delayed. +- Improved crash diagnostics with hints identifying loaded extensions that appear in the stack trace. ## [0.86.1] - 2026-09-20 diff --git a/packages/coding-agent/src/core/crash-log.ts b/packages/coding-agent/src/core/crash-log.ts index f36d7c7b3..ef62cc046 100644 --- a/packages/coding-agent/src/core/crash-log.ts +++ b/packages/coding-agent/src/core/crash-log.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getAgentDir, VERSION } from "../config.ts"; +import type { Extension } from "./extensions/types.ts"; export interface CrashRecord { timestamp: string; @@ -42,6 +43,82 @@ function writeCrashLog(records: readonly CrashRecord[], path: string): void { writeFileSync(path, `${JSON.stringify(records, null, 2)}\n`); } +type ExtensionStackMetadata = Pick; + +function normalizeStackPath(value: string): string { + return value.replace(/\\/g, "/").replace(/\/+$/u, ""); +} + +function stackContainsPath(stack: string, targetPath: string, includeDescendants: boolean): boolean { + const target = normalizeStackPath(targetPath); + if (!target || target.startsWith("<")) return false; + const caseInsensitive = /^[a-z]:\//iu.test(target); + const haystack = caseInsensitive ? stack.toLowerCase() : stack; + const needle = caseInsensitive ? target.toLowerCase() : target; + if (includeDescendants) return haystack.includes(`${needle}/`); + + let index = haystack.indexOf(needle); + while (index !== -1) { + const next = haystack[index + needle.length]; + if (next === undefined || next === ":" || next === ")" || /\s/u.test(next)) return true; + index = haystack.indexOf(needle, index + needle.length); + } + return false; +} + +/** Find loaded extensions with source files in a stack trace. */ +export function findExtensionStackMatches( + stack: string | undefined, + extensions: readonly ExtensionStackMetadata[], +): string[] { + if (!stack) return []; + const normalizedStack = stack + .split("\n") + .slice(1) + .filter((line) => /^\s+at\s/u.test(line)) + .map((line) => { + try { + return decodeURI(line); + } catch { + return line; + } + }) + .join("\n") + .replace(/\\/g, "/"); + const matches: string[] = []; + const seen = new Set(); + + for (const extension of extensions) { + const resolvedPath = normalizeStackPath(extension.resolvedPath); + const singleFilePackage = + extension.sourceInfo.origin === "package" && + !/^(?:npm:|git:|https?:\/\/|ssh:\/\/)/u.test(extension.sourceInfo.source) && + /\.[cm]?[jt]s$/u.test(extension.sourceInfo.source); + const packageRoot = + extension.sourceInfo.origin === "package" && !singleFilePackage && extension.sourceInfo.baseDir + ? extension.sourceInfo.baseDir + : undefined; + const slashIndex = resolvedPath.lastIndexOf("/"); + const directoryEntry = /\/index\.[cm]?[jt]s$/u.test(resolvedPath); + const matched = packageRoot + ? stackContainsPath(normalizedStack, packageRoot, true) + : directoryEntry && slashIndex !== -1 + ? stackContainsPath(normalizedStack, resolvedPath.slice(0, slashIndex), true) + : stackContainsPath(normalizedStack, resolvedPath, false); + if (!matched) continue; + + const label = + extension.sourceInfo.origin === "package" && extension.sourceInfo.source + ? extension.sourceInfo.source + : extension.path; + if (!seen.has(label)) { + seen.add(label); + matches.push(label); + } + } + return matches; +} + /** Best-effort persistence for callers that are already crashing. */ export function recordCrash( crash: { kind: CrashRecord["kind"]; error: unknown; sessionFile?: string; cwd: string }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 238302f9d..96cae384f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -73,7 +73,7 @@ import { detectCacheMiss, } from "../../core/cache-stats.ts"; import { formatCacheWarmingStatus, formatCacheWarmingUsage } from "../../core/cache-warmer.ts"; -import { recordCrash, takeUnnotifiedCrash } from "../../core/crash-log.ts"; +import { findExtensionStackMatches, recordCrash, takeUnnotifiedCrash } from "../../core/crash-log.ts"; import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts"; import type { AutocompleteProviderFactory, @@ -271,6 +271,23 @@ function isDeadTerminalError(error: unknown): boolean { return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); } +export function formatCrashExtensionHint(extensionMatches: readonly string[] | undefined): string | undefined { + const matches = Array.isArray(extensionMatches) + ? extensionMatches.filter((match): match is string => typeof match === "string" && match.length > 0) + : []; + if (matches.length === 0) return undefined; + const quoted = matches.map((match) => `\`${match}\``); + const labels = + quoted.length === 1 + ? quoted[0] + : quoted.length === 2 + ? quoted.join(" and ") + : `${quoted.slice(0, -1).join(", ")}, and ${quoted[quoted.length - 1]}`; + const noun = matches.length === 1 ? "extension" : "extensions"; + const pronoun = matches.length === 1 ? "it" : "them"; + return `A stack frame came from loaded ${noun} ${labels}, which may be involved. Try disabling ${pronoun} with \`${APP_NAME} config\`, or run \`${APP_NAME} -ne\` to confirm.`; +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; @@ -2029,6 +2046,10 @@ export class InteractiveMode { private async handleFatalRuntimeError(prefix: string, error: unknown): Promise { const message = error instanceof Error ? error.message : String(error); this.showError(`${prefix}: ${message}`); + const extensionHint = this.getCrashExtensionHint(error); + if (extensionHint) { + this.chatContainer.addChild(new Text(theme.fg("warning", extensionHint), this.outputPad, 0)); + } if (this.recordCrash("fatal_error", error)) { this.chatContainer.addChild(new Text(theme.fg("muted", this.crashReportInstructions()), this.outputPad, 0)); } @@ -2037,6 +2058,19 @@ export class InteractiveMode { process.exit(1); } + private getCrashExtensionHint(error: unknown): string | undefined { + try { + return formatCrashExtensionHint( + findExtensionStackMatches( + error instanceof Error ? error.stack : undefined, + this.session.resourceLoader.getExtensions().extensions, + ), + ); + } catch { + return undefined; + } + } + /** Persist a crash so the next start can point the user at `/bug`. Returns false when nothing was written. */ private recordCrash(kind: "uncaught_exception" | "fatal_error", error: unknown): boolean { try { @@ -4142,6 +4176,8 @@ export class InteractiveMode { } catch {} console.error(`${APP_NAME} exiting due to uncaughtException:`); console.error(error); + const extensionHint = this.getCrashExtensionHint(error); + if (extensionHint) console.error(`\n${extensionHint}`); if (this.recordCrash("uncaught_exception", error)) { console.error(`\n${this.crashReportInstructions()}`); } diff --git a/packages/coding-agent/test/crash-log.test.ts b/packages/coding-agent/test/crash-log.test.ts new file mode 100644 index 000000000..7f53bdc0e --- /dev/null +++ b/packages/coding-agent/test/crash-log.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; +import { findExtensionStackMatches } from "../src/core/crash-log.ts"; + +type StackExtension = Parameters[1][number]; + +function packageExtension(source: string, baseDir: string, entry = "extensions/index.ts"): StackExtension { + const normalizedBaseDir = baseDir.replace(/\\/g, "/"); + const resolvedPath = `${normalizedBaseDir}/${entry}`; + return { + path: resolvedPath, + resolvedPath, + sourceInfo: { + path: resolvedPath, + source, + scope: "user", + origin: "package", + baseDir, + }, + }; +} + +describe("extension crash attribution", () => { + test("matches stack frames beneath loaded package roots", () => { + const memory = packageExtension( + "npm:pi-observational-memory", + "/home/fedora/.pi/agent/npm/node_modules/pi-observational-memory", + ); + const unrelated = packageExtension("npm:unrelated", "/home/fedora/.pi/agent/npm/node_modules/unrelated"); + const stack = + "TypeError: Cannot read properties of undefined (reading 'runtime')\n" + + " at streamSimple (file:///home/fedora/.local/lib/node_modules/@earendil-works/pi-coding-agent/dist/bundle/chunks/chunk-CMRUVXTE.js:1093:16944)\n" + + " at /home/fedora/.pi/agent/npm/node_modules/pi-observational-memory/src/agents/worker-stream.ts:43:45"; + + expect(findExtensionStackMatches(stack, [memory, unrelated])).toEqual(["npm:pi-observational-memory"]); + }); + + test("normalizes Windows paths and deduplicates package extensions", () => { + const root = "C:\\Users\\reporter\\.pi\\agent\\npm\\node_modules\\@scope\\memory"; + const first = packageExtension("npm:@scope/memory", root, "extensions/first.ts"); + const second = packageExtension("npm:@scope/memory", root, "extensions/second.ts"); + const stack = + "Error: broken\n" + + " at run (c:\\users\\reporter\\.pi\\agent\\npm\\node_modules\\@scope\\memory\\src\\worker.ts:4:2)"; + + expect(findExtensionStackMatches(stack, [first, second])).toEqual(["npm:@scope/memory"]); + }); + + test("does not attribute sibling single-file packages", () => { + const extension = (name: string): StackExtension => ({ + path: `/plugins/${name}.ts`, + resolvedPath: `/plugins/${name}.ts`, + sourceInfo: { + path: `/plugins/${name}.ts`, + source: `/plugins/${name}.ts`, + scope: "user", + origin: "package", + baseDir: "/plugins", + }, + }); + const stack = "Error: broken\n at run (file:///plugins/b.ts:4:2)"; + + expect(findExtensionStackMatches(stack, [extension("a"), extension("b")])).toEqual(["/plugins/b.ts"]); + }); + + test("ignores extension paths in the error message", () => { + const extension = packageExtension("npm:memory", "/tmp/node_modules/memory"); + const stack = `Error: Failed to read ${extension.resolvedPath}\n at run (file:///opt/pi/dist/core.js:4:2)`; + + expect(findExtensionStackMatches(stack, [extension])).toEqual([]); + }); + + test("decodes frame paths independently from malformed error text", () => { + const extension: StackExtension = { + path: "/Users/reporter/.pi/agent/extensions/local memory/index.ts", + resolvedPath: "/Users/reporter/.pi/agent/extensions/local memory/index.ts", + sourceInfo: { + path: "/Users/reporter/.pi/agent/extensions/local memory/index.ts", + source: "local", + scope: "user", + origin: "top-level", + baseDir: "/Users/reporter/.pi/agent/extensions", + }, + }; + const stack = + "Error: progress 100%\n at run (file:///Users/reporter/.pi/agent/extensions/local%20memory/worker.ts:4:2)"; + + expect(findExtensionStackMatches(stack, [extension])).toEqual([extension.path]); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-bug-report-hint.test.ts b/packages/coding-agent/test/interactive-mode-bug-report-hint.test.ts index b0a28878e..7cdf230fa 100644 --- a/packages/coding-agent/test/interactive-mode-bug-report-hint.test.ts +++ b/packages/coding-agent/test/interactive-mode-bug-report-hint.test.ts @@ -1,6 +1,6 @@ import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, test, vi } from "vitest"; -import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { formatCrashExtensionHint, InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; type BugReportHintContext = { suggestBugReport(): void; @@ -16,6 +16,13 @@ function errorMessage(errorMessage: string): AssistantMessage { } describe("InteractiveMode bug report hints", () => { + test("identifies extensions with frames in a crash stack", () => { + expect(formatCrashExtensionHint(["npm:pi-observational-memory"])).toBe( + "A stack frame came from loaded extension `npm:pi-observational-memory`, which may be involved. Try disabling it with `pi config`, or run `pi -ne` to confirm.", + ); + expect(formatCrashExtensionHint(undefined)).toBeUndefined(); + }); + test("does not suggest reports for retryable provider failures", () => { const context = { suggestBugReport: vi.fn() }; const failures = [