@oh-my-pi/pi-coding-agent 14.1.2 → 14.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/package.json +8 -8
  3. package/scripts/build-binary.ts +61 -0
  4. package/src/autoresearch/helpers.ts +10 -0
  5. package/src/autoresearch/index.ts +1 -11
  6. package/src/autoresearch/tools/init-experiment.ts +1 -10
  7. package/src/autoresearch/tools/log-experiment.ts +1 -11
  8. package/src/autoresearch/tools/run-experiment.ts +1 -10
  9. package/src/bun-imports.d.ts +6 -0
  10. package/src/cli/plugin-cli.ts +23 -45
  11. package/src/commit/agentic/tools/propose-commit.ts +1 -14
  12. package/src/commit/agentic/tools/split-commit.ts +1 -15
  13. package/src/commit/utils.ts +15 -1
  14. package/src/config/model-registry.ts +3 -3
  15. package/src/config/prompt-templates.ts +4 -12
  16. package/src/config/settings-schema.ts +27 -2
  17. package/src/config/settings.ts +1 -1
  18. package/src/discovery/claude-plugins.ts +61 -6
  19. package/src/discovery/codex.ts +2 -15
  20. package/src/discovery/gemini.ts +2 -15
  21. package/src/discovery/helpers.ts +40 -1
  22. package/src/discovery/opencode.ts +2 -15
  23. package/src/edit/apply-patch/index.ts +87 -0
  24. package/src/edit/apply-patch/parser.ts +174 -0
  25. package/src/edit/diff.ts +3 -14
  26. package/src/edit/index.ts +65 -2
  27. package/src/edit/modes/apply-patch.lark +19 -0
  28. package/src/edit/modes/apply-patch.ts +63 -0
  29. package/src/edit/modes/hashline.ts +3 -3
  30. package/src/edit/modes/replace.ts +2 -13
  31. package/src/edit/read-file.ts +18 -0
  32. package/src/edit/renderer.ts +61 -33
  33. package/src/extensibility/extensions/compact-handler.ts +40 -0
  34. package/src/extensibility/extensions/runner.ts +11 -29
  35. package/src/extensibility/utils.ts +7 -1
  36. package/src/internal-urls/docs-index.generated.ts +9 -2
  37. package/src/lsp/render.ts +14 -2
  38. package/src/main.ts +1 -0
  39. package/src/mcp/manager.ts +29 -48
  40. package/src/memories/index.ts +7 -1
  41. package/src/modes/acp/acp-agent.ts +3 -16
  42. package/src/modes/components/model-selector.ts +15 -24
  43. package/src/modes/components/plugin-settings.ts +16 -5
  44. package/src/modes/components/read-tool-group.ts +92 -9
  45. package/src/modes/components/settings-defs.ts +18 -0
  46. package/src/modes/components/settings-selector.ts +2 -6
  47. package/src/modes/components/tool-execution.ts +61 -28
  48. package/src/modes/controllers/event-controller.ts +3 -1
  49. package/src/modes/controllers/extension-ui-controller.ts +99 -150
  50. package/src/modes/controllers/selector-controller.ts +3 -12
  51. package/src/modes/interactive-mode.ts +4 -2
  52. package/src/modes/print-mode.ts +4 -22
  53. package/src/modes/rpc/rpc-mode.ts +18 -38
  54. package/src/modes/shared.ts +10 -1
  55. package/src/modes/utils/ui-helpers.ts +6 -2
  56. package/src/plan-mode/approved-plan.ts +5 -4
  57. package/src/prompts/system/subagent-system-prompt.md +4 -4
  58. package/src/prompts/system/subagent-user-prompt.md +2 -2
  59. package/src/prompts/system/system-prompt.md +208 -243
  60. package/src/prompts/tools/apply-patch.md +67 -0
  61. package/src/prompts/tools/ast-edit.md +18 -23
  62. package/src/prompts/tools/ast-grep.md +24 -32
  63. package/src/prompts/tools/bash.md +11 -23
  64. package/src/prompts/tools/debug.md +8 -22
  65. package/src/prompts/tools/find.md +0 -4
  66. package/src/prompts/tools/grep.md +3 -5
  67. package/src/prompts/tools/hashline.md +16 -10
  68. package/src/prompts/tools/python.md +10 -14
  69. package/src/prompts/tools/read.md +17 -24
  70. package/src/prompts/tools/task.md +57 -21
  71. package/src/prompts/tools/todo-write.md +45 -67
  72. package/src/session/agent-session.ts +4 -4
  73. package/src/session/session-manager.ts +15 -7
  74. package/src/session/streaming-output.ts +24 -0
  75. package/src/slash-commands/builtin-registry.ts +3 -14
  76. package/src/task/executor.ts +13 -34
  77. package/src/task/index.ts +82 -18
  78. package/src/task/simple-mode.ts +27 -0
  79. package/src/task/template.ts +17 -3
  80. package/src/task/types.ts +77 -30
  81. package/src/tools/ask.ts +2 -4
  82. package/src/tools/ast-edit.ts +4 -15
  83. package/src/tools/ast-grep.ts +8 -27
  84. package/src/tools/bash-skill-urls.ts +9 -7
  85. package/src/tools/bash.ts +4 -12
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/fetch.ts +1 -14
  88. package/src/tools/file-recorder.ts +35 -0
  89. package/src/tools/find.ts +6 -3
  90. package/src/tools/gh-format.ts +12 -0
  91. package/src/tools/gh-renderer.ts +1 -8
  92. package/src/tools/gh.ts +6 -13
  93. package/src/tools/grep.ts +9 -22
  94. package/src/tools/jtd-to-json-schema.ts +16 -0
  95. package/src/tools/match-line-format.ts +20 -0
  96. package/src/tools/path-utils.ts +30 -2
  97. package/src/tools/plan-mode-guard.ts +6 -5
  98. package/src/tools/python.ts +1 -1
  99. package/src/tools/read.ts +1 -1
  100. package/src/tools/render-utils.ts +38 -6
  101. package/src/tools/renderers.ts +1 -0
  102. package/src/tools/ssh.ts +3 -11
  103. package/src/tools/submit-result.ts +1 -13
  104. package/src/tools/todo-write.ts +137 -103
  105. package/src/tools/write.ts +2 -23
  106. package/src/tui/code-cell.ts +12 -7
  107. package/src/utils/edit-mode.ts +3 -2
  108. package/src/utils/git.ts +1 -1
  109. package/src/vim/engine.ts +41 -58
  110. package/src/web/scrapers/crates-io.ts +1 -14
  111. package/src/web/scrapers/types.ts +13 -0
  112. package/src/web/search/providers/base.ts +13 -0
  113. package/src/web/search/providers/brave.ts +2 -5
  114. package/src/web/search/providers/codex.ts +20 -24
  115. package/src/web/search/providers/gemini.ts +39 -1
  116. package/src/web/search/providers/jina.ts +2 -5
  117. package/src/web/search/providers/kagi.ts +3 -8
  118. package/src/web/search/providers/kimi.ts +3 -7
  119. package/src/web/search/providers/parallel.ts +3 -8
  120. package/src/web/search/providers/synthetic.ts +3 -7
  121. package/src/web/search/providers/tavily.ts +15 -11
  122. package/src/web/search/providers/utils.ts +36 -0
  123. package/src/web/search/providers/zai.ts +3 -7
@@ -0,0 +1,19 @@
1
+ start: begin_patch hunk+ end_patch
2
+ begin_patch: "*** Begin Patch" LF
3
+ end_patch: "*** End Patch" LF?
4
+
5
+ hunk: add_hunk | delete_hunk | update_hunk
6
+ add_hunk: "*** Add File: " filename LF add_line+
7
+ delete_hunk: "*** Delete File: " filename LF
8
+ update_hunk: "*** Update File: " filename LF change_move? change?
9
+
10
+ filename: /(.+)/
11
+ add_line: "+" /(.*)/ LF -> line
12
+
13
+ change_move: "*** Move to: " filename LF
14
+ change: (change_context | change_line)+ eof_line?
15
+ change_context: ("@@" | "@@ " /(.+)/) LF
16
+ change_line: ("+" | "-" | " ") /(.*)/ LF
17
+ eof_line: "*** End of File" LF
18
+
19
+ %import common.LF
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Edit mode wrapper for the Codex `apply_patch` envelope format.
3
+ *
4
+ * The mode accepts a single `input` string containing a full
5
+ * `*** Begin Patch ... *** End Patch` block, parses it, and fans out to
6
+ * the existing `executePatchSingle` — so all the machinery (plan mode,
7
+ * LSP writethrough, fs-cache invalidation, diagnostics) is shared with
8
+ * the `patch` mode.
9
+ */
10
+
11
+ import { type Static, Type } from "@sinclair/typebox";
12
+ import { parseApplyPatch, parseApplyPatchStreaming } from "../apply-patch/parser";
13
+ import { ApplyPatchError } from "../diff";
14
+ import type { PatchEditEntry } from "./patch";
15
+
16
+ export const applyPatchSchema = Type.Object({
17
+ input: Type.String({
18
+ description:
19
+ "Full Codex apply_patch envelope, including '*** Begin Patch' and '*** End Patch'. Contains any mix of Add/Delete/Update (with optional Move to) file operations.",
20
+ }),
21
+ });
22
+
23
+ export type ApplyPatchParams = Static<typeof applyPatchSchema>;
24
+
25
+ export function isApplyPatchParams(params: unknown): params is ApplyPatchParams {
26
+ return (
27
+ typeof params === "object" &&
28
+ params !== null &&
29
+ "input" in params &&
30
+ typeof (params as { input: unknown }).input === "string"
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Parse the envelope and lower each hunk to a `PatchEditEntry` so it can
36
+ * be routed through `executePatchSingle`.
37
+ */
38
+ export function expandApplyPatchToEntries(params: ApplyPatchParams): PatchEditEntry[] {
39
+ const hunks = parseApplyPatch(params.input);
40
+ if (hunks.length === 0) {
41
+ throw new ApplyPatchError("No files were modified.");
42
+ }
43
+ return hunks.map(
44
+ (h): PatchEditEntry => ({
45
+ path: h.path,
46
+ op: h.op,
47
+ rename: h.rename,
48
+ diff: h.diff,
49
+ }),
50
+ );
51
+ }
52
+
53
+ export function expandApplyPatchToPreviewEntries(params: ApplyPatchParams): PatchEditEntry[] {
54
+ const hunks = parseApplyPatchStreaming(params.input);
55
+ return hunks.map(
56
+ (h): PatchEditEntry => ({
57
+ path: h.path,
58
+ op: h.op,
59
+ rename: h.rename,
60
+ diff: h.diff,
61
+ }),
62
+ );
63
+ }
@@ -1157,11 +1157,11 @@ export async function computeHashlineDiff(
1157
1157
  }
1158
1158
  > {
1159
1159
  const { path, edits, move } = input;
1160
- const absolutePath = resolveToCwd(path, cwd);
1161
- const movePath = move ? resolveToCwd(move, cwd) : undefined;
1162
- const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1163
1160
 
1164
1161
  try {
1162
+ const absolutePath = resolveToCwd(path, cwd);
1163
+ const movePath = move ? resolveToCwd(move, cwd) : undefined;
1164
+ const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
1165
1165
  const resolvedEdits = resolveHashlineEditsForDiff(edits);
1166
1166
  const file = Bun.file(absolutePath);
1167
1167
 
@@ -5,7 +5,6 @@
5
5
  * fallback strategies for finding text in files.
6
6
  */
7
7
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
- import { isEnoent } from "@oh-my-pi/pi-utils";
9
8
  import { type Static, Type } from "@sinclair/typebox";
10
9
  import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
11
10
  import type { ToolSession } from "../../tools";
@@ -22,6 +21,7 @@ import {
22
21
  restoreLineEndings,
23
22
  stripBom,
24
23
  } from "../normalize";
24
+ import { readEditFileText } from "../read-file";
25
25
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
26
26
 
27
27
  export interface FuzzyMatch {
@@ -140,17 +140,6 @@ function formatOccurrenceError(path: string, matchOutcome: MatchOutcome): string
140
140
  return `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`;
141
141
  }
142
142
 
143
- async function readReplaceFileContent(absolutePath: string, path: string): Promise<string> {
144
- try {
145
- return await Bun.file(absolutePath).text();
146
- } catch (error) {
147
- if (isEnoent(error)) {
148
- throw new Error(`File not found: ${path}`);
149
- }
150
- throw error;
151
- }
152
- }
153
-
154
143
  // ═══════════════════════════════════════════════════════════════════════════
155
144
  // Constants
156
145
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1045,7 +1034,7 @@ export async function executeReplaceSingle(
1045
1034
  }
1046
1035
 
1047
1036
  const absolutePath = resolvePlanPath(session, path);
1048
- const rawContent = await readReplaceFileContent(absolutePath, path);
1037
+ const rawContent = await readEditFileText(absolutePath, path);
1049
1038
  const { bom, text: content } = stripBom(rawContent);
1050
1039
  const originalEnding = detectLineEnding(content);
1051
1040
  const normalizedContent = normalizeToLF(content);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared file-read helper for edit-mode utilities.
3
+ *
4
+ * Reads a file via Bun and rethrows ENOENT as a user-facing "File not found"
5
+ * error referencing the display path.
6
+ */
7
+ import { isEnoent } from "@oh-my-pi/pi-utils";
8
+
9
+ export async function readEditFileText(absolutePath: string, path: string): Promise<string> {
10
+ try {
11
+ return await Bun.file(absolutePath).text();
12
+ } catch (error) {
13
+ if (isEnoent(error)) {
14
+ throw new Error(`File not found: ${path}`);
15
+ }
16
+ throw error;
17
+ }
18
+ }
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Edit tool renderer and LSP batching helpers.
3
3
  */
4
- import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
5
4
  import type { Component } from "@oh-my-pi/pi-tui";
6
5
  import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
7
6
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -16,6 +15,8 @@ import {
16
15
  formatStatusIcon,
17
16
  formatTitle,
18
17
  getDiffStats,
18
+ getLspBatchRequest,
19
+ type LspBatchRequest,
19
20
  PREVIEW_LIMITS,
20
21
  replaceTabs,
21
22
  shortenPath,
@@ -25,34 +26,16 @@ import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
25
26
  import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
26
27
  import type { VimToolDetails } from "../vim/types";
27
28
  import type { DiffError, DiffResult } from "./diff";
29
+ import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
28
30
  import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
29
31
  import type { HashlineToolEdit } from "./modes/hashline";
30
- import type { Operation } from "./modes/patch";
32
+ import type { Operation, PatchEditEntry } from "./modes/patch";
31
33
 
32
34
  // ═══════════════════════════════════════════════════════════════════════════
33
35
  // LSP Batching
34
36
  // ═══════════════════════════════════════════════════════════════════════════
35
37
 
36
- const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
37
-
38
- export interface LspBatchRequest {
39
- id: string;
40
- flush: boolean;
41
- }
42
-
43
- export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBatchRequest | undefined {
44
- if (!toolCall) {
45
- return undefined;
46
- }
47
- const hasOtherWrites = toolCall.toolCalls.some(
48
- (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
49
- );
50
- if (!hasOtherWrites) {
51
- return undefined;
52
- }
53
- const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name));
54
- return { id: toolCall.batchId, flush: !hasLaterWrites };
55
- }
38
+ export { getLspBatchRequest, type LspBatchRequest };
56
39
 
57
40
  // ═══════════════════════════════════════════════════════════════════════════
58
41
  // Tool Details Types
@@ -97,6 +80,7 @@ interface EditRenderArgs {
97
80
  oldText?: string;
98
81
  newText?: string;
99
82
  patch?: string;
83
+ input?: string;
100
84
  all?: boolean;
101
85
  // Patch mode fields
102
86
  op?: Operation;
@@ -107,7 +91,19 @@ interface EditRenderArgs {
107
91
  */
108
92
  previewDiff?: string;
109
93
  // Hashline / chunk mode fields
110
- edits?: Partial<HashlineToolEdit | ChunkToolEdit>[];
94
+ edits?: EditRenderEntry[];
95
+ }
96
+
97
+ type EditRenderEntry = {
98
+ path?: string;
99
+ rename?: string;
100
+ move?: string;
101
+ op?: Operation;
102
+ };
103
+
104
+ interface ApplyPatchRenderSummary {
105
+ entries: PatchEditEntry[];
106
+ error?: string;
111
107
  }
112
108
 
113
109
  function isVimRenderArgs(args: EditRenderArgs | VimRenderArgs): args is VimRenderArgs {
@@ -163,8 +159,8 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
163
159
  }
164
160
 
165
161
  /** Count distinct file paths in an edits array. */
166
- function countEditFiles(edits: any[]): number {
167
- return new Set(edits.map((e: any) => filePathFromEditEntry(e?.path)).filter(Boolean)).size;
162
+ function countEditFiles(edits: EditRenderEntry[]): number {
163
+ return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
168
164
  }
169
165
 
170
166
  function countLines(text: string): number {
@@ -370,6 +366,24 @@ function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme):
370
366
  return "";
371
367
  }
372
368
 
369
+ const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
370
+
371
+ function getApplyPatchRenderSummary(args: EditRenderArgs, isPartial: boolean): ApplyPatchRenderSummary | undefined {
372
+ if (typeof args.input !== "string") {
373
+ return undefined;
374
+ }
375
+
376
+ try {
377
+ return { entries: expandApplyPatchToEntries({ input: args.input }) };
378
+ } catch (err) {
379
+ const error = err instanceof Error ? err.message : String(err);
380
+ if (isPartial && error === MISSING_APPLY_PATCH_END_ERROR) {
381
+ return { entries: expandApplyPatchToPreviewEntries({ input: args.input }) };
382
+ }
383
+ return { entries: [], error };
384
+ }
385
+ }
386
+
373
387
  function renderDiffSection(
374
388
  diff: string,
375
389
  rawPath: string,
@@ -437,21 +451,29 @@ export const editToolRenderer = {
437
451
  return vimToolRenderer.renderCall(args, options, uiTheme);
438
452
  }
439
453
 
454
+ const applyPatchSummary = getApplyPatchRenderSummary(args, options.isPartial);
455
+ const firstApplyPatchEntry = applyPatchSummary?.entries[0];
440
456
  // Extract path from first edit entry when top-level path is absent (new schema)
441
457
  const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
442
- const rawPath = args.file_path || args.path || (firstEdit as any)?.path || "";
443
- const rename = args.rename || (firstEdit as any)?.rename;
444
- const op = args.op || (firstEdit as any)?.op;
458
+ const rawPath =
459
+ args.file_path || args.path || filePathFromEditEntry(firstEdit?.path) || firstApplyPatchEntry?.path || "";
460
+ const rename = args.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
461
+ const op = args.op || firstEdit?.op || firstApplyPatchEntry?.op;
445
462
  const { description } = formatEditDescription(rawPath, uiTheme, { rename });
446
463
  const spinner =
447
464
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
448
465
  let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
449
466
  // Show file count hint for multi-file edits
450
- const fileCount = Array.isArray(args.edits) ? countEditFiles(args.edits as any[]) : 0;
467
+ const fileCount = Array.isArray(args.edits)
468
+ ? countEditFiles(args.edits)
469
+ : (applyPatchSummary?.entries.length ?? 0);
451
470
  if (fileCount > 1) {
452
471
  text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
453
472
  }
454
473
  text += getCallPreview(args, rawPath, uiTheme);
474
+ if (applyPatchSummary?.error) {
475
+ text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
476
+ }
455
477
 
456
478
  return new Text(text, 0, 0);
457
479
  },
@@ -471,7 +493,7 @@ export const editToolRenderer = {
471
493
  }
472
494
 
473
495
  const perFileResults = result.details?.perFileResults;
474
- const totalFiles = Array.isArray(args?.edits) ? countEditFiles(args!.edits as any[]) : 0;
496
+ const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;
475
497
  if (perFileResults && (perFileResults.length > 1 || totalFiles > 1)) {
476
498
  return renderMultiFileResult(perFileResults, totalFiles, options, uiTheme);
477
499
  }
@@ -491,9 +513,15 @@ function renderSingleFileResult(
491
513
  ): Component {
492
514
  const details = result.details;
493
515
  const isError = result.isError ?? (details && "isError" in details ? details.isError : false);
494
- const rawPath = args?.file_path || args?.path || (details && "path" in details ? details.path : "") || "";
495
- const op = args?.op || details?.op;
496
- const rename = args?.rename || details?.move;
516
+ const firstEdit = args?.edits?.[0];
517
+ const rawPath =
518
+ args?.file_path ||
519
+ args?.path ||
520
+ filePathFromEditEntry(firstEdit?.path) ||
521
+ (details && "path" in details ? details.path : "") ||
522
+ "";
523
+ const op = args?.op || firstEdit?.op || details?.op;
524
+ const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
497
525
  const { language } = formatEditDescription(rawPath, uiTheme, { rename });
498
526
 
499
527
  const metadataLine =
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Helper for wiring the `compact` action of an {@link ExtensionContext}.
3
+ *
4
+ * Extension-facing APIs accept `string | CompactOptions`, but `AgentSession.compact`
5
+ * takes two positional arguments `(instructions, options)`. This helper splits the
6
+ * union so the same adapter can be reused by print-mode, rpc-mode, and the executor.
7
+ */
8
+ import type { Model } from "@oh-my-pi/pi-ai";
9
+ import type { CompactOptions } from "./types";
10
+
11
+ interface CompactableSession {
12
+ compact(instructions?: string, options?: CompactOptions): Promise<unknown>;
13
+ }
14
+
15
+ export async function runExtensionCompact(
16
+ session: CompactableSession,
17
+ instructionsOrOptions: string | CompactOptions | undefined,
18
+ ): Promise<void> {
19
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
20
+ const options =
21
+ instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
22
+ await session.compact(instructions, options);
23
+ }
24
+
25
+ interface SetModelCapableSession {
26
+ modelRegistry: { getApiKey(model: Model): Promise<string | undefined> };
27
+ setModel(model: Model): Promise<unknown>;
28
+ }
29
+
30
+ /**
31
+ * Helper for wiring the `setModel` action of an {@link ExtensionContext}.
32
+ *
33
+ * Returns false when no API key is available for the requested model.
34
+ */
35
+ export async function runExtensionSetModel(session: SetModelCapableSession, model: Model): Promise<boolean> {
36
+ const key = await session.modelRegistry.getApiKey(model);
37
+ if (!key) return false;
38
+ await session.setModel(model);
39
+ return true;
40
+ }
@@ -554,53 +554,35 @@ export class ExtensionRunner {
554
554
  }
555
555
 
556
556
  async emitUserBash(event: UserBashEvent): Promise<UserBashEventResult | undefined> {
557
- const ctx = this.createContext();
558
-
559
- for (const ext of this.extensions) {
560
- const handlers = ext.handlers.get("user_bash");
561
- if (!handlers || handlers.length === 0) continue;
562
-
563
- for (const handler of handlers) {
564
- try {
565
- const handlerResult = await handler(event, ctx);
566
- if (handlerResult) {
567
- return handlerResult as UserBashEventResult;
568
- }
569
- } catch (err) {
570
- const message = err instanceof Error ? err.message : String(err);
571
- const stack = err instanceof Error ? err.stack : undefined;
572
- this.emitError({
573
- extensionPath: ext.path,
574
- event: "user_bash",
575
- error: message,
576
- stack,
577
- });
578
- }
579
- }
580
- }
581
-
582
- return undefined;
557
+ return this.emitUserEvent<UserBashEventResult>(event, "user_bash");
583
558
  }
584
559
 
585
560
  async emitUserPython(event: UserPythonEvent): Promise<UserPythonEventResult | undefined> {
561
+ return this.emitUserEvent<UserPythonEventResult>(event, "user_python");
562
+ }
563
+
564
+ private async emitUserEvent<R>(
565
+ event: UserBashEvent | UserPythonEvent,
566
+ eventName: "user_bash" | "user_python",
567
+ ): Promise<R | undefined> {
586
568
  const ctx = this.createContext();
587
569
 
588
570
  for (const ext of this.extensions) {
589
- const handlers = ext.handlers.get("user_python");
571
+ const handlers = ext.handlers.get(eventName);
590
572
  if (!handlers || handlers.length === 0) continue;
591
573
 
592
574
  for (const handler of handlers) {
593
575
  try {
594
576
  const handlerResult = await handler(event, ctx);
595
577
  if (handlerResult) {
596
- return handlerResult as UserPythonEventResult;
578
+ return handlerResult as R;
597
579
  }
598
580
  } catch (err) {
599
581
  const message = err instanceof Error ? err.message : String(err);
600
582
  const stack = err instanceof Error ? err.stack : undefined;
601
583
  this.emitError({
602
584
  extensionPath: ext.path,
603
- event: "user_python",
585
+ event: eventName,
604
586
  error: message,
605
587
  stack,
606
588
  });
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { theme } from "../modes/theme/theme";
3
- import { expandPath } from "../tools/path-utils";
3
+ import { expandPath, normalizeLocalScheme } from "../tools/path-utils";
4
4
  import type { HookUIContext } from "./hooks/types";
5
5
 
6
6
  /**
@@ -11,6 +11,12 @@ import type { HookUIContext } from "./hooks/types";
11
11
  */
12
12
  export function resolvePath(filePath: string, cwd: string): string {
13
13
  const expanded = expandPath(filePath);
14
+ const expandedAndNormalized = normalizeLocalScheme(expanded);
15
+ if (expandedAndNormalized.startsWith("local://")) {
16
+ throw new Error(
17
+ `Path "${filePath}" uses internal scheme "local://" and must be resolved through the proper protocol handler, not as a filesystem path.`,
18
+ );
19
+ }
14
20
  if (path.isAbsolute(expanded)) {
15
21
  return expanded;
16
22
  }