From 79cb8f09066327753317f5c1dba05b0a8b8a5120 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 5 Jan 2026 03:38:56 +0100 Subject: [PATCH] Add extensions option to createAgentSession SDK - Accept ExtensionFactory[] for inline extensions (merged with discovery) - Mark preloadedExtensions as @internal (CLI implementation detail) - Update sdk.md with inline extension example - Update CHANGELOG --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/sdk.md | 25 ++++++++-- packages/coding-agent/docs/session.md | 41 ++++++++-------- .../coding-agent/src/core/extensions/index.ts | 2 +- .../src/core/extensions/loader.ts | 48 +++++++++++++++++++ packages/coding-agent/src/core/sdk.ts | 45 ++++++++++++++++- 6 files changed, 138 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 25836f92..f83ba770 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -198,6 +198,7 @@ pi --extension ./safety.ts -e ./todo.ts - Examples: `examples/hooks/` and `examples/custom-tools/` merged into `examples/extensions/` - README: Extensions section expanded with custom tools, commands, events, state persistence, shortcuts, flags, and UI examples - SDK: `customTools` option now accepts `ToolDefinition[]` directly (simplified from `Array<{ path?, tool }>`) +- SDK: `extensions` option accepts `ExtensionFactory[]` for inline extensions - SDK: `additionalExtensionPaths` replaces both `additionalHookPaths` and `additionalCustomToolPaths` ## [0.34.2] - 2026-01-04 diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index b946af55..b1c04d4e 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -468,12 +468,31 @@ Custom tools passed via `customTools` are combined with extension-registered too ### Extensions -Extensions are discovered from `~/.pi/agent/extensions/` and `.pi/extensions/`. Use `additionalExtensionPaths` to add extra paths: +Extensions are discovered from `~/.pi/agent/extensions/` and `.pi/extensions/`. You can also pass inline extensions or additional paths: ```typescript -import { createAgentSession } from "@mariozechner/pi-coding-agent"; +import { createAgentSession, type ExtensionFactory } from "@mariozechner/pi-coding-agent"; -// Add extension paths (merged with discovery) +// Inline extension +const myExtension: ExtensionFactory = (pi) => { + pi.on("tool_call", async (event, ctx) => { + console.log(`Tool: ${event.toolName}`); + }); + + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify("Hello!", "info"); + }, + }); +}; + +// Pass inline extensions (merged with discovery) +const { session } = await createAgentSession({ + extensions: [myExtension], +}); + +// Or add paths to load (merged with discovery) const { session } = await createAgentSession({ additionalExtensionPaths: ["/path/to/my-extension.ts"], }); diff --git a/packages/coding-agent/docs/session.md b/packages/coding-agent/docs/session.md index 5044adcc..02b7426e 100644 --- a/packages/coding-agent/docs/session.md +++ b/packages/coding-agent/docs/session.md @@ -16,14 +16,15 @@ Sessions have a version field in the header: - **Version 1**: Linear entry sequence (legacy, auto-migrated on load) - **Version 2**: Tree structure with `id`/`parentId` linking +- **Version 3**: Renamed `hookMessage` role to `custom` (extensions unification) -Existing v1 sessions are automatically migrated to v2 when loaded. +Existing sessions are automatically migrated to the current version (v3) when loaded. ## Type Definitions - [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Session entry types -- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `Attachment`, `ThinkingLevel` -- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall` +- [`packages/agent-core/src/types.ts`](../../agent-core/src/types.ts) - `AgentMessage` +- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `Usage`, `ToolCall`, `ImageContent`, `TextContent` ## Entry Base @@ -45,13 +46,13 @@ interface SessionEntryBase { First line of the file. Metadata only, not part of the tree (no `id`/`parentId`). ```json -{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"} +{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"} ``` For sessions with a parent (created via `/branch` or `newSession({ parentSession })`): ```json -{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"} +{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"} ``` ### SessionMessageEntry @@ -89,8 +90,8 @@ Created when context is compacted. Stores a summary of earlier messages. ``` Optional fields: -- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for custom hook implementations) -- `fromHook`: `true` if generated by a hook, `false`/`undefined` if pi-generated +- `details`: Compaction-implementation specific data (e.g., file operations for default implementation, or custom data for extension implementations) +- `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated ### BranchSummaryEntry @@ -102,30 +103,30 @@ Created when switching branches via `/tree` with an LLM generated summary of the Optional fields: - `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default implementation, arbitrary for custom implementation -- `fromHook`: `true` if generated by a hook +- `fromHook`: `true` if generated by an extension ### CustomEntry -Hook state persistence. Does NOT participate in LLM context. +Extension state persistence. Does NOT participate in LLM context. ```json -{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-hook","data":{"count":42}} +{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}} ``` -Use `customType` to identify your hook's entries on reload. +Use `customType` to identify your extension's entries on reload. ### CustomMessageEntry -Hook-injected messages that DO participate in LLM context. +Extension-injected messages that DO participate in LLM context. ```json -{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-hook","content":"Injected context...","display":true} +{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-extension","content":"Injected context...","display":true} ``` Fields: - `content`: String or `(TextContent | ImageContent)[]` (same as UserMessage) -- `display`: `true` = show in TUI with purple styling, `false` = hidden -- `details`: Optional hook-specific metadata (not sent to LLM) +- `display`: `true` = show in TUI with distinct styling, `false` = hidden +- `details`: Optional extension-specific metadata (not sent to LLM) ### LabelEntry @@ -190,7 +191,7 @@ for (const line of lines) { console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`); break; case "custom_message": - console.log(`[${entry.id}] Hook message (${entry.customType}): ${entry.content}`); + console.log(`[${entry.id}] Extension message (${entry.customType}): ${entry.content}`); break; case "label": console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`); @@ -220,18 +221,20 @@ Key methods for working with sessions programmatically: - `appendThinkingLevelChange(level)` - Record thinking change - `appendModelChange(provider, modelId)` - Record model change - `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)` - Add compaction -- `appendCustomEntry(customType, data?)` - Hook state (not in context) -- `appendCustomMessageEntry(customType, content, display, details?)` - Hook message (in context) +- `appendCustomEntry(customType, data?)` - Extension state (not in context) +- `appendCustomMessageEntry(customType, content, display, details?)` - Extension message (in context) - `appendLabelChange(targetId, label)` - Set/clear label ### Tree Navigation - `getLeafId()` - Current position +- `getLeafEntry()` - Get current leaf entry - `getEntry(id)` - Get entry by ID -- `getPath(fromId?)` - Walk from entry to root +- `getBranch(fromId?)` - Walk from entry to root - `getTree()` - Get full tree structure - `getChildren(parentId)` - Get direct children - `getLabel(id)` - Get label for entry - `branch(entryId)` - Move leaf to earlier entry +- `resetLeaf()` - Reset leaf to null (before any entries) - `branchWithSummary(entryId, summary, details?, fromHook?)` - Branch with context summary ### Context diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 58929706..c04e1107 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -2,7 +2,7 @@ * Extension system for lifecycle events and custom tools. */ -export { discoverAndLoadExtensions, loadExtensions } from "./loader.js"; +export { discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions } from "./loader.js"; export type { BranchHandler, ExtensionErrorListener, NavigateTreeHandler, NewSessionHandler } from "./runner.js"; export { ExtensionRunner } from "./runner.js"; export type { diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 3c2f0609..b043eb31 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -375,6 +375,54 @@ async function loadExtension( } } +/** + * Create a LoadedExtension from an inline factory function. + */ +export function loadExtensionFromFactory( + factory: ExtensionFactory, + cwd: string, + eventBus: EventBus, + sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, + name = "", +): LoadedExtension { + const handlers = new Map(); + const tools = new Map(); + const { + api, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler, + setAppendEntryHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, + setFlagValue, + } = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI); + + factory(api); + + return { + path: name, + resolvedPath: name, + handlers, + tools, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler, + setAppendEntryHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, + setFlagValue, + }; +} + /** * Load extensions from paths. */ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index af5dbb55..e575a485 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -29,9 +29,11 @@ import { AuthStorage } from "./auth-storage.js"; import { createEventBus, type EventBus } from "./event-bus.js"; import { discoverAndLoadExtensions, + type ExtensionFactory, ExtensionRunner, type LoadExtensionsResult, type LoadedExtension, + loadExtensionFromFactory, type ToolDefinition, wrapRegisteredTools, wrapToolsWithExtensions, @@ -99,9 +101,14 @@ export interface CreateAgentSessionOptions { tools?: Tool[]; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; + /** Inline extensions (merged with discovery). */ + extensions?: ExtensionFactory[]; /** Additional extension paths to load (merged with discovery). */ additionalExtensionPaths?: string[]; - /** Pre-loaded extensions (skips loading, used when extensions were loaded early for CLI flags). */ + /** + * Pre-loaded extensions (skips file discovery). + * @internal Used by CLI when extensions are loaded early to parse custom flags. + */ preloadedExtensions?: LoadedExtension[]; /** Shared event bus for tool/extension communication. Default: creates new bus. */ @@ -447,6 +454,42 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } + // Load inline extensions from factories + if (options.extensions && options.extensions.length > 0) { + // Create shared UI context holder that will be set later + const uiHolder: { ui: any; hasUI: boolean } = { + ui: { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return {} as any; + }, + }, + hasUI: false, + }; + for (let i = 0; i < options.extensions.length; i++) { + const factory = options.extensions[i]; + const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, ``); + extensionsResult.extensions.push(loaded); + } + // Extend setUIContext to update inline extensions too + const originalSetUIContext = extensionsResult.setUIContext; + extensionsResult.setUIContext = (uiContext, hasUI) => { + originalSetUIContext(uiContext, hasUI); + uiHolder.ui = uiContext; + uiHolder.hasUI = hasUI; + }; + } + // Create extension runner if we have extensions let extensionRunner: ExtensionRunner | undefined; if (extensionsResult.extensions.length > 0) {