@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4

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 (164) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3682 -3615
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/cli/update-cli.d.ts +15 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/collab/replication-shrink.d.ts +39 -0
  12. package/dist/types/config/provider-globals.d.ts +7 -0
  13. package/dist/types/config/settings-schema.d.ts +81 -0
  14. package/dist/types/debug/log-viewer.d.ts +1 -0
  15. package/dist/types/debug/raw-sse.d.ts +1 -0
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  18. package/dist/types/extensibility/utils.d.ts +12 -0
  19. package/dist/types/mcp/transports/index.d.ts +1 -0
  20. package/dist/types/mcp/transports/sse.d.ts +20 -0
  21. package/dist/types/memories/index.d.ts +20 -1
  22. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  23. package/dist/types/modes/components/index.d.ts +1 -0
  24. package/dist/types/modes/components/model-selector.d.ts +9 -1
  25. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/component.d.ts +35 -1
  27. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  28. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  29. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  30. package/dist/types/modes/interactive-mode.d.ts +11 -4
  31. package/dist/types/modes/skill-command.d.ts +32 -0
  32. package/dist/types/modes/types.d.ts +9 -3
  33. package/dist/types/session/agent-session.d.ts +58 -10
  34. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  35. package/dist/types/session/messages.d.ts +26 -0
  36. package/dist/types/session/messages.test.d.ts +1 -0
  37. package/dist/types/session/session-entries.d.ts +31 -3
  38. package/dist/types/session/session-history-format.d.ts +6 -0
  39. package/dist/types/session/session-loader.d.ts +9 -1
  40. package/dist/types/session/session-manager.d.ts +8 -7
  41. package/dist/types/session/session-storage.d.ts +11 -0
  42. package/dist/types/session/session-title-slot.d.ts +19 -0
  43. package/dist/types/ssh/connection-manager.d.ts +47 -0
  44. package/dist/types/ssh/utils.d.ts +16 -0
  45. package/dist/types/task/executor.d.ts +3 -16
  46. package/dist/types/task/render.d.ts +0 -5
  47. package/dist/types/task/renderer.d.ts +13 -0
  48. package/dist/types/task/types.d.ts +16 -0
  49. package/dist/types/task/yield-assembly.d.ts +28 -0
  50. package/dist/types/tiny/text.d.ts +8 -0
  51. package/dist/types/tools/render-utils.d.ts +2 -0
  52. package/dist/types/tools/review.d.ts +6 -4
  53. package/dist/types/tools/ssh.d.ts +1 -1
  54. package/dist/types/tools/todo.d.ts +6 -0
  55. package/dist/types/tools/yield.d.ts +8 -3
  56. package/dist/types/utils/thinking-display.d.ts +4 -0
  57. package/package.json +12 -12
  58. package/src/advisor/__tests__/advisor.test.ts +242 -10
  59. package/src/advisor/__tests__/config.test.ts +173 -0
  60. package/src/advisor/advise-tool.ts +11 -6
  61. package/src/advisor/config.ts +256 -0
  62. package/src/advisor/index.ts +1 -0
  63. package/src/advisor/runtime.ts +12 -2
  64. package/src/advisor/transcript-recorder.ts +25 -2
  65. package/src/advisor/watchdog.ts +57 -31
  66. package/src/autolearn/controller.ts +13 -22
  67. package/src/autoresearch/index.ts +7 -2
  68. package/src/cli/gc-cli.ts +17 -10
  69. package/src/cli/update-cli.ts +254 -0
  70. package/src/collab/guest.ts +43 -7
  71. package/src/collab/host.ts +13 -8
  72. package/src/collab/replication-shrink.ts +111 -0
  73. package/src/config/model-registry.ts +80 -18
  74. package/src/config/provider-globals.ts +25 -0
  75. package/src/config/settings-schema.ts +77 -0
  76. package/src/debug/index.ts +32 -7
  77. package/src/debug/log-viewer.ts +111 -53
  78. package/src/debug/raw-sse.ts +68 -48
  79. package/src/discovery/codex.ts +13 -5
  80. package/src/edit/hashline/diff.ts +57 -4
  81. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  82. package/src/eval/js/shared/local-module-loader.ts +23 -1
  83. package/src/export/html/template.js +13 -7
  84. package/src/extensibility/extensions/loader.ts +5 -3
  85. package/src/extensibility/extensions/wrapper.ts +9 -3
  86. package/src/extensibility/hooks/loader.ts +3 -3
  87. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  88. package/src/extensibility/plugins/manager.ts +2 -1
  89. package/src/extensibility/tool-event-input.ts +23 -0
  90. package/src/extensibility/utils.ts +74 -0
  91. package/src/internal-urls/docs-index.generated.txt +1 -1
  92. package/src/mcp/client.ts +3 -1
  93. package/src/mcp/manager.ts +12 -5
  94. package/src/mcp/transports/index.ts +1 -0
  95. package/src/mcp/transports/sse.ts +377 -0
  96. package/src/memories/index.ts +130 -15
  97. package/src/memory-backend/local-backend.ts +5 -3
  98. package/src/modes/components/advisor-config.ts +555 -0
  99. package/src/modes/components/advisor-message.ts +9 -2
  100. package/src/modes/components/agent-hub.ts +9 -4
  101. package/src/modes/components/index.ts +2 -0
  102. package/src/modes/components/model-selector.ts +79 -48
  103. package/src/modes/components/settings-selector.ts +1 -0
  104. package/src/modes/components/status-line/component.ts +150 -5
  105. package/src/modes/components/status-line/segments.ts +46 -21
  106. package/src/modes/components/status-line/types.ts +13 -1
  107. package/src/modes/components/tool-execution.ts +47 -6
  108. package/src/modes/controllers/command-controller.ts +27 -36
  109. package/src/modes/controllers/event-controller.ts +113 -1
  110. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  111. package/src/modes/controllers/input-controller.ts +61 -61
  112. package/src/modes/controllers/selector-controller.ts +100 -9
  113. package/src/modes/interactive-mode.ts +74 -25
  114. package/src/modes/skill-command.ts +116 -0
  115. package/src/modes/types.ts +9 -3
  116. package/src/modes/utils/ui-helpers.ts +41 -23
  117. package/src/prompts/agents/reviewer.md +11 -10
  118. package/src/prompts/goals/goal-todo-context.md +12 -0
  119. package/src/prompts/review-custom-request.md +1 -2
  120. package/src/prompts/review-request.md +1 -2
  121. package/src/prompts/system/interrupted-thinking.md +7 -0
  122. package/src/prompts/system/recap-user.md +9 -0
  123. package/src/prompts/system/subagent-system-prompt.md +8 -5
  124. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  125. package/src/prompts/system/system-prompt.md +0 -1
  126. package/src/prompts/tools/irc.md +2 -2
  127. package/src/prompts/tools/read.md +2 -2
  128. package/src/sdk.ts +28 -24
  129. package/src/session/agent-session.ts +899 -429
  130. package/src/session/indexed-session-storage.ts +86 -13
  131. package/src/session/messages.test.ts +125 -0
  132. package/src/session/messages.ts +172 -9
  133. package/src/session/redis-session-storage.ts +49 -2
  134. package/src/session/session-entries.ts +39 -2
  135. package/src/session/session-history-format.ts +29 -2
  136. package/src/session/session-listing.ts +54 -24
  137. package/src/session/session-loader.ts +66 -3
  138. package/src/session/session-manager.ts +115 -22
  139. package/src/session/session-persistence.ts +95 -1
  140. package/src/session/session-storage.ts +36 -0
  141. package/src/session/session-title-slot.ts +141 -0
  142. package/src/session/sql-session-storage.ts +71 -11
  143. package/src/slash-commands/builtin-registry.ts +23 -24
  144. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  145. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  146. package/src/ssh/connection-manager.ts +139 -12
  147. package/src/ssh/file-transfer.ts +23 -18
  148. package/src/ssh/ssh-executor.ts +2 -13
  149. package/src/ssh/utils.ts +19 -0
  150. package/src/task/executor.ts +21 -23
  151. package/src/task/render.ts +162 -20
  152. package/src/task/renderer.ts +14 -0
  153. package/src/task/types.ts +17 -0
  154. package/src/task/yield-assembly.ts +207 -0
  155. package/src/tiny/text.ts +23 -0
  156. package/src/tools/ask.ts +55 -4
  157. package/src/tools/render-utils.ts +2 -0
  158. package/src/tools/renderers.ts +8 -2
  159. package/src/tools/review.ts +17 -7
  160. package/src/tools/ssh.ts +8 -4
  161. package/src/tools/todo.ts +17 -1
  162. package/src/tools/yield.ts +140 -31
  163. package/src/utils/thinking-display.ts +15 -0
  164. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -342,12 +342,20 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
342
342
  const codexDir = getProjectCodexDir(ctx);
343
343
  const projectHooksDir = path.join(codexDir, "hooks");
344
344
 
345
+ // OMP hooks must be named `pre-<tool>.<ts|js>` or `post-<tool>.<ts|js>`.
346
+ // Files without that prefix are not OMP hooks (e.g. the standalone Codex
347
+ // hook scripts users keep alongside) — silently dropping the prefix and
348
+ // defaulting to `pre:<basename>` caused those scripts to be imported as
349
+ // extension factories and any top-level `process.exit()` killed startup
350
+ // (#3680).
345
351
  const transformHook =
346
- (level: "user" | "project") => (name: string, _content: string, path: string, source: SourceMeta) => {
352
+ (level: "user" | "project") =>
353
+ (name: string, _content: string, path: string, source: SourceMeta): Hook | null => {
347
354
  const baseName = name.replace(/\.(ts|js)$/, "");
348
355
  const match = baseName.match(/^(pre|post)-(.+)$/);
349
- const hookType = (match?.[1] as "pre" | "post") || "pre";
350
- const toolName = match?.[2] || baseName;
356
+ if (!match) return null;
357
+ const hookType = match[1] as "pre" | "post";
358
+ const toolName = match[2];
351
359
  return {
352
360
  name,
353
361
  path,
@@ -359,11 +367,11 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
359
367
  };
360
368
 
361
369
  const results = await Promise.all([
362
- loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
370
+ loadFilesFromDir<Hook>(ctx, userHooksDir, PROVIDER_ID, "user", {
363
371
  extensions: ["ts", "js"],
364
372
  transform: transformHook("user"),
365
373
  }),
366
- loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
374
+ loadFilesFromDir<Hook>(ctx, projectHooksDir, PROVIDER_ID, "project", {
367
375
  extensions: ["ts", "js"],
368
376
  transform: transformHook("project"),
369
377
  }),
@@ -9,6 +9,7 @@
9
9
  * match is accepted even when the tag was minted by a source that did not keep
10
10
  * history, and stale tags recover through the session snapshot store when possible.
11
11
  */
12
+ import * as path from "node:path";
12
13
  import {
13
14
  type ApplyResult,
14
15
  applyEdits,
@@ -30,6 +31,7 @@ import {
30
31
  } from "@oh-my-pi/hashline";
31
32
  import { resolveToCwd } from "../../tools/path-utils";
32
33
  import { generateDiffString } from "../diff";
34
+ import { canonicalSnapshotKey } from "../file-snapshot-store";
33
35
  import { readEditFileText } from "../read-file";
34
36
  import { nativeBlockResolver } from "./block-resolver";
35
37
 
@@ -90,6 +92,54 @@ async function readSectionTextCached(absolutePath: string, sectionPath: string):
90
92
  return rawContent;
91
93
  }
92
94
 
95
+ /**
96
+ * Resolve a missing authored path to a file read this session by matching its
97
+ * basename and snapshot tag, mirroring {@link Patcher}'s apply-time recovery so
98
+ * a bare/wrong-directory `[basename#tag]` header previews against the same file
99
+ * the edit will land on. Returns `undefined` when no unique basename+tag match
100
+ * exists, leaving the caller to surface the original read error.
101
+ */
102
+ function recoverSectionPathFromTag(
103
+ section: PatchSection,
104
+ authoredAbsolutePath: string,
105
+ snapshots: SnapshotStore,
106
+ ): string | undefined {
107
+ if (section.fileHash === undefined) return undefined;
108
+ const authoredName = path.basename(section.path);
109
+ const authoredKey = canonicalSnapshotKey(authoredAbsolutePath);
110
+ const candidates = [
111
+ ...new Set(
112
+ snapshots
113
+ .findByHash(section.fileHash)
114
+ .filter(snapshot => path.basename(snapshot.path) === authoredName)
115
+ .map(snapshot => snapshot.path),
116
+ ),
117
+ ].filter(candidate => candidate !== authoredKey);
118
+ return candidates.length === 1 ? candidates[0] : undefined;
119
+ }
120
+
121
+ /**
122
+ * Read the section's target file for a preview, recovering a bare/mis-typed
123
+ * `[basename#tag]` path onto the file its tag uniquely names. Recovery fires
124
+ * only when the authored path is absent — matching {@link Patcher}'s apply-time
125
+ * order — so a permission/parse error on an existing file surfaces against the
126
+ * authored path instead of silently previewing a different tagged file. Returns
127
+ * the path actually read so callers key snapshot lookups off the same file.
128
+ */
129
+ async function readSectionForPreview(
130
+ section: PatchSection,
131
+ authoredAbsolutePath: string,
132
+ snapshots: SnapshotStore,
133
+ streaming: boolean | undefined,
134
+ ): Promise<{ absolutePath: string; rawContent: string }> {
135
+ const read = streaming ? readSectionTextCached : readSectionText;
136
+ const recovered = (await Bun.file(authoredAbsolutePath).exists())
137
+ ? undefined
138
+ : recoverSectionPathFromTag(section, authoredAbsolutePath, snapshots);
139
+ const target = recovered ?? authoredAbsolutePath;
140
+ return { absolutePath: target, rawContent: await read(target, section.path) };
141
+ }
142
+
93
143
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
94
144
  return edits.some(edit => {
95
145
  if (edit.kind === "delete") return true;
@@ -258,10 +308,13 @@ export async function computeHashlineSectionDiff(
258
308
  options: HashlineDiffOptions = {},
259
309
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
260
310
  try {
261
- const absolutePath = resolveToCwd(section.path, cwd);
262
- const rawContent = options.streaming
263
- ? await readSectionTextCached(absolutePath, section.path)
264
- : await readSectionText(absolutePath, section.path);
311
+ const authoredPath = resolveToCwd(section.path, cwd);
312
+ const { absolutePath, rawContent } = await readSectionForPreview(
313
+ section,
314
+ authoredPath,
315
+ snapshots,
316
+ options.streaming,
317
+ );
265
318
  const { text: content } = stripBom(rawContent);
266
319
  const normalized = normalizeToLF(content);
267
320
  // Streaming favors a stable, monotonic preview over an exact unified
@@ -9,7 +9,7 @@ const OWNER_ID = "julia-prelude-tests";
9
9
  describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
10
10
  afterEach(async () => {
11
11
  await disposeJuliaKernelSessionsByOwner(OWNER_ID);
12
- });
12
+ }, 30_000);
13
13
 
14
14
  it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
15
15
  using tempDir = TempDir.createSync("@omp-eval-julia-output-");
@@ -44,7 +44,7 @@ nothing
44
44
  expect(result.output).toContain("STRIPPED=red");
45
45
  expect(result.output).toContain("META=alpha:true");
46
46
  expect(result.output).toContain("MULTI=2:alpha:json");
47
- }, 30_000);
47
+ }, 60_000);
48
48
 
49
49
  it("surfaces the exception type and message in the error output, not just stack frames", async () => {
50
50
  using tempDir = TempDir.createSync("@omp-eval-julia-error-");
@@ -29,6 +29,7 @@ export class LocalModuleLoader {
29
29
  #externalModules = new Map<string, Promise<vm.Module>>();
30
30
  #requireCache = new Map<string, NodeJS.Require>();
31
31
  #modulePaths = new WeakMap<vm.Module, string>();
32
+ #linkChain: Promise<void> = Promise.resolve();
32
33
 
33
34
  constructor(sessionId: string) {
34
35
  this.#context = vm.createContext(globalThis);
@@ -147,7 +148,17 @@ export class LocalModuleLoader {
147
148
  async #linkAndEvaluate(entry: LocalModuleEntry, modulePath: string): Promise<void> {
148
149
  const { module } = entry;
149
150
  try {
150
- if (module.status === "unlinked") await module.link(this.#linkResolve);
151
+ // Serialize the link phase across every graph root. Bun's node:vm linker
152
+ // segfaults (getImportedModule on a null record) when two link passes
153
+ // instantiate overlapping module instances concurrently — e.g.
154
+ // Promise.all([import("./a"), import("./b")]) over a graph that shares
155
+ // dependencies. Holding the lock for the whole module.link() (including its
156
+ // async resolver callbacks) guarantees the linker is never re-entered
157
+ // mid-instantiation. The lock is released before evaluate(), so a dynamic
158
+ // import during evaluation can re-acquire it without deadlock.
159
+ await this.#serializeLink(async () => {
160
+ if (module.status === "unlinked") await module.link(this.#linkResolve);
161
+ });
151
162
  if (module.status === "linked") await module.evaluate();
152
163
  } catch (error) {
153
164
  this.#invalidateFailedLoad(modulePath);
@@ -159,6 +170,17 @@ export class LocalModuleLoader {
159
170
  }
160
171
  }
161
172
 
173
+ // Promise-chain mutex serializing node:vm link passes (see #linkAndEvaluate).
174
+ // #linkChain is kept non-rejecting so a failed link never wedges the queue.
175
+ #serializeLink<T>(run: () => Promise<T>): Promise<T> {
176
+ const result = this.#linkChain.then(run);
177
+ this.#linkChain = result.then(
178
+ () => undefined,
179
+ () => undefined,
180
+ );
181
+ return result;
182
+ }
183
+
162
184
  // Shared static-link resolver for `module.link()`. node:vm passes the referencing
163
185
  // module and reuses this one resolver for the entire graph, so the referrer path is
164
186
  // recovered from `#modulePaths`. Local dependencies are constructed but NOT linked or
@@ -1236,7 +1236,7 @@
1236
1236
  let html = `
1237
1237
  <div class="header">
1238
1238
  <h1>Session: ${escapeHtml(header?.id || 'unknown')}</h1>
1239
- <div class="help-bar">Ctrl+T toggle thinking · Ctrl+O toggle tools</div>
1239
+ <div class="help-bar">T toggle thinking · O toggle tools</div>
1240
1240
  <div class="header-info">
1241
1241
  <div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div>
1242
1242
  <div class="info-item"><span class="info-label">Models:</span><span class="info-value">${globalStats.models.join(', ') || 'unknown'}</span></div>
@@ -1580,13 +1580,19 @@
1580
1580
  searchQuery = '';
1581
1581
  navigateTo(leafId, 'bottom');
1582
1582
  }
1583
- if (e.ctrlKey && e.key === 't') {
1583
+ if (e.key === 't' || e.key === 'T' || e.key === 'o' || e.key === 'O') {
1584
+ // Skip when typing in the sidebar search (or any other editable target)
1585
+ // so the chord can't fire on a user's letter input. Avoid Ctrl/Cmd-based
1586
+ // chords entirely — every major browser reserves Ctrl+T (new tab) and
1587
+ // Ctrl+O (open file), so the shortcut would never reach the page.
1588
+ const t = e.target;
1589
+ const editable =
1590
+ t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
1591
+ if (editable) return;
1592
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1584
1593
  e.preventDefault();
1585
- toggleThinking();
1586
- }
1587
- if (e.ctrlKey && e.key === 'o') {
1588
- e.preventDefault();
1589
- toggleToolOutputs();
1594
+ if (e.key === 't' || e.key === 'T') toggleThinking();
1595
+ else toggleToolOutputs();
1590
1596
  }
1591
1597
  });
1592
1598
 
@@ -24,7 +24,7 @@ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/leg
24
24
  import { getAllPluginExtensionPaths } from "../plugins/loader";
25
25
  import * as TypeBox from "../typebox";
26
26
 
27
- import { resolvePath } from "../utils";
27
+ import { resolvePath, withExitGuard } from "../utils";
28
28
  import type {
29
29
  AssistantThinkingRenderer,
30
30
  Extension,
@@ -290,7 +290,7 @@ async function loadExtension(
290
290
  ): Promise<{ extension: Extension | null; error: string | null }> {
291
291
  const resolvedPath = resolvePath(extensionPath, cwd);
292
292
  try {
293
- const module = (await loadLegacyPiModule(resolvedPath)) as LoadedExtensionModule;
293
+ const module = (await withExitGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
294
294
  const factory = getExtensionFactory(module);
295
295
 
296
296
  if (typeof factory !== "function") {
@@ -302,7 +302,9 @@ async function loadExtension(
302
302
 
303
303
  const extension = createExtension(extensionPath, resolvedPath);
304
304
  const api = new ConcreteExtensionAPI(PiCodingAgent, extension, runtime, cwd, eventBus);
305
- await factory(api);
305
+ await withExitGuard(async () => {
306
+ await factory(api);
307
+ });
306
308
 
307
309
  return { extension, error: null };
308
310
  } catch (err) {
@@ -6,7 +6,7 @@ import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai
6
6
  import type { Settings } from "../../config/settings";
7
7
  import type { Theme } from "../../modes/theme/theme";
8
8
  import { type ApprovalMode, formatApprovalPrompt, requiresApproval } from "../../tools/approval";
9
- import { normalizeToolEventInput } from "../tool-event-input";
9
+ import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
10
10
  import { applyToolProxy } from "../tool-proxy";
11
11
  import type { ExtensionRunner } from "./runner";
12
12
  import type { RegisteredTool, ToolCallEventResult } from "./types";
@@ -186,7 +186,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
186
186
  type: "tool_call",
187
187
  toolName: this.tool.name,
188
188
  toolCallId,
189
- input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
189
+ input: normalizeToolEventInput(
190
+ this.tool.name,
191
+ resolveToolEventInput(this.tool, params as Record<string, unknown>),
192
+ ),
190
193
  })) as ToolCallEventResult | undefined;
191
194
 
192
195
  if (callResult?.block) {
@@ -221,7 +224,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
221
224
  type: "tool_result",
222
225
  toolName: this.tool.name,
223
226
  toolCallId,
224
- input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
227
+ input: normalizeToolEventInput(
228
+ this.tool.name,
229
+ resolveToolEventInput(this.tool, params as Record<string, unknown>),
230
+ ),
225
231
  content: result.content,
226
232
  details: result.details,
227
233
  isError: !!executionError,
@@ -12,7 +12,7 @@ import { loadCapability } from "../../discovery";
12
12
  import * as PiCodingAgent from "../../index";
13
13
  import type { HookMessage } from "../../session/messages";
14
14
  import * as typebox from "../typebox";
15
- import { resolvePath } from "../utils";
15
+ import { resolvePath, withExitGuard } from "../utils";
16
16
  import { execCommand } from "./runner";
17
17
  import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types";
18
18
 
@@ -149,7 +149,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
149
149
 
150
150
  try {
151
151
  // Import the module using native Bun import
152
- const module = await import(resolvedPath);
152
+ const module = await withExitGuard(() => import(resolvedPath));
153
153
  const factory = module.default as HookFactory;
154
154
 
155
155
  if (typeof factory !== "function") {
@@ -164,7 +164,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
164
164
  );
165
165
 
166
166
  // Call factory to register handlers
167
- factory(api);
167
+ await withExitGuard(async () => factory(api));
168
168
 
169
169
  return {
170
170
  hook: {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@oh-my-pi/pi-ai";
6
- import { normalizeToolEventInput } from "../tool-event-input";
6
+ import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
7
7
  import { applyToolProxy } from "../tool-proxy";
8
8
  import type { HookRunner } from "./runner";
9
9
  import type { ToolCallEventResult, ToolResultEventResult } from "./types";
@@ -47,7 +47,10 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
47
47
  type: "tool_call",
48
48
  toolName: this.tool.name,
49
49
  toolCallId,
50
- input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
50
+ input: normalizeToolEventInput(
51
+ this.tool.name,
52
+ resolveToolEventInput(this.tool, params as Record<string, unknown>),
53
+ ),
51
54
  })) as ToolCallEventResult | undefined;
52
55
 
53
56
  if (callResult?.block) {
@@ -73,7 +76,10 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
73
76
  type: "tool_result",
74
77
  toolName: this.tool.name,
75
78
  toolCallId,
76
- input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
79
+ input: normalizeToolEventInput(
80
+ this.tool.name,
81
+ resolveToolEventInput(this.tool, params as Record<string, unknown>),
82
+ ),
77
83
  content: result.content,
78
84
  details: result.details,
79
85
  isError: false,
@@ -96,7 +102,10 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
96
102
  type: "tool_result",
97
103
  toolName: this.tool.name,
98
104
  toolCallId,
99
- input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
105
+ input: normalizeToolEventInput(
106
+ this.tool.name,
107
+ resolveToolEventInput(this.tool, params as Record<string, unknown>),
108
+ ),
100
109
  content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
101
110
  details: undefined,
102
111
  isError: true,
@@ -11,6 +11,7 @@ import {
11
11
  isEnoent,
12
12
  logger,
13
13
  } from "@oh-my-pi/pi-utils";
14
+ import { withExitGuard } from "../utils";
14
15
  import { type GitSource, parseGitUrl } from "./git-url";
15
16
  import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
16
17
  import { resolvePluginManifestEntries } from "./loader";
@@ -363,7 +364,7 @@ export class PluginManager {
363
364
  installLegacyPiSpecifierShim();
364
365
  for (const extensionPath of loadable) {
365
366
  try {
366
- const module = await loadLegacyPiModule(extensionPath);
367
+ const module = await withExitGuard(() => loadLegacyPiModule(extensionPath));
367
368
  if (!hasExtensionFactoryExport(module)) {
368
369
  errors.push(`${extensionPath}: extension does not export a valid factory function`);
369
370
  }
@@ -1,6 +1,29 @@
1
1
  const HASHLINE_FILE_PREFIX = "¶";
2
2
  const HASHLINE_FILE_TAG_RE = /#[0-9a-fA-F]{4}$/u;
3
3
 
4
+ interface ToolEventInputResolver {
5
+ name: string;
6
+ resolveEventInput?: (input: string) => string;
7
+ }
8
+
9
+ /** Resolves mode-specific textual tool input before extension/hook event normalization. */
10
+ export function resolveToolEventInput(
11
+ tool: ToolEventInputResolver,
12
+ input: Record<string, unknown>,
13
+ ): Record<string, unknown> {
14
+ if (tool.name !== "edit" || typeof tool.resolveEventInput !== "function") return input;
15
+ let resolved = input;
16
+ for (const key of ["input", "_input"] as const) {
17
+ const value = stringField(resolved, key);
18
+ if (value === undefined) continue;
19
+ const nextValue = tool.resolveEventInput(value);
20
+ if (nextValue === value) continue;
21
+ if (resolved === input) resolved = { ...input };
22
+ resolved[key] = nextValue;
23
+ }
24
+ return resolved;
25
+ }
26
+
4
27
  function stringField(input: Record<string, unknown>, key: string): string | undefined {
5
28
  const value = input[key];
6
29
  return typeof value === "string" && value.length > 0 ? value : undefined;
@@ -42,3 +42,77 @@ export function createNoOpUIContext(): HookUIContext {
42
42
  },
43
43
  };
44
44
  }
45
+
46
+ /**
47
+ * Raised by {@link withExitGuard} when a guarded callback synchronously
48
+ * attempts to terminate the host process. Callers catch this like any other
49
+ * load-time failure so the extension/hook is skipped with a logged error
50
+ * instead of taking the CLI down with it.
51
+ */
52
+ export class ExtensionExitError extends Error {
53
+ readonly code: number | string | undefined;
54
+ constructor(
55
+ code: number | string | undefined,
56
+ readonly alias = "process.exit",
57
+ ) {
58
+ super(
59
+ `Module called ${alias}(${code === undefined ? "" : String(code)}) during guarded extension/hook loading; ` +
60
+ `OMP extension/hook modules must not terminate the host process.`,
61
+ );
62
+ this.name = "ExtensionExitError";
63
+ this.code = code;
64
+ }
65
+ }
66
+
67
+ type ExitAliasName = "process.exit" | "process.reallyExit";
68
+
69
+ let exitGuardDepth = 0;
70
+ let exitGuardOriginalProcessExit: typeof process.exit | null = null;
71
+ let exitGuardOriginalReallyExit: typeof process.reallyExit | null = null;
72
+
73
+ /**
74
+ * Run `fn` with hard-exit APIs patched so any synchronous attempt to terminate
75
+ * the host raises {@link ExtensionExitError} instead. Restored in `finally`.
76
+ *
77
+ * Guards the dynamic-import and factory-invocation sites that load third-party
78
+ * extension / hook modules — a `process.exit(0)` or `process.reallyExit(0)` in
79
+ * a stranger's script (e.g. a Codex hook script that happens to live next to
80
+ * OMP-shaped modules) would otherwise kill OMP during startup with no error
81
+ * surface, since `try/catch` cannot intercept a synchronous exit.
82
+ *
83
+ * Nested and concurrent guard windows are safe: only the outermost guard
84
+ * restores the real hard-exit APIs.
85
+ */
86
+ function guardedExit(alias: ExitAliasName): (code?: number | string) => never {
87
+ return (code?: number | string): never => {
88
+ throw new ExtensionExitError(code, alias);
89
+ };
90
+ }
91
+
92
+ export async function withExitGuard<T>(fn: () => Promise<T>): Promise<T> {
93
+ if (exitGuardDepth === 0) {
94
+ exitGuardOriginalProcessExit = process.exit;
95
+ process.exit = guardedExit("process.exit") as typeof process.exit;
96
+
97
+ if (typeof process.reallyExit === "function") {
98
+ exitGuardOriginalReallyExit = process.reallyExit;
99
+ process.reallyExit = guardedExit("process.reallyExit") as typeof process.reallyExit;
100
+ }
101
+ }
102
+ exitGuardDepth++;
103
+ try {
104
+ return await fn();
105
+ } finally {
106
+ exitGuardDepth--;
107
+ if (exitGuardDepth === 0) {
108
+ if (exitGuardOriginalProcessExit) {
109
+ process.exit = exitGuardOriginalProcessExit;
110
+ exitGuardOriginalProcessExit = null;
111
+ }
112
+ if (exitGuardOriginalReallyExit) {
113
+ process.reallyExit = exitGuardOriginalReallyExit;
114
+ exitGuardOriginalReallyExit = null;
115
+ }
116
+ }
117
+ }
118
+ }