diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 62abf7a03..f636e66d7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -95,6 +95,10 @@ - Fixed WSL2 TUI stutter by adding a `git.enabled` setting and skipping footer/status-line git probes when disabled or when no git-backed status segment is visible ([#2847](https://github.com/can1357/oh-my-pi/issues/2847)). - Fixed JSON-mode startup notices (export/resume/session-picker messages) writing to stdout before the JSON event stream; they now route to stderr so stdout remains newline-delimited JSON. +### Fixed + +- Fixed legacy plugin validation for extensions that import `defineTool`, `StringEnum`, frontmatter helpers, `SettingsManager`, `createCodingTools`, or the bare `typebox` package through the hosted Pi compatibility shims ([#2858](https://github.com/can1357/oh-my-pi/issues/2858)). + ## [16.0.4] - 2026-06-17 ### Fixed diff --git a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts index 8006e475b..cbe230d1b 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts @@ -11,14 +11,54 @@ * bare package root. Subpath imports (`@oh-my-pi/pi-ai/oauth`, etc.) * continue to resolve directly against the bundled pi-ai package. * - * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that - * already serves bare `@sinclair/typebox` imports for the same extension - * class, keeping the legacy-compat surface internally consistent. + * The `Type` runtime and legacy `StringEnum()` helper are borrowed from the + * Zod-backed TypeBox shim that already serves TypeBox imports for the same + * extension class, keeping the legacy-compat surface internally consistent. * * Type-level `Static` and `TSchema` continue to come from pi-ai's own * `types.ts` via the `export *` below — pi-ai still exports both as types, - * only the runtime `Type` builder was removed. + * only the runtime `Type` builder and `StringEnum()` helper were removed. */ +import { z } from "zod/v4"; +import { type TSchema, Type } from "./typebox"; + +export interface StringEnumOptions { + description?: string; + default?: T; + examples?: T[]; + [key: string]: unknown; +} + +function stringEnumWireSchema(values: readonly T[], options: StringEnumOptions | undefined) { + const schema: Record = { + type: "string", + enum: [...values], + }; + if (!options) return schema; + for (const key in options) { + if (options[key] !== undefined) { + schema[key] = options[key]; + } + } + return schema; +} + +export function StringEnum(values: readonly T[], options?: StringEnumOptions): TSchema { + let schema: TSchema = + values.length === 0 + ? z.never() + : z.enum(values).describe(options?.description ?? "Legacy string enum compatibility schema"); + if (options && "default" in options) { + schema = schema.default(options.default); + } + Object.defineProperty(schema, "toJSON", { + value: () => stringEnumWireSchema(values, options), + enumerable: false, + writable: true, + configurable: true, + }); + return schema; +} export * from "@oh-my-pi/pi-ai"; -export { Type } from "./typebox"; +export { Type }; diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 8eb32d814..28731ca40 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -12,4 +12,117 @@ * the same module identity as a direct `@oh-my-pi/pi-coding-agent` import. */ +import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { TSchema } from "@oh-my-pi/pi-ai"; +import { parseFrontmatter as parseOmpFrontmatter } from "@oh-my-pi/pi-utils"; +import { Settings } from "../config/settings"; +import { BUILTIN_TOOLS, type Tool, type ToolSession } from "../tools"; +import type { ToolDefinition } from "./extensions/types"; +import { Type } from "./typebox"; + +const TOOL_DEFINITION_MARKER = "__isToolDefinition"; +const LEGACY_BUILTIN_TOOL_MARKER = "__ompLegacyBuiltinTool"; +const LEGACY_CODING_TOOL_NAMES = ["read", "bash", "edit", "write"] as const; + +type LegacyCodingToolName = (typeof LEGACY_CODING_TOOL_NAMES)[number]; +type LegacyBuiltinToolDefinition = ToolDefinition & { [LEGACY_BUILTIN_TOOL_MARKER]: true }; + +function markToolDefinition( + tool: ToolDefinition, +): ToolDefinition { + Object.defineProperty(tool, TOOL_DEFINITION_MARKER, { + value: true, + enumerable: false, + writable: false, + configurable: true, + }); + return tool; +} +function legacyToolSession(cwd: string): ToolSession { + return { + cwd, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => null, + settings: Settings.isolated(), + }; +} + +function createBuiltinTool(cwd: string, name: LegacyCodingToolName): Tool { + const tool = BUILTIN_TOOLS[name](legacyToolSession(cwd)); + if (tool instanceof Promise) { + throw new Error(`Built-in ${name} tool factory unexpectedly returned a promise.`); + } + if (!tool) { + throw new Error(`Built-in ${name} tool is unavailable.`); + } + return tool; +} + +async function executeBuiltinTool( + cwd: string, + name: LegacyCodingToolName, + toolCallId: string, + params: unknown, + signal: AbortSignal | undefined, + onUpdate: AgentToolUpdateCallback | undefined, +) { + const tool = createBuiltinTool(cwd, name); + return tool.execute(toolCallId, params, signal, onUpdate); +} + +function legacyBuiltinTool(cwd: string, name: LegacyCodingToolName): ToolDefinition { + const tool = createBuiltinTool(cwd, name); + const definition: LegacyBuiltinToolDefinition = { + name: tool.name, + label: tool.label, + description: tool.description, + parameters: tool.parameters, + hidden: tool.hidden, + deferrable: tool.deferrable, + approval: tool.approval, + execute: (toolCallId, params, signal, onUpdate) => + executeBuiltinTool(cwd, name, toolCallId, params, signal, onUpdate), + [LEGACY_BUILTIN_TOOL_MARKER]: true, + }; + return markToolDefinition(definition); +} + +export interface ParsedFrontmatter = Record> { + frontmatter: T; + body: string; +} + +export function parseFrontmatter = Record>( + content: string, +): ParsedFrontmatter { + const { frontmatter, body } = parseOmpFrontmatter(content, { level: "fatal" }); + return { frontmatter: frontmatter as T, body }; +} + +export function stripFrontmatter(content: string): string { + return parseFrontmatter(content).body; +} + +export function defineTool( + tool: ToolDefinition, +): ToolDefinition { + return markToolDefinition(tool); +} + +export function createCodingTools(cwd: string): ToolDefinition[] { + return LEGACY_CODING_TOOL_NAMES.map(name => legacyBuiltinTool(cwd, name)); +} + +export const SettingsManager = { + create(cwd: string, agentDir?: string): Promise { + return Settings.init({ cwd, agentDir }); + }, + + inMemory(): Settings { + return Settings.isolated(); + }, +} as const; + export * from "../index"; +export { Type }; diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 49b4a0026..dd024c390 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -64,14 +64,14 @@ const packageRootCache = new Map(); const packageImportsCache = new Map | null>(); const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded"); -// Extensions that imported `@sinclair/typebox` directly used to resolve against a -// real `@sinclair/typebox` install. The runtime dep was replaced with the Zod-backed -// shim under `extensibility/typebox.ts`; plugins still importing the public name -// are redirected to that shim so existing extensions keep working without code -// changes. Submodules like `@sinclair/typebox/compiler` are intentionally not -// remapped — those expose TypeBox-only APIs the shim does not provide and plugins -// relying on them must vendor `@sinclair/typebox` directly. -const TYPEBOX_SPECIFIER_FILTER = /^@sinclair\/typebox$/; +// Extensions that imported TypeBox directly used to resolve against a real +// `@sinclair/typebox` or `typebox` install. The runtime dep was replaced with +// the Zod-backed shim under `extensibility/typebox.ts`; plugins still importing +// either public name are redirected to that shim so existing extensions keep +// working without code changes. Submodules like `@sinclair/typebox/compiler` +// are intentionally not remapped — those expose TypeBox-only APIs the shim does +// not provide and plugins relying on them must vendor TypeBox directly. +const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/; // Compat shim and bundled-package paths used in compiled-binary mode. The shim // paths must point at files that ship inside the bunfs root; in dev / @@ -295,14 +295,14 @@ function rewriteLegacyPiImports(source: string): string { ); } -// Match the bare `@sinclair/typebox` import specifier (static + dynamic). -// Subpath imports like `@sinclair/typebox/compiler` are intentionally excluded — -// they expose TypeBox-only APIs the Zod-backed shim does not provide. -const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g; +// Match the bare TypeBox import specifiers (static + dynamic). Subpath imports +// like `@sinclair/typebox/compiler` are intentionally excluded — they expose +// TypeBox-only APIs the Zod-backed shim does not provide. +const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox|typebox)(["'])/g; /** * Rewrite the extension-owned specifiers OMP must host-resolve — legacy - * `@(scope)/pi-*`, bare `@sinclair/typebox`, and package `imports` aliases like + * `@(scope)/pi-*`, bare TypeBox packages, and package `imports` aliases like * `#src/*` — to absolute `file://` URLs. Every other specifier (relative * siblings and third-party dependencies) is left untouched so Bun resolves it * natively from the extension's real on-disk location. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 592c79384..9fb83aca6 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -528,6 +528,11 @@ export interface CreateAgentSessionOptions { /** Settings instance. Default: Settings.init({ cwd, agentDir }) */ settings?: Settings; + /** + * Legacy alias for `settings`. Older Pi extensions pass SettingsManager.create(...) + * through this field; accept it so their SDK calls keep the configured settings. + */ + settingsManager?: Settings | Promise; /** Whether UI is available (enables interactive tools like ask). Default: false */ hasUI?: boolean; @@ -874,6 +879,10 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool { return !(tool as any).__isToolDefinition; } +function isLegacyBuiltinToolDefinition(tool: CustomTool | ToolDefinition): boolean { + return !isCustomTool(tool) && "__ompLegacyBuiltinTool" in tool && tool.__ompLegacyBuiltinTool === true; +} + const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition"); /** Matches the truncation applied to per-server instructions inside `rebuildSystemPrompt`. */ @@ -1138,7 +1147,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} startupCredentialDisabledEvents.push(event); } }); - const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir })); + const settings = await (options.settings ?? + options.settingsManager ?? + logger.time("settings", Settings.init, { cwd, agentDir })); logger.time("initializeWithSettings", initializeWithSettings, settings); if (!options.modelRegistry) { modelRegistry.refreshInBackground(); @@ -2008,12 +2019,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const toolContextStore = new ToolContextStore(getSessionContext); const registeredTools = extensionRunner.getAllRegisteredTools(); + const sdkCustomTools = options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? []; const allCustomTools = [ ...registeredTools, - ...(options.customTools?.map(tool => { + ...sdkCustomTools.map(tool => { const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool; return { definition, extensionPath: "" }; - }) ?? []), + }), ]; // `wrapToolWithMetaNotice` runs the centralized large-output → artifact spill. // Built-in tools get it in `createTools`; extension, SDK-custom, image-gen, @@ -2291,7 +2303,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Custom tools and extension-registered tools are always included regardless of toolNames filter const alwaysInclude: string[] = [ - ...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []), + ...sdkCustomTools.map(t => (isCustomTool(t) ? t.name : t.name)), ...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name), ]; for (const name of alwaysInclude) { diff --git a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts index f56a0d305..e1dc937f5 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts @@ -88,6 +88,29 @@ describe("legacy-pi @(scope)/pi-ai root `Type` remap (issue #1437)", () => { expect(loaded.zodObj.safeParse({ name: "ok" }).success).toBe(true); expect(loaded.zodObj.safeParse({}).success).toBe(false); }); + it("preserves legacy StringEnum root imports as plain string enum schemas", async () => { + const entry = await writeFixtureExtension( + [ + 'import { StringEnum } from "@earendil-works/pi-ai";', + 'export const schema = StringEnum(["upstream", "downstream"] as const, { default: "upstream" });', + "export const wireSchema = JSON.parse(JSON.stringify(schema));", + ].join("\n"), + ); + + const loaded = (await loadLegacyPiModule(entry)) as { + schema: { safeParse: (input: unknown) => { success: boolean } }; + wireSchema: { type?: string; enum?: string[]; default?: string; anyOf?: unknown }; + }; + + expect(loaded.schema.safeParse("downstream").success).toBe(true); + expect(loaded.schema.safeParse("sideways").success).toBe(false); + expect(loaded.wireSchema).toEqual({ + type: "string", + enum: ["upstream", "downstream"], + default: "upstream", + }); + expect(loaded.wireSchema.anyOf).toBeUndefined(); + }); it("does not redirect subpath imports such as @oh-my-pi/pi-ai/utils/schema", async () => { const entry = await writeFixtureExtension( @@ -124,6 +147,68 @@ describe("legacy pi package root remaps (issue #1474)", () => { expect(loaded.loadedVersion).toMatch(/^\d+\.\d+\.\d+/); }); + it("preserves legacy defineTool root imports and usable coding tools", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-coding-tools-")); + tempRoots.push(dir); + await fs.writeFile(path.join(dir, "sample.txt"), "legacy read body", "utf8"); + const entry = path.join(dir, "index.ts"); + await fs.writeFile( + entry, + [ + 'import { dirname } from "node:path";', + 'import { fileURLToPath } from "node:url";', + 'import { createCodingTools, defineTool, Type } from "@earendil-works/pi-coding-agent";', + "const definition = {", + '\tname: "legacy_define_tool",', + '\tlabel: "Legacy Define Tool",', + '\tdescription: "legacy helper probe",', + "\tparameters: Type.Object({}),", + '\texecute: async () => ({ content: [{ type: "text", text: "ok" }] }),', + "};", + "const cwd = dirname(fileURLToPath(import.meta.url));", + "const codingTools = createCodingTools(cwd);", + "const readTool = codingTools.find(tool => tool.name === 'read');", + "export const tool = defineTool(definition);", + "export const sameReference = tool === definition;", + "export const codingToolNames = codingTools.map(tool => tool.name);", + "export const readResult = await readTool?.execute('legacy-read', { path: 'sample.txt' });", + ].join("\n"), + "utf8", + ); + + const loaded = (await loadLegacyPiModule(entry)) as { + tool: { name: string; parameters: { safeParse: (input: unknown) => { success: boolean } } }; + sameReference: boolean; + codingToolNames: string[]; + readResult: { content: Array<{ type: string; text?: string }> }; + }; + + expect(loaded.sameReference).toBe(true); + expect(loaded.tool.name).toBe("legacy_define_tool"); + expect(loaded.codingToolNames).toEqual(["read", "bash", "edit", "write"]); + expect(loaded.readResult.content[0]?.text).toContain("legacy read body"); + }); + + it("preserves legacy frontmatter helper root imports", async () => { + const entry = await writeFixtureExtension( + [ + 'import { parseFrontmatter, stripFrontmatter } from "@earendil-works/pi-coding-agent";', + "const content = ['---', 'name: demo', '---', '# Body'].join('\\n');", + "export const parsed = parseFrontmatter(content);", + "export const stripped = stripFrontmatter(content);", + ].join("\n"), + ); + + const loaded = (await loadLegacyPiModule(entry)) as { + parsed: { frontmatter: { name?: string }; body: string }; + stripped: string; + }; + + expect(loaded.parsed.frontmatter.name).toBe("demo"); + expect(loaded.parsed.body).toBe("# Body"); + expect(loaded.stripped).toBe("# Body"); + }); + it("falls back to legacy-scoped subpath peers for direct plugin imports", async () => { const realResolveSync = Bun.resolveSync.bind(Bun); vi.spyOn(Bun, "resolveSync").mockImplementation((specifier: string, from: string) => { diff --git a/packages/coding-agent/test/extensibility/typebox-remap.test.ts b/packages/coding-agent/test/extensibility/typebox-remap.test.ts index a1709a017..220993ae2 100644 --- a/packages/coding-agent/test/extensibility/typebox-remap.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-remap.test.ts @@ -31,7 +31,7 @@ async function writeFixtureExtension(source: string): Promise { return entry; } -describe("legacy-pi @sinclair/typebox remap", () => { +describe("legacy-pi TypeBox remap", () => { it("redirects bare @sinclair/typebox imports inside legacy extensions to the in-repo shim", async () => { const entry = await writeFixtureExtension( [ @@ -50,4 +50,23 @@ describe("legacy-pi @sinclair/typebox remap", () => { expect(loaded.objectSchema.safeParse({ name: "ok" }).success).toBe(true); expect(loaded.objectSchema.safeParse({ name: "ok", extra: 1 }).success).toBe(false); }); + + it("redirects bare typebox imports inside legacy extensions to the in-repo shim", async () => { + const entry = await writeFixtureExtension( + [ + 'import { Type } from "typebox";', + "export const probe = Type;", + "export const enumSchema = Type.Enum(['upstream', 'downstream']);", + ].join("\n"), + ); + + const loaded = (await loadLegacyPiModule(entry)) as { + probe: typeof TypeBoxShimType; + enumSchema: { safeParse: (input: unknown) => { success: boolean } }; + }; + + expect(loaded.probe).toBe(TypeBoxShimType); + expect(loaded.enumSchema.safeParse("upstream").success).toBe(true); + expect(loaded.enumSchema.safeParse("sideways").success).toBe(false); + }); });