fix(coding-agent): bind extension APIs to host runtime

This commit is contained in:
jiwangyihao
2026-06-11 16:17:01 +08:00
parent e6ee124d7c
commit 788910b7cd
10 changed files with 167 additions and 23 deletions
+1
View File
@@ -152,6 +152,7 @@
- Default API auto-retries now use 10 attempts with a 500ms Anthropic-style exponential backoff capped at 8s with jitter, so transient 502/gateway failures get a longer retry budget without multi-minute local sleeps.
### Fixed
- Fixed extension, custom-tool, custom-command, and hook loaders injecting `pi` through bare `@oh-my-pi/pi-coding-agent` self-imports, which could pull a different cached package version into global installs during plugin load and trigger mixed-runtime stack overflows.
- Fixed `ask` question/result renders so option and answer rows are no longer duplicated when the component is re-rendered
- Fixed streaming `write`/`diff` previews to keep line-number gutter widths stable while content grows, preventing already-rendered preview rows from being reflowed mid-stream
@@ -10,6 +10,8 @@ import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils
import * as zod from "zod/v4";
import { getConfigDirs } from "../../config";
import { execCommand } from "../../exec/exec";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import * as typebox from "../typebox";
import { GreenCommand } from "./bundled/ci-green";
import { ReviewCommand } from "./bundled/review";
@@ -185,7 +187,7 @@ export async function loadCustomCommands(options: LoadCustomCommandsOptions = {}
execCommand(command, args, execOptions?.cwd ?? cwd, execOptions),
typebox,
zod,
pi: await import("@oh-my-pi/pi-coding-agent"),
pi: PiCodingAgent,
};
// 1. Load bundled commands first (lowest priority - can be overridden)
@@ -5,7 +5,10 @@
* Unlike markdown commands which expand to prompts, custom commands can execute
* arbitrary logic with full access to the hook context.
*/
import type * as Zod from "zod/v4";
import type { ExecOptions, ExecResult, HookCommandContext } from "../../extensibility/hooks/types";
import type * as PiCodingAgent from "../../index";
import type * as TypeBox from "../typebox";
// Re-export for custom commands to use
export type { ExecOptions, ExecResult, HookCommandContext };
@@ -20,11 +23,11 @@ export interface CustomCommandAPI {
/** Execute a shell command */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** Injected zod-backed typebox shim (legacy/compat). */
typebox: typeof import("../typebox");
typebox: typeof TypeBox;
/** Injected zod module for Zod-authored custom commands. */
zod: typeof import("zod/v4");
zod: typeof Zod;
/** Injected pi-coding-agent exports */
pi: typeof import("../..");
pi: typeof PiCodingAgent;
}
/**
@@ -14,6 +14,8 @@ import type { ExecOptions } from "../../exec/exec";
import { execCommand } from "../../exec/exec";
import type { HookUIContext } from "../../extensibility/hooks/types";
import { getAllPluginToolPaths } from "../../extensibility/plugins/loader";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import * as typebox from "../typebox";
import { createNoOpUIContext, resolvePath } from "../utils";
import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
@@ -88,7 +90,7 @@ export class CustomToolLoader {
#seenNames: Set<string>;
constructor(
pi: typeof import("@oh-my-pi/pi-coding-agent"),
pi: typeof PiCodingAgent,
cwd: string,
builtInToolNames: string[],
pushPendingAction?: (action: {
@@ -174,12 +176,7 @@ export async function loadCustomTools(
reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
}) => void,
) {
const loader = new CustomToolLoader(
await import("@oh-my-pi/pi-coding-agent"),
cwd,
builtInToolNames,
pushPendingAction,
);
const loader = new CustomToolLoader(PiCodingAgent, cwd, builtInToolNames, pushPendingAction);
await loader.load(pathsWithSources);
return {
tools: loader.tools,
@@ -14,14 +14,18 @@ import type {
import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
import type { FetchImpl, Model, Static, TSchema } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
import type * as Zod from "zod/v4";
import type { Rule } from "../../capability/rule";
import type { ModelRegistry } from "../../config/model-registry";
import type { Settings } from "../../config/settings";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { HookUIContext } from "../../extensibility/hooks/types";
import type * as PiCodingAgent from "../../index";
import type { Theme } from "../../modes/theme/theme";
import type { ReadonlySessionManager } from "../../session/session-manager";
import type { TodoItem } from "../../tools/todo";
import type * as TypeBox from "../typebox";
/** Alias for clarity */
export type CustomToolUIContext = HookUIContext;
@@ -56,13 +60,13 @@ export interface CustomToolAPI {
/** Whether UI is available (false in print/RPC mode) */
hasUI: boolean;
/** File logger for error/warning/debug messages */
logger: typeof import("@oh-my-pi/pi-utils").logger;
logger: typeof PiLogger;
/** Injected zod-backed typebox shim (legacy/compat — Zod-authored tools are preferred). */
typebox: typeof import("../typebox");
typebox: typeof TypeBox;
/** Injected zod module for Zod-authored custom tools. */
zod: typeof import("zod/v4");
zod: typeof Zod;
/** Injected pi-coding-agent exports */
pi: typeof import("../..");
pi: typeof PiCodingAgent;
/** Push a preview action that can later be resolved with the hidden resolve tool */
pushPendingAction(action: CustomToolPendingAction): void;
}
@@ -6,7 +6,6 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, Model, TextContent, TSchema } from "@oh-my-pi/pi-ai";
import * as PiCodingAgent from "@oh-my-pi/pi-coding-agent";
import type { KeyId } from "@oh-my-pi/pi-tui";
import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils";
import * as Zod from "zod/v4";
@@ -15,6 +14,8 @@ import { loadCapability } from "../../discovery";
import { getExtensionNameFromPath } from "../../discovery/helpers";
import type { ExecOptions } from "../../exec/exec";
import { execCommand } from "../../exec/exec";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import type { CustomMessage } from "../../session/messages";
import { EventBus } from "../../utils/event-bus";
import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat";
@@ -30,7 +30,6 @@ import type {
TSchema,
} from "@oh-my-pi/pi-ai";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
import type * as Zod from "zod/v4";
@@ -40,6 +39,7 @@ import type { EditToolDetails } from "../../edit";
import type { PythonResult } from "../../eval/py/executor";
import type { BashResult } from "../../exec/bash-executor";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type * as PiCodingAgent from "../../index";
import type { MemoryRuntimeContext } from "../../memory-backend";
import type { CustomEditor } from "../../modes/components/custom-editor";
import type { Theme } from "../../modes/theme/theme";
@@ -893,7 +893,7 @@ export interface ExtensionAPI {
zod: typeof Zod;
/** Injected pi-coding-agent exports for accessing SDK utilities */
pi: typeof piCodingAgent;
pi: typeof PiCodingAgent;
// =========================================================================
// Event Subscription
@@ -7,6 +7,8 @@ import * as zod from "zod/v4";
import { hookCapability } from "../../capability/hook";
import type { Hook } from "../../discovery";
import { loadCapability } from "../../discovery";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import type { HookMessage } from "../../session/messages";
import type { SessionManager } from "../../session/session-manager";
import * as typebox from "../typebox";
@@ -138,7 +140,7 @@ async function createHookAPI(
logger,
typebox,
zod,
pi: await import("@oh-my-pi/pi-coding-agent"),
pi: PiCodingAgent,
} as HookAPI;
return {
@@ -1,8 +1,11 @@
import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
import type { Component, TUI } from "@oh-my-pi/pi-tui";
import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
import type * as Zod from "zod/v4";
import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type * as PiCodingAgent from "../../index";
import type { Theme } from "../../modes/theme/theme";
import type { HookMessage } from "../../session/messages";
import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
@@ -39,6 +42,7 @@ import type {
TurnEndEvent,
TurnStartEvent,
} from "../shared-events";
import type * as TypeBox from "../typebox";
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -577,13 +581,13 @@ export interface HookAPI {
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** File logger for error/warning/debug messages */
logger: typeof import("@oh-my-pi/pi-utils").logger;
logger: typeof PiLogger;
/** Injected zod-backed typebox shim (legacy/compat — prefer `zod`). */
typebox: typeof import("../typebox");
typebox: typeof TypeBox;
/** Injected zod module for Zod-authored hooks. */
zod: typeof import("zod/v4");
zod: typeof Zod;
/** Injected pi-coding-agent exports */
pi: typeof import("../..");
pi: typeof PiCodingAgent;
}
/**
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as PiCodingAgent from "@oh-my-pi/pi-coding-agent";
import { loadCustomCommands } from "@oh-my-pi/pi-coding-agent/extensibility/custom-commands/loader";
import { loadCustomTools } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/loader";
import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { loadHooks } from "@oh-my-pi/pi-coding-agent/extensibility/hooks/loader";
import { TempDir } from "@oh-my-pi/pi-utils";
declare global {
var __ompHostPiForLoaderIdentityTest: typeof PiCodingAgent | undefined;
}
describe("extension loader host runtime binding", () => {
let projectDir: TempDir | undefined;
beforeEach(() => {
projectDir = TempDir.createSync("@loader-host-runtime-");
globalThis.__ompHostPiForLoaderIdentityTest = PiCodingAgent;
});
afterEach(() => {
projectDir?.removeSync();
projectDir = undefined;
globalThis.__ompHostPiForLoaderIdentityTest = undefined;
});
function writeModule(relativePath: string, source: string): string {
expect(projectDir).toBeDefined();
const modulePath = path.join(projectDir!.path(), relativePath);
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
fs.writeFileSync(modulePath, source);
return modulePath;
}
const identityGuard = `
const expectedPi = globalThis.__ompHostPiForLoaderIdentityTest;
if (!expectedPi) throw new Error("missing host pi module");
if (api.pi !== expectedPi) throw new Error("injected pi module did not match host module");
`;
it("passes the in-process host pi module through every loader API", async () => {
expect(projectDir).toBeDefined();
const cwd = projectDir!.path();
const extensionPath = writeModule(
"extension.ts",
`
export default function(api) {
${identityGuard}
api.registerCommand("identity_extension", { handler: async () => {} });
}
`,
);
const extensionResult = await loadExtensions([extensionPath], cwd);
expect(extensionResult.errors).toEqual([]);
expect(extensionResult.extensions).toHaveLength(1);
expect(extensionResult.extensions[0].commands.has("identity_extension")).toBe(true);
const toolPath = writeModule(
"tool.ts",
`
export default function(api) {
${identityGuard}
return {
name: "identity_tool",
label: "Identity Tool",
description: "Asserts injected pi identity",
parameters: api.zod.object({}),
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
};
}
`,
);
const toolResult = await loadCustomTools([{ path: toolPath }], cwd, []);
expect(toolResult.errors).toEqual([]);
expect(toolResult.tools.map(tool => tool.tool.name)).toEqual(["identity_tool"]);
const agentDir = path.join(cwd, "agent");
const commandPath = writeModule(
path.join("agent", "commands", "identity", "index.ts"),
`
export default function(api) {
${identityGuard}
return {
name: "identity_command",
description: "Asserts injected pi identity",
execute: () => "ok",
};
}
`,
);
const commandResult = await loadCustomCommands({ cwd, agentDir });
expect(commandResult.errors.filter(error => error.path === commandPath)).toEqual([]);
expect(commandResult.commands.some(command => command.command.name === "identity_command")).toBe(true);
const hookPath = writeModule(
"hook.ts",
`
export default function(api) {
${identityGuard}
api.on("identity:event", async () => "ok");
}
`,
);
const hookResult = await loadHooks([hookPath], cwd);
expect(hookResult.errors).toEqual([]);
expect(hookResult.hooks).toHaveLength(1);
expect(hookResult.hooks[0].handlers.has("identity:event")).toBe(true);
});
it("keeps runtime loaders free of bare package self-imports", async () => {
// Normal workspace resolution can make a bare self-import resolve to the same module,
// so keep a narrow static tripwire for the global-install mixed-version layout.
const loaderPaths = [
path.join(import.meta.dir, "..", "src", "extensibility", "extensions", "loader.ts"),
path.join(import.meta.dir, "..", "src", "extensibility", "custom-tools", "loader.ts"),
path.join(import.meta.dir, "..", "src", "extensibility", "custom-commands", "loader.ts"),
path.join(import.meta.dir, "..", "src", "extensibility", "hooks", "loader.ts"),
];
for (const loaderPath of loaderPaths) {
const source = await Bun.file(loaderPath).text();
expect(source).not.toMatch(/from\s+["']@oh-my-pi\/pi-coding-agent["']/);
expect(source).not.toMatch(/import\(\s*["']@oh-my-pi\/pi-coding-agent["']\s*\)/);
}
});
});