@oh-my-pi/pi-coding-agent 17.3.3 → 17.3.5

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 (154) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/{CHANGELOG-s5t1xdmy.md → CHANGELOG-tt9k4jpr.md} +81 -0
  3. package/dist/cli.js +3724 -3705
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/config/model-discovery.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/debug/report-bundle.d.ts +7 -2
  9. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  10. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +11 -0
  14. package/dist/types/lsp/client.d.ts +22 -0
  15. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  16. package/dist/types/markit/converters/pdf/index.d.ts +2 -1
  17. package/dist/types/mcp/config.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  19. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  20. package/dist/types/registry/agent-registry.d.ts +4 -0
  21. package/dist/types/session/agent-session-types.d.ts +0 -2
  22. package/dist/types/session/agent-session.d.ts +20 -0
  23. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  24. package/dist/types/session/irc-bridge.d.ts +2 -0
  25. package/dist/types/session/messages.d.ts +4 -0
  26. package/dist/types/session/session-loader.d.ts +18 -11
  27. package/dist/types/session/session-maintenance.d.ts +6 -2
  28. package/dist/types/session/session-manager.d.ts +19 -3
  29. package/dist/types/session/session-tools.d.ts +0 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -7
  31. package/dist/types/tools/browser/attach.d.ts +16 -0
  32. package/dist/types/tools/browser/launch.d.ts +2 -2
  33. package/dist/types/tools/browser.d.ts +1 -1
  34. package/dist/types/tools/builtin-names.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +6 -0
  36. package/dist/types/tools/hub/types.d.ts +6 -0
  37. package/dist/types/tools/read-pdf.d.ts +15 -0
  38. package/dist/types/utils/external-editor.d.ts +7 -0
  39. package/dist/types/utils/markit.d.ts +3 -4
  40. package/dist/types/vibe/runtime.d.ts +3 -3
  41. package/package.json +13 -16
  42. package/scripts/build-binary.ts +0 -2
  43. package/scripts/bundle-dist.ts +0 -1
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/read-cli.ts +2 -0
  47. package/src/commit/analysis/conventional.ts +15 -9
  48. package/src/commit/analysis/summary.ts +15 -9
  49. package/src/commit/changelog/generate.ts +15 -9
  50. package/src/commit/map-reduce/map-phase.ts +3 -19
  51. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  52. package/src/config/model-discovery.ts +3 -3
  53. package/src/config/model-registry.ts +29 -16
  54. package/src/config/settings-schema.ts +13 -0
  55. package/src/debug/report-bundle.ts +25 -59
  56. package/src/discovery/claude-plugins.ts +2 -1
  57. package/src/discovery/claude.ts +4 -2
  58. package/src/discovery/helpers.ts +5 -0
  59. package/src/edit/renderer.ts +48 -19
  60. package/src/extensibility/extensions/loader.ts +9 -5
  61. package/src/extensibility/extensions/runner.ts +124 -21
  62. package/src/extensibility/extensions/types.ts +2 -0
  63. package/src/extensibility/extensions/wrapper.ts +16 -10
  64. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  65. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  66. package/src/irc/bus.ts +1 -1
  67. package/src/launch/broker.ts +8 -1
  68. package/src/launch/presence.ts +77 -1
  69. package/src/lsp/client.ts +35 -3
  70. package/src/lsp/clients/biome-client.ts +47 -93
  71. package/src/lsp/servers.ts +17 -11
  72. package/src/lsp/tool.ts +2 -4
  73. package/src/lsp/workspace-diagnostics.ts +24 -2
  74. package/src/markit/NOTICE +8 -8
  75. package/src/markit/converters/pdf/index.ts +14 -126
  76. package/src/mcp/client.ts +8 -9
  77. package/src/mcp/config.ts +50 -6
  78. package/src/memories/index.ts +29 -25
  79. package/src/mnemopi/backend.ts +13 -11
  80. package/src/modes/components/ask-dialog.ts +25 -5
  81. package/src/modes/components/tool-execution.ts +10 -6
  82. package/src/modes/components/welcome.ts +4 -1
  83. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  84. package/src/modes/controllers/selector-controller.ts +6 -0
  85. package/src/modes/controllers/tan-command-controller.ts +1 -0
  86. package/src/modes/interactive-mode.ts +49 -10
  87. package/src/modes/rpc/rpc-client.ts +7 -0
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  90. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  91. package/src/prompts/system/date-cwd-reminder.md +3 -0
  92. package/src/prompts/system/project-prompt.md +0 -1
  93. package/src/prompts/system/rewind-report.md +1 -3
  94. package/src/prompts/tools/browser.md +1 -0
  95. package/src/prompts/tools/rewind.md +1 -13
  96. package/src/registry/agent-lifecycle.ts +34 -0
  97. package/src/registry/agent-registry.ts +27 -2
  98. package/src/registry/persisted-agents.ts +40 -20
  99. package/src/sdk.ts +32 -2
  100. package/src/session/agent-session-types.ts +0 -2
  101. package/src/session/agent-session.ts +178 -46
  102. package/src/session/date-cwd-reminder.ts +77 -0
  103. package/src/session/irc-bridge.ts +5 -0
  104. package/src/session/messages.ts +5 -1
  105. package/src/session/session-loader.ts +106 -64
  106. package/src/session/session-maintenance.ts +189 -115
  107. package/src/session/session-manager.ts +142 -35
  108. package/src/session/session-persistence.ts +4 -4
  109. package/src/session/session-storage.ts +18 -5
  110. package/src/session/session-tools.ts +5 -10
  111. package/src/session/turn-recovery.ts +102 -20
  112. package/src/session/unexpected-stop-classifier.ts +33 -21
  113. package/src/system-prompt.ts +0 -5
  114. package/src/task/executor.ts +4 -15
  115. package/src/task/persisted-revive.ts +3 -6
  116. package/src/tools/ask.ts +10 -3
  117. package/src/tools/browser/attach.ts +80 -25
  118. package/src/tools/browser/launch.ts +44 -15
  119. package/src/tools/browser/relay/daemon.ts +3 -7
  120. package/src/tools/browser/render.ts +1 -1
  121. package/src/tools/browser/shared-daemon.ts +3 -7
  122. package/src/tools/browser.ts +5 -5
  123. package/src/tools/builtin-names.ts +7 -3
  124. package/src/tools/checkpoint.ts +1 -7
  125. package/src/tools/hub/index.ts +1 -1
  126. package/src/tools/hub/jobs.ts +20 -3
  127. package/src/tools/hub/types.ts +6 -0
  128. package/src/tools/read-pdf.ts +135 -0
  129. package/src/tools/read.ts +63 -37
  130. package/src/tools/render-utils.ts +53 -21
  131. package/src/tools/think.ts +15 -3
  132. package/src/tts/speech-enhancer.ts +19 -14
  133. package/src/utils/commit-message-generator.ts +14 -12
  134. package/src/utils/external-editor.ts +24 -5
  135. package/src/utils/markit.ts +6 -44
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
  138. package/dist/types/markit/converters/pdf/columns.d.ts +0 -35
  139. package/dist/types/markit/converters/pdf/extract.d.ts +0 -10
  140. package/dist/types/markit/converters/pdf/grid.d.ts +0 -25
  141. package/dist/types/markit/converters/pdf/headers.d.ts +0 -24
  142. package/dist/types/markit/converters/pdf/render.d.ts +0 -24
  143. package/dist/types/markit/converters/pdf/types.d.ts +0 -75
  144. package/dist/types/tools/read-pdf-images.d.ts +0 -12
  145. package/dist/types/utils/mupdf-wasm-embed.d.ts +0 -1
  146. package/scripts/embed-mupdf-wasm.ts +0 -67
  147. package/src/markit/converters/pdf/columns.ts +0 -103
  148. package/src/markit/converters/pdf/extract.ts +0 -598
  149. package/src/markit/converters/pdf/grid.ts +0 -780
  150. package/src/markit/converters/pdf/headers.ts +0 -106
  151. package/src/markit/converters/pdf/render.ts +0 -501
  152. package/src/markit/converters/pdf/types.ts +0 -84
  153. package/src/tools/read-pdf-images.ts +0 -250
  154. package/src/utils/mupdf-wasm-embed.ts +0 -12
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Creates a .tar.gz archive with session data, logs, system info, and optional profiling data.
5
5
  */
6
+
7
+ import type { Dirent } from "node:fs";
6
8
  import * as fs from "node:fs/promises";
7
9
  import * as path from "node:path";
8
10
  import type { WorkProfile } from "@oh-my-pi/pi-natives";
@@ -66,8 +68,8 @@ export interface DebugLogSource {
66
68
  *
67
69
  * Bundle contents:
68
70
  * - session.jsonl: Current session transcript
69
- * - artifacts/: Session artifacts directory
70
- * - subagents/: Subagent sessions + artifacts
71
+ * - artifacts/: Current session's artifacts subtree (recursive), including any
72
+ * subagent session transcripts nested under it
71
73
  * - logs.txt: Recent log entries
72
74
  * - system.json: OS, arch, CPU, memory, versions
73
75
  * - env.json: Sanitized environment variables
@@ -130,14 +132,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
130
132
  // Session file might not exist yet
131
133
  }
132
134
 
133
- // Artifacts directory (same path without .jsonl)
135
+ // Artifacts subtree (same path without .jsonl). Recursing captures the
136
+ // current session's nested subagent transcripts and their artifacts while
137
+ // staying inside this session's own directory — unrelated co-located
138
+ // sessions in the sessions root are never touched (#8648).
134
139
  const artifactsDir = options.sessionFile.slice(0, -6);
135
140
  await addDirectoryToArchive(data, files, artifactsDir, "artifacts");
136
-
137
- // Look for subagent sessions in the same directory
138
- const sessionDir = path.dirname(options.sessionFile);
139
- const sessionBasename = path.basename(options.sessionFile, ".jsonl");
140
- await addSubagentSessions(data, files, sessionDir, sessionBasename);
141
141
  }
142
142
 
143
143
  // CPU profile
@@ -172,68 +172,34 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
172
172
  return { path: outputPath, files };
173
173
  }
174
174
 
175
- /** Add all files from a directory to the archive */
175
+ /** Recursively add every file under a directory to the archive. */
176
176
  async function addDirectoryToArchive(
177
177
  data: Record<string, string>,
178
178
  files: string[],
179
179
  dirPath: string,
180
180
  archivePrefix: string,
181
181
  ): Promise<void> {
182
+ let entries: Dirent[];
182
183
  try {
183
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
184
- for (const entry of entries) {
185
- if (!entry.isFile()) continue;
186
- const filePath = path.join(dirPath, entry.name);
187
- const archivePath = `${archivePrefix}/${entry.name}`;
188
- try {
189
- const content = await Bun.file(filePath).text();
190
- data[archivePath] = content;
191
- files.push(archivePath);
192
- } catch {
193
- // Skip files we can't read
194
- }
195
- }
184
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
196
185
  } catch {
197
186
  // Directory doesn't exist
187
+ return;
198
188
  }
199
- }
200
-
201
- /** Find and add subagent session files */
202
- async function addSubagentSessions(
203
- data: Record<string, string>,
204
- files: string[],
205
- sessionDir: string,
206
- parentBasename: string,
207
- ): Promise<void> {
208
- // Subagent sessions are named with task IDs in the same directory
209
- // They follow the pattern: {timestamp}_{sessionId}.jsonl
210
- // We look for any sessions created after the parent session
211
- try {
212
- const entries = await fs.readdir(sessionDir, { withFileTypes: true });
213
- const sessionFiles = entries
214
- .filter(e => e.isFile() && e.name.endsWith(".jsonl") && e.name !== `${parentBasename}.jsonl`)
215
- .map(e => e.name);
216
-
217
- // Limit to most recent 10 subagent sessions
218
- const sortedFiles = sessionFiles.sort().slice(-10);
219
-
220
- for (const filename of sortedFiles) {
221
- const filePath = path.join(sessionDir, filename);
222
- const archivePath = `subagents/${filename}`;
223
- try {
224
- const content = await Bun.file(filePath).text();
225
- data[archivePath] = content;
226
- files.push(archivePath);
227
-
228
- // Also add artifacts for this subagent session
229
- const artifactsDir = filePath.slice(0, -6);
230
- await addDirectoryToArchive(data, files, artifactsDir, `subagents/${filename.slice(0, -6)}`);
231
- } catch {
232
- // Skip files we can't read
233
- }
189
+ for (const entry of entries) {
190
+ const entryPath = path.join(dirPath, entry.name);
191
+ const archivePath = `${archivePrefix}/${entry.name}`;
192
+ if (entry.isDirectory()) {
193
+ await addDirectoryToArchive(data, files, entryPath, archivePath);
194
+ continue;
195
+ }
196
+ if (!entry.isFile()) continue;
197
+ try {
198
+ data[archivePath] = await Bun.file(entryPath).text();
199
+ files.push(archivePath);
200
+ } catch {
201
+ // Skip files we can't read
234
202
  }
235
- } catch {
236
- // Directory doesn't exist
237
203
  }
238
204
  }
239
205
 
@@ -379,8 +379,9 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
379
379
  roots.map(async root => {
380
380
  const toolsDir = path.join(root.path, "tools");
381
381
  return loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.scope, {
382
+ extensions: ["ts", "js"],
382
383
  transform: (name, _content, filePath, source) => {
383
- const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
384
+ const toolName = name.replace(/\.(ts|js)$/, "");
384
385
  return {
385
386
  name: toolName,
386
387
  path: filePath,
@@ -406,8 +406,9 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
406
406
  const userToolsDir = path.join(userBase, "tools");
407
407
 
408
408
  const userResult = await loadFilesFromDir<CustomTool>(ctx, userToolsDir, PROVIDER_ID, "user", {
409
+ extensions: ["ts", "js"],
409
410
  transform: (name, _content, path, source) => {
410
- const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
411
+ const toolName = name.replace(/\.(ts|js)$/, "");
411
412
  return {
412
413
  name: toolName,
413
414
  path,
@@ -425,8 +426,9 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
425
426
  const projectToolsDir = path.join(projectBase, "tools");
426
427
 
427
428
  const projectResult = await loadFilesFromDir<CustomTool>(ctx, projectToolsDir, PROVIDER_ID, "project", {
429
+ extensions: ["ts", "js"],
428
430
  transform: (name, _content, path, source) => {
429
- const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
431
+ const toolName = name.replace(/\.(ts|js)$/, "");
430
432
  return {
431
433
  name: toolName,
432
434
  path,
@@ -512,6 +512,11 @@ export async function loadFilesFromDir<T>(
512
512
  gitignore: true,
513
513
  hidden: false,
514
514
  fileType: FileType.File,
515
+ // Thread the caller's non-recursive intent explicitly: the native glob
516
+ // defaults `recursive` to true and rewrites `*.{ts,js}` -> `**/*.{ts,js}`,
517
+ // which would walk the entire subtree (e.g. a venv's site-packages under
518
+ // ~/.codex/tools) and import arbitrary frontend assets as tools (#8552).
519
+ recursive,
515
520
  });
516
521
  matches = result.matches;
517
522
  } catch {
@@ -412,6 +412,42 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string
412
412
  }
413
413
  return preview.trimEnd();
414
414
  }
415
+
416
+ interface StreamingDiffTail {
417
+ content: string;
418
+ hidden: boolean;
419
+ }
420
+
421
+ /**
422
+ * Select the trailing physical lines that fit the live preview budget.
423
+ *
424
+ * Walk backward from the end instead of splitting the complete diff. This keeps
425
+ * allocation and scanning proportional to the visible suffix. One physical line
426
+ * may exceed the budget, but it is still kept so the newest change is visible.
427
+ */
428
+ function sliceStreamingDiffTail(diff: string, innerWidth: number, budget: number): StreamingDiffTail {
429
+ let end = diff.length;
430
+ while (end > 0 && diff.charCodeAt(end - 1) === 10) end--;
431
+ if (end === 0) return { content: "", hidden: false };
432
+
433
+ const rowLimit = Math.max(1, budget);
434
+ let start = end;
435
+ let cursor = end;
436
+ let visualRows = 0;
437
+ while (cursor >= 0) {
438
+ const newline = cursor > 0 ? diff.lastIndexOf("\n", cursor - 1) : -1;
439
+ const lineStart = newline + 1;
440
+ const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(diff.slice(lineStart, cursor)), innerWidth).length);
441
+ if (visualRows > 0 && visualRows + lineRows > rowLimit) break;
442
+ visualRows += lineRows;
443
+ start = lineStart;
444
+ if (newline < 0) break;
445
+ cursor = newline;
446
+ }
447
+
448
+ return { content: diff.slice(start, end), hidden: start > 0 };
449
+ }
450
+
415
451
  function formatStreamingDiff(
416
452
  diff: string,
417
453
  rawPath: string,
@@ -442,26 +478,14 @@ function formatStreamingDiff(
442
478
  // its Myers alignment is not monotonic in payload length, so a hunk-aware
443
479
  // window stutters as rows move between hunks. Expanded widens the window
444
480
  // to the viewport; the full diff appears once the result finalizes.
445
- const allLines = diff.replace(/\n+$/u, "").split("\n");
446
- let visualUsed = 0;
447
- let cut = allLines.length;
448
- for (let i = allLines.length - 1; i >= 0; i--) {
449
- const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length);
450
- if (visualUsed + lineRows > budget && visualUsed > 0) break;
451
- visualUsed += lineRows;
452
- cut = i;
453
- }
454
- const hiddenLines = cut;
455
- const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
481
+ const tail = sliceStreamingDiffTail(diff, innerWidth, budget);
456
482
  let rendered = "\n\n";
457
- if (hiddenLines > 0) {
458
- const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks;
459
- const remainder: string[] = [];
460
- if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
461
- remainder.push(`${hiddenLines} more lines`);
462
- rendered += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
483
+ if (tail.hidden) {
484
+ // Exact hidden line/hunk counts require scanning the discarded prefix,
485
+ // which would make every streaming update scale with the complete diff.
486
+ rendered += `${uiTheme.fg("dim", "… (content above)")}\n`;
463
487
  }
464
- rendered += renderDiffColored(visible.join("\n"), { filePath: rawPath });
488
+ rendered += renderDiffColored(tail.content, { filePath: rawPath });
465
489
  return rendered;
466
490
  });
467
491
  // The animated glyph rides this trailing line — inside the transcript's
@@ -864,6 +888,7 @@ function renderSingleFileResult(
864
888
 
865
889
  let diffSectionRenderDiffFn: ((t: string, o?: { filePath?: string }) => string) | undefined;
866
890
  const diffSectionCache = createRenderedStringCache();
891
+ const statsSuffixCache = createRenderedStringCache();
867
892
 
868
893
  return framedBlock(uiTheme, width => {
869
894
  const { expanded, renderContext } = options;
@@ -887,7 +912,11 @@ function renderSingleFileResult(
887
912
  // Change stats ride inline on the header bar next to the path.
888
913
  const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
889
914
  const headerDiff = isError ? undefined : details?.diff || previewDiff;
890
- const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
915
+ const statsSuffix = headerDiff
916
+ ? cachedRenderedString(statsSuffixCache, uiTheme, false, "", headerDiff, () =>
917
+ formatDiffStatsSuffix(headerDiff, uiTheme),
918
+ )
919
+ : "";
891
920
  const header = renderEditHeader(width, uiTheme, {
892
921
  icon: isError ? "error" : "success",
893
922
  iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
@@ -613,6 +613,8 @@ async function discoverHooksInPackageRoot(root: string): Promise<string[]> {
613
613
  export interface DiscoverExtensionPathOptions {
614
614
  /** Include ambient native extensions, hooks, and installed plugins. */
615
615
  ambient?: boolean;
616
+ /** Include ambient hook factories. Disable for read-only catalog commands. */
617
+ includeAmbientHooks?: boolean;
616
618
  }
617
619
 
618
620
  export async function discoverExtensionPaths(
@@ -665,11 +667,13 @@ export async function discoverExtensionPaths(
665
667
  // scans only this invocation's configured package roots; it must not consult
666
668
  // settings, installed packages, or process-global CLI injection state.
667
669
  if (ambient) {
668
- const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
669
- for (const hookPath of hooks.items
670
- .map(hook => hook.path)
671
- .filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
672
- addPath(hookPath);
670
+ if (options.includeAmbientHooks !== false) {
671
+ const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
672
+ for (const hookPath of hooks.items
673
+ .map(hook => hook.path)
674
+ .filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
675
+ addPath(hookPath);
676
+ }
673
677
  }
674
678
  } else {
675
679
  for (const configuredPath of configuredPaths) {
@@ -92,6 +92,10 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
92
92
  extensionHandlerTimeoutMs = timeoutMs;
93
93
  }
94
94
 
95
+ function normalizeHandlerTimeout(timeoutMs: number): number {
96
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : EXTENSION_HANDLER_TIMEOUT_MS;
97
+ }
98
+
95
99
  /**
96
100
  * Dedicated cap for `session_shutdown` handlers. The generic 30s budget is
97
101
  * appropriate for events extensions can observe (e.g. `session_start`,
@@ -117,6 +121,11 @@ function handlerTimeoutForEvent(eventType: string): number {
117
121
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
118
122
  const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
119
123
 
124
+ interface HandlerTimeoutBudget {
125
+ pause(): void;
126
+ resume(): void;
127
+ }
128
+
120
129
  function attachHandlerSignal(
121
130
  dialogOptions: ExtensionUIDialogOptions | undefined,
122
131
  handlerSignal: AbortSignal,
@@ -127,22 +136,57 @@ function attachHandlerSignal(
127
136
  return { ...dialogOptions, signal: AbortSignal.any([dialogOptions.signal, handlerSignal]) };
128
137
  }
129
138
 
130
- function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSignal): ExtensionUIContext {
139
+ function createHandlerUIContext(
140
+ ui: ExtensionUIContext,
141
+ handlerSignal: AbortSignal,
142
+ timeoutBudget?: HandlerTimeoutBudget,
143
+ ): ExtensionUIContext {
131
144
  const askDialog = ui.askDialog;
145
+ const runDialog = async <T>(dialog: () => Promise<T>): Promise<T> => {
146
+ timeoutBudget?.pause();
147
+ try {
148
+ return await dialog();
149
+ } finally {
150
+ timeoutBudget?.resume();
151
+ }
152
+ };
132
153
  const dialogMethods = {
133
154
  select: (title, options, dialogOptions) =>
134
- ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal)),
155
+ runDialog(() => ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal))),
135
156
  confirm: (title, message, dialogOptions) =>
136
- ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal)),
157
+ runDialog(() => ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal))),
137
158
  input: (title, placeholder, dialogOptions) =>
138
- ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal)),
159
+ runDialog(() => ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal))),
139
160
  askDialog: askDialog
140
161
  ? (questions, dialogOptions) =>
141
- askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal))
162
+ runDialog(() => askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal)))
142
163
  : undefined,
164
+ custom: async (factory, options) => {
165
+ let customSettled = false;
166
+ let componentReady = false;
167
+ try {
168
+ return await ui.custom(
169
+ async (...args) => {
170
+ const component = await factory(...args);
171
+ if (!customSettled) {
172
+ timeoutBudget?.pause();
173
+ componentReady = true;
174
+ }
175
+ return component;
176
+ },
177
+ {
178
+ ...options,
179
+ signal: options?.signal ? AbortSignal.any([options.signal, handlerSignal]) : handlerSignal,
180
+ },
181
+ );
182
+ } finally {
183
+ customSettled = true;
184
+ if (componentReady) timeoutBudget?.resume();
185
+ }
186
+ },
143
187
  editor: (title, prefill, dialogOptions, editorOptions) =>
144
- ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions),
145
- } satisfies Pick<ExtensionUIContext, "select" | "confirm" | "input" | "askDialog" | "editor">;
188
+ runDialog(() => ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions)),
189
+ } satisfies Pick<ExtensionUIContext, "select" | "confirm" | "input" | "askDialog" | "custom" | "editor">;
146
190
  const delegatedMethods = new Map<PropertyKey, unknown>();
147
191
 
148
192
  return new Proxy(ui, {
@@ -167,10 +211,14 @@ function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSign
167
211
  * `pi.setModel()` and then reading `ctx.model` would see a stale model.
168
212
  * Prototype delegation keeps every getter live while overriding `ui`.
169
213
  */
170
- function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal): ExtensionContext {
214
+ function createHandlerContext(
215
+ ctx: ExtensionContext,
216
+ handlerSignal: AbortSignal,
217
+ timeoutBudget?: HandlerTimeoutBudget,
218
+ ): ExtensionContext {
171
219
  const scoped: ExtensionContext = Object.create(ctx);
172
220
  Object.defineProperty(scoped, "ui", {
173
- value: createHandlerUIContext(ctx.ui, handlerSignal),
221
+ value: createHandlerUIContext(ctx.ui, handlerSignal, timeoutBudget),
174
222
  enumerable: true,
175
223
  configurable: true,
176
224
  });
@@ -190,7 +238,7 @@ function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal)
190
238
  * can `clearTimeout` on the winning branch.
191
239
  */
192
240
  async function raceHandlerWithTimeout<T>(
193
- work: (handlerSignal: AbortSignal) => Promise<T> | T,
241
+ work: (handlerSignal: AbortSignal, timeoutBudget: HandlerTimeoutBudget) => Promise<T> | T,
194
242
  timeoutMs: number,
195
243
  signal?: AbortSignal,
196
244
  ): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED> {
@@ -203,13 +251,52 @@ async function raceHandlerWithTimeout<T>(
203
251
  >();
204
252
  const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED);
205
253
  signal?.addEventListener("abort", onAbort, { once: true });
206
- const timer = setTimeout(() => {
254
+ let timer: Timer | undefined;
255
+ let remainingMs = timeoutMs;
256
+ let activeSince = performance.now();
257
+ let pauseDepth = 0;
258
+ let settled = false;
259
+ const clearTimer = () => {
260
+ if (timer === undefined) return;
261
+ clearTimeout(timer);
262
+ timer = undefined;
263
+ };
264
+ const expire = () => {
265
+ if (settled) return;
266
+ settled = true;
267
+ clearTimer();
207
268
  timeoutController.abort(new DOMException(`Handler timed out after ${timeoutMs}ms`, "TimeoutError"));
208
269
  resolveInterrupt(EXTENSION_HANDLER_TIMEOUT);
209
- }, timeoutMs);
270
+ };
271
+ const armTimer = () => {
272
+ if (settled || pauseDepth > 0) return;
273
+ activeSince = performance.now();
274
+ timer = setTimeout(expire, Math.max(0, remainingMs));
275
+ };
276
+ const settle = () => {
277
+ if (settled) return;
278
+ settled = true;
279
+ clearTimer();
280
+ };
281
+ const timeoutBudget: HandlerTimeoutBudget = {
282
+ pause: () => {
283
+ if (settled) return;
284
+ pauseDepth++;
285
+ if (pauseDepth !== 1) return;
286
+ remainingMs = Math.max(0, remainingMs - (performance.now() - activeSince));
287
+ clearTimer();
288
+ if (remainingMs <= 0) expire();
289
+ },
290
+ resume: () => {
291
+ if (settled || pauseDepth === 0) return;
292
+ pauseDepth--;
293
+ if (pauseDepth === 0) armTimer();
294
+ },
295
+ };
296
+ armTimer();
210
297
  try {
211
298
  if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
212
- const workPromise = Promise.resolve(work(handlerSignal));
299
+ const workPromise = Promise.resolve(work(handlerSignal, timeoutBudget));
213
300
  const result = await Promise.race([workPromise, interruptPromise]);
214
301
  if (result === EXTENSION_HANDLER_TIMEOUT) {
215
302
  await Promise.race([
@@ -222,7 +309,7 @@ async function raceHandlerWithTimeout<T>(
222
309
  }
223
310
  return result;
224
311
  } finally {
225
- clearTimeout(timer);
312
+ settle();
226
313
  signal?.removeEventListener("abort", onAbort);
227
314
  }
228
315
  }
@@ -1043,23 +1130,33 @@ export class ExtensionRunner {
1043
1130
  ext: Extension,
1044
1131
  timeoutMs: number,
1045
1132
  onFailure?: (kind: "timeout" | "error", message: string) => TResult,
1133
+ outerSignal?: AbortSignal,
1046
1134
  ): Promise<TResult | undefined> {
1047
- const signal =
1135
+ // `session_stop` carries its own signal on the event; `tool_call` receives
1136
+ // the outer dispatch signal (loop request or wrapper execute) so an abort
1137
+ // while a handler awaits a human dialog cancels the dialog and settles the
1138
+ // gate without executing the underlying tool. Compose whichever apply.
1139
+ const sessionStopSignal =
1048
1140
  event.type === "session_stop" && "signal" in event && event.signal instanceof AbortSignal
1049
1141
  ? event.signal
1050
1142
  : undefined;
1143
+ const signals = [outerSignal, sessionStopSignal].filter((s): s is AbortSignal => s !== undefined);
1144
+ const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
1051
1145
  if (signal?.aborted) return undefined;
1052
1146
  const registrationScope: ToolRegistrationScope = { pending: new Set(), closed: false };
1053
1147
  let handlerResult: TResult | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED | undefined;
1054
1148
  let handlerFailure: { error: unknown } | undefined;
1055
1149
  try {
1056
1150
  handlerResult = await raceHandlerWithTimeout(
1057
- async handlerSignal => {
1151
+ async (handlerSignal, budget) => {
1058
1152
  registrationScope.signal = handlerSignal;
1059
1153
  let result: TResult | undefined;
1060
1154
  try {
1061
1155
  result = await this.#toolRegistrationScope.run(registrationScope, () =>
1062
- handler(event, createHandlerContext(ctx, handlerSignal)),
1156
+ handler(
1157
+ event,
1158
+ createHandlerContext(ctx, handlerSignal, event.type === "tool_call" ? budget : undefined),
1159
+ ),
1063
1160
  );
1064
1161
  } catch (error) {
1065
1162
  handlerFailure = { error };
@@ -1220,8 +1317,8 @@ export class ExtensionRunner {
1220
1317
  /**
1221
1318
  * Emit a `tool_call` event to every subscribed extension before the tool executes.
1222
1319
  *
1223
- * Each handler is bounded by `extensionHandlerTimeoutMs` (default 30s). This
1224
- * matches the timeout policy already applied to `emitToolResult` and every
1320
+ * Each handler is bounded by `extensionHandlers.toolCallTimeoutMs` (default
1321
+ * 30s). This matches the timeout policy already applied to `emitToolResult` and every
1225
1322
  * other handler routed through `#runHandlerWithTimeout`; without it a single
1226
1323
  * hung extension (unresolved `await`, network call with no timeout) would
1227
1324
  * park `ExtensionToolWrapper.execute` indefinitely and freeze tool
@@ -1232,9 +1329,11 @@ export class ExtensionRunner {
1232
1329
  * pre-execution gate — an unresponsive extension MUST NOT be treated as
1233
1330
  * silent consent to run the tool.
1234
1331
  */
1235
- async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
1332
+ async emitToolCall(event: ToolCallEvent, signal?: AbortSignal): Promise<ToolCallEventResult | undefined> {
1236
1333
  const ctx = this.createContext();
1237
- const timeoutMs = extensionHandlerTimeoutMs;
1334
+ const timeoutMs = normalizeHandlerTimeout(
1335
+ this.settings?.get("extensionHandlers.toolCallTimeoutMs") ?? extensionHandlerTimeoutMs,
1336
+ );
1238
1337
  let result: ToolCallEventResult | undefined;
1239
1338
 
1240
1339
  for (const ext of this.extensions) {
@@ -1255,6 +1354,7 @@ export class ExtensionRunner {
1255
1354
  ? `Extension ${ext.path} timed out after ${timeoutMs}ms`
1256
1355
  : `Extension ${ext.path} failed: ${message}`,
1257
1356
  }),
1357
+ signal,
1258
1358
  );
1259
1359
 
1260
1360
  if (handlerResult) {
@@ -1266,6 +1366,9 @@ export class ExtensionRunner {
1266
1366
  }
1267
1367
  }
1268
1368
 
1369
+ if (signal?.aborted) {
1370
+ return { block: true, reason: `Tool execution was cancelled while an extension handler was pending` };
1371
+ }
1269
1372
  return result;
1270
1373
  }
1271
1374
 
@@ -234,6 +234,8 @@ export interface ExtensionCustomOptions {
234
234
  overlayOptions?: OverlayOptions | (() => OverlayOptions);
235
235
  /** Invoked with the overlay handle once the overlay is created (overlay mode only). */
236
236
  onHandle?: (handle: OverlayHandle) => void;
237
+ /** Abort the custom UI and reject its promise. */
238
+ signal?: AbortSignal;
237
239
  }
238
240
 
239
241
  /** Wrap the current autocomplete provider with additional behavior (pi-compatible). */
@@ -205,15 +205,18 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
205
205
  let effectiveParams = params;
206
206
  if (!loopEmittedToolCall && this.runner.hasHandlers("tool_call")) {
207
207
  try {
208
- const callResult = (await this.runner.emitToolCall({
209
- type: "tool_call",
210
- toolName: this.tool.name,
211
- toolCallId,
212
- input: normalizeToolEventInput(
213
- this.tool.name,
214
- resolveToolEventInput(this.tool, toolEventArgs(params, context)),
215
- ),
216
- })) as ToolCallEventResult | undefined;
208
+ const callResult = (await this.runner.emitToolCall(
209
+ {
210
+ type: "tool_call",
211
+ toolName: this.tool.name,
212
+ toolCallId,
213
+ input: normalizeToolEventInput(
214
+ this.tool.name,
215
+ resolveToolEventInput(this.tool, toolEventArgs(params, context)),
216
+ ),
217
+ },
218
+ signal,
219
+ )) as ToolCallEventResult | undefined;
217
220
 
218
221
  if (callResult?.block) {
219
222
  const reason = callResult.reason || "Tool execution was blocked by an extension";
@@ -265,7 +268,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
265
268
 
266
269
  if (approvalCheck.required) {
267
270
  const scheduledCall = context?.toolCall?.toolCalls[context.toolCall.index];
268
- if (scheduledCall?.id === toolCallId && scheduledCall.name === this.tool.name) {
271
+ if (
272
+ scheduledCall?.id === toolCallId &&
273
+ (scheduledCall.name === this.tool.name || scheduledCall.name === this.tool.customWireName)
274
+ ) {
269
275
  await untilAborted(signal, () => this.runner.waitForToolApprovalPreview(toolCallId));
270
276
  }
271
277
 
@@ -134,6 +134,10 @@ export const getModels = getBundledModels;
134
134
  *
135
135
  * Legacy `/compat` callers pass {@link SimpleStreamOptions}; routing through
136
136
  * `streamSimple` preserves option normalization before provider dispatch.
137
+ *
138
+ * Transient-failure retry (overload, rate-limit, 5xx) is the **caller's
139
+ * responsibility**. Oneshot callers that collect the full result before acting
140
+ * should wrap with `retryTransientCompletion` from `@oh-my-pi/pi-ai`.
137
141
  */
138
142
  export function streamSimpleOpenAIResponses(
139
143
  model: Model<"openai-responses">,