diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 464aa420..d44b581f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `Agent.signal` to expose the active abort signal for the current turn, allowing callers to forward cancellation into nested async work ([#2660](https://github.com/badlogic/pi-mono/issues/2660)) + ## [0.63.1] - 2026-03-27 ## [0.63.0] - 2026-03-27 diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 012adeae..08b4aaf7 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -371,6 +371,11 @@ export class Agent { this._state.messages = []; } + /** The current abort signal, or undefined when the agent is not streaming. */ + get signal(): AbortSignal | undefined { + return this.abortController?.signal; + } + abort() { this.abortController?.abort(); } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c8eefdb0..1b63c89a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `ctx.signal` to `ExtensionContext` and wired it to the active agent turn so extension handlers can forward cancellation into nested model calls, `fetch()`, and other abort-aware work ([#2660](https://github.com/badlogic/pi-mono/issues/2660)) + ## [0.63.1] - 2026-03-27 ### Added diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 4ea30e75..a450b211 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -629,6 +629,8 @@ Fired after tool execution finishes and before `tool_execution_end` plus the fin - Each handler sees the latest result after previous handler changes - Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values +Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension. + ```typescript import { isBashToolResult } from "@mariozechner/pi-coding-agent"; @@ -640,6 +642,12 @@ pi.on("tool_result", async (event, ctx) => { // event.details is typed as BashToolDetails } + const response = await fetch("https://example.com/summarize", { + method: "POST", + body: JSON.stringify({ content: event.content }), + signal: ctx.signal, + }); + // Modify result: return { content: [...], details: {...}, isError: false }; }); @@ -759,6 +767,31 @@ ctx.sessionManager.getLeafId() // Current leaf entry ID Access to models and API keys. +### ctx.signal + +The current agent abort signal, or `undefined` when no agent turn is active. + +Use this for abort-aware nested work started by extension handlers, for example: +- `fetch(..., { signal: ctx.signal })` +- model calls that accept `signal` +- file or process helpers that accept `AbortSignal` + +`ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`. +It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while pi is idle. + +```typescript +pi.on("tool_result", async (event, ctx) => { + const response = await fetch("https://example.com/api", { + method: "POST", + body: JSON.stringify(event), + signal: ctx.signal, + }); + + const data = await response.json(); + return { details: data }; +}); +``` + ### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() Control flow helpers. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 384d5621..821a6182 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2219,6 +2219,7 @@ export class AgentSession { { getModel: () => this.model, isIdle: () => !this.isStreaming, + getSignal: () => this.agent.signal, abort: () => this.abort(), hasPendingMessages: () => this.pendingMessageCount > 0, shutdown: () => { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 14a91d9f..a44b20ea 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -208,6 +208,7 @@ export class ExtensionRunner { private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; private isIdleFn: () => boolean = () => true; + private getSignalFn: () => AbortSignal | undefined = () => undefined; private waitForIdleFn: () => Promise = async () => {}; private abortFn: () => void = () => {}; private hasPendingMessagesFn: () => boolean = () => false; @@ -265,6 +266,7 @@ export class ExtensionRunner { // Context actions (required) this.getModel = contextActions.getModel; this.isIdleFn = contextActions.isIdle; + this.getSignalFn = contextActions.getSignal; this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; this.shutdownHandler = contextActions.shutdown; @@ -541,6 +543,7 @@ export class ExtensionRunner { return getModel(); }, isIdle: () => this.isIdleFn(), + signal: this.getSignalFn(), abort: () => this.abortFn(), hasPendingMessages: () => this.hasPendingMessagesFn(), shutdown: () => this.shutdownHandler(), diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 42048733..4bd5adfe 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -274,6 +274,8 @@ export interface ExtensionContext { model: Model | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; + /** The current abort signal, or undefined when the agent is not streaming. */ + signal: AbortSignal | undefined; /** Abort the current agent operation */ abort(): void; /** Whether there are queued messages waiting */ @@ -1391,6 +1393,7 @@ export interface ExtensionActions { export interface ExtensionContextActions { getModel: () => Model | undefined; isIdle: () => boolean; + getSignal: () => AbortSignal | undefined; abort: () => void; hasPendingMessages: () => boolean; shutdown: () => void; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 38a0b705..64876740 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1280,6 +1280,7 @@ export class InteractiveMode { modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, + signal: this.session.agent.signal, abort: () => this.session.abort(), hasPendingMessages: () => this.session.pendingMessageCount > 0, shutdown: () => { diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 3d18a78b..1b584173 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -71,6 +71,7 @@ describe("ExtensionRunner", () => { const extensionContextActions: ExtensionContextActions = { getModel: () => undefined, isIdle: () => true, + getSignal: () => undefined, abort: () => {}, hasPendingMessages: () => false, shutdown: () => {}, @@ -399,6 +400,26 @@ describe("ExtensionRunner", () => { }); }); + describe("context creation", () => { + it("exposes the current abort signal on ExtensionContext", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const controller = new AbortController(); + + runner.bindCore(extensionActions, { + ...extensionContextActions, + getSignal: () => controller.signal, + }); + + const ctx = runner.createContext(); + expect(ctx.signal).toBe(controller.signal); + expect(ctx.signal?.aborted).toBe(false); + + controller.abort(); + expect(ctx.signal?.aborted).toBe(true); + }); + }); + describe("error handling", () => { it("calls error listeners when handler throws", async () => { const extCode = ` diff --git a/packages/coding-agent/test/trigger-compact-extension.test.ts b/packages/coding-agent/test/trigger-compact-extension.test.ts index c65288e4..92d2ce95 100644 --- a/packages/coding-agent/test/trigger-compact-extension.test.ts +++ b/packages/coding-agent/test/trigger-compact-extension.test.ts @@ -11,6 +11,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte modelRegistry: {} as ExtensionContext["modelRegistry"], model: undefined, isIdle: () => true, + signal: undefined, abort: vi.fn(), hasPendingMessages: () => false, shutdown: vi.fn(),