@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. package/src/edit/modes/hashline.ts +0 -2039
@@ -0,0 +1,213 @@
1
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
+ import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
+ import { theme } from "../modes/theme/theme";
4
+ import { copyToClipboard } from "../utils/clipboard";
5
+ import { formatRawSseIsoTime, type RawSseDebugBuffer, rawSseRecordLines } from "./raw-sse-buffer";
6
+
7
+ const MIN_VIEWER_WIDTH = 20;
8
+ const VIEWER_FRAME_LINES = 5;
9
+
10
+ function sanitizeFrameLine(line: string, width: number): string {
11
+ return truncateToWidth(replaceTabs(sanitizeText(line)), width);
12
+ }
13
+
14
+ export interface RawSseViewerOptions {
15
+ buffer: RawSseDebugBuffer;
16
+ terminalRows: number;
17
+ onExit: () => void;
18
+ onStatus?: (message: string) => void;
19
+ onUpdate?: () => void;
20
+ }
21
+
22
+ export class RawSseViewerComponent implements Component {
23
+ readonly #buffer: RawSseDebugBuffer;
24
+ readonly #terminalRows: number;
25
+ readonly #onExit: () => void;
26
+ readonly #onStatus?: (message: string) => void;
27
+ readonly #onUpdate?: () => void;
28
+ readonly #unsubscribe: () => void;
29
+ #scrollOffset = 0;
30
+ #followTail = true;
31
+ #lastRenderWidth = MIN_VIEWER_WIDTH;
32
+ #statusMessage: string | undefined;
33
+
34
+ constructor(options: RawSseViewerOptions) {
35
+ this.#buffer = options.buffer;
36
+ this.#terminalRows = options.terminalRows;
37
+ this.#onExit = options.onExit;
38
+ this.#onStatus = options.onStatus;
39
+ this.#onUpdate = options.onUpdate;
40
+ this.#unsubscribe = this.#buffer.subscribe(() => {
41
+ this.#followIfNeeded();
42
+ this.#onUpdate?.();
43
+ });
44
+ }
45
+
46
+ handleInput(keyData: string): void {
47
+ if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
48
+ this.#unsubscribe();
49
+ this.#onExit();
50
+ return;
51
+ }
52
+
53
+ if (matchesKey(keyData, "ctrl+c")) {
54
+ this.#copyAll();
55
+ return;
56
+ }
57
+
58
+ if (matchesKey(keyData, "up")) {
59
+ this.#followTail = false;
60
+ this.#scrollOffset = Math.max(0, this.#scrollOffset - 1);
61
+ this.#onUpdate?.();
62
+ return;
63
+ }
64
+
65
+ if (matchesKey(keyData, "down")) {
66
+ this.#followTail = false;
67
+ this.#scrollOffset = Math.min(this.#maxScrollOffset(), this.#scrollOffset + 1);
68
+ this.#onUpdate?.();
69
+ return;
70
+ }
71
+
72
+ if (matchesKey(keyData, "pageUp")) {
73
+ this.#followTail = false;
74
+ this.#scrollOffset = Math.max(0, this.#scrollOffset - this.#bodyHeight());
75
+ this.#onUpdate?.();
76
+ return;
77
+ }
78
+
79
+ if (matchesKey(keyData, "pageDown")) {
80
+ this.#followTail = false;
81
+ this.#scrollOffset = Math.min(this.#maxScrollOffset(), this.#scrollOffset + this.#bodyHeight());
82
+ this.#onUpdate?.();
83
+ return;
84
+ }
85
+
86
+ if (matchesKey(keyData, "end")) {
87
+ this.#followTail = true;
88
+ this.#scrollToTail();
89
+ this.#onUpdate?.();
90
+ }
91
+ }
92
+
93
+ invalidate(): void {}
94
+
95
+ render(width: number): string[] {
96
+ this.#lastRenderWidth = Math.max(MIN_VIEWER_WIDTH, width);
97
+ this.#followIfNeeded();
98
+
99
+ const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
100
+ const bodyHeight = this.#bodyHeight();
101
+ const rawLines = this.#renderRawLines(innerWidth);
102
+ const body = rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight);
103
+ while (body.length < bodyHeight) body.push("");
104
+
105
+ return [
106
+ this.#frameTop(innerWidth),
107
+ this.#frameLine(this.#summaryText(), innerWidth),
108
+ this.#frameSeparator(innerWidth),
109
+ ...body.map(line => this.#frameLine(line, innerWidth)),
110
+ this.#frameLine(this.#statusText(), innerWidth),
111
+ this.#frameBottom(innerWidth),
112
+ ];
113
+ }
114
+
115
+ #renderRawLines(innerWidth: number): string[] {
116
+ const snapshot = this.#buffer.snapshot();
117
+ if (snapshot.records.length === 0) {
118
+ return [
119
+ theme.fg("muted", "No raw SSE frames captured yet."),
120
+ theme.fg("muted", "HTTP SSE providers populate this view while a model response is streaming."),
121
+ ];
122
+ }
123
+
124
+ const lines: string[] = [];
125
+ if (snapshot.droppedRecords > 0) {
126
+ lines.push(
127
+ theme.fg(
128
+ "warning",
129
+ `: omp-debug-dropped records=${snapshot.droppedRecords} chars=${snapshot.droppedChars}`,
130
+ ),
131
+ );
132
+ lines.push("");
133
+ }
134
+ for (const record of snapshot.records) {
135
+ for (const line of rawSseRecordLines(record)) {
136
+ lines.push(sanitizeFrameLine(line, innerWidth));
137
+ }
138
+ if (record.kind === "event" && record.truncated) {
139
+ lines.push(theme.fg("warning", `: omp-debug-event-truncated originalChars=${record.originalChars}`));
140
+ }
141
+ lines.push("");
142
+ }
143
+ return lines;
144
+ }
145
+
146
+ #summaryText(): string {
147
+ const snapshot = this.#buffer.snapshot();
148
+ const last = snapshot.lastUpdatedAt ? ` last=${formatRawSseIsoTime(snapshot.lastUpdatedAt)}` : "";
149
+ const follow = this.#followTail ? "follow:on" : "follow:off";
150
+ return ` # raw SSE | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
151
+ }
152
+
153
+ #statusText(): string {
154
+ return this.#statusMessage ?? " Up/Down scroll PgUp/PgDn page";
155
+ }
156
+
157
+ #bodyHeight(): number {
158
+ return Math.max(3, this.#terminalRows - VIEWER_FRAME_LINES);
159
+ }
160
+
161
+ #followIfNeeded(): void {
162
+ if (this.#followTail) this.#scrollToTail();
163
+ }
164
+
165
+ #scrollToTail(): void {
166
+ this.#scrollOffset = this.#maxScrollOffset();
167
+ }
168
+
169
+ #maxScrollOffset(): number {
170
+ const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
171
+ return Math.max(0, this.#renderRawLines(innerWidth).length - this.#bodyHeight());
172
+ }
173
+
174
+ #copyAll(): void {
175
+ const payload = this.#buffer.toRawText();
176
+ if (payload.trim().length === 0) {
177
+ const message = "No raw SSE frames to copy";
178
+ this.#statusMessage = message;
179
+ this.#onStatus?.(message);
180
+ this.#onUpdate?.();
181
+ return;
182
+ }
183
+
184
+ try {
185
+ copyToClipboard(payload);
186
+ const message = "Copied raw SSE stream";
187
+ this.#statusMessage = message;
188
+ this.#onStatus?.(message);
189
+ } catch (error) {
190
+ const message = error instanceof Error ? error.message : String(error);
191
+ this.#statusMessage = `Copy failed: ${message}`;
192
+ }
193
+ this.#onUpdate?.();
194
+ }
195
+
196
+ #frameTop(innerWidth: number): string {
197
+ return `${theme.boxSharp.topLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.topRight}`;
198
+ }
199
+
200
+ #frameSeparator(innerWidth: number): string {
201
+ return `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.teeLeft}`;
202
+ }
203
+
204
+ #frameBottom(innerWidth: number): string {
205
+ return `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal.repeat(innerWidth)}${theme.boxSharp.bottomRight}`;
206
+ }
207
+
208
+ #frameLine(content: string, innerWidth: number): string {
209
+ const truncated = truncateToWidth(content, innerWidth);
210
+ const remaining = Math.max(0, innerWidth - visibleWidth(truncated));
211
+ return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`;
212
+ }
213
+ }
package/src/edit/index.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import { prompt } from "@oh-my-pi/pi-utils";
3
3
  import type { Static } from "@sinclair/typebox";
4
+ import {
5
+ executeHashlineSingle,
6
+ HashlineMismatchError,
7
+ type HashlineParams,
8
+ hashlineEditParamsSchema,
9
+ } from "../hashline";
10
+ import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" };
11
+ import { resolveHashlineGrammarPlaceholders } from "../hashline/hash";
4
12
  import {
5
13
  createLspWritethrough,
6
14
  type FileDiagnosticsResult,
@@ -16,16 +24,8 @@ import type { ToolSession } from "../tools";
16
24
  import { VimTool, vimSchema } from "../tools/vim";
17
25
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
18
26
  import type { VimToolDetails } from "../vim/types";
19
- import { resolveHashlineGrammarPlaceholders } from "./line-hash";
20
27
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
21
28
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
22
- import {
23
- executeHashlineSingle,
24
- HashlineMismatchError,
25
- type HashlineParams,
26
- hashlineEditParamsSchema,
27
- } from "./modes/hashline";
28
- import hashlineGrammarTemplate from "./modes/hashline.lark" with { type: "text" };
29
29
  import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
30
30
  import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
31
31
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
@@ -34,13 +34,12 @@ export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/ed
34
34
  export * from "./apply-patch";
35
35
  export * from "./diff";
36
36
  export * from "./file-read-cache";
37
- export * from "./line-hash";
38
37
 
39
38
  // Resolve the `$HFMT$` and `$HSEP$` placeholders in the hashline Lark grammar.
40
39
  const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
41
40
 
41
+ export * from "../hashline";
42
42
  export * from "./modes/apply-patch";
43
- export * from "./modes/hashline";
44
43
  export * from "./modes/patch";
45
44
  export * from "./modes/replace";
46
45
  export * from "./normalize";
@@ -12,17 +12,18 @@
12
12
  * The shared renderer / `ToolExecutionComponent` consult the strategy via
13
13
  * the injected `editMode` rather than probing argument shape.
14
14
  */
15
- import type { Theme } from "../modes/theme/theme";
16
- import { type EditMode, resolveEditMode } from "../utils/edit-mode";
17
- import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
18
- import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
15
+
19
16
  import {
20
17
  computeHashlineDiff,
21
18
  computeHashlineSectionDiff,
22
19
  containsRecognizableHashlineOperations,
23
20
  type HashlineInputSection,
24
21
  splitHashlineInputs,
25
- } from "./modes/hashline";
22
+ } from "../hashline";
23
+ import type { Theme } from "../modes/theme/theme";
24
+ import { type EditMode, resolveEditMode } from "../utils/edit-mode";
25
+ import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
26
+ import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
26
27
  import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
27
28
  import type { ReplaceEditEntry } from "./modes/replace";
28
29
 
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
5
5
  import * as util from "node:util";
6
6
  import * as vm from "node:vm";
7
7
 
8
+ import { parse as babelParse } from "@babel/parser";
8
9
  import * as Diff from "diff";
9
10
  import type { ToolSession } from "../../tools";
10
11
  import { ToolError } from "../../tools/tool-errors";
@@ -488,65 +489,108 @@ function buildRequire(cwd: string): NodeJS.Require {
488
489
  return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
489
490
  }
490
491
 
491
- // Static `import ... from "x"` is not valid inside vm.runInContext. Rewrite the common
492
- // forms to dynamic `await import(...)` so users can paste ESM-style imports verbatim.
493
- const STATIC_IMPORT_RE = /^[ \t]*import\b(?:[ \t]+([^'"\n]+?)[ \t]+from)?[ \t]*(['"])([^'"\n]+)\2[ \t]*;?[ \t]*$/gm;
494
-
495
- function splitTopLevel(clause: string): string[] {
496
- const out: string[] = [];
497
- let depth = 0;
498
- let buf = "";
499
- for (const ch of clause) {
500
- if (ch === "{") depth++;
501
- else if (ch === "}") depth--;
502
- if (ch === "," && depth === 0) {
503
- if (buf.trim()) out.push(buf.trim());
504
- buf = "";
505
- } else {
506
- buf += ch;
507
- }
508
- }
509
- if (buf.trim()) out.push(buf.trim());
510
- return out;
492
+ // Static `import ... from "x"` is not valid inside vm.runInContext (script-mode parsing).
493
+ // Rewrite top-level static imports to dynamic `await import(...)` so users can paste ESM
494
+ // source verbatim. We use a real parser instead of regex matching so imports embedded in
495
+ // string literals, template literals, or comments — common in codemods — stay intact.
496
+
497
+ type BabelImportDeclaration = {
498
+ type: "ImportDeclaration";
499
+ start: number;
500
+ end: number;
501
+ source: { value: string };
502
+ specifiers: ReadonlyArray<{
503
+ type: "ImportDefaultSpecifier" | "ImportNamespaceSpecifier" | "ImportSpecifier";
504
+ local: { name: string };
505
+ imported?: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string };
506
+ }>;
507
+ attributes?: ReadonlyArray<{
508
+ key: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string };
509
+ value: { value: string };
510
+ }>;
511
+ };
512
+
513
+ function buildDynamicImportCall(sourceLiteral: string, withClause: string | undefined): string {
514
+ return withClause ? `import(${sourceLiteral}, { with: ${withClause} })` : `import(${sourceLiteral})`;
511
515
  }
512
516
 
513
- function rewriteImportClause(clause: string, sourceLiteral: string): string {
517
+ function buildWithClause(node: BabelImportDeclaration): string | undefined {
518
+ const attrs = node.attributes;
519
+ if (!attrs || attrs.length === 0) return undefined;
520
+ const pairs = attrs.map(attr => {
521
+ const key = attr.key.type === "Identifier" ? attr.key.name : JSON.stringify(attr.key.value);
522
+ return `${key}: ${JSON.stringify(attr.value.value)}`;
523
+ });
524
+ return `{ ${pairs.join(", ")} }`;
525
+ }
526
+
527
+ function rewriteImportNode(node: BabelImportDeclaration): string {
528
+ const sourceLiteral = JSON.stringify(node.source.value);
529
+ const withClause = buildWithClause(node);
530
+ const importCall = buildDynamicImportCall(sourceLiteral, withClause);
531
+
514
532
  let defaultName: string | undefined;
515
533
  let namespaceName: string | undefined;
516
- let namedBlock: string | undefined;
517
- for (const part of splitTopLevel(clause)) {
518
- if (part.startsWith("{")) {
519
- namedBlock = part;
520
- } else if (part.startsWith("*")) {
521
- const m = part.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
522
- if (!m) return `await import(${sourceLiteral}); /* unrewritten import: ${clause} */`;
523
- namespaceName = m[1];
524
- } else if (/^[A-Za-z_$][\w$]*$/.test(part)) {
525
- defaultName = part;
526
- } else {
527
- return `await import(${sourceLiteral}); /* unrewritten import: ${clause} */`;
534
+ const namedPairs: Array<[string, string]> = [];
535
+ for (const spec of node.specifiers) {
536
+ if (spec.type === "ImportDefaultSpecifier") {
537
+ defaultName = spec.local.name;
538
+ } else if (spec.type === "ImportNamespaceSpecifier") {
539
+ namespaceName = spec.local.name;
540
+ } else if (spec.type === "ImportSpecifier" && spec.imported) {
541
+ const imported = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
542
+ namedPairs.push([imported, spec.local.name]);
528
543
  }
529
544
  }
530
- if (namedBlock) {
531
- const inner = namedBlock.slice(1, -1).trim();
532
- const renamed = inner.replace(/([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/g, "$1: $2");
533
- const props = defaultName ? `default: ${defaultName}, ${renamed}` : renamed;
534
- return `const { ${props} } = await import(${sourceLiteral});`;
545
+
546
+ if (namedPairs.length > 0) {
547
+ const inner = namedPairs.map(([imp, loc]) => (imp === loc ? imp : `${imp}: ${loc}`)).join(", ");
548
+ const props = defaultName ? `default: ${defaultName}, ${inner}` : inner;
549
+ return `const { ${props} } = await ${importCall};`;
535
550
  }
536
551
  if (namespaceName && defaultName) {
537
- return `const ${namespaceName} = await import(${sourceLiteral}); const ${defaultName} = ${namespaceName}.default;`;
552
+ return `const ${namespaceName} = await ${importCall}; const ${defaultName} = ${namespaceName}.default;`;
538
553
  }
539
- if (namespaceName) return `const ${namespaceName} = await import(${sourceLiteral});`;
540
- if (defaultName) return `const ${defaultName} = (await import(${sourceLiteral})).default;`;
541
- return `await import(${sourceLiteral});`;
554
+ if (namespaceName) return `const ${namespaceName} = await ${importCall};`;
555
+ if (defaultName) return `const ${defaultName} = (await ${importCall}).default;`;
556
+ return `await ${importCall};`;
542
557
  }
543
558
 
544
559
  export function rewriteStaticImports(code: string): string {
545
- return code.replace(STATIC_IMPORT_RE, (_match, clause: string | undefined, _quote, source: string) => {
546
- const literal = JSON.stringify(source);
547
- if (!clause) return `await import(${literal});`;
548
- return rewriteImportClause(clause.trim(), literal);
549
- });
560
+ if (!code.includes("import")) return code;
561
+
562
+ let ast: { program: { body: ReadonlyArray<{ type: string }> } };
563
+ try {
564
+ ast = babelParse(code, {
565
+ sourceType: "module",
566
+ allowAwaitOutsideFunction: true,
567
+ allowReturnOutsideFunction: true,
568
+ allowImportExportEverywhere: true,
569
+ allowNewTargetOutsideFunction: true,
570
+ allowSuperOutsideMethod: true,
571
+ allowUndeclaredExports: true,
572
+ errorRecovery: true,
573
+ }) as unknown as typeof ast;
574
+ } catch {
575
+ // Parser bailed entirely — let the VM surface the real syntax error.
576
+ return code;
577
+ }
578
+
579
+ // Only rewrite top-level imports. Anything nested deeper is invalid JS anyway and the
580
+ // VM will report it.
581
+ const imports: BabelImportDeclaration[] = [];
582
+ for (const node of ast.program.body) {
583
+ if (node.type === "ImportDeclaration") imports.push(node as unknown as BabelImportDeclaration);
584
+ }
585
+ if (imports.length === 0) return code;
586
+
587
+ // Splice from the back so earlier offsets stay valid.
588
+ imports.sort((a, b) => b.start - a.start);
589
+ let result = code;
590
+ for (const node of imports) {
591
+ result = result.slice(0, node.start) + rewriteImportNode(node) + result.slice(node.end);
592
+ }
593
+ return result;
550
594
  }
551
595
 
552
596
  function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
@@ -17,7 +17,7 @@ import type { ExecOptions } from "../../exec/exec";
17
17
  import { execCommand } from "../../exec/exec";
18
18
  import type { CustomMessage } from "../../session/messages";
19
19
  import { EventBus } from "../../utils/event-bus";
20
- import { installLegacyPiSpecifierShim } from "../plugins/legacy-pi-compat";
20
+ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat";
21
21
  import { getAllPluginExtensionPaths } from "../plugins/loader";
22
22
 
23
23
  import { resolvePath } from "../utils";
@@ -36,6 +36,12 @@ import type {
36
36
  installLegacyPiSpecifierShim();
37
37
 
38
38
  type HandlerFn = (...args: unknown[]) => Promise<unknown>;
39
+ type LoadedExtensionModule = ExtensionFactory | { default?: ExtensionFactory };
40
+
41
+ function getExtensionFactory(module: LoadedExtensionModule): ExtensionFactory | null {
42
+ const candidate = typeof module === "function" ? module : module.default;
43
+ return typeof candidate === "function" ? candidate : null;
44
+ }
39
45
 
40
46
  export class ExtensionRuntimeNotInitializedError extends Error {
41
47
  constructor() {
@@ -272,8 +278,8 @@ async function loadExtension(
272
278
  ): Promise<{ extension: Extension | null; error: string | null }> {
273
279
  const resolvedPath = resolvePath(extensionPath, cwd);
274
280
  try {
275
- const module = await import(`omp-legacy-pi-file:${resolvedPath}`);
276
- const factory = (module.default ?? module) as ExtensionFactory;
281
+ const module = (await loadLegacyPiModule(resolvedPath)) as LoadedExtensionModule;
282
+ const factory = getExtensionFactory(module);
277
283
 
278
284
  if (typeof factory !== "function") {
279
285
  return {
@@ -1,4 +1,7 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
4
+ import * as url from "node:url";
2
5
 
3
6
  const LEGACY_PI_PACKAGE_MAP = {
4
7
  "@mariozechner/pi-agent-core": "@oh-my-pi/pi-agent-core",
@@ -54,6 +57,10 @@ function getResolvedSpecifier(specifier: string): string {
54
57
  return resolved;
55
58
  }
56
59
 
60
+ function toImportSpecifier(resolvedPath: string): string {
61
+ return url.pathToFileURL(resolvedPath).href;
62
+ }
63
+
57
64
  function rewriteLegacyPiImports(source: string): string {
58
65
  return source.replace(
59
66
  LEGACY_PI_IMPORT_SPECIFIER_REGEX,
@@ -63,42 +70,118 @@ function rewriteLegacyPiImports(source: string): string {
63
70
  return match;
64
71
  }
65
72
 
66
- return `${prefix}${getResolvedSpecifier(remappedSpecifier)}${suffix}`;
73
+ return `${prefix}${toImportSpecifier(getResolvedSpecifier(remappedSpecifier))}${suffix}`;
67
74
  },
68
75
  );
69
76
  }
70
77
 
71
- // Match `from "..."`, `from '...'`, `import("...")`, `import('...')` import specifiers.
78
+ // Match static `from "..."` / `from '...'` import specifiers.
79
+ const STATIC_IMPORT_SPECIFIER_REGEX = /(from\s+["'])([^"']+)(["'])/g;
80
+ // Match static imports plus dynamic `import("...")` / `import('...')` specifiers.
72
81
  const ANY_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])([^"']+)(["'])/g;
73
82
 
74
- /**
75
- * Resolves bare module specifiers in a legacy-namespaced extension source file
76
- * to absolute paths anchored at the extension's own directory. Without this,
77
- * imports inside files loaded via the `omp-legacy-pi-file:` namespace bypass
78
- * Node-style node_modules lookup, so an extension cannot use its own deps.
79
- * Relative paths and already-resolved absolute paths are left untouched.
80
- */
83
+ /** Resolve bare imports against the extension directory before loading mirrored legacy Pi files. */
84
+ function isUrlLikeSpecifier(specifier: string): boolean {
85
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier);
86
+ }
87
+
88
+ function shouldPreserveImportSpecifier(specifier: string): boolean {
89
+ return specifier.startsWith(".") || path.isAbsolute(specifier) || isUrlLikeSpecifier(specifier);
90
+ }
91
+
92
+ function toRewrittenImportSpecifier(resolvedPath: string): string {
93
+ return isUrlLikeSpecifier(resolvedPath) ? resolvedPath : toImportSpecifier(resolvedPath);
94
+ }
95
+
81
96
  function rewriteBareImportsForLegacyExtension(source: string, importerPath: string): string {
82
97
  const importerDir = path.dirname(importerPath);
83
98
  return source.replace(ANY_IMPORT_SPECIFIER_REGEX, (match, prefix: string, specifier: string, suffix: string) => {
84
99
  // Skip relative, absolute, URL-style, and already-resolved Node specifiers.
85
- if (
86
- specifier.startsWith(".") ||
87
- specifier.startsWith("/") ||
88
- specifier.startsWith("node:") ||
89
- specifier.includes("://")
90
- ) {
100
+ if (shouldPreserveImportSpecifier(specifier)) {
91
101
  return match;
92
102
  }
93
103
  try {
94
104
  const resolved = Bun.resolveSync(specifier, importerDir);
95
- return `${prefix}${resolved}${suffix}`;
105
+ return `${prefix}${toRewrittenImportSpecifier(resolved)}${suffix}`;
96
106
  } catch {
97
107
  return match;
98
108
  }
99
109
  });
100
110
  }
101
111
 
112
+ interface LegacyPiMirrorState {
113
+ root: string;
114
+ seen: Map<string, string>;
115
+ }
116
+
117
+ function getMirrorPath(sourcePath: string, state: LegacyPiMirrorState): string {
118
+ const extension = path.extname(sourcePath) || ".js";
119
+ const digest = Bun.hash(sourcePath).toString(36);
120
+ return path.join(state.root, `${digest}${extension}`);
121
+ }
122
+
123
+ async function rewriteRelativeImportsForLegacyExtension(
124
+ source: string,
125
+ importerPath: string,
126
+ state: LegacyPiMirrorState,
127
+ ): Promise<string> {
128
+ const replacements = new Map<string, string>();
129
+
130
+ for (const match of source.matchAll(STATIC_IMPORT_SPECIFIER_REGEX)) {
131
+ const specifier = match[2];
132
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) {
133
+ continue;
134
+ }
135
+
136
+ const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
137
+ const mirrored = await mirrorLegacyPiFile(resolved, state);
138
+ replacements.set(specifier, toImportSpecifier(mirrored));
139
+ }
140
+
141
+ if (replacements.size === 0) {
142
+ return source;
143
+ }
144
+
145
+ return source.replace(STATIC_IMPORT_SPECIFIER_REGEX, (match, prefix: string, specifier: string, suffix: string) => {
146
+ const replacement = replacements.get(specifier);
147
+ return replacement ? `${prefix}${replacement}${suffix}` : match;
148
+ });
149
+ }
150
+
151
+ async function rewriteLegacyPiImportsForRuntime(
152
+ source: string,
153
+ importerPath: string,
154
+ state: LegacyPiMirrorState,
155
+ ): Promise<string> {
156
+ const withRelativeResolved = await rewriteRelativeImportsForLegacyExtension(source, importerPath, state);
157
+ const withLegacyRemap = rewriteLegacyPiImports(withRelativeResolved);
158
+ return rewriteBareImportsForLegacyExtension(withLegacyRemap, importerPath);
159
+ }
160
+
161
+ async function mirrorLegacyPiFile(sourcePath: string, state: LegacyPiMirrorState): Promise<string> {
162
+ const resolvedPath = path.resolve(sourcePath);
163
+ const cached = state.seen.get(resolvedPath);
164
+ if (cached) {
165
+ return cached;
166
+ }
167
+
168
+ const mirrorPath = getMirrorPath(resolvedPath, state);
169
+ state.seen.set(resolvedPath, mirrorPath);
170
+
171
+ const raw = await Bun.file(resolvedPath).text();
172
+ const rewritten = await rewriteLegacyPiImportsForRuntime(raw, resolvedPath, state);
173
+ await Bun.write(mirrorPath, rewritten);
174
+ return mirrorPath;
175
+ }
176
+
177
+ export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
178
+ const root = path.join(os.tmpdir(), "omp-legacy-pi-file", Bun.hash(resolvedPath).toString(36));
179
+ await fs.rm(root, { recursive: true, force: true });
180
+ const state: LegacyPiMirrorState = { root, seen: new Map() };
181
+ const mirroredEntry = await mirrorLegacyPiFile(resolvedPath, state);
182
+ return import(`${toImportSpecifier(mirroredEntry)}?mtime=${Date.now()}`);
183
+ }
184
+
102
185
  function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
103
186
  if (path.endsWith(".tsx")) {
104
187
  return "tsx";
@@ -150,10 +233,6 @@ export function installLegacyPiSpecifierShim(): void {
150
233
 
151
234
  build.onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: LEGACY_PI_FILE_NAMESPACE }, async args => {
152
235
  const raw = await Bun.file(args.path).text();
153
- // Bare specifiers (e.g. "lodash", "@scope/pkg/sub") imported from a legacy-namespaced
154
- // extension file would otherwise bypass Node-style node_modules lookup because the
155
- // importer lives in a custom namespace. Pre-resolve them to absolute paths so the
156
- // extension's own node_modules are honored.
157
236
  const withLegacyRemap = rewriteLegacyPiImports(raw);
158
237
  const withBareResolved = rewriteBareImportsForLegacyExtension(withLegacyRemap, args.path);
159
238
  return {