diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b32d1a19..fedc9397 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `session_shutdown` to fire on `SIGHUP` and `SIGTERM` in interactive, print, and RPC modes so extensions can run shutdown cleanup on those signal-driven exits ([#3212](https://github.com/badlogic/pi-mono/issues/3212)) - Fixed screenshot path parsing to handle lower case am/pm in macOS screenshot filenames ([#3194](https://github.com/badlogic/pi-mono/pull/3194) by [@jay-aye-see-kay](https://github.com/jay-aye-see-kay)) - Fixed interactive auto-retry status updates to show a live countdown during backoff instead of a static retry delay message ([#3187](https://github.com/badlogic/pi-mono/issues/3187)) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 5d53f4da..50a5db89 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -283,7 +283,7 @@ user sends another prompt ◄───────────────── /model or Ctrl+P (model selection/cycling) └─► model_select -exit (Ctrl+C, Ctrl+D) +exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) └─► session_shutdown ``` @@ -403,7 +403,7 @@ pi.on("session_tree", async (event, ctx) => { #### session_shutdown -Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). +Fired on exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM). ```typescript pi.on("session_shutdown", async (_event, ctx) => { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 2c20469f..10ef2632 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -479,7 +479,7 @@ export interface SessionCompactEvent { fromExtension: boolean; } -/** Fired on process exit */ +/** Fired on graceful process shutdown paths such as Ctrl+C, Ctrl+D, SIGHUP, and SIGTERM. */ export interface SessionShutdownEvent { type: "session_shutdown"; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index d71b9dd6..d3da96a1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -212,6 +212,7 @@ export class InteractiveMode { // Agent subscription unsubscribe function private unsubscribe?: () => void; + private signalCleanupHandlers: Array<() => void> = []; // Track if editor is in bash mode (text starts with !) private isBashMode = false; @@ -477,6 +478,8 @@ export class InteractiveMode { async init(): Promise { if (this.isInitialized) return; + this.registerSignalHandlers(); + // Load changelog (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); @@ -2905,6 +2908,7 @@ export class InteractiveMode { private async shutdown(): Promise { if (this.isShuttingDown) return; this.isShuttingDown = true; + this.unregisterSignalHandlers(); await this.runtimeHost.dispose(); // Wait for any pending renders to complete @@ -2927,6 +2931,30 @@ export class InteractiveMode { await this.shutdown(); } + private registerSignalHandlers(): void { + this.unregisterSignalHandlers(); + + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") { + signals.push("SIGHUP"); + } + + for (const signal of signals) { + const handler = () => { + void this.shutdown(); + }; + process.on(signal, handler); + this.signalCleanupHandlers.push(() => process.off(signal, handler)); + } + } + + private unregisterSignalHandlers(): void { + for (const cleanup of this.signalCleanupHandlers) { + cleanup(); + } + this.signalCleanupHandlers = []; + } + private handleCtrlZ(): void { // Keep the event loop alive while suspended. Without this, stopping the TUI // can leave Node with no ref'ed handles, causing the process to exit on fg @@ -4785,6 +4813,7 @@ export class InteractiveMode { } stop(): void { + this.unregisterSignalHandlers(); if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 235bffa7..ae69dfc0 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -33,6 +33,34 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr let exitCode = 0; let session = runtimeHost.session; let unsubscribe: (() => void) | undefined; + let disposed = false; + const signalCleanupHandlers: Array<() => void> = []; + + const disposeRuntime = async (): Promise => { + if (disposed) return; + disposed = true; + unsubscribe?.(); + await runtimeHost.dispose(); + }; + + const registerSignalHandlers = (): void => { + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") { + signals.push("SIGHUP"); + } + + for (const signal of signals) { + const handler = () => { + void disposeRuntime().finally(() => { + process.exit(signal === "SIGHUP" ? 129 : 143); + }); + }; + process.on(signal, handler); + signalCleanupHandlers.push(() => process.off(signal, handler)); + } + }; + + registerSignalHandlers(); const rebindSession = async (): Promise => { session = runtimeHost.session; @@ -128,8 +156,10 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr console.error(error instanceof Error ? error.message : String(error)); return 1; } finally { - unsubscribe?.(); - await runtimeHost.dispose(); + for (const cleanup of signalCleanupHandlers) { + cleanup(); + } + await disposeRuntime(); await flushRawStdout(); } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index be562e69..2ca456b8 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -75,6 +75,8 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void> = []; /** Helper for dialog methods with signal/timeout support */ function createDialogPromise( @@ -336,7 +338,23 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") { + signals.push("SIGHUP"); + } + + for (const signal of signals) { + const handler = () => { + void shutdown(signal === "SIGHUP" ? 129 : 143); + }; + process.on(signal, handler); + signalCleanupHandlers.push(() => process.off(signal, handler)); + } + }; + await rebindSession(); + registerSignalHandlers(); // Handle a single command const handleCommand = async (command: RpcCommand): Promise => { @@ -614,12 +632,19 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise {}; - async function shutdown(): Promise { + async function shutdown(exitCode = 0): Promise { + if (shuttingDown) { + process.exit(exitCode); + } + shuttingDown = true; + for (const cleanup of signalCleanupHandlers) { + cleanup(); + } unsubscribe?.(); await runtimeHost.dispose(); detachInput(); process.stdin.pause(); - process.exit(0); + process.exit(exitCode); } async function checkShutdownRequested(): Promise {