@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.1

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 (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. package/src/utils/image-convert.ts +0 -27
@@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import { InternalUrlRouter } from "../internal-urls";
10
9
  import type { Theme } from "../modes/theme/theme";
11
10
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
12
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
11
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
13
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
13
  import type { ToolSession } from ".";
15
14
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
15
  import { formatGroupedFiles } from "./grouped-file-output";
17
16
  import { formatMatchLine } from "./match-line-format";
18
17
  import type { OutputMeta } from "./output-meta";
18
+ import { resolveToolSearchScope } from "./path-utils";
19
19
  import {
20
- formatPathRelativeToCwd,
21
- hasGlobPathChars,
22
- normalizePathLikeInput,
23
- parseSearchPath,
24
- partitionExistingPaths,
25
- resolveExplicitSearchPaths,
26
- resolveToCwd,
27
- } from "./path-utils";
28
- import {
20
+ appendParseErrorsBulletList,
21
+ createCachedComponent,
29
22
  dedupeParseErrors,
30
23
  formatCodeFrameLine,
31
24
  formatCount,
32
25
  formatEmptyMessage,
33
26
  formatErrorMessage,
34
27
  formatParseErrors,
35
- PARSE_ERRORS_LIMIT,
28
+ formatParseErrorsCountLabel,
36
29
  PREVIEW_LIMITS,
30
+ splitGroupsByBlankLine,
37
31
  } from "./render-utils";
38
32
  import { ToolError } from "./tool-errors";
39
33
  import { toolResult } from "./tool-result";
@@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
150
144
  if (!Number.isFinite(skip) || skip < 0) {
151
145
  throw new ToolError("skip must be a non-negative number");
152
146
  }
153
- const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
154
- let searchPath: string;
155
- let scopePath: string;
156
- let globFilter: string | undefined;
157
- let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
158
- const rawPaths = params.paths.map(normalizePathLikeInput);
159
- if (rawPaths.some(rawPath => rawPath.length === 0)) {
160
- throw new ToolError("`paths` must contain non-empty paths or globs");
161
- }
162
- const internalRouter = InternalUrlRouter.instance();
163
- const resolvedPathInputs: string[] = [];
164
- for (const rawPath of rawPaths) {
165
- if (!internalRouter.canHandle(rawPath)) {
166
- resolvedPathInputs.push(rawPath);
167
- continue;
168
- }
169
- if (hasGlobPathChars(rawPath)) {
170
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
171
- }
172
- const resource = await internalRouter.resolve(rawPath);
173
- if (!resource.sourcePath) {
174
- throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
175
- }
176
- resolvedPathInputs.push(resource.sourcePath);
177
- }
178
- let effectivePathInputs = resolvedPathInputs;
179
- if (resolvedPathInputs.length > 1) {
180
- const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
181
- if (partition.valid.length === 0) {
182
- throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
183
- }
184
- effectivePathInputs = partition.valid;
185
- }
186
- if (effectivePathInputs.length === 1) {
187
- const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
188
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
189
- globFilter = parsedPath.glob;
190
- scopePath = formatScopePath(searchPath);
191
- } else {
192
- const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
193
- if (!multiSearchPath) {
194
- throw new ToolError("`paths` must contain at least one path or glob");
195
- }
196
- searchPath = multiSearchPath.basePath;
197
- globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
198
- multiTargets = multiSearchPath.targets;
199
- scopePath = multiSearchPath.scopePath;
200
- }
201
-
202
- const resolvedSearchPath = searchPath;
203
- scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
204
- let isDirectory: boolean;
205
- try {
206
- const stat = await Bun.file(resolvedSearchPath).stat();
207
- isDirectory = stat.isDirectory();
208
- } catch {
209
- throw new ToolError(`Path not found: ${scopePath}`);
210
- }
147
+ const scope = await resolveToolSearchScope({
148
+ rawPaths: params.paths,
149
+ cwd: this.session.cwd,
150
+ internalUrlAction: "search",
151
+ });
152
+ const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
211
153
 
212
154
  const DEFAULT_AST_LIMIT = 50;
213
155
  const result = multiTargets
@@ -388,13 +330,7 @@ export const astGrepToolRenderer = {
388
330
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
389
331
  if (details?.parseErrors?.length) {
390
332
  lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
391
- const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
392
- for (const err of capped) {
393
- lines.push(uiTheme.fg("warning", ` - ${err}`));
394
- }
395
- if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
396
- lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
397
- }
333
+ appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
398
334
  }
399
335
  return new Text(lines.join("\n"), 0, 0);
400
336
  }
@@ -411,28 +347,7 @@ export const astGrepToolRenderer = {
411
347
  );
412
348
 
413
349
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
414
- const rawLines = textContent.split("\n");
415
- const hasSeparators = rawLines.some(line => line.trim().length === 0);
416
- const allGroups: string[][] = [];
417
- if (hasSeparators) {
418
- let current: string[] = [];
419
- for (const line of rawLines) {
420
- if (line.trim().length === 0) {
421
- if (current.length > 0) {
422
- allGroups.push(current);
423
- current = [];
424
- }
425
- continue;
426
- }
427
- current.push(line);
428
- }
429
- if (current.length > 0) allGroups.push(current);
430
- } else {
431
- const nonEmpty = rawLines.filter(line => line.trim().length > 0);
432
- if (nonEmpty.length > 0) {
433
- allGroups.push(nonEmpty);
434
- }
435
- }
350
+ const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
436
351
  const matchGroups = allGroups.filter(
437
352
  group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
438
353
  );
@@ -442,24 +357,16 @@ export const astGrepToolRenderer = {
442
357
  extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
443
358
  }
444
359
  if (details?.parseErrors?.length) {
445
- const total = details.parseErrors.length;
446
- const label =
447
- total > PARSE_ERRORS_LIMIT
448
- ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
449
- : `${total} parse issue${total !== 1 ? "s" : ""}`;
450
- extraLines.push(uiTheme.fg("warning", label));
360
+ extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
451
361
  }
452
362
 
453
- let cached: RenderCache | undefined;
454
- return {
455
- render(width: number): string[] {
456
- const { expanded } = options;
457
- const key = new Hasher().bool(expanded).u32(width).digest();
458
- if (cached?.key === key) return cached.lines;
363
+ return createCachedComponent(
364
+ () => options.expanded,
365
+ width => {
459
366
  const matchLines = renderTreeList(
460
367
  {
461
368
  items: matchGroups,
462
- expanded,
369
+ expanded: options.expanded,
463
370
  maxCollapsed: matchGroups.length,
464
371
  maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
465
372
  itemType: "match",
@@ -473,14 +380,9 @@ export const astGrepToolRenderer = {
473
380
  },
474
381
  uiTheme,
475
382
  );
476
- const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
477
- cached = { key, lines: rendered };
478
- return rendered;
383
+ return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
479
384
  },
480
- invalidate() {
481
- cached = undefined;
482
- },
483
- };
385
+ );
484
386
  },
485
387
  mergeCallAndResult: true,
486
388
  };
@@ -12,10 +12,12 @@ import {
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
14
14
  import xterm from "@xterm/headless";
15
+ import { Settings } from "../config/settings";
15
16
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
16
17
  import type { Theme } from "../modes/theme/theme";
17
18
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
18
19
  import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
20
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
19
21
  import { formatStatusIcon, replaceTabs } from "./render-utils";
20
22
 
21
23
  export interface BashInteractiveResult extends OutputSummary {
@@ -294,7 +296,13 @@ export async function runInteractiveBashPty(
294
296
  artifactId?: string;
295
297
  },
296
298
  ): Promise<BashInteractiveResult> {
297
- const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
299
+ const settings = await Settings.init();
300
+ const sink = new OutputSink({
301
+ artifactPath: options.artifactPath,
302
+ artifactId: options.artifactId,
303
+ headBytes: resolveOutputSinkHeadBytes(settings),
304
+ maxColumns: resolveOutputMaxColumns(settings),
305
+ });
298
306
  const result = await ui.custom<BashInteractiveResult>(
299
307
  (tui, uiTheme, _keybindings, done) => {
300
308
  const session = new PtySession();
package/src/tools/bash.ts CHANGED
@@ -2,15 +2,16 @@ import * as fs from "node:fs";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
- import { $env, getProjectDir, isEnoent, prompt } from "@oh-my-pi/pi-utils";
5
+ import { $env, getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
6
6
  import { Type } from "@sinclair/typebox";
7
7
  import { AsyncJobManager } from "../async";
8
8
  import { type BashResult, executeBash } from "../exec/bash-executor";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import { InternalUrlRouter } from "../internal-urls";
11
11
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
12
- import type { Theme } from "../modes/theme/theme";
12
+ import { highlightCode, type Theme } from "../modes/theme/theme";
13
13
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
14
+ import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
14
15
  import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
15
16
  import { renderStatusLine } from "../tui";
16
17
  import { CachedOutputBlock } from "../tui/output-block";
@@ -84,6 +85,7 @@ export interface BashToolDetails {
84
85
  meta?: OutputMeta;
85
86
  timeoutSeconds?: number;
86
87
  requestedTimeoutSeconds?: number;
88
+ terminalId?: string;
87
89
  async?: {
88
90
  state: "running" | "completed" | "failed";
89
91
  jobId: string;
@@ -289,7 +291,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
289
291
  #buildCompletedResult(
290
292
  result: BashResult | BashInteractiveResult,
291
293
  timeoutSec: number,
292
- options: { requestedTimeoutSec?: number; notices?: string[] } = {},
294
+ options: { requestedTimeoutSec?: number; notices?: string[]; terminalId?: string } = {},
293
295
  ): AgentToolResult<BashToolDetails> {
294
296
  const outputLines = [this.#formatResultOutput(result)];
295
297
  const notices = options.notices?.filter(Boolean) ?? [];
@@ -299,6 +301,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
299
301
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
300
302
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
301
303
  }
304
+ if (options.terminalId !== undefined) {
305
+ details.terminalId = options.terminalId;
306
+ }
302
307
  const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
303
308
  this.#buildResultText(result, timeoutSec, outputText);
304
309
  return resultBuilder.done();
@@ -479,8 +484,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
479
484
  const env = normalizeBashEnv(rawEnv);
480
485
 
481
486
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
487
+ // Constrained to a single line so a `&&` that sits on a later line of a multiline
488
+ // script can't pull the entire script into the "cwd" capture.
482
489
  if (!cwd) {
483
- const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/);
490
+ const cdMatch = command.match(/^cd[ \t]+((?:[^&\\\n\r]|\\.)+?)[ \t]*&&[ \t]*/);
484
491
  if (cdMatch) {
485
492
  cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
486
493
  command = command.slice(cdMatch[0].length);
@@ -618,6 +625,175 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
618
625
  });
619
626
  }
620
627
 
628
+ // Route through the client terminal when the client advertises the terminal capability.
629
+ // Skip when pty=true (PTY needs the local terminal UI).
630
+ const clientBridge = this.session.getClientBridge?.();
631
+ if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
632
+ const handle = await clientBridge.createTerminal({
633
+ command,
634
+ cwd: commandCwd,
635
+ env: resolvedEnv
636
+ ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
637
+ : undefined,
638
+ outputByteLimit: DEFAULT_MAX_BYTES,
639
+ });
640
+
641
+ // Emit partial update so the editor can embed the live terminal card.
642
+ onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
643
+
644
+ const exitPromise = handle.waitForExit();
645
+ let exitStatus!: ClientBridgeTerminalExitStatus;
646
+
647
+ type BridgeRaceResult =
648
+ | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
649
+ | { kind: "poll" }
650
+ | { kind: "timeout" }
651
+ | { kind: "aborted" };
652
+
653
+ // Set up abort listener before entering the poll loop. The listener
654
+ // kicks off `handle.kill()` synchronously so a `session/cancel`
655
+ // arriving mid-poll terminates the remote command immediately,
656
+ // instead of waiting for the next `currentOutput()` to return.
657
+ const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
658
+ let killStarted = false;
659
+ const fireKill = (): Promise<void> => {
660
+ if (killStarted) return Promise.resolve();
661
+ killStarted = true;
662
+ return handle.kill().catch((error: unknown) => {
663
+ logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
664
+ });
665
+ };
666
+ const onAbortSignal = () => {
667
+ resolveAborted();
668
+ void fireKill();
669
+ };
670
+ signal?.addEventListener("abort", onAbortSignal, { once: true });
671
+
672
+ try {
673
+ try {
674
+ if (signal?.aborted) {
675
+ await fireKill();
676
+ throw new ToolAbortError("Command aborted");
677
+ }
678
+
679
+ const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
680
+ // Poll until the process exits, times out, or the caller aborts.
681
+ for (;;) {
682
+ const racers: Array<Promise<BridgeRaceResult>> = [
683
+ exitPromise.then(s => ({ kind: "exit" as const, status: s })),
684
+ timeoutPromise,
685
+ Bun.sleep(250).then(() => ({ kind: "poll" as const })),
686
+ ];
687
+ if (signal) {
688
+ racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
689
+ }
690
+ const raced = await Promise.race(racers);
691
+
692
+ if (raced.kind === "aborted" || signal?.aborted) {
693
+ await fireKill();
694
+ throw new ToolAbortError("Command aborted");
695
+ }
696
+
697
+ if (raced.kind === "timeout") {
698
+ // Kill before reading final output so a slow `terminal/output`
699
+ // RPC cannot let a timed-out command keep running past the
700
+ // enforced timeout. The handle stays valid post-kill so the
701
+ // buffered output is still readable.
702
+ await fireKill();
703
+ let current = { output: "", truncated: false };
704
+ try {
705
+ current = await handle.currentOutput();
706
+ } catch (error) {
707
+ logger.warn("ACP terminal final output read failed", {
708
+ terminalId: handle.terminalId,
709
+ error,
710
+ });
711
+ }
712
+ const timedOutResult: BashInteractiveResult = {
713
+ output: current.output,
714
+ exitCode: undefined,
715
+ cancelled: false,
716
+ timedOut: true,
717
+ truncated: current.truncated,
718
+ totalLines: current.output.length > 0 ? current.output.split("\n").length : 0,
719
+ totalBytes: current.output.length,
720
+ outputLines: current.output.length > 0 ? current.output.split("\n").length : 0,
721
+ outputBytes: current.output.length,
722
+ };
723
+ return this.#buildCompletedResult(timedOutResult, timeoutSec, {
724
+ requestedTimeoutSec,
725
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
726
+ terminalId: handle.terminalId,
727
+ });
728
+ }
729
+
730
+ if (raced.kind === "exit") {
731
+ exitStatus = raced.status;
732
+ break;
733
+ }
734
+
735
+ // Poll tick: push current output so agent-loop transcript stays consistent.
736
+ // Race the read against abort so a stuck `terminal/output` RPC does not
737
+ // delay cancellation.
738
+ const pollOutput = await Promise.race([
739
+ handle.currentOutput(),
740
+ abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
741
+ ]);
742
+ if (pollOutput === undefined) {
743
+ // Abort fired during the poll-tick read; let the next loop iteration
744
+ // observe `signal?.aborted` and exit via the abort branch.
745
+ continue;
746
+ }
747
+ onUpdate?.({
748
+ content: [{ type: "text", text: pollOutput.output }],
749
+ details: { terminalId: handle.terminalId },
750
+ });
751
+ }
752
+ } finally {
753
+ signal?.removeEventListener("abort", onAbortSignal);
754
+ }
755
+
756
+ // Fetch final output; the terminal is released in the outer finally.
757
+ const finalOutput = await handle.currentOutput();
758
+
759
+ // Map exit status: null exitCode with a signal → treat as signal kill (137).
760
+ const rawExitCode = exitStatus.exitCode;
761
+ const exitCode: number | undefined =
762
+ rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
763
+
764
+ const outputText = finalOutput.output;
765
+ const outputByteLen = outputText.length;
766
+ const outputLineCount = outputText.length > 0 ? outputText.split("\n").length : 0;
767
+
768
+ const bridgeResult: BashResult = {
769
+ output: outputText,
770
+ exitCode,
771
+ cancelled: false,
772
+ truncated: finalOutput.truncated,
773
+ totalLines: outputLineCount,
774
+ totalBytes: outputByteLen,
775
+ outputLines: outputLineCount,
776
+ outputBytes: outputByteLen,
777
+ };
778
+
779
+ const bridgeNotices: string[] = [];
780
+ if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
781
+ if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice);
782
+
783
+ return this.#buildCompletedResult(bridgeResult, timeoutSec, {
784
+ requestedTimeoutSec,
785
+ notices: bridgeNotices,
786
+ terminalId: handle.terminalId,
787
+ });
788
+ } finally {
789
+ try {
790
+ await handle.release();
791
+ } catch (error) {
792
+ logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
793
+ }
794
+ }
795
+ }
796
+
621
797
  // Track output for streaming updates (tail only)
622
798
  const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
623
799
 
@@ -718,6 +894,27 @@ export function formatBashCommand(args: BashRenderArgs): string {
718
894
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
719
895
  }
720
896
 
897
+ /**
898
+ * Returns the bash command formatted for the result body: the dim `$ cd … &&`
899
+ * prefix joined with syntax-highlighted command lines. The prefix is applied
900
+ * only to the first line so multi-line commands display cleanly — terminals
901
+ * reset SGR state at line boundaries, which made the previous single-string
902
+ * `theme.fg("dim", ...)` form render only the first line as dim.
903
+ */
904
+ export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): string[] {
905
+ const command = replaceTabs(args.command || "…");
906
+ const cwd = getProjectDir();
907
+ const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd);
908
+ const envAssignments = formatBashEnvAssignments(getBashEnvForDisplay(args));
909
+ const prefixParts = ["$"];
910
+ if (displayWorkdir) prefixParts.push(`cd ${displayWorkdir} &&`);
911
+ if (envAssignments) prefixParts.push(envAssignments);
912
+ const prefix = uiTheme.fg("dim", `${prefixParts.join(" ")} `);
913
+ const highlightedLines = highlightCode(command, "bash");
914
+ if (highlightedLines.length === 0) return [prefix.trimEnd()];
915
+ return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
916
+ }
917
+
721
918
  function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
722
919
  return {
723
920
  command: config.resolveCommand?.(args),
@@ -748,7 +945,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
748
945
  args?: TArgs,
749
946
  ): Component {
750
947
  const renderArgs = toBashRenderArgs(args, config);
751
- const cmdText = args ? formatBashCommand(renderArgs) : undefined;
948
+ const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
752
949
  const isError = result.isError === true;
753
950
  const icon = options.isPartial ? "pending" : isError ? "error" : "success";
754
951
  const title = config.resolveTitle(args, options);
@@ -826,7 +1023,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
826
1023
  header,
827
1024
  state: options.isPartial ? "pending" : isError ? "error" : "success",
828
1025
  sections: [
829
- { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
1026
+ { lines: cmdLines ?? [] },
830
1027
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
831
1028
  ],
832
1029
  width,
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
3
  import type { Browser, Page } from "puppeteer-core";
4
4
  import { ToolError, throwIfAborted } from "../tool-errors";
5
5
 
6
- export const ATTACH_TARGET_SKIP_PATTERN =
6
+ const ATTACH_TARGET_SKIP_PATTERN =
7
7
  /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
8
8
 
9
9
  /**
@@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
62
62
  * accepts both `--flag=value` and `--flag value`). Returns null if absent or
63
63
  * malformed.
64
64
  */
65
- export function findCdpPortInArgs(args: string[]): number | null {
65
+ function findCdpPortInArgs(args: string[]): number | null {
66
66
  for (const arg of args) {
67
67
  const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
68
68
  if (m) {
@@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null {
80
80
  }
81
81
 
82
82
  /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
83
- export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
83
+ async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
84
84
  const probeTimeout = AbortSignal.timeout(1500);
85
85
  const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
86
86
  try {