diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83f9d09d0..db47cfa5f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed summarized `read` output stalling agents on elided regions by appending an explicit footer like `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`. The footer fires whenever the structural summarizer elided at least one span, so the model gets a concrete recovery selector instead of having to guess from a bare `...` / `{ .. }` marker. Surfaces `elidedLines` on `ReadToolDetails.summary` alongside the existing `elidedSpans`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046)) - Updated the `read` tool prompt to describe the new elision footer and instruct the model to follow `:raw` (or an explicit line range) when the elided body is actually needed, rather than guessing. +- Fixed plugin extensions failing to load when their `peerDependencies` reference internal `pi-*` packages under any scope other than `@mariozechner` (e.g. `Cannot find module '@earendil-works/pi-tui'` from `@juicesharp/rpiv-ask-user-question`, or `Cannot find module '@oh-my-pi/pi-utils'` from `@oh-my-pi/swarm-extension`). The legacy-pi specifier shim now treats `@mariozechner`, `@earendil-works`, **and** the canonical `@oh-my-pi` itself as aliases for the same set of bundled in-process packages (`pi-agent-core`, `pi-ai`, `pi-coding-agent`, `pi-natives`, `pi-tui`, `pi-utils`), and additionally rewrites the upstream-only `pi-ai/oauth` subpath onto our `pi-ai/utils/oauth` layout. Restored the `Key` runtime helper export on `@oh-my-pi/pi-tui` to match upstream — plugins using `Key.enter` / `Key.ctrl("c")` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) no longer fail with `Export named 'Key' not found`. End-to-end verified against `@juicesharp/rpiv-ask-user-question`, `@oh-my-pi/swarm-extension`, and `@plannotator/pi-extension` — each now loads cleanly with all of its tools/commands/handlers registered. Plugins importing any of those scopes are remapped to the omp binary's own copy at load time, so peer deps are no longer dragged in from npm and there is exactly one module instance per package regardless of which scope name the plugin's manifest happened to declare. ## [15.0.1] - 2026-05-14 ### Breaking Changes 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 06dd5be39..f878346ae 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -3,21 +3,46 @@ import * as os from "node:os"; import * as path from "node:path"; import * as url from "node:url"; -const LEGACY_PI_PACKAGE_MAP = { - "@mariozechner/pi-agent-core": "@oh-my-pi/pi-agent-core", - "@mariozechner/pi-ai": "@oh-my-pi/pi-ai", - "@mariozechner/pi-coding-agent": "@oh-my-pi/pi-coding-agent", - "@mariozechner/pi-tui": "@oh-my-pi/pi-tui", -} as const; +// Canonical scope for in-process pi packages. Plugins published against any of +// the aliased scopes below (mariozechner's original publish, earendil-works' +// fork, or the canonical @oh-my-pi scope itself) are remapped to this scope and +// resolved against the bundled copy that ships inside the omp binary. This +// keeps plugins running against the exact runtime state of the host (single +// module registry, single tool registry, etc.) regardless of which historical +// scope name they happened to declare in their peerDependencies. +const CANONICAL_PI_SCOPE = "@oh-my-pi"; -const LEGACY_PI_CODING_AGENT_SUBPATH_MAP = { - "extensibility/extensions": "@oh-my-pi/pi-coding-agent/extensibility/extensions", - "extensibility/hooks": "@oh-my-pi/pi-coding-agent/extensibility/hooks", -} as const; +// Scopes that have historically been used to publish (or alias) the same set +// of internal pi-* packages. `@oh-my-pi` is intentionally included so that +// direct imports of the canonical name still flow through `Bun.resolveSync` +// against the host binary, avoiding a duplicate copy being pulled in from a +// plugin's own node_modules tree at install time. +const PI_SCOPE_ALIASES = ["oh-my-pi", "mariozechner", "earendil-works"] as const; -const LEGACY_PI_SPECIFIER_FILTER = /^@mariozechner\/pi-(agent-core|ai|coding-agent|tui)(\/.*)?$/; -const LEGACY_PI_IMPORT_SPECIFIER_REGEX = - /((?:from\s+|import\s*\(\s*)["'])(@mariozechner\/pi-(?:agent-core|ai|coding-agent|tui)(?:\/[^"'()\s]+)?)(["'])/g; +// Internal pi-* package basenames bundled inside the omp binary. +const PI_PACKAGE_NAMES = ["pi-agent-core", "pi-ai", "pi-coding-agent", "pi-natives", "pi-tui", "pi-utils"] as const; + +const PI_SCOPE_ALTERNATION = PI_SCOPE_ALIASES.join("|"); +const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|"); + +// Upstream `@mariozechner/*` packages exposed a few subpaths at the package +// root that we relocated under a different folder. Each entry rewrites +// `/` → `/` after the scope has been canonicalised, so +// plugins importing the upstream layout still resolve to a real file in our +// bundled copy. Add new entries as `pkg/from -> pkg/to` whenever a plugin +// surfaces another upstream-only subpath that breaks resolution. +const PI_SUBPATH_REMAPS: ReadonlyMap = new Map([ + // `@mariozechner/pi-ai/oauth` re-exported `./utils/oauth/index.js`. + // Our pi-ai keeps the implementation under `utils/oauth` but never added a + // root-level re-export, so map the upstream subpath onto it directly. + ["pi-ai/oauth", "pi-ai/utils/oauth"], +]); + +const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`); +const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp( + `((?:from\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`, + "g", +); const LEGACY_PI_FILE_PREFIX = "omp-legacy-pi-file:"; const LEGACY_PI_FILE_NAMESPACE = "omp-legacy-pi-file"; const resolvedSpecifierFallbacks = new Map(); @@ -25,25 +50,17 @@ const resolvedSpecifierFallbacks = new Map(); let isLegacyPiSpecifierShimInstalled = false; function remapLegacyPiSpecifier(specifier: string): string | null { - const [legacyScope, packageName, ...subpathParts] = specifier.split("/"); - const legacyPackageName = `${legacyScope}/${packageName}`; - const mappedPackageName = LEGACY_PI_PACKAGE_MAP[legacyPackageName as keyof typeof LEGACY_PI_PACKAGE_MAP]; - if (!mappedPackageName) { + if (!LEGACY_PI_SPECIFIER_FILTER.test(specifier)) { return null; } - if (subpathParts.length === 0) { - return mappedPackageName; + const slashIdx = specifier.indexOf("/", 1); + // Filter guarantees a slash exists, but guard anyway to keep the type narrow. + if (slashIdx === -1) { + return null; } - - const subpath = subpathParts.join("/"); - if (legacyPackageName === "@mariozechner/pi-coding-agent") { - return ( - LEGACY_PI_CODING_AGENT_SUBPATH_MAP[subpath as keyof typeof LEGACY_PI_CODING_AGENT_SUBPATH_MAP] ?? - `${mappedPackageName}/${subpath}` - ); - } - - return `${mappedPackageName}/${subpath}`; + const rest = specifier.slice(slashIdx + 1); + const remappedSubpath = PI_SUBPATH_REMAPS.get(rest) ?? rest; + return `${CANONICAL_PI_SCOPE}/${remappedSubpath}`; } function getResolvedSpecifier(specifier: string): string { diff --git a/packages/coding-agent/test/pi-scope-aliases.test.ts b/packages/coding-agent/test/pi-scope-aliases.test.ts new file mode 100644 index 000000000..318ad64a5 --- /dev/null +++ b/packages/coding-agent/test/pi-scope-aliases.test.ts @@ -0,0 +1,136 @@ +/** + * Regression: plugin extensions must resolve `pi-*` imports across every scope + * that has ever been used to publish or alias the internal packages — + * `@mariozechner` (original), `@earendil-works` (fork), and `@oh-my-pi` + * (canonical). The shim in `legacy-pi-compat.ts` remaps all three to the same + * in-process bundled copy so that plugins observe a single module registry + * regardless of which scope name their peerDependencies happened to declare. + * + * Reported failures the test covers: + * - `@juicesharp/rpiv-ask-user-question` ⇒ `@earendil-works/pi-tui` + * - `@oh-my-pi/swarm-extension` ⇒ `@oh-my-pi/pi-utils` + * - `@plannotator/pi-extension` ⇒ `@oh-my-pi/pi-agent-core` + * - `@runfusion/fusion` ⇒ `@oh-my-pi/pi-coding-agent/...` + * + * Plus the two upstream-only surfaces that turned up via real-plugin E2E: + * - `Key` runtime helper from `pi-tui` (used by plannotator + rpiv-*). + * - `pi-ai/oauth` subpath (used by runfusion's bundled extension). + */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const canonicalCodingAgent = Bun.resolveSync("@oh-my-pi/pi-coding-agent", import.meta.dir); +const canonicalCodingAgentExtensions = Bun.resolveSync( + "@oh-my-pi/pi-coding-agent/extensibility/extensions", + import.meta.dir, +); +const canonicalUtils = Bun.resolveSync("@oh-my-pi/pi-utils", import.meta.dir); +const canonicalTui = Bun.resolveSync("@oh-my-pi/pi-tui", import.meta.dir); +// Subpath remap: upstream `pi-ai/oauth` re-exported `utils/oauth/index`; the +// shim rewrites the legacy subpath onto its current home so plugins keep +// importing the upstream layout. +const canonicalAiOauth = Bun.resolveSync("@oh-my-pi/pi-ai/utils/oauth", import.meta.dir); + +interface AliasCase { + id: string; + aliasSpecifier: string; + canonicalPath: string; + symbol: string; +} + +const CASES: readonly AliasCase[] = [ + // @earendil-works fork — used by @juicesharp/rpiv-* plugins. + { + id: "earendil-tui", + aliasSpecifier: "@earendil-works/pi-tui", + canonicalPath: canonicalTui, + symbol: "visibleWidth", + }, + // @oh-my-pi self-import — canonical scope must still flow through the shim + // so a duplicate copy is never dragged in from a plugin's own node_modules. + { id: "ohmypi-utils", aliasSpecifier: "@oh-my-pi/pi-utils", canonicalPath: canonicalUtils, symbol: "logger" }, + { + id: "ohmypi-coding-agent", + aliasSpecifier: "@oh-my-pi/pi-coding-agent", + canonicalPath: canonicalCodingAgent, + symbol: "isToolCallEventType", + }, + // @mariozechner — defends the original remap (regression: issue #973). + { + id: "mariozechner-extensions", + aliasSpecifier: "@mariozechner/pi-coding-agent/extensibility/extensions", + canonicalPath: canonicalCodingAgentExtensions, + symbol: "isToolCallEventType", + }, + // Subpath remap: legacy `pi-ai/oauth` should resolve to `pi-ai/utils/oauth`. + { + id: "mariozechner-ai-oauth", + aliasSpecifier: "@mariozechner/pi-ai/oauth", + canonicalPath: canonicalAiOauth, + // `refreshOAuthToken` is exported by our `utils/oauth/index` and by + // upstream's `oauth.d.ts`; it makes a stable probe across both layouts. + symbol: "refreshOAuthToken", + }, + // `Key` runtime helper restored on pi-tui (plannotator + rpiv-* import it). + { + id: "earendil-tui-key", + aliasSpecifier: "@earendil-works/pi-tui", + canonicalPath: canonicalTui, + symbol: "Key", + }, +]; + +describe("pi-* scope aliases", () => { + let projectDir: TempDir; + let extensionPath: string; + + beforeEach(() => { + projectDir = TempDir.createSync("@pi-scope-aliases-"); + const pluginDir = path.join(projectDir.path(), "alias-probe-plugin"); + extensionPath = path.join(pluginDir, "dist", "extension.ts"); + fs.mkdirSync(path.dirname(extensionPath), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "alias-probe-plugin", + version: "1.0.0", + pi: { extensions: ["./dist/extension.ts"] }, + }), + ); + + // Each case imports the same symbol via the aliased scope and via the + // resolved canonical absolute path. The default factory throws unless the + // two are object-identical, proving they came from a single module + // instance. + const lines: string[] = []; + const checks: string[] = []; + for (const [idx, c] of CASES.entries()) { + lines.push(`import { ${c.symbol} as alias${idx} } from "${c.aliasSpecifier}";`); + lines.push(`import { ${c.symbol} as canonical${idx} } from ${JSON.stringify(c.canonicalPath)};`); + checks.push( + `if (alias${idx} !== canonical${idx}) throw new Error(${JSON.stringify( + `${c.aliasSpecifier} did not remap to the bundled copy (case ${c.id})`, + )});`, + ); + } + + fs.writeFileSync( + extensionPath, + [...lines, "", ...checks, "", "export default function(pi) {", "\t/* no-op */", "}"].join("\n"), + ); + }); + + afterEach(() => { + projectDir.removeSync(); + }); + + it("remaps every aliased pi-* scope and known upstream subpath to the bundled in-process copy", async () => { + const result = await loadExtensions([extensionPath], projectDir.path()); + expect(result.errors).toEqual([]); + const extension = result.extensions.find(ext => ext.path === extensionPath); + expect(extension).toBeDefined(); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ccf9c3c04..80ff783c3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Restored the `Key` runtime helper on `@oh-my-pi/pi-tui` to mirror upstream `@mariozechner/pi-tui`'s surface. `Key.enter`, `Key.escape`, `Key.tab`, … return the canonical key-name strings; modifier methods (`Key.ctrl(k)`, `Key.shift(k)`, `Key.ctrlShift(k)`, etc.) build precisely-typed `KeyId` literals like `"ctrl+c"`. Pure runtime convenience for typed key-id construction — plugins built against the upstream package surface that import `Key` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) load again now that the specifier shim remaps them onto this package. + ## [15.0.1] - 2026-05-14 ### Breaking Changes diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index b68c82ac8..f671bf50b 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -186,6 +186,100 @@ type ModifiedKeyId; +/** + * Typed helper for constructing key identifiers with autocomplete. + * + * The runtime values are just the canonical key-name strings (so `Key.enter` + * is literally `"enter"`); the value of `Key` over a bag of magic strings is + * that each property is typed to the exact `KeyId` literal it produces and the + * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")` + * is `"ctrl+c"`, not just `string`). This mirrors the upstream + * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any + * scope alias (`@mariozechner`, `@earendil-works`, `@oh-my-pi`) keep working + * once the specifier shim remaps them to this package. + */ +export const Key = { + escape: "escape", + esc: "esc", + enter: "enter", + return: "return", + tab: "tab", + space: "space", + backspace: "backspace", + delete: "delete", + insert: "insert", + clear: "clear", + home: "home", + end: "end", + pageUp: "pageUp", + pageDown: "pageDown", + up: "up", + down: "down", + left: "left", + right: "right", + f1: "f1", + f2: "f2", + f3: "f3", + f4: "f4", + f5: "f5", + f6: "f6", + f7: "f7", + f8: "f8", + f9: "f9", + f10: "f10", + f11: "f11", + f12: "f12", + backtick: "`", + hyphen: "-", + equals: "=", + leftbracket: "[", + rightbracket: "]", + backslash: "\\", + semicolon: ";", + quote: "'", + comma: ",", + period: ".", + slash: "/", + exclamation: "!", + at: "@", + hash: "#", + dollar: "$", + percent: "%", + caret: "^", + ampersand: "&", + asterisk: "*", + leftparen: "(", + rightparen: ")", + underscore: "_", + plus: "+", + pipe: "|", + tilde: "~", + leftbrace: "{", + rightbrace: "}", + colon: ":", + lessthan: "<", + greaterthan: ">", + question: "?", + ctrl: (key: K) => `ctrl+${key}` as const, + shift: (key: K) => `shift+${key}` as const, + alt: (key: K) => `alt+${key}` as const, + super: (key: K) => `super+${key}` as const, + ctrlShift: (key: K) => `ctrl+shift+${key}` as const, + shiftCtrl: (key: K) => `shift+ctrl+${key}` as const, + ctrlAlt: (key: K) => `ctrl+alt+${key}` as const, + altCtrl: (key: K) => `alt+ctrl+${key}` as const, + shiftAlt: (key: K) => `shift+alt+${key}` as const, + altShift: (key: K) => `alt+shift+${key}` as const, + ctrlSuper: (key: K) => `ctrl+super+${key}` as const, + superCtrl: (key: K) => `super+ctrl+${key}` as const, + shiftSuper: (key: K) => `shift+super+${key}` as const, + superShift: (key: K) => `super+shift+${key}` as const, + altSuper: (key: K) => `alt+super+${key}` as const, + superAlt: (key: K) => `super+alt+${key}` as const, + ctrlShiftAlt: (key: K) => `ctrl+shift+alt+${key}` as const, + ctrlShiftSuper: (key: K) => `ctrl+shift+super+${key}` as const, +} as const; + // ============================================================================= // Kitty Protocol Parsing // =============================================================================