Skip to content
Closed
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added a cancellable `session_before_shutdown` extension lifecycle event so interactive quit flows can be safely aborted before Atomic tears down UI/runtime state ([#1378](https://github.com/bastani-inc/atomic/issues/1378)).

### Fixed

- Fixed custom tool renderer disposal to honor renderer-owned cleanup callbacks, preventing stale animation registry entries after terminal workflow tool rows are finalized ([#1518](https://github.com/bastani-inc/atomic/issues/1518)).
Expand Down
24 changes: 21 additions & 3 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,11 @@ user sends another prompt ◄─────────────────
thinking level changes (settings, keybinding, pi.setThinkingLevel())
└─► thinking_level_select

exit (CTRL+C, CTRL+D, SIGHUP, SIGTERM)
exit (CTRL+C, CTRL+D)
├─► session_before_shutdown (can cancel; reason: "quit")
└─► session_shutdown

signal exit (SIGHUP, SIGTERM)
└─► session_shutdown
```

Expand Down Expand Up @@ -494,9 +498,23 @@ pi.on("session_tree", async (event, ctx) => {
});
```

#### session_before_shutdown

Fired before an interactive quit shutdown. Return `{ cancel: true }` to abort the quit before Atomic stops the UI or disposes the runtime. Signal shutdowns (`SIGHUP`, `SIGTERM`) skip this cancellable prompt path so process teardown remains non-interactive. Reloads and session replacement flows are not cancellable through this hook; use `session_shutdown` for terminal cleanup in those paths.

```typescript
pi.on("session_before_shutdown", async (event, ctx) => {
// event.reason - "quit"
if (ctx.hasUI) {
const ok = await ctx.ui.confirm("Quit?", "Stop background work and exit?");
if (!ok) return { cancel: true };
}
});
```

#### session_shutdown

Fired before a started session runtime is torn down. Use this to clean up resources opened from `session_start` or other session-scoped hooks.
Fired before a started session runtime is torn down, after any cancellable `session_before_shutdown` handlers have allowed the shutdown to proceed. Use this to clean up resources opened from `session_start` or other session-scoped hooks.

```typescript
pi.on("session_shutdown", async (event, ctx) => {
Expand Down Expand Up @@ -1006,7 +1024,7 @@ Request a graceful shutdown of Atomic.
- **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command).
- **Print mode:** No-op. The process exits automatically when all prompts are processed.

Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).
Interactive quit requests first emit cancellable `session_before_shutdown`; when not cancelled, Atomic emits `session_shutdown` before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).

```typescript
pi.on("tool_call", (event, ctx) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
|--------|---------|-------------|
| `app.interrupt` | `escape` | Cancel / abort |
| `app.clear` | `ctrl+c` | Clear editor |
| `app.exit` | `ctrl+d` | Exit (when editor empty) |
| `app.exit` | `ctrl+d` | Exit (when editor empty; active workflows prompt to confirm) |
| `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background |
| `app.editor.external` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) |
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard |
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/exit` | Exit Atomic |
| `/quit` | Quit Atomic |

When workflows are active, `/exit`, `/quit`, and empty-editor Ctrl+D show an exit confirmation. The prompt defaults to cancel so active workflow runs continue unless you explicitly confirm; confirming quits Atomic and cleans up active workflow work during shutdown.

## Message Queue

You can submit messages while the agent is still working:
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/core/extensions/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
MessageEndEventResult,
SessionBeforeCompactResult,
SessionBeforeForkResult,
SessionBeforeShutdownResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
ToolCallEventResult,
Expand All @@ -52,6 +53,7 @@ import type {
ResourcesDiscoverResult,
SessionBeforeCompactEvent,
SessionBeforeForkEvent,
SessionBeforeShutdownEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionCompactEvent,
Expand Down Expand Up @@ -86,6 +88,10 @@ export interface ExtensionAPI {
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
on(
event: "session_before_shutdown",
handler: ExtensionHandler<SessionBeforeShutdownEvent, SessionBeforeShutdownResult>,
): void;
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/src/core/extensions/event-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export interface SessionBeforeCompactResult {
deletionRequest?: ContextDeletionRequest;
}

export interface SessionBeforeShutdownResult {
cancel?: boolean;
}

export interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: {
Expand Down
4 changes: 3 additions & 1 deletion packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type {
ShutdownHandler,
SwitchSessionHandler,
} from "./runner.ts";
export { ExtensionRunner } from "./runner.ts";
export { emitSessionBeforeShutdownEvent, ExtensionRunner } from "./runner.ts";
export type {
AfterProviderResponseEvent,
AgentEndEvent,
Expand Down Expand Up @@ -127,6 +127,8 @@ export type {
SessionBeforeCompactResult,
SessionBeforeForkEvent,
SessionBeforeForkResult,
SessionBeforeShutdownEvent,
SessionBeforeShutdownResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
Expand Down
13 changes: 9 additions & 4 deletions packages/coding-agent/src/core/extensions/runner-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ResourcesDiscoverResult,
SessionBeforeCompactResult,
SessionBeforeForkResult,
SessionBeforeShutdownResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
ToolCallEvent,
Expand Down Expand Up @@ -60,13 +61,14 @@ export type RunnerEmitEvent = Exclude<

type SessionBeforeEvent = Extract<
RunnerEmitEvent,
{ type: "session_before_switch" | "session_before_fork" | "session_before_compact" | "session_before_tree" }
{ type: "session_before_switch" | "session_before_fork" | "session_before_compact" | "session_before_shutdown" | "session_before_tree" }
>;

type SessionBeforeEventResult =
| SessionBeforeSwitchResult
| SessionBeforeForkResult
| SessionBeforeCompactResult
| SessionBeforeShutdownResult
| SessionBeforeTreeResult;

export type RunnerEmitResult<TEvent extends RunnerEmitEvent> = TEvent extends { type: "session_before_switch" }
Expand All @@ -75,16 +77,19 @@ export type RunnerEmitResult<TEvent extends RunnerEmitEvent> = TEvent extends {
? SessionBeforeForkResult | undefined
: TEvent extends { type: "session_before_compact" }
? SessionBeforeCompactResult | undefined
: TEvent extends { type: "session_before_tree" }
? SessionBeforeTreeResult | undefined
: undefined;
: TEvent extends { type: "session_before_shutdown" }
? SessionBeforeShutdownResult | undefined
: TEvent extends { type: "session_before_tree" }
? SessionBeforeTreeResult | undefined
: undefined;

type EmitExtensionError = (error: ExtensionError) => void;

const isSessionBeforeEvent = (event: RunnerEmitEvent): event is SessionBeforeEvent =>
event.type === "session_before_switch" ||
event.type === "session_before_fork" ||
event.type === "session_before_compact" ||
event.type === "session_before_shutdown" ||
event.type === "session_before_tree";

const emitCaughtError = (
Expand Down
16 changes: 16 additions & 0 deletions packages/coding-agent/src/core/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import type {
RegisteredTool,
ResolvedCommand,
ResourcesDiscoverEvent,
SessionBeforeShutdownEvent,
SessionShutdownEvent,
ToolCallEvent,
ToolCallEventResult,
Expand All @@ -94,6 +95,21 @@ export type {
} from "./runner-handlers.ts";
export { emitProjectTrustEvent } from "./runner-project-trust.ts";

/**
* Helper function to emit session_before_shutdown event to extensions.
* Returns cancellation and emission status.
*/
export async function emitSessionBeforeShutdownEvent(
extensionRunner: ExtensionRunner,
event: SessionBeforeShutdownEvent,
): Promise<{ cancelled: boolean; emitted: boolean }> {
if (!extensionRunner.hasHandlers("session_before_shutdown")) {
return { cancelled: false, emitted: false };
}
const result = await extensionRunner.emit(event);
return { cancelled: result?.cancel === true, emitted: true };
}

/**
* Helper function to emit session_shutdown event to extensions.
* Returns true if the event was emitted, false if there were no handlers.
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/core/extensions/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export interface SessionCompactEvent {
fromExtension: boolean;
}

/** Fired before an interactive quit shutdown (can be cancelled). */
export interface SessionBeforeShutdownEvent {
type: "session_before_shutdown";
reason: "quit";
}

/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */
export interface SessionShutdownEvent {
type: "session_shutdown";
Expand Down Expand Up @@ -111,6 +117,7 @@ export type SessionEvent =
| SessionBeforeForkEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionBeforeShutdownEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export {
type RegisteredCommand,
type SessionBeforeCompactEvent,
type SessionBeforeForkEvent,
type SessionBeforeShutdownEvent,
type SessionBeforeShutdownResult,
type SessionBeforeSwitchEvent,
type SessionBeforeTreeEvent,
type SessionCompactEvent,
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/index-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export type {
ResolvedCommand,
SessionBeforeCompactEvent,
SessionBeforeForkEvent,
SessionBeforeShutdownEvent,
SessionBeforeShutdownResult,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionCompactEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,5 @@ export class InteractiveModeBase {
* repaint the final frame while the process is exiting.
*/
isShuttingDown = false;
shutdownConfirmationPending = false;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { emitSessionBeforeShutdownEvent } from "../../core/extensions/index.ts";
import { InteractiveModeBase } from "./interactive-mode-base.ts";
import { chalk, killTrackedDetachedChildren } from "./interactive-mode-deps.ts";
import { formatResumeCommand, isDeadTerminalError } from "./interactive-mode-helpers.ts";
Expand All @@ -19,6 +20,29 @@ InteractiveModeBase.prototype.handleCtrlD = function(this: InteractiveModeBase):

InteractiveModeBase.prototype.shutdown = async function(this: InteractiveModeBase, options?: { fromSignal?: boolean }): Promise<void> {
if (this.isShuttingDown) return;

if (!options?.fromSignal) {
// While a cancellable quit prompt is mounted, further in-process quit
// requests (including double Ctrl+C) are owned by that overlay. Real
// process signals still bypass this path through `fromSignal`.
if (this.shutdownConfirmationPending) return;
this.shutdownConfirmationPending = true;
let beforeShutdown: Awaited<ReturnType<typeof emitSessionBeforeShutdownEvent>>;
try {
beforeShutdown = await emitSessionBeforeShutdownEvent(this.session.extensionRunner, {
type: "session_before_shutdown",
reason: "quit",
});
} finally {
this.shutdownConfirmationPending = false;
}
if (beforeShutdown.cancelled) {
this.shutdownRequested = false;
return;
}
if (this.isShuttingDown) return;
}

this.isShuttingDown = true;
// Keep signal handlers registered until terminal cleanup has completed.
// `signal-exit` checks the listener list during the same SIGTERM/SIGHUP
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/test/extensions-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ import "./extensions-runner/shortcut-conflicts.suite.ts";
import "./extensions-runner/tool-command-collection.suite.ts";
import "./extensions-runner/context-error-renderer-flags.suite.ts";
import "./extensions-runner/lifecycle-tool-result.suite.ts";
import "./extensions-runner/session-shutdown.suite.ts";
import "./extensions-runner/provider-command-handlers.suite.ts";
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** Tests for ExtensionRunner session shutdown lifecycle helpers. */

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../../src/core/auth-storage.ts";
import { loadExtensions } from "../../src/core/extensions/loader.ts";
import { emitSessionBeforeShutdownEvent, ExtensionRunner } from "../../src/core/extensions/runner.ts";
import { ModelRegistry } from "../../src/core/model-registry.ts";
import { SessionManager } from "../../src/core/session-manager.ts";

describe("ExtensionRunner session_before_shutdown", () => {
let tempDir: string;
let extensionsDir: string;
let sessionManager: SessionManager;
let modelRegistry: ModelRegistry;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-session-shutdown-test-"));
extensionsDir = path.join(tempDir, "extensions");
fs.mkdirSync(extensionsDir);
sessionManager = SessionManager.inMemory();
const authStorage = AuthStorage.create(path.join(tempDir, "auth.json"));
modelRegistry = ModelRegistry.create(authStorage);
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it("returns cancellation from pre-shutdown handlers", async () => {
const extPath = path.join(extensionsDir, "before-shutdown.ts");
fs.writeFileSync(
extPath,
`export default function(pi) {
pi.on("session_before_shutdown", (event) => ({ cancel: event.reason === "quit" }));
}`,
);

const result = await loadExtensions([extPath], tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);

expect(
await emitSessionBeforeShutdownEvent(runner, { type: "session_before_shutdown", reason: "quit" }),
).toEqual({ cancelled: true, emitted: true });
});

it("reports no emission when no pre-shutdown handlers are registered", async () => {
const result = await loadExtensions([], tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);

expect(
await emitSessionBeforeShutdownEvent(runner, { type: "session_before_shutdown", reason: "quit" }),
).toEqual({ cancelled: false, emitted: false });
});
});
Loading
Loading