fix(coding-agent): bind extension APIs to host runtime
This commit is contained in:
@@ -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*\)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user