diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a9db4e40a..7527d916a 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -604,6 +604,27 @@ const { session } = await createAgentSession({ resourceLoader: loader }); Extensions can register tools, subscribe to events, add commands, and more. See [extensions.md](extensions.md) for the full API. +**Named inline extensions:** By default, inline factories display as ``, ``, etc. in the startup Extensions list. To show a descriptive name instead, wrap the factory: + +```typescript +import type { InlineExtension } from "@earendil-works/pi-coding-agent"; + +const myProvider: InlineExtension = { + name: "my-provider", + factory: (pi) => { + pi.on("agent_start", () => { + console.log("[my-provider] Agent starting"); + }); + }, +}; + +const loader = new DefaultResourceLoader({ + extensionFactories: [myProvider], +}); +``` + +This displays as `` instead of ``. Bare factory functions are still accepted for backward compatibility. + **Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `DefaultResourceLoader` if you need to emit or listen from outside: ```typescript @@ -1161,6 +1182,7 @@ createGrepTool, createFindTool, createLsTool type CreateAgentSessionOptions type CreateAgentSessionResult type ExtensionFactory +type InlineExtension type ExtensionAPI type ToolDefinition type Skill diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 94b648ebd..56174d58e 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -84,6 +84,7 @@ export type { GetThinkingLevelHandler, GrepToolCallEvent, GrepToolResultEvent, + InlineExtension, // Events - Input InputEvent, InputEventResult, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 61a783b81..1e0f73127 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1446,6 +1446,14 @@ export interface ProviderModelConfig { /** Extension factory function type. Supports both sync and async initialization. */ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; +export type InlineExtension = + | ExtensionFactory + | { + /** Display name shown as `` in the startup Extensions list. */ + name: string; + factory: ExtensionFactory; + }; + // ============================================================================ // Loaded Extension Types // ============================================================================ diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index b7654f422..a63a471f7 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -55,6 +55,7 @@ export { ExtensionRunner, type ExtensionShortcut, type ExtensionUIContext, + type InlineExtension, type LoadExtensionsResult, type MessageRenderer, type RegisteredCommand, diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index a187af8d9..d34f5a960 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -15,7 +15,7 @@ import { loadExtensionFromFactory, loadExtensionsCached, } from "./extensions/loader.ts"; -import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; +import type { Extension, ExtensionRuntime, InlineExtension, LoadExtensionsResult } from "./extensions/types.ts"; import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; import { loadPromptTemplates } from "./prompt-templates.ts"; @@ -131,7 +131,7 @@ export interface DefaultResourceLoaderOptions { additionalSkillPaths?: string[]; additionalPromptTemplatePaths?: string[]; additionalThemePaths?: string[]; - extensionFactories?: ExtensionFactory[]; + extensionFactories?: InlineExtension[]; noExtensions?: boolean; noSkills?: boolean; noPromptTemplates?: boolean; @@ -169,7 +169,7 @@ export class DefaultResourceLoader implements ResourceLoader { private additionalSkillPaths: string[]; private additionalPromptTemplatePaths: string[]; private additionalThemePaths: string[]; - private extensionFactories: ExtensionFactory[]; + private extensionFactories: InlineExtension[]; private noExtensions: boolean; private noSkills: boolean; private noPromptTemplates: boolean; @@ -896,8 +896,10 @@ export class DefaultResourceLoader implements ResourceLoader { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; - for (const [index, factory] of this.extensionFactories.entries()) { - const extensionPath = ``; + for (const [index, input] of this.extensionFactories.entries()) { + const isNamed = typeof input !== "function"; + const factory = isNamed ? input.factory : input; + const extensionPath = ``; try { const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath); extensions.push(extension); diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 3bec2c32e..75b2ef732 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -100,6 +100,7 @@ export type { ExtensionCommandContext, ExtensionContext, ExtensionFactory, + InlineExtension, SlashCommandInfo, SlashCommandSource, ToolDefinition, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 034db0064..df4c76123 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -100,6 +100,7 @@ export type { ExtensionWidgetOptions, FindToolCallEvent, GrepToolCallEvent, + InlineExtension, InputEvent, InputEventResult, InputSource, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6b3459828..a58e67169 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -25,7 +25,7 @@ import { import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; -import type { ExtensionFactory } from "./core/extensions/types.ts"; +import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; @@ -462,7 +462,7 @@ async function promptForMissingSessionCwd( } export interface MainOptions { - extensionFactories?: ExtensionFactory[]; + extensionFactories?: InlineExtension[]; } export async function main(args: string[], options?: MainOptions) { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index c354c6e35..5c1a0a1cc 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -15,7 +15,7 @@ import { type SelfUpdatePackageTarget, VERSION, } from "./config.ts"; -import type { ExtensionFactory } from "./core/extensions/types.ts"; +import type { InlineExtension } from "./core/extensions/types.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; @@ -484,7 +484,7 @@ function prepareWindowsNpmSelfUpdate(): void { } export interface PackageCommandRuntimeOptions { - extensionFactories?: ExtensionFactory[]; + extensionFactories?: InlineExtension[]; } interface CommandSettingsResult { @@ -507,7 +507,7 @@ async function createCommandSettingsManager(options: { agentDir: string; projectTrustOverride?: boolean; useSavedProjectTrustOnly?: boolean; - extensionFactories?: ExtensionFactory[]; + extensionFactories?: InlineExtension[]; }): Promise { const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); const projectTrustWarnings: string[] = []; diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 0fb95825c..1f01cad03 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -22,7 +22,7 @@ import { ModelRegistry } from "../../src/core/model-registry.ts"; import { SessionManager } from "../../src/core/session-manager.ts"; import type { Settings } from "../../src/core/settings-manager.ts"; import { SettingsManager } from "../../src/core/settings-manager.ts"; -import type { ExtensionFactory, ResourceLoader } from "../../src/index.ts"; +import type { InlineExtension, ResourceLoader } from "../../src/index.ts"; import { type CreateTestExtensionsResultInput, createTestExtensionsResult, @@ -69,7 +69,7 @@ export interface HarnessOptions { allowedToolNames?: string[]; excludedToolNames?: string[]; resourceLoader?: ResourceLoader; - extensionFactories?: Array; + extensionFactories?: Array; withConfiguredAuth?: boolean; } diff --git a/packages/coding-agent/test/suite/regressions/6260-inline-extension-naming.test.ts b/packages/coding-agent/test/suite/regressions/6260-inline-extension-naming.test.ts new file mode 100644 index 000000000..522e3fa25 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6260-inline-extension-naming.test.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DefaultResourceLoader } from "../../../src/core/resource-loader.ts"; +import type { ExtensionAPI } from "../../../src/index.ts"; + +const noop: (pi: ExtensionAPI) => void = () => {}; + +describe("inline extension naming", () => { + const roots: string[] = []; + + function fixture(name: string) { + const root = join(tmpdir(), `pi-inline-naming-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const cwd = join(root, "project"); + const agentDir = join(root, "agent"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + roots.push(root); + return { root, cwd, agentDir }; + } + + beforeEach(() => { + roots.length = 0; + }); + + afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root && existsSync(root)) { + rmSync(root, { recursive: true, force: true }); + } + } + }); + + it("displays bare factories as ", async () => { + const { cwd, agentDir } = fixture("bare"); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [noop, noop], + }); + + await loader.reload(); + + const result = loader.getExtensions(); + + expect(result.extensions).toHaveLength(2); + expect(result.extensions[0].path).toBe(""); + expect(result.extensions[1].path).toBe(""); + }); + + it("displays named wrappers as ", async () => { + const { cwd, agentDir } = fixture("named"); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [ + { name: "my-provider", factory: noop }, + { name: "my-commands", factory: noop }, + ], + }); + + await loader.reload(); + + const result = loader.getExtensions(); + + expect(result.extensions).toHaveLength(2); + expect(result.extensions[0].path).toBe(""); + expect(result.extensions[1].path).toBe(""); + }); + + it("supports mixed bare and named factories", async () => { + const { cwd, agentDir } = fixture("mixed"); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [noop, { name: "named-ext", factory: noop }, noop], + }); + + await loader.reload(); + + const result = loader.getExtensions(); + + expect(result.extensions).toHaveLength(3); + expect(result.extensions[0].path).toBe(""); + expect(result.extensions[1].path).toBe(""); + expect(result.extensions[2].path).toBe(""); + }); +}); diff --git a/packages/coding-agent/test/test-harness.ts b/packages/coding-agent/test/test-harness.ts index 0ae601ef6..ea4fe2466 100644 --- a/packages/coding-agent/test/test-harness.ts +++ b/packages/coding-agent/test/test-harness.ts @@ -32,7 +32,7 @@ import { ModelRegistry } from "../src/core/model-registry.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import type { Settings } from "../src/core/settings-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -import type { ExtensionFactory, ResourceLoader } from "../src/index.ts"; +import type { InlineExtension, ResourceLoader } from "../src/index.ts"; import { type CreateTestExtensionsResultInput, createTestExtensionsResult, @@ -335,7 +335,7 @@ export interface HarnessOptions { /** Optional resource loader override. */ resourceLoader?: ResourceLoader; /** Inline extensions to load into the session resource loader. */ - extensionFactories?: Array; + extensionFactories?: Array; } export interface Harness { diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 73177ce9a..c76e89ebd 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -11,7 +11,12 @@ import { getOAuthApiKey } from "@earendil-works/pi-ai/oauth"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { createEventBus } from "../src/core/event-bus.ts"; -import type { Extension, ExtensionFactory, LoadExtensionsResult } from "../src/core/extensions/index.ts"; +import type { + Extension, + ExtensionFactory, + InlineExtension, + LoadExtensionsResult, +} from "../src/core/extensions/index.ts"; import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts"; import { ModelRegistry } from "../src/core/model-registry.ts"; import type { ResourceLoader } from "../src/core/resource-loader.ts"; @@ -181,8 +186,10 @@ export interface CreateTestExtensionsResultInput { path?: string; } +type TestExtensionInput = InlineExtension | CreateTestExtensionsResultInput; + export async function createTestExtensionsResult( - inputs: Array, + inputs: TestExtensionInput[], cwd = process.cwd(), ): Promise { const runtime = createExtensionRuntime(); @@ -190,9 +197,12 @@ export async function createTestExtensionsResult( const extensions: Extension[] = []; for (const [index, input] of inputs.entries()) { - const factory = typeof input === "function" ? input : input.factory; - const extensionPath = - typeof input === "function" ? `` : (input.path ?? ``); + const isObject = typeof input !== "function"; + const hasName = isObject && "name" in input; + const hasPath = isObject && "path" in input && typeof input.path === "string" && input.path !== ""; + const factory = isObject ? input.factory : input; + const extensionPath = hasName ? `` : hasPath ? input.path : ``; + extensions.push(await loadExtensionFromFactory(factory, cwd, eventBus, runtime, extensionPath)); }