feat(coding-agent): add InlineExtension type for named inline extension factories (#6267)

* feat(coding-agent): add InlineExtension type for named inline extension factories

* test(coding-agent): update utilities and add regression test for InlineExtension
This commit is contained in:
Victor Araújo 2026-07-06 15:59:37 -03:00 committed by GitHub
parent c8ada4e76e
commit b3dff19a04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 164 additions and 19 deletions

View file

@ -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 `<inline:1>`, `<inline:2>`, 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 `<inline:my-provider>` instead of `<inline:1>`. 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

View file

@ -84,6 +84,7 @@ export type {
GetThinkingLevelHandler,
GrepToolCallEvent,
GrepToolResultEvent,
InlineExtension,
// Events - Input
InputEvent,
InputEventResult,

View file

@ -1446,6 +1446,14 @@ export interface ProviderModelConfig {
/** Extension factory function type. Supports both sync and async initialization. */
export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
export type InlineExtension =
| ExtensionFactory
| {
/** Display name shown as `<inline:name>` in the startup Extensions list. */
name: string;
factory: ExtensionFactory;
};
// ============================================================================
// Loaded Extension Types
// ============================================================================

View file

@ -55,6 +55,7 @@ export {
ExtensionRunner,
type ExtensionShortcut,
type ExtensionUIContext,
type InlineExtension,
type LoadExtensionsResult,
type MessageRenderer,
type RegisteredCommand,

View file

@ -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 = `<inline:${index + 1}>`;
for (const [index, input] of this.extensionFactories.entries()) {
const isNamed = typeof input !== "function";
const factory = isNamed ? input.factory : input;
const extensionPath = `<inline:${isNamed ? input.name : index + 1}>`;
try {
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);
extensions.push(extension);

View file

@ -100,6 +100,7 @@ export type {
ExtensionCommandContext,
ExtensionContext,
ExtensionFactory,
InlineExtension,
SlashCommandInfo,
SlashCommandSource,
ToolDefinition,

View file

@ -100,6 +100,7 @@ export type {
ExtensionWidgetOptions,
FindToolCallEvent,
GrepToolCallEvent,
InlineExtension,
InputEvent,
InputEventResult,
InputSource,

View file

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

View file

@ -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<CommandSettingsResult> {
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false });
const projectTrustWarnings: string[] = [];

View file

@ -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<ExtensionFactory | CreateTestExtensionsResultInput>;
extensionFactories?: Array<InlineExtension | CreateTestExtensionsResultInput>;
withConfiguredAuth?: boolean;
}

View file

@ -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 <inline:N>", 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("<inline:1>");
expect(result.extensions[1].path).toBe("<inline:2>");
});
it("displays named wrappers as <inline:name>", 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("<inline:my-provider>");
expect(result.extensions[1].path).toBe("<inline:my-commands>");
});
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("<inline:1>");
expect(result.extensions[1].path).toBe("<inline:named-ext>");
expect(result.extensions[2].path).toBe("<inline:3>");
});
});

View file

@ -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<ExtensionFactory | CreateTestExtensionsResultInput>;
extensionFactories?: Array<InlineExtension | CreateTestExtensionsResultInput>;
}
export interface Harness {

View file

@ -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<ExtensionFactory | CreateTestExtensionsResultInput>,
inputs: TestExtensionInput[],
cwd = process.cwd(),
): Promise<LoadExtensionsResult> {
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" ? `<inline:${index + 1}>` : (input.path ?? `<inline:${index + 1}>`);
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 ? `<inline:${input.name}>` : hasPath ? input.path : `<inline:${index + 1}>`;
extensions.push(await loadExtensionFromFactory(factory, cwd, eventBus, runtime, extensionPath));
}