diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ba25b3dd4..bc59f7951 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -142,6 +142,10 @@ - Fixed heavily branched conversation trees shifting linear continuations into disconnected columns. - Fixed plugin installation validation failures for legacy compatibility shims. - Removed hard-coded references to disabled or absent agents in system and tool prompts. +### Added + +- Added resumable session details to fatal crash output, including an `omp --resume ` command for every persisted live agent session. + ### Fixed - Fixed unobserved promise continuations from browser helpers such as `tab.waitForResponse()` wedging or killing the tab worker when they reject; browser facade promises now retain native promise behavior while observing every `then`, `catch`, and `finally` continuation. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7573608a9..9a34d5da4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -82,6 +82,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models"; import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives"; import { $env, + APP_NAME, escapeXmlText, formatDuration, getAgentDbPath, @@ -445,6 +446,7 @@ export class AgentSession { // Event subscription state #unsubscribeAgent?: () => void; #cancelExitRecorder?: () => void; + #cancelFatalRecoveryHint?: () => void; #exitRecorded = false; #unsubscribeAppendOnly?: () => void; #unsubscribeModelRoles?: () => void; @@ -1376,6 +1378,14 @@ export class AgentSession { this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => { this.#recordSessionExit(reason); }); + this.#cancelFatalRecoveryHint = postmortem.registerFatalRecoveryHint(() => { + const sessionId = this.sessionManager.getSessionId(); + if (!sessionId || !this.sessionManager.getSessionFile()) return undefined; + return { + label: this.#agentId ?? (this.#agentKind === "main" ? "Main" : "Agent"), + command: `${APP_NAME} --resume ${sessionId}`, + }; + }); const advisorsHost: SessionAdvisorsHost = { agent: this.agent, @@ -3758,6 +3768,8 @@ export class AgentSession { this.#recordSessionExit(options.reason ?? "dispose"); this.#cancelExitRecorder?.(); this.#cancelExitRecorder = undefined; + this.#cancelFatalRecoveryHint?.(); + this.#cancelFatalRecoveryHint = undefined; try { await emitSessionShutdownEvent(this.#extensionRunner); } catch (error) { diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index a05d90562..6e093b703 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -28,6 +28,9 @@ ### Changed - Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations. +### Added + +- Added postmortem fatal recovery hint providers so applications can print actionable recovery commands before cleanup starts. ## [17.2.4] - 2026-08-01 diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 62d3c76b4..2df9a7e8f 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -67,6 +67,14 @@ function exitProcess(code: number): never { let cleanupPromise: Promise | undefined; let stdioDisconnectRegistrations = 0; +export interface FatalRecoveryHint { + label: string; + command: string; +} + +type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined; +const fatalRecoveryHintProviders = new Set(); + /** * Internal: runs all registered cleanup callbacks for the given reason. * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy. @@ -196,6 +204,38 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b return () => rejectionInterceptors.delete(interceptor); } +/** + * Register a synchronous recovery command to print when the process exits + * through an uncaught exception or unhandled rejection. + */ +export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void { + fatalRecoveryHintProviders.add(provider); + return () => fatalRecoveryHintProviders.delete(provider); +} + +function escapeFatalHintText(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => { + const code = char.codePointAt(0) ?? 0; + return `\\u${code.toString(16).padStart(4, "0")}`; + }); +} + +function formatFatalRecoveryHints(): string { + const lines: string[] = []; + const seenCommands = new Set(); + for (const provider of fatalRecoveryHintProviders) { + try { + const hint = provider(); + if (!hint?.command || seenCommands.has(hint.command)) continue; + seenCommands.add(hint.command); + lines.push(` ${escapeFatalHintText(hint.label)}: ${escapeFatalHintText(hint.command)}`); + } catch (err) { + logger.warn("Fatal recovery hint provider failed", { err }); + } + } + return lines.length > 0 ? `\n[Recovery]\n${lines.join("\n")}\n` : ""; +} + function formatFatalError(label: string, err: Error): string { const name = err.name || "Error"; const message = err.message || "(no message)"; @@ -212,7 +252,7 @@ async function exitAfterFatal(label: string, logMessage: string, err: Error, rea // A revoked terminal can make stream writes raise another fatal error. Use // the descriptor directly so failure stays synchronous and contained. try { - fs.writeSync(2, formatFatalError(label, err)); + fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`); } catch {} logger.error(logMessage, { err }); await runCleanup(reason); diff --git a/packages/utils/test/postmortem-cleanup-error.test.ts b/packages/utils/test/postmortem-cleanup-error.test.ts index 840b0e760..fc8c93dcc 100644 --- a/packages/utils/test/postmortem-cleanup-error.test.ts +++ b/packages/utils/test/postmortem-cleanup-error.test.ts @@ -123,6 +123,23 @@ describe("postmortem expected cleanup errors", () => { expect(result.stderr).toContain("[Unhandled Rejection] Error: unexpected cleanup rejection"); }); + it("prints registered recovery commands before fatal cleanup", async () => { + const result = await runPostmortemProbe(` + import { postmortem } from "${postmortemModuleUrl}"; + + postmortem.registerFatalRecoveryHint(() => ({ + label: "Main", + command: "omp --resume 019cafe0-dead-beef", + })); + Promise.reject(new Error("session crashed")); + await Promise.resolve(); + `); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("[Unhandled Rejection] Error: session crashed"); + expect(result.stderr).toContain("[Recovery]\n Main: omp --resume 019cafe0-dead-beef"); + }); + it("exits after an uncaught exception when terminal stderr is revoked", async () => { const result = await runPostmortemProbe(` import { spyOn } from "bun:test";