@oh-my-pi/pi-coding-agent 12.18.1 → 12.19.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 (231) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +7 -7
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +341 -0
  5. package/src/cli/file-processor.ts +3 -3
  6. package/src/cli/list-models.ts +3 -17
  7. package/src/cli/stats-cli.ts +3 -22
  8. package/src/cli/web-search-cli.ts +8 -16
  9. package/src/commit/agentic/agent.ts +6 -9
  10. package/src/commit/agentic/index.ts +44 -50
  11. package/src/commit/agentic/state.ts +0 -9
  12. package/src/commit/agentic/tools/propose-commit.ts +1 -30
  13. package/src/commit/agentic/tools/schemas.ts +31 -0
  14. package/src/commit/agentic/tools/split-commit.ts +1 -30
  15. package/src/commit/agentic/validation.ts +1 -18
  16. package/src/commit/analysis/conventional.ts +3 -50
  17. package/src/commit/analysis/summary.ts +2 -13
  18. package/src/commit/changelog/detect.ts +4 -1
  19. package/src/commit/changelog/generate.ts +2 -25
  20. package/src/commit/changelog/index.ts +1 -2
  21. package/src/commit/cli.ts +4 -12
  22. package/src/commit/map-reduce/reduce-phase.ts +2 -43
  23. package/src/commit/pipeline.ts +7 -15
  24. package/src/commit/utils.ts +44 -0
  25. package/src/config/prompt-templates.ts +1 -81
  26. package/src/config/settings-schema.ts +20 -1
  27. package/src/config.ts +2 -3
  28. package/src/debug/index.ts +1 -6
  29. package/src/debug/system-info.ts +2 -6
  30. package/src/discovery/builtin.ts +5 -9
  31. package/src/discovery/helpers.ts +0 -26
  32. package/src/discovery/ssh.ts +1 -8
  33. package/src/exa/company.ts +8 -39
  34. package/src/exa/factory.ts +64 -0
  35. package/src/exa/index.ts +0 -16
  36. package/src/exa/linkedin.ts +8 -39
  37. package/src/exa/mcp-client.ts +0 -64
  38. package/src/exa/researcher.ts +17 -59
  39. package/src/exa/search.ts +30 -154
  40. package/src/extensibility/custom-tools/loader.ts +3 -41
  41. package/src/extensibility/extensions/loader.ts +2 -9
  42. package/src/extensibility/hooks/loader.ts +3 -20
  43. package/src/extensibility/hooks/runner.ts +3 -19
  44. package/src/extensibility/plugins/installer.ts +2 -1
  45. package/src/extensibility/plugins/loader.ts +29 -117
  46. package/src/extensibility/skills.ts +2 -89
  47. package/src/extensibility/slash-commands.ts +1 -63
  48. package/src/extensibility/utils.ts +38 -0
  49. package/src/index.ts +9 -25
  50. package/src/internal-urls/index.ts +1 -0
  51. package/src/internal-urls/jobs-protocol.ts +118 -0
  52. package/src/ipy/kernel.ts +2 -0
  53. package/src/lsp/config.ts +1 -5
  54. package/src/lsp/lspmux.ts +0 -17
  55. package/src/lsp/utils.ts +2 -24
  56. package/src/main.ts +16 -24
  57. package/src/mcp/client.ts +1 -46
  58. package/src/mcp/render.ts +8 -1
  59. package/src/mcp/tool-cache.ts +1 -5
  60. package/src/mcp/transports/http.ts +2 -7
  61. package/src/mcp/transports/stdio.ts +2 -7
  62. package/src/modes/components/bash-execution.ts +2 -16
  63. package/src/modes/components/extensions/inspector-panel.ts +8 -18
  64. package/src/modes/components/footer.ts +10 -50
  65. package/src/modes/components/model-selector.ts +2 -21
  66. package/src/modes/components/python-execution.ts +2 -16
  67. package/src/modes/components/settings-selector.ts +1 -10
  68. package/src/modes/components/status-line/segments.ts +8 -25
  69. package/src/modes/components/status-line.ts +14 -31
  70. package/src/modes/components/tool-execution.ts +8 -2
  71. package/src/modes/controllers/command-controller.ts +71 -30
  72. package/src/modes/controllers/event-controller.ts +34 -4
  73. package/src/modes/controllers/mcp-command-controller.ts +3 -34
  74. package/src/modes/controllers/selector-controller.ts +2 -2
  75. package/src/modes/controllers/ssh-command-controller.ts +3 -34
  76. package/src/modes/interactive-mode.ts +6 -2
  77. package/src/modes/rpc/rpc-client.ts +1 -5
  78. package/src/modes/shared.ts +73 -0
  79. package/src/modes/types.ts +1 -0
  80. package/src/modes/utils/ui-helpers.ts +26 -2
  81. package/src/patch/index.ts +4 -4
  82. package/src/patch/normalize.ts +22 -65
  83. package/src/patch/shared.ts +16 -16
  84. package/src/prompts/system/custom-system-prompt.md +0 -10
  85. package/src/prompts/system/system-prompt.md +69 -89
  86. package/src/prompts/tools/async-result.md +5 -0
  87. package/src/prompts/tools/bash.md +5 -0
  88. package/src/prompts/tools/cancel-job.md +7 -0
  89. package/src/prompts/tools/poll-jobs.md +7 -0
  90. package/src/prompts/tools/task.md +4 -0
  91. package/src/sdk.ts +70 -6
  92. package/src/session/agent-session.ts +40 -6
  93. package/src/session/agent-storage.ts +69 -278
  94. package/src/session/auth-storage.ts +14 -1430
  95. package/src/session/session-manager.ts +69 -5
  96. package/src/session/session-storage.ts +1 -5
  97. package/src/session/streaming-output.ts +637 -76
  98. package/src/slash-commands/builtin-registry.ts +8 -0
  99. package/src/ssh/connection-manager.ts +4 -12
  100. package/src/ssh/sshfs-mount.ts +3 -7
  101. package/src/ssh/utils.ts +8 -0
  102. package/src/system-prompt.ts +24 -90
  103. package/src/task/executor.ts +11 -1
  104. package/src/task/index.ts +258 -13
  105. package/src/task/parallel.ts +32 -0
  106. package/src/task/render.ts +15 -7
  107. package/src/task/types.ts +5 -0
  108. package/src/tools/ask.ts +4 -7
  109. package/src/tools/bash-interactive.ts +4 -5
  110. package/src/tools/bash.ts +125 -41
  111. package/src/tools/cancel-job.ts +93 -0
  112. package/src/tools/fetch.ts +7 -27
  113. package/src/tools/find.ts +3 -3
  114. package/src/tools/gemini-image.ts +15 -14
  115. package/src/tools/grep.ts +3 -3
  116. package/src/tools/index.ts +13 -29
  117. package/src/tools/json-tree.ts +12 -1
  118. package/src/tools/jtd-to-json-schema.ts +10 -74
  119. package/src/tools/jtd-to-typescript.ts +10 -72
  120. package/src/tools/jtd-utils.ts +102 -0
  121. package/src/tools/notebook.ts +4 -9
  122. package/src/tools/output-meta.ts +52 -26
  123. package/src/tools/path-utils.ts +13 -7
  124. package/src/tools/poll-jobs.ts +178 -0
  125. package/src/tools/python.ts +32 -35
  126. package/src/tools/read.ts +61 -82
  127. package/src/tools/render-utils.ts +8 -159
  128. package/src/tools/ssh.ts +7 -20
  129. package/src/tools/submit-result.ts +1 -1
  130. package/src/tools/tool-errors.ts +0 -30
  131. package/src/tools/tool-result.ts +1 -2
  132. package/src/tools/write.ts +8 -10
  133. package/src/tui/code-cell.ts +8 -3
  134. package/src/tui/status-line.ts +4 -4
  135. package/src/tui/types.ts +0 -1
  136. package/src/tui/utils.ts +1 -14
  137. package/src/utils/command-args.ts +76 -0
  138. package/src/utils/file-mentions.ts +15 -19
  139. package/src/utils/frontmatter.ts +5 -10
  140. package/src/utils/shell-snapshot.ts +0 -11
  141. package/src/utils/title-generator.ts +0 -12
  142. package/src/web/scrapers/artifacthub.ts +7 -16
  143. package/src/web/scrapers/arxiv.ts +3 -8
  144. package/src/web/scrapers/aur.ts +8 -22
  145. package/src/web/scrapers/biorxiv.ts +5 -14
  146. package/src/web/scrapers/bluesky.ts +13 -36
  147. package/src/web/scrapers/brew.ts +5 -10
  148. package/src/web/scrapers/cheatsh.ts +2 -12
  149. package/src/web/scrapers/chocolatey.ts +63 -26
  150. package/src/web/scrapers/choosealicense.ts +3 -18
  151. package/src/web/scrapers/cisa-kev.ts +4 -18
  152. package/src/web/scrapers/clojars.ts +6 -33
  153. package/src/web/scrapers/coingecko.ts +25 -33
  154. package/src/web/scrapers/crates-io.ts +7 -26
  155. package/src/web/scrapers/crossref.ts +4 -18
  156. package/src/web/scrapers/devto.ts +11 -41
  157. package/src/web/scrapers/discogs.ts +7 -10
  158. package/src/web/scrapers/discourse.ts +6 -31
  159. package/src/web/scrapers/dockerhub.ts +12 -35
  160. package/src/web/scrapers/fdroid.ts +8 -33
  161. package/src/web/scrapers/firefox-addons.ts +10 -34
  162. package/src/web/scrapers/flathub.ts +7 -24
  163. package/src/web/scrapers/github-gist.ts +2 -12
  164. package/src/web/scrapers/github.ts +9 -47
  165. package/src/web/scrapers/gitlab.ts +130 -185
  166. package/src/web/scrapers/go-pkg.ts +12 -22
  167. package/src/web/scrapers/hackage.ts +88 -43
  168. package/src/web/scrapers/hackernews.ts +25 -45
  169. package/src/web/scrapers/hex.ts +19 -36
  170. package/src/web/scrapers/huggingface.ts +26 -91
  171. package/src/web/scrapers/iacr.ts +3 -8
  172. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  173. package/src/web/scrapers/lemmy.ts +5 -23
  174. package/src/web/scrapers/lobsters.ts +16 -28
  175. package/src/web/scrapers/mastodon.ts +24 -43
  176. package/src/web/scrapers/maven.ts +6 -21
  177. package/src/web/scrapers/mdn.ts +7 -11
  178. package/src/web/scrapers/metacpan.ts +9 -41
  179. package/src/web/scrapers/musicbrainz.ts +4 -28
  180. package/src/web/scrapers/npm.ts +8 -25
  181. package/src/web/scrapers/nuget.ts +14 -37
  182. package/src/web/scrapers/nvd.ts +6 -28
  183. package/src/web/scrapers/ollama.ts +7 -34
  184. package/src/web/scrapers/open-vsx.ts +5 -19
  185. package/src/web/scrapers/opencorporates.ts +30 -14
  186. package/src/web/scrapers/openlibrary.ts +49 -33
  187. package/src/web/scrapers/orcid.ts +4 -18
  188. package/src/web/scrapers/osv.ts +7 -24
  189. package/src/web/scrapers/packagist.ts +9 -24
  190. package/src/web/scrapers/pub-dev.ts +7 -50
  191. package/src/web/scrapers/pubmed.ts +54 -21
  192. package/src/web/scrapers/pypi.ts +8 -26
  193. package/src/web/scrapers/rawg.ts +11 -19
  194. package/src/web/scrapers/readthedocs.ts +4 -9
  195. package/src/web/scrapers/reddit.ts +5 -15
  196. package/src/web/scrapers/repology.ts +8 -20
  197. package/src/web/scrapers/rfc.ts +5 -14
  198. package/src/web/scrapers/rubygems.ts +6 -21
  199. package/src/web/scrapers/searchcode.ts +8 -36
  200. package/src/web/scrapers/sec-edgar.ts +4 -18
  201. package/src/web/scrapers/semantic-scholar.ts +15 -35
  202. package/src/web/scrapers/snapcraft.ts +5 -19
  203. package/src/web/scrapers/sourcegraph.ts +5 -43
  204. package/src/web/scrapers/spdx.ts +4 -18
  205. package/src/web/scrapers/spotify.ts +4 -23
  206. package/src/web/scrapers/stackoverflow.ts +8 -13
  207. package/src/web/scrapers/terraform.ts +9 -37
  208. package/src/web/scrapers/tldr.ts +3 -7
  209. package/src/web/scrapers/twitter.ts +3 -7
  210. package/src/web/scrapers/types.ts +105 -27
  211. package/src/web/scrapers/utils.ts +97 -103
  212. package/src/web/scrapers/vimeo.ts +7 -27
  213. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  214. package/src/web/scrapers/w3c.ts +6 -14
  215. package/src/web/scrapers/wikidata.ts +5 -19
  216. package/src/web/scrapers/wikipedia.ts +2 -12
  217. package/src/web/scrapers/youtube.ts +5 -34
  218. package/src/web/search/index.ts +0 -9
  219. package/src/web/search/providers/anthropic.ts +3 -2
  220. package/src/web/search/providers/brave.ts +3 -18
  221. package/src/web/search/providers/exa.ts +1 -12
  222. package/src/web/search/providers/kimi.ts +5 -44
  223. package/src/web/search/providers/perplexity.ts +1 -12
  224. package/src/web/search/providers/synthetic.ts +3 -26
  225. package/src/web/search/providers/utils.ts +36 -0
  226. package/src/web/search/providers/zai.ts +9 -50
  227. package/src/web/search/types.ts +0 -28
  228. package/src/web/search/utils.ts +17 -0
  229. package/src/tools/output-utils.ts +0 -63
  230. package/src/tools/truncate.ts +0 -385
  231. package/src/web/search/auth.ts +0 -178
@@ -82,3 +82,35 @@ export async function mapWithConcurrencyLimit<T, R>(
82
82
 
83
83
  return { results, aborted: signal?.aborted ?? false };
84
84
  }
85
+
86
+ /**
87
+ * Simple counting semaphore for limiting concurrency across independently-scheduled async work.
88
+ */
89
+ export class Semaphore {
90
+ #max: number;
91
+ #current = 0;
92
+ #queue: Array<() => void> = [];
93
+
94
+ constructor(max: number) {
95
+ this.#max = Math.max(1, max);
96
+ }
97
+
98
+ async acquire(): Promise<void> {
99
+ if (this.#current < this.#max) {
100
+ this.#current++;
101
+ return;
102
+ }
103
+ const { promise, resolve } = Promise.withResolvers<void>();
104
+ this.#queue.push(resolve);
105
+ return promise;
106
+ }
107
+
108
+ release(): void {
109
+ const next = this.#queue.shift();
110
+ if (next) {
111
+ next();
112
+ } else {
113
+ this.#current--;
114
+ }
115
+ }
116
+ }
@@ -7,6 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Container, Text } from "@oh-my-pi/pi-tui";
10
+ import { formatNumber } from "@oh-my-pi/pi-utils";
10
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
12
  import type { Theme } from "../modes/theme/theme";
12
13
  import {
@@ -14,7 +15,6 @@ import {
14
15
  formatDuration,
15
16
  formatMoreItems,
16
17
  formatStatusIcon,
17
- formatTokens,
18
18
  replaceTabs,
19
19
  truncateToWidth,
20
20
  } from "../tools/render-utils";
@@ -518,13 +518,19 @@ function renderAgentProgress(
518
518
  const taskPreview = truncateToWidth(progress.task, 40);
519
519
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
520
520
  }
521
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
521
+ if (progress.toolCount > 0) {
522
+ statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
523
+ }
522
524
  if (progress.tokens > 0) {
523
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(progress.tokens)} tokens`)}`;
525
+ statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`;
524
526
  }
525
527
  } else if (progress.status === "completed") {
526
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
527
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(progress.tokens)} tokens`)}`;
528
+ if (progress.toolCount > 0) {
529
+ statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
530
+ }
531
+ if (progress.tokens > 0) {
532
+ statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`;
533
+ }
528
534
  }
529
535
 
530
536
  lines.push(statusLine);
@@ -744,7 +750,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
744
750
  theme,
745
751
  )}`;
746
752
  if (result.tokens > 0) {
747
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(result.tokens)} tokens`)}`;
753
+ statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(result.tokens)} tokens`)}`;
748
754
  }
749
755
  statusLine += `${theme.sep.dot}${theme.fg("dim", formatDuration(result.durationMs))}`;
750
756
 
@@ -882,7 +888,9 @@ export function renderResult(
882
888
 
883
889
  const lines: string[] = [];
884
890
 
885
- if (isPartial && details.progress) {
891
+ const shouldRenderProgress =
892
+ Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
893
+ if (shouldRenderProgress && details.progress) {
886
894
  details.progress.forEach((progress, i) => {
887
895
  const isLast = i === details.progress!.length - 1;
888
896
  lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame));
package/src/task/types.ts CHANGED
@@ -193,4 +193,9 @@ export interface TaskToolDetails {
193
193
  usage?: Usage;
194
194
  outputPaths?: string[];
195
195
  progress?: AgentProgress[];
196
+ async?: {
197
+ state: "running" | "completed" | "failed";
198
+ jobId: string;
199
+ type: "task";
200
+ };
196
201
  }
package/src/tools/ask.ts CHANGED
@@ -24,7 +24,7 @@ import { type Theme, theme } from "../modes/theme/theme";
24
24
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
25
25
  import { renderStatusLine } from "../tui";
26
26
  import type { ToolSession } from ".";
27
- import { ToolUIKit } from "./render-utils";
27
+ import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
28
28
 
29
29
  // =============================================================================
30
30
  // Types
@@ -75,8 +75,6 @@ export interface AskToolDetails {
75
75
 
76
76
  const OTHER_OPTION = "Other (type your own)";
77
77
  const RECOMMENDED_SUFFIX = " (Recommended)";
78
- /** Default timeout in milliseconds (used when settings unavailable) */
79
- const _DEFAULT_ASK_TIMEOUT_MS = 30000;
80
78
 
81
79
  function getDoneOptionLabel(): string {
82
80
  return `${theme.status.success} Done selecting`;
@@ -381,8 +379,7 @@ interface AskRenderArgs {
381
379
 
382
380
  export const askToolRenderer = {
383
381
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
384
- const ui = new ToolUIKit(uiTheme);
385
- const label = ui.title("Ask");
382
+ const label = formatTitle("Ask", uiTheme);
386
383
 
387
384
  // Multi-part questions
388
385
  if (args.questions && args.questions.length > 0) {
@@ -417,14 +414,14 @@ export const askToolRenderer = {
417
414
 
418
415
  // Single question
419
416
  if (!args.question) {
420
- return new Text(ui.errorMessage("No question provided"), 0, 0);
417
+ return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0);
421
418
  }
422
419
 
423
420
  let text = `${label} ${uiTheme.fg("accent", args.question)}`;
424
421
  const meta: string[] = [];
425
422
  if (args.multi) meta.push("multi");
426
423
  if (args.options?.length) meta.push(`options:${args.options.length}`);
427
- text += ui.meta(meta);
424
+ text += formatMeta(meta, uiTheme);
428
425
 
429
426
  if (args.options?.length) {
430
427
  for (let i = 0; i < args.options.length; i++) {
@@ -13,8 +13,7 @@ import type { Terminal as XtermTerminalType } from "@xterm/headless";
13
13
  import xterm from "@xterm/headless";
14
14
  import type { Theme } from "../modes/theme/theme";
15
15
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
16
- import { getStateIcon } from "../tui";
17
- import { replaceTabs } from "./render-utils";
16
+ import { formatStatusIcon, replaceTabs } from "./render-utils";
18
17
 
19
18
  export interface BashInteractiveResult extends OutputSummary {
20
19
  exitCode: number | undefined;
@@ -235,10 +234,10 @@ class BashInteractiveOverlayComponent implements Component {
235
234
  }
236
235
  const statusIcon =
237
236
  this.#state === "running"
238
- ? getStateIcon("running", this.uiTheme)
237
+ ? formatStatusIcon("running", this.uiTheme)
239
238
  : this.#state === "complete" && this.#exitCode === 0
240
- ? getStateIcon("success", this.uiTheme)
241
- : getStateIcon("warning", this.uiTheme);
239
+ ? formatStatusIcon("success", this.uiTheme)
240
+ : formatStatusIcon("warning", this.uiTheme);
242
241
  const title = this.uiTheme.fg("accent", "Console");
243
242
  const statusBadge = `${this.uiTheme.fg("dim", this.uiTheme.format.bracketLeft)}${this.#stateText()}${this.uiTheme.fg("dim", this.uiTheme.format.bracketRight)}`;
244
243
  const prefix = `${statusIcon} ${title} `;
package/src/tools/bash.ts CHANGED
@@ -5,13 +5,14 @@ import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { $env, isEnoent } from "@oh-my-pi/pi-utils";
7
7
  import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
8
- import { type Static, Type } from "@sinclair/typebox";
8
+ import { Type } from "@sinclair/typebox";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import { type BashResult, executeBash } from "../exec/bash-executor";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
13
13
  import type { Theme } from "../modes/theme/theme";
14
14
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
15
+ import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
15
16
  import { renderStatusLine } from "../tui";
16
17
  import { CachedOutputBlock } from "../tui/output-block";
17
18
  import type { ToolSession } from ".";
@@ -19,17 +20,15 @@ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-intera
19
20
  import { checkBashInterception } from "./bash-interceptor";
20
21
  import { applyHeadTail } from "./bash-normalize";
21
22
  import { expandInternalUrls } from "./bash-skill-urls";
22
- import type { OutputMeta } from "./output-meta";
23
- import { allocateOutputArtifact, createTailBuffer } from "./output-utils";
23
+ import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
24
24
  import { resolveToCwd } from "./path-utils";
25
- import { formatBytes, replaceTabs, wrapBrackets } from "./render-utils";
25
+ import { replaceTabs } from "./render-utils";
26
26
  import { ToolAbortError, ToolError } from "./tool-errors";
27
27
  import { toolResult } from "./tool-result";
28
- import { DEFAULT_MAX_BYTES } from "./truncate";
29
28
 
30
29
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
31
30
 
32
- const bashSchema = Type.Object({
31
+ const bashSchemaBase = Type.Object({
33
32
  command: Type.String({ description: "Command to execute" }),
34
33
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 300)" })),
35
34
  cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
@@ -37,10 +36,33 @@ const bashSchema = Type.Object({
37
36
  tail: Type.Optional(Type.Number({ description: "Return only last N lines of output" })),
38
37
  });
39
38
 
40
- export type BashToolInput = Static<typeof bashSchema>;
39
+ const bashSchemaWithAsync = Type.Object({
40
+ ...bashSchemaBase.properties,
41
+ async: Type.Optional(
42
+ Type.Boolean({
43
+ description: "Run in background; returns immediately with a job ID. Result delivered as follow-up.",
44
+ }),
45
+ ),
46
+ });
47
+
48
+ type BashToolSchema = typeof bashSchemaBase | typeof bashSchemaWithAsync;
49
+
50
+ export interface BashToolInput {
51
+ command: string;
52
+ timeout?: number;
53
+ cwd?: string;
54
+ head?: number;
55
+ tail?: number;
56
+ async?: boolean;
57
+ }
41
58
 
42
59
  export interface BashToolDetails {
43
60
  meta?: OutputMeta;
61
+ async?: {
62
+ state: "running" | "completed" | "failed";
63
+ jobId: string;
64
+ type: "bash";
65
+ };
44
66
  }
45
67
 
46
68
  export interface BashToolOptions {}
@@ -57,26 +79,61 @@ function isInteractiveResult(result: BashResult | BashInteractiveResult): result
57
79
  *
58
80
  * Executes bash commands with optional timeout and working directory.
59
81
  */
60
- export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
82
+ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
61
83
  readonly name = "bash";
62
84
  readonly label = "Bash";
63
85
  readonly description: string;
64
- readonly parameters = bashSchema;
86
+ readonly parameters: BashToolSchema;
65
87
  readonly concurrency = "exclusive";
88
+ readonly #asyncEnabled: boolean;
66
89
 
67
90
  constructor(private readonly session: ToolSession) {
68
- this.description = renderPromptTemplate(bashDescription);
91
+ this.#asyncEnabled = this.session.settings.get("async.enabled");
92
+ this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
93
+ this.description = renderPromptTemplate(bashDescription, { asyncEnabled: this.#asyncEnabled });
94
+ }
95
+
96
+ #formatResultOutput(result: BashResult | BashInteractiveResult, headLines?: number, tailLines?: number): string {
97
+ let outputText = normalizeResultOutput(result);
98
+ const headTailResult = applyHeadTail(outputText, headLines, tailLines);
99
+ if (headTailResult.applied) {
100
+ outputText = headTailResult.text;
101
+ }
102
+ if (!outputText) {
103
+ outputText = "(no output)";
104
+ }
105
+ return outputText;
106
+ }
107
+
108
+ #buildResultText(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): string {
109
+ if (result.cancelled) {
110
+ throw new ToolError(normalizeResultOutput(result) || "Command aborted");
111
+ }
112
+ if (isInteractiveResult(result) && result.timedOut) {
113
+ throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
114
+ }
115
+ if (result.exitCode === undefined) {
116
+ throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
117
+ }
118
+ if (result.exitCode !== 0) {
119
+ throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
120
+ }
121
+ return outputText;
69
122
  }
70
123
 
71
124
  async execute(
72
125
  _toolCallId: string,
73
- { command: rawCommand, timeout: rawTimeout = 300, cwd, head, tail }: BashToolInput,
126
+ { command: rawCommand, timeout: rawTimeout = 300, cwd, head, tail, async: asyncRequested = false }: BashToolInput,
74
127
  signal?: AbortSignal,
75
128
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
76
129
  ctx?: AgentToolContext,
77
130
  ): Promise<AgentToolResult<BashToolDetails>> {
78
131
  let command = rawCommand;
79
132
 
133
+ if (asyncRequested && !this.#asyncEnabled) {
134
+ throw new ToolError("Async bash execution is disabled. Enable async.enabled to use async mode.");
135
+ }
136
+
80
137
  // Only apply explicit head/tail params from tool input.
81
138
  const headLines = head;
82
139
  const tailLines = tail;
@@ -113,13 +170,64 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
113
170
  const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
114
171
  const timeoutMs = timeoutSec * 1000;
115
172
 
173
+ if (asyncRequested) {
174
+ const manager = this.session.asyncJobManager;
175
+ if (!manager) {
176
+ throw new ToolError("Async job manager unavailable for this session.");
177
+ }
178
+ const label = command.length > 120 ? `${command.slice(0, 117)}...` : command;
179
+ const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
180
+ const jobId = manager.register(
181
+ "bash",
182
+ label,
183
+ async ({ jobId, signal: runSignal, reportProgress }) => {
184
+ const artifactsDir = this.session.getArtifactsDir?.();
185
+ const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
186
+ const { path: artifactPath, id: artifactId } =
187
+ (await this.session.allocateOutputArtifact?.("bash")) ?? {};
188
+ try {
189
+ const result = await executeBash(command, {
190
+ cwd: commandCwd,
191
+ sessionKey: this.session.getSessionId?.() ?? undefined,
192
+ timeout: timeoutMs,
193
+ signal: runSignal,
194
+ env: extraEnv,
195
+ artifactPath,
196
+ artifactId,
197
+ onChunk: chunk => {
198
+ tailBuffer.append(chunk);
199
+ void reportProgress(tailBuffer.text(), { async: { state: "running", jobId, type: "bash" } });
200
+ },
201
+ });
202
+ const outputText = this.#formatResultOutput(result, headLines, tailLines);
203
+ const finalText = this.#buildResultText(result, timeoutSec, outputText);
204
+ await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
205
+ return finalText;
206
+ } catch (error) {
207
+ const message = error instanceof Error ? error.message : String(error);
208
+ await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
209
+ throw error;
210
+ }
211
+ },
212
+ {
213
+ onProgress: (text, details) => {
214
+ onUpdate?.({ content: [{ type: "text", text }], details: details ?? {} });
215
+ },
216
+ },
217
+ );
218
+ return {
219
+ content: [{ type: "text", text: `Background job ${jobId} started: ${label}` }],
220
+ details: { async: { state: "running", jobId, type: "bash" } },
221
+ };
222
+ }
223
+
116
224
  // Track output for streaming updates (tail only)
117
- const tailBuffer = createTailBuffer(DEFAULT_MAX_BYTES);
225
+ const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
118
226
 
119
227
  // Set up artifacts environment and allocation
120
228
  const artifactsDir = this.session.getArtifactsDir?.();
121
229
  const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
122
- const { artifactPath, artifactId } = await allocateOutputArtifact(this.session, "bash");
230
+ const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
123
231
 
124
232
  const usePty =
125
233
  this.session.settings.get("bash.virtualTerminal") === "on" &&
@@ -163,15 +271,8 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
163
271
  if (isInteractiveResult(result) && result.timedOut) {
164
272
  throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
165
273
  }
166
- // Apply head/tail filtering if specified
167
- let outputText = normalizeResultOutput(result);
168
- const headTailResult = applyHeadTail(outputText, headLines, tailLines);
169
- if (headTailResult.applied) {
170
- outputText = headTailResult.text;
171
- }
172
- if (!outputText) {
173
- outputText = "(no output)";
174
- }
274
+
275
+ const outputText = this.#formatResultOutput(result, headLines, tailLines);
175
276
  const details: BashToolDetails = {};
176
277
  const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
177
278
  if (result.exitCode === undefined) {
@@ -232,9 +333,6 @@ function formatBashCommand(args: BashRenderArgs, _uiTheme: Theme): string {
232
333
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${command}` : `${prompt} ${command}`;
233
334
  }
234
335
 
235
- // Preview line limit when not expanded (matches tool-execution behavior)
236
- export const BASH_PREVIEW_LINES = 10;
237
-
238
336
  export const bashToolRenderer = {
239
337
  renderCall(args: BashRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
240
338
  const cmdText = formatBashCommand(args, uiTheme);
@@ -256,7 +354,6 @@ export const bashToolRenderer = {
256
354
  const isError = result.isError === true;
257
355
  const header = renderStatusLine({ icon: isError ? "error" : "success", title: "Bash" }, uiTheme);
258
356
  const details = result.details;
259
- const truncation = details?.meta?.truncation;
260
357
  const outputBlock = new CachedOutputBlock();
261
358
 
262
359
  return {
@@ -281,21 +378,8 @@ export const bashToolRenderer = {
281
378
  )
282
379
  : undefined;
283
380
  let warningLine: string | undefined;
284
- if (truncation && !showingFullOutput) {
285
- const warnings: string[] = [];
286
- if (truncation?.artifactId) {
287
- warnings.push(`Full output: artifact://${truncation.artifactId}`);
288
- }
289
- if (truncation.truncatedBy === "lines") {
290
- warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
291
- } else {
292
- warnings.push(
293
- `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`,
294
- );
295
- }
296
- if (warnings.length > 0) {
297
- warningLine = uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme));
298
- }
381
+ if (details?.meta?.truncation && !showingFullOutput) {
382
+ warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
299
383
  }
300
384
 
301
385
  const outputLines: string[] = [];
@@ -0,0 +1,93 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { type Static, Type } from "@sinclair/typebox";
3
+ import { renderPromptTemplate } from "../config/prompt-templates";
4
+ import cancelJobDescription from "../prompts/tools/cancel-job.md" with { type: "text" };
5
+ import type { ToolSession } from "./index";
6
+
7
+ const cancelJobSchema = Type.Object({
8
+ job_id: Type.String({ description: "Background job ID" }),
9
+ });
10
+
11
+ type CancelJobParams = Static<typeof cancelJobSchema>;
12
+
13
+ export interface CancelJobToolDetails {
14
+ status: "cancelled" | "not_found" | "already_completed";
15
+ jobId: string;
16
+ }
17
+
18
+ export class CancelJobTool implements AgentTool<typeof cancelJobSchema, CancelJobToolDetails> {
19
+ readonly name = "cancel_job";
20
+ readonly label = "CancelJob";
21
+ readonly description: string;
22
+ readonly parameters = cancelJobSchema;
23
+
24
+ constructor(private readonly session: ToolSession) {
25
+ this.description = renderPromptTemplate(cancelJobDescription);
26
+ }
27
+
28
+ static createIf(session: ToolSession): CancelJobTool | null {
29
+ if (!session.settings.get("async.enabled")) return null;
30
+ return new CancelJobTool(session);
31
+ }
32
+
33
+ async execute(
34
+ _toolCallId: string,
35
+ params: CancelJobParams,
36
+ _signal?: AbortSignal,
37
+ _onUpdate?: AgentToolUpdateCallback<CancelJobToolDetails>,
38
+ _context?: AgentToolContext,
39
+ ): Promise<AgentToolResult<CancelJobToolDetails>> {
40
+ const manager = this.session.asyncJobManager;
41
+ if (!manager) {
42
+ return {
43
+ content: [
44
+ { type: "text", text: "Async execution is disabled; no background jobs are available to cancel." },
45
+ ],
46
+ details: {
47
+ status: "not_found",
48
+ jobId: params.job_id,
49
+ },
50
+ };
51
+ }
52
+
53
+ const existing = manager.getJob(params.job_id);
54
+ if (!existing) {
55
+ return {
56
+ content: [{ type: "text", text: `Background job not found: ${params.job_id}` }],
57
+ details: {
58
+ status: "not_found",
59
+ jobId: params.job_id,
60
+ },
61
+ };
62
+ }
63
+
64
+ if (existing.status !== "running") {
65
+ return {
66
+ content: [{ type: "text", text: `Background job ${params.job_id} is already ${existing.status}.` }],
67
+ details: {
68
+ status: "already_completed",
69
+ jobId: params.job_id,
70
+ },
71
+ };
72
+ }
73
+
74
+ const cancelled = manager.cancel(params.job_id);
75
+ if (!cancelled) {
76
+ return {
77
+ content: [{ type: "text", text: `Background job ${params.job_id} is already completed.` }],
78
+ details: {
79
+ status: "already_completed",
80
+ jobId: params.job_id,
81
+ },
82
+ };
83
+ }
84
+
85
+ return {
86
+ content: [{ type: "text", text: `Cancelled background job ${params.job_id}.` }],
87
+ details: {
88
+ status: "cancelled",
89
+ jobId: params.job_id,
90
+ },
91
+ };
92
+ }
93
+ }
@@ -3,13 +3,14 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
3
3
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { ptree } from "@oh-my-pi/pi-utils";
6
+ import { ptree, truncate } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import { parse as parseHtml } from "node-html-parser";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import { type Theme, theme } from "../modes/theme/theme";
12
12
  import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" };
13
+ import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
13
14
  import { renderStatusLine } from "../tui";
14
15
  import { CachedOutputBlock } from "../tui/output-block";
15
16
  import { ensureTool } from "../utils/tools-manager";
@@ -19,12 +20,10 @@ import { finalizeOutput, loadPage, MAX_OUTPUT_CHARS } from "../web/scrapers/type
19
20
  import { convertWithMarkitdown, fetchBinary } from "../web/scrapers/utils";
20
21
  import type { ToolSession } from ".";
21
22
  import { applyListLimit } from "./list-limit";
22
- import type { OutputMeta } from "./output-meta";
23
- import { allocateOutputArtifact } from "./output-utils";
24
- import { formatExpandHint } from "./render-utils";
23
+ import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
24
+ import { formatExpandHint, getDomain } from "./render-utils";
25
25
  import { ToolAbortError } from "./tool-errors";
26
26
  import { toolResult } from "./tool-result";
27
- import { DEFAULT_MAX_BYTES, truncateHead } from "./truncate";
28
27
 
29
28
  // =============================================================================
30
29
  // Types and Constants
@@ -900,10 +899,10 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
900
899
  };
901
900
 
902
901
  if (needsArtifact) {
903
- const { artifactPath, artifactId: allocatedId } = await allocateOutputArtifact(this.session, "fetch");
902
+ const { path: artifactPath, id } = (await this.session.allocateOutputArtifact?.("fetch")) ?? {};
904
903
  if (artifactPath) {
905
904
  await Bun.write(artifactPath, buildOutput(result.content));
906
- artifactId = allocatedId;
905
+ artifactId = id;
907
906
  }
908
907
  }
909
908
 
@@ -942,23 +941,6 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
942
941
  // TUI Rendering
943
942
  // =============================================================================
944
943
 
945
- /** Truncate text to max length with ellipsis */
946
- function truncate(text: string, maxLen: number, ellipsis: string): string {
947
- if (text.length <= maxLen) return text;
948
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
949
- return `${text.slice(0, sliceLen)}${ellipsis}`;
950
- }
951
-
952
- /** Extract domain from URL */
953
- function getDomain(url: string): string {
954
- try {
955
- const u = new URL(url);
956
- return u.hostname.replace(/^www\./, "");
957
- } catch {
958
- return url;
959
- }
960
- }
961
-
962
944
  /** Count non-empty lines */
963
945
  function countNonEmptyLines(text: string): number {
964
946
  return text.split("\n").filter(l => l.trim()).length;
@@ -1029,9 +1011,7 @@ export function renderFetchResult(
1029
1011
  metadataLines.push(`${uiTheme.fg("muted", "Chars:")} ${charCount}`);
1030
1012
  if (truncated) {
1031
1013
  metadataLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`));
1032
- if (truncation?.artifactId) {
1033
- metadataLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`));
1034
- }
1014
+ if (truncation?.artifactId) metadataLines.push(formatStyledArtifactReference(truncation.artifactId, uiTheme));
1035
1015
  }
1036
1016
  if (hasNotes) {
1037
1017
  metadataLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
package/src/tools/find.ts CHANGED
@@ -11,6 +11,7 @@ import { renderPromptTemplate } from "../config/prompt-templates";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
14
+ import { type TruncationResult, truncateHead } from "../session/streaming-output";
14
15
  import {
15
16
  Ellipsis,
16
17
  Hasher,
@@ -22,12 +23,11 @@ import {
22
23
  } from "../tui";
23
24
  import type { ToolSession } from ".";
24
25
  import { applyListLimit } from "./list-limit";
25
- import type { OutputMeta } from "./output-meta";
26
+ import { formatFullOutputReference, type OutputMeta } from "./output-meta";
26
27
  import { resolveToCwd } from "./path-utils";
27
28
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
28
29
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
29
30
  import { toolResult } from "./tool-result";
30
- import { type TruncationResult, truncateHead } from "./truncate";
31
31
 
32
32
  const findSchema = Type.Object({
33
33
  pattern: Type.String({ description: "Glob pattern, e.g. '*.ts', 'src/**/*.json', 'lib/*.tsx'" }),
@@ -506,7 +506,7 @@ export const findToolRenderer = {
506
506
  if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
507
507
  if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
508
508
  const artifactId = truncation && "artifactId" in truncation ? truncation.artifactId : undefined;
509
- if (artifactId) truncationReasons.push(`full output: artifact://${artifactId}`);
509
+ if (artifactId) truncationReasons.push(formatFullOutputReference(artifactId));
510
510
 
511
511
  const extraLines: string[] = [];
512
512
  if (truncationReasons.length > 0) {