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
This commit is contained in:
Mario Zechner 2026-01-05 03:38:56 +01:00
parent 8da793b1ba
commit 79cb8f0906
6 changed files with 138 additions and 24 deletions

View file

@ -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

View file

@ -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"],
});

View file

@ -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

View file

@ -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 {

View file

@ -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 = "<inline>",
): LoadedExtension {
const handlers = new Map<string, HandlerFn[]>();
const tools = new Map<string, RegisteredTool>();
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.
*/

View file

@ -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, `<inline-${i}>`);
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) {