@oh-my-pi/pi-coding-agent 17.3.4 → 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 (121) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-tt9k4jpr.md} +68 -0
  3. package/dist/cli.js +3146 -3153
  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/mcp/config.d.ts +3 -1
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  18. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  19. package/dist/types/registry/agent-registry.d.ts +4 -0
  20. package/dist/types/session/agent-session-types.d.ts +0 -2
  21. package/dist/types/session/agent-session.d.ts +20 -0
  22. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  23. package/dist/types/session/irc-bridge.d.ts +2 -0
  24. package/dist/types/session/messages.d.ts +4 -0
  25. package/dist/types/session/session-loader.d.ts +18 -11
  26. package/dist/types/session/session-maintenance.d.ts +6 -2
  27. package/dist/types/session/session-manager.d.ts +19 -3
  28. package/dist/types/session/session-tools.d.ts +0 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -7
  30. package/dist/types/tools/browser/attach.d.ts +16 -0
  31. package/dist/types/tools/browser/launch.d.ts +2 -2
  32. package/dist/types/tools/browser.d.ts +1 -1
  33. package/dist/types/tools/builtin-names.d.ts +1 -1
  34. package/dist/types/tools/hub/jobs.d.ts +6 -0
  35. package/dist/types/tools/hub/types.d.ts +6 -0
  36. package/dist/types/vibe/runtime.d.ts +3 -3
  37. package/package.json +13 -13
  38. package/src/auto-thinking/classifier.ts +37 -23
  39. package/src/cli/models-cli.ts +1 -1
  40. package/src/commit/analysis/conventional.ts +15 -9
  41. package/src/commit/analysis/summary.ts +15 -9
  42. package/src/commit/changelog/generate.ts +15 -9
  43. package/src/commit/map-reduce/map-phase.ts +3 -19
  44. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  45. package/src/config/model-discovery.ts +3 -3
  46. package/src/config/model-registry.ts +29 -16
  47. package/src/config/settings-schema.ts +13 -0
  48. package/src/debug/report-bundle.ts +25 -59
  49. package/src/discovery/claude-plugins.ts +2 -1
  50. package/src/discovery/claude.ts +4 -2
  51. package/src/discovery/helpers.ts +5 -0
  52. package/src/edit/renderer.ts +48 -19
  53. package/src/extensibility/extensions/loader.ts +9 -5
  54. package/src/extensibility/extensions/runner.ts +124 -21
  55. package/src/extensibility/extensions/types.ts +2 -0
  56. package/src/extensibility/extensions/wrapper.ts +16 -10
  57. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  58. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  59. package/src/irc/bus.ts +1 -1
  60. package/src/launch/broker.ts +8 -1
  61. package/src/launch/presence.ts +77 -1
  62. package/src/lsp/client.ts +35 -3
  63. package/src/lsp/clients/biome-client.ts +47 -93
  64. package/src/lsp/servers.ts +17 -11
  65. package/src/lsp/tool.ts +2 -4
  66. package/src/lsp/workspace-diagnostics.ts +24 -2
  67. package/src/mcp/config.ts +50 -6
  68. package/src/memories/index.ts +29 -25
  69. package/src/mnemopi/backend.ts +13 -11
  70. package/src/modes/components/ask-dialog.ts +25 -5
  71. package/src/modes/components/tool-execution.ts +10 -6
  72. package/src/modes/components/welcome.ts +4 -1
  73. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  74. package/src/modes/controllers/selector-controller.ts +6 -0
  75. package/src/modes/controllers/tan-command-controller.ts +1 -0
  76. package/src/modes/interactive-mode.ts +49 -10
  77. package/src/modes/theme/defaults/birch.json +2 -2
  78. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  79. package/src/prompts/system/date-cwd-reminder.md +3 -0
  80. package/src/prompts/system/project-prompt.md +0 -1
  81. package/src/prompts/system/rewind-report.md +1 -3
  82. package/src/prompts/tools/browser.md +1 -0
  83. package/src/prompts/tools/rewind.md +1 -13
  84. package/src/registry/agent-lifecycle.ts +34 -0
  85. package/src/registry/agent-registry.ts +27 -2
  86. package/src/registry/persisted-agents.ts +40 -20
  87. package/src/sdk.ts +32 -2
  88. package/src/session/agent-session-types.ts +0 -2
  89. package/src/session/agent-session.ts +178 -46
  90. package/src/session/date-cwd-reminder.ts +77 -0
  91. package/src/session/irc-bridge.ts +5 -0
  92. package/src/session/messages.ts +5 -1
  93. package/src/session/session-loader.ts +106 -64
  94. package/src/session/session-maintenance.ts +189 -115
  95. package/src/session/session-manager.ts +142 -35
  96. package/src/session/session-persistence.ts +4 -4
  97. package/src/session/session-storage.ts +18 -5
  98. package/src/session/session-tools.ts +5 -10
  99. package/src/session/turn-recovery.ts +102 -20
  100. package/src/session/unexpected-stop-classifier.ts +33 -21
  101. package/src/system-prompt.ts +0 -5
  102. package/src/task/executor.ts +4 -15
  103. package/src/task/persisted-revive.ts +3 -6
  104. package/src/tools/ask.ts +10 -3
  105. package/src/tools/browser/attach.ts +80 -25
  106. package/src/tools/browser/launch.ts +44 -15
  107. package/src/tools/browser/relay/daemon.ts +3 -7
  108. package/src/tools/browser/render.ts +1 -1
  109. package/src/tools/browser/shared-daemon.ts +3 -7
  110. package/src/tools/browser.ts +5 -5
  111. package/src/tools/builtin-names.ts +7 -3
  112. package/src/tools/checkpoint.ts +1 -7
  113. package/src/tools/hub/index.ts +1 -1
  114. package/src/tools/hub/jobs.ts +20 -3
  115. package/src/tools/hub/types.ts +6 -0
  116. package/src/tools/render-utils.ts +53 -21
  117. package/src/tools/think.ts +15 -3
  118. package/src/tts/speech-enhancer.ts +19 -14
  119. package/src/utils/commit-message-generator.ts +14 -12
  120. package/src/utils/title-generator.ts +23 -19
  121. package/src/vibe/runtime.ts +32 -17
@@ -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">,
@@ -11,6 +11,7 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
13
  import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
+ import { expandTilde } from "../../../tools/path-utils";
14
15
  import { normalizePluginRuntimeConfig } from "../runtime-config";
15
16
  import type { PluginRuntimeConfig } from "../types";
16
17
 
@@ -107,12 +108,11 @@ export class MarketplaceManager {
107
108
  }
108
109
 
109
110
  const sourceType = classifySource(source);
110
- const normalizedSource =
111
- sourceType === "local"
112
- ? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source)
113
- : source;
111
+ const normalizedSource = sourceType === "local" ? path.resolve(expandTilde(source)) : source;
114
112
 
115
- const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json");
113
+ const catalogPath = path.resolve(
114
+ expandTilde(path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json")),
115
+ );
116
116
 
117
117
  // Persist the fetched catalog so subsequent reads don't require re-fetching.
118
118
  await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
@@ -174,11 +174,13 @@ export class MarketplaceManager {
174
174
  await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name);
175
175
  }
176
176
 
177
- // Overwrite cached catalog
178
- await Bun.write(existing.catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
177
+ // Overwrite the cached catalog and migrate legacy home-relative registry entries.
178
+ const catalogPath = path.resolve(expandTilde(existing.catalogPath));
179
+ await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
179
180
 
180
181
  const updatedEntry: MarketplaceRegistryEntry = {
181
182
  ...existing,
183
+ catalogPath,
182
184
  updatedAt: new Date().toISOString(),
183
185
  };
184
186
 
@@ -893,14 +895,13 @@ export class MarketplaceManager {
893
895
  }
894
896
 
895
897
  async #readCatalog(entry: MarketplaceRegistryEntry): Promise<MarketplaceCatalog> {
898
+ const catalogPath = path.resolve(expandTilde(entry.catalogPath));
896
899
  try {
897
- const content = await Bun.file(entry.catalogPath).text();
898
- return parseMarketplaceCatalog(content, entry.catalogPath);
900
+ const content = await Bun.file(catalogPath).text();
901
+ return parseMarketplaceCatalog(content, catalogPath);
899
902
  } catch (err) {
900
903
  if (isEnoent(err)) {
901
- throw new Error(
902
- `Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`,
903
- );
904
+ throw new Error(`Marketplace catalog not found at ${catalogPath}. Try: /marketplace update ${entry.name}`);
904
905
  }
905
906
  throw err;
906
907
  }
@@ -918,14 +919,10 @@ export class MarketplaceManager {
918
919
  */
919
920
  #resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string {
920
921
  if (entry.sourceType === "local") {
921
- // expandHome already happened in fetcher; resolve to ensure absolute.
922
- const expanded = entry.sourceUri.startsWith("~/")
923
- ? path.join(os.homedir(), entry.sourceUri.slice(2))
924
- : entry.sourceUri;
925
- return path.resolve(expanded);
922
+ return path.resolve(expandTilde(entry.sourceUri));
926
923
  }
927
924
  // For git/github/url sources, the catalog lives at <cloneDir>/marketplace.json
928
925
  // under marketplacesCacheDir/<name>/; parent = <marketplacesCacheDir>/<name>/
929
- return path.dirname(entry.catalogPath);
926
+ return path.dirname(path.resolve(expandTilde(entry.catalogPath)));
930
927
  }
931
928
  }
package/src/irc/bus.ts CHANGED
@@ -276,7 +276,7 @@ export class IrcBus {
276
276
  if (liveness) {
277
277
  const { registry, senderId } = liveness;
278
278
  const hasRunningSender = (from?: string): boolean =>
279
- registry.listVisibleTo(senderId).some(ref => ref.status === "running" && (!from || ref.id === from));
279
+ registry.listVisibleTo(senderId).some(ref => registry.isRunning(ref) && (!from || ref.id === from));
280
280
  const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender();
281
281
  unsubscribeLiveness = registry.onChange(() => {
282
282
  if (!check()) {
@@ -8,7 +8,7 @@ import { hostHasInheritableConsole } from "../eval/py/spawn-options";
8
8
  import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
9
9
  import { workerEnvFromParent } from "../subprocess/worker-client";
10
10
  import { daemonBrokerEndpoint } from "./paths";
11
- import { hasLiveDaemonProjectPresence } from "./presence";
11
+ import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence";
12
12
  import {
13
13
  DAEMON_IDLE_GRACE_ENV,
14
14
  DAEMON_PROJECT_DIR_ENV,
@@ -1384,6 +1384,13 @@ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStar
1384
1384
  const lease = await acquireBrokerLease(runtimeDir);
1385
1385
  if (!lease) return;
1386
1386
  setProcessName("omp daemon broker");
1387
+ // Reclaim sibling daemon scopes left behind by dead brokers (issue #8674).
1388
+ // Detached and non-throwing so it never delays clients connecting to us.
1389
+ void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => {
1390
+ logger.warn("Daemon runtime prune failed", {
1391
+ error: error instanceof Error ? error.message : String(error),
1392
+ });
1393
+ });
1387
1394
  const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
1388
1395
  if (!token) throw new Error("Daemon broker token is empty");
1389
1396
  const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs);