@oh-my-pi/pi-coding-agent 15.0.1 → 15.1.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 (168) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/examples/custom-tools/README.md +11 -7
  3. package/examples/custom-tools/hello/index.ts +2 -2
  4. package/examples/extensions/README.md +19 -8
  5. package/examples/extensions/api-demo.ts +15 -19
  6. package/examples/extensions/hello.ts +5 -6
  7. package/examples/extensions/plan-mode.ts +1 -1
  8. package/examples/extensions/reload-runtime.ts +4 -3
  9. package/examples/extensions/with-deps/index.ts +4 -3
  10. package/examples/sdk/06-extensions.ts +4 -2
  11. package/package.json +8 -18
  12. package/src/autoresearch/tools/init-experiment.ts +38 -41
  13. package/src/autoresearch/tools/log-experiment.ts +32 -41
  14. package/src/autoresearch/tools/run-experiment.ts +3 -3
  15. package/src/autoresearch/tools/update-notes.ts +11 -11
  16. package/src/commands/commit.ts +10 -0
  17. package/src/commit/agentic/tools/analyze-file.ts +4 -4
  18. package/src/commit/agentic/tools/git-file-diff.ts +4 -4
  19. package/src/commit/agentic/tools/git-hunk.ts +5 -5
  20. package/src/commit/agentic/tools/git-overview.ts +4 -4
  21. package/src/commit/agentic/tools/propose-changelog.ts +13 -13
  22. package/src/commit/agentic/tools/propose-commit.ts +6 -6
  23. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  24. package/src/commit/agentic/tools/schemas.ts +28 -28
  25. package/src/commit/agentic/tools/split-commit.ts +22 -21
  26. package/src/commit/analysis/summary.ts +4 -4
  27. package/src/commit/changelog/generate.ts +7 -11
  28. package/src/commit/shared-llm.ts +22 -34
  29. package/src/config/config-file.ts +35 -13
  30. package/src/config/model-registry.ts +40 -191
  31. package/src/config/models-config-schema.ts +166 -0
  32. package/src/config/settings-schema.ts +29 -0
  33. package/src/discovery/claude-plugins.ts +19 -7
  34. package/src/edit/index.ts +2 -2
  35. package/src/edit/modes/apply-patch.ts +7 -6
  36. package/src/edit/modes/patch.ts +18 -25
  37. package/src/edit/modes/replace.ts +18 -20
  38. package/src/eval/js/shared/rewrite-imports.ts +131 -10
  39. package/src/eval/py/executor.ts +233 -623
  40. package/src/eval/py/kernel.ts +27 -2
  41. package/src/eval/py/runner.py +42 -11
  42. package/src/eval/py/runtime.ts +1 -0
  43. package/src/exa/factory.ts +5 -4
  44. package/src/exa/mcp-client.ts +1 -1
  45. package/src/exa/researcher.ts +9 -20
  46. package/src/exa/search.ts +26 -52
  47. package/src/exa/types.ts +1 -1
  48. package/src/exa/websets.ts +54 -53
  49. package/src/exec/bash-executor.ts +2 -1
  50. package/src/extensibility/custom-commands/loader.ts +5 -3
  51. package/src/extensibility/custom-commands/types.ts +4 -2
  52. package/src/extensibility/custom-tools/loader.ts +5 -3
  53. package/src/extensibility/custom-tools/types.ts +7 -6
  54. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  55. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  56. package/src/extensibility/extensions/loader.ts +7 -3
  57. package/src/extensibility/extensions/types.ts +9 -5
  58. package/src/extensibility/extensions/wrapper.ts +1 -2
  59. package/src/extensibility/hooks/loader.ts +3 -1
  60. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  61. package/src/extensibility/hooks/types.ts +4 -2
  62. package/src/extensibility/plugins/legacy-pi-compat.ts +78 -31
  63. package/src/extensibility/shared-events.ts +1 -1
  64. package/src/extensibility/typebox.ts +391 -0
  65. package/src/goals/tools/goal-tool.ts +6 -12
  66. package/src/hashline/input.ts +2 -1
  67. package/src/hashline/parser.ts +27 -3
  68. package/src/hashline/types.ts +4 -4
  69. package/src/hindsight/state.ts +2 -2
  70. package/src/index.ts +0 -2
  71. package/src/internal-urls/docs-index.generated.ts +15 -15
  72. package/src/internal-urls/router.ts +8 -0
  73. package/src/internal-urls/types.ts +21 -0
  74. package/src/lsp/config.ts +15 -6
  75. package/src/lsp/defaults.json +6 -2
  76. package/src/lsp/types.ts +30 -38
  77. package/src/mcp/manager.ts +1 -1
  78. package/src/mcp/tool-bridge.ts +1 -1
  79. package/src/modes/acp/acp-agent.ts +248 -50
  80. package/src/modes/components/session-observer-overlay.ts +12 -1
  81. package/src/modes/components/status-line/segments.ts +39 -4
  82. package/src/modes/controllers/command-controller.ts +27 -2
  83. package/src/modes/controllers/event-controller.ts +3 -4
  84. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  85. package/src/modes/interactive-mode.ts +1 -1
  86. package/src/modes/rpc/host-tools.ts +1 -1
  87. package/src/modes/rpc/host-uris.ts +235 -0
  88. package/src/modes/rpc/rpc-client.ts +1 -1
  89. package/src/modes/rpc/rpc-mode.ts +27 -1
  90. package/src/modes/rpc/rpc-types.ts +58 -1
  91. package/src/modes/runtime-init.ts +2 -1
  92. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  94. package/src/modes/theme/theme.ts +117 -117
  95. package/src/modes/types.ts +1 -1
  96. package/src/modes/utils/context-usage.ts +2 -2
  97. package/src/prompts/tools/github.md +4 -4
  98. package/src/prompts/tools/hashline.md +22 -26
  99. package/src/prompts/tools/read.md +55 -37
  100. package/src/sdk.ts +31 -8
  101. package/src/session/agent-session.ts +74 -104
  102. package/src/session/messages.ts +16 -51
  103. package/src/session/session-manager.ts +22 -2
  104. package/src/session/streaming-output.ts +16 -6
  105. package/src/task/discovery.ts +5 -2
  106. package/src/task/executor.ts +210 -87
  107. package/src/task/index.ts +15 -11
  108. package/src/task/render.ts +32 -5
  109. package/src/task/types.ts +54 -39
  110. package/src/tools/ask.ts +12 -12
  111. package/src/tools/ast-edit.ts +11 -15
  112. package/src/tools/ast-grep.ts +9 -10
  113. package/src/tools/bash-command-fixup.ts +47 -0
  114. package/src/tools/bash.ts +48 -38
  115. package/src/tools/browser/render.ts +2 -2
  116. package/src/tools/browser.ts +39 -53
  117. package/src/tools/calculator.ts +12 -11
  118. package/src/tools/checkpoint.ts +7 -7
  119. package/src/tools/debug.ts +40 -43
  120. package/src/tools/eval.ts +16 -10
  121. package/src/tools/find.ts +10 -13
  122. package/src/tools/gh.ts +108 -132
  123. package/src/tools/hindsight-recall.ts +4 -6
  124. package/src/tools/hindsight-reflect.ts +5 -5
  125. package/src/tools/hindsight-retain.ts +15 -17
  126. package/src/tools/image-gen.ts +31 -81
  127. package/src/tools/index.ts +4 -1
  128. package/src/tools/inspect-image.ts +8 -9
  129. package/src/tools/irc.ts +15 -27
  130. package/src/tools/job.ts +30 -28
  131. package/src/tools/output-meta.ts +26 -0
  132. package/src/tools/read.ts +39 -12
  133. package/src/tools/recipe/index.ts +7 -9
  134. package/src/tools/render-mermaid.ts +12 -12
  135. package/src/tools/report-tool-issue.ts +4 -4
  136. package/src/tools/resolve.ts +11 -11
  137. package/src/tools/review.ts +14 -26
  138. package/src/tools/search-tool-bm25.ts +7 -9
  139. package/src/tools/search.ts +19 -22
  140. package/src/tools/ssh.ts +10 -9
  141. package/src/tools/todo-write.ts +26 -34
  142. package/src/tools/vim.ts +10 -26
  143. package/src/tools/write.ts +25 -5
  144. package/src/tools/yield.ts +100 -54
  145. package/src/web/search/index.ts +9 -24
  146. package/src/web/search/providers/anthropic.ts +5 -0
  147. package/src/web/search/providers/exa.ts +3 -0
  148. package/src/web/search/providers/gemini.ts +5 -0
  149. package/src/web/search/providers/jina.ts +5 -2
  150. package/src/web/search/providers/zai.ts +5 -2
  151. package/src/prompts/compaction/branch-summary-context.md +0 -5
  152. package/src/prompts/compaction/branch-summary-preamble.md +0 -2
  153. package/src/prompts/compaction/branch-summary.md +0 -30
  154. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  155. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  156. package/src/prompts/compaction/compaction-summary.md +0 -38
  157. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  158. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  159. package/src/prompts/system/auto-handoff-threshold-focus.md +0 -1
  160. package/src/prompts/system/file-operations.md +0 -10
  161. package/src/prompts/system/handoff-document.md +0 -49
  162. package/src/prompts/system/summarization-system.md +0 -3
  163. package/src/session/compaction/branch-summarization.ts +0 -324
  164. package/src/session/compaction/compaction.ts +0 -1420
  165. package/src/session/compaction/errors.ts +0 -31
  166. package/src/session/compaction/index.ts +0 -8
  167. package/src/session/compaction/pruning.ts +0 -91
  168. package/src/session/compaction/utils.ts +0 -184
package/src/task/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Usage } from "@oh-my-pi/pi-ai";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
- import { type Static, type TSchema, Type } from "@sinclair/typebox";
4
+ import * as z from "zod/v4";
5
5
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
6
6
  import type { NestedRepoPatch } from "./worktree";
7
7
 
@@ -63,65 +63,65 @@ const assignmentDescriptionForContextDisabled =
63
63
  "Complete per-task instructions the subagent executes. Must follow the Target/Change/Edge Cases/Acceptance structure, and include any background that would otherwise live in `context` since shared context is disabled in this mode.";
64
64
 
65
65
  const createTaskItemSchema = (contextEnabled: boolean) =>
66
- Type.Object({
67
- id: Type.String({
68
- description: "CamelCase identifier, max 48 chars",
69
- maxLength: 48,
70
- }),
71
- description: Type.String({
72
- description: "Short one-liner for UI display only — not seen by the subagent",
73
- }),
74
- assignment: Type.String({
75
- description: contextEnabled ? assignmentDescriptionForContextEnabled : assignmentDescriptionForContextDisabled,
76
- }),
66
+ z.object({
67
+ id: z.string().max(48).describe("CamelCase identifier, max 48 chars"),
68
+ description: z.string().describe("Short one-liner for UI display only — not seen by the subagent"),
69
+ assignment: z
70
+ .string()
71
+ .describe(contextEnabled ? assignmentDescriptionForContextEnabled : assignmentDescriptionForContextDisabled),
77
72
  });
78
73
 
79
74
  /** Single task item for parallel execution (default shape with context enabled). */
80
75
  export const taskItemSchema = createTaskItemSchema(true);
81
- export type TaskItem = Static<typeof taskItemSchema>;
76
+ export type TaskItem = z.infer<typeof taskItemSchema>;
82
77
 
83
78
  const createTaskSchema = (options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }) => {
84
79
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(options.simpleMode);
85
80
  const itemSchema = createTaskItemSchema(contextEnabled);
86
- const properties: Record<string, TSchema> = {
87
- agent: Type.String({ description: "Agent type for all tasks in this batch" }),
88
- tasks: Type.Array(itemSchema, {
89
- description: contextEnabled
90
- ? "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and self-contained given context + assignment."
91
- : "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and fully self-contained inside assignment because shared context is disabled.",
92
- }),
93
- };
81
+
82
+ let schema = z.object({
83
+ agent: z.string().describe("Agent type for all tasks in this batch"),
84
+ tasks: z
85
+ .array(itemSchema)
86
+ .describe(
87
+ contextEnabled
88
+ ? "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and self-contained given context + assignment."
89
+ : "Tasks to execute in parallel. Each must be small-scoped (3-5 files max) and fully self-contained inside assignment because shared context is disabled.",
90
+ ),
91
+ });
94
92
 
95
93
  if (contextEnabled) {
96
- properties.context = Type.Optional(
97
- Type.String({
98
- description:
94
+ schema = schema.extend({
95
+ context: z
96
+ .string()
97
+ .optional()
98
+ .describe(
99
99
  "Shared background prepended to every task's assignment. Put goal, non-goals, constraints, conventions, reference paths, API contracts, and global acceptance commands here once — instead of duplicating across assignments.",
100
- }),
101
- );
100
+ ),
101
+ });
102
102
  }
103
103
 
104
104
  if (customSchemaEnabled) {
105
- properties.schema = Type.Optional(
106
- Type.String({
107
- description:
105
+ schema = schema.extend({
106
+ schema: z
107
+ .string()
108
+ .optional()
109
+ .describe(
108
110
  "JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.",
109
- }),
110
- );
111
+ ),
112
+ });
111
113
  }
112
114
 
113
115
  if (options.isolationEnabled) {
114
- return Type.Object({
115
- ...properties,
116
- isolated: Type.Optional(
117
- Type.Boolean({
118
- description: "Run in isolated environment; returns patches. Use when tasks edit overlapping files.",
119
- }),
120
- ),
116
+ schema = schema.extend({
117
+ isolated: z
118
+ .boolean()
119
+ .optional()
120
+ .describe("Run in isolated environment; returns patches. Use when tasks edit overlapping files."),
121
121
  });
122
122
  }
123
123
 
124
- return Type.Object(properties);
124
+ return schema;
125
125
  };
126
126
 
127
127
  export const taskSchema = createTaskSchema({ isolationEnabled: true, simpleMode: "default" });
@@ -141,6 +141,8 @@ const ALL_TASK_SCHEMAS = [
141
141
 
142
142
  type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number];
143
143
  export type TaskSchema = typeof taskSchema;
144
+ /** Active task tool parameter schema for the current simple-mode / isolation flags */
145
+ export type TaskToolSchemaInstance = DynamicTaskSchema;
144
146
 
145
147
  export function getTaskSchema(options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }): DynamicTaskSchema {
146
148
  switch (options.simpleMode) {
@@ -219,6 +221,15 @@ export interface AgentProgress {
219
221
  toolCount: number;
220
222
  /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
221
223
  tokens: number;
224
+ /**
225
+ * Current per-turn context size: latest assistant message's `usage.totalTokens`.
226
+ * This is the number to compare against `contextWindow` — what compaction
227
+ * decides on, what the user typically reads as "how full is the context".
228
+ * Distinct from `tokens`, which is a lifetime billing-volume counter.
229
+ */
230
+ contextTokens?: number;
231
+ /** Model's context window in tokens, when known. Lets the UI render `<curr>/<window>` gauges. */
232
+ contextWindow?: number;
222
233
  /** Cumulative billing cost in USD, accumulated incrementally from message_end events. */
223
234
  cost: number;
224
235
  durationMs: number;
@@ -244,6 +255,10 @@ export interface SingleResult {
244
255
  durationMs: number;
245
256
  /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
246
257
  tokens: number;
258
+ /** Latest per-turn context size at task completion. See `AgentProgress.contextTokens`. */
259
+ contextTokens?: number;
260
+ /** Model's context window in tokens, when known. */
261
+ contextWindow?: number;
247
262
  modelOverride?: string | string[];
248
263
  error?: string;
249
264
  aborted?: boolean;
package/src/tools/ask.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
20
20
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
21
- import { type Static, Type } from "@sinclair/typebox";
21
+ import * as z from "zod/v4";
22
22
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
23
23
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
24
24
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
@@ -31,23 +31,23 @@ import { ToolAbortError } from "./tool-errors";
31
31
  // Types
32
32
  // =============================================================================
33
33
 
34
- const OptionItem = Type.Object({
35
- label: Type.String({ description: "display label" }),
34
+ const OptionItem = z.object({
35
+ label: z.string().describe("display label"),
36
36
  });
37
37
 
38
- const QuestionItem = Type.Object({
39
- id: Type.String({ description: "question id", examples: ["auth", "cache"] }),
40
- question: Type.String({ description: "question text" }),
41
- options: Type.Array(OptionItem, { description: "available options" }),
42
- multi: Type.Optional(Type.Boolean({ description: "allow multiple selections" })),
43
- recommended: Type.Optional(Type.Number({ description: "recommended option index" })),
38
+ const QuestionItem = z.object({
39
+ id: z.string().describe("question id"),
40
+ question: z.string().describe("question text"),
41
+ options: z.array(OptionItem).describe("available options"),
42
+ multi: z.boolean().describe("allow multiple selections").optional(),
43
+ recommended: z.number().describe("recommended option index").optional(),
44
44
  });
45
45
 
46
- const askSchema = Type.Object({
47
- questions: Type.Array(QuestionItem, { description: "questions to ask", minItems: 1 }),
46
+ const askSchema = z.object({
47
+ questions: z.array(QuestionItem).min(1).describe("questions to ask"),
48
48
  });
49
49
 
50
- export type AskToolInput = Static<typeof askSchema>;
50
+ export type AskToolInput = z.infer<typeof askSchema>;
51
51
 
52
52
  /** Result for a single question */
53
53
  export interface QuestionResult {
@@ -4,7 +4,7 @@ import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-m
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
- import { type Static, Type } from "@sinclair/typebox";
7
+ import * as z from "zod/v4";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
10
  import type { Theme } from "../modes/theme/theme";
@@ -33,21 +33,17 @@ import { queueResolveHandler } from "./resolve";
33
33
  import { ToolError } from "./tool-errors";
34
34
  import { toolResult } from "./tool-result";
35
35
 
36
- const astEditOpSchema = Type.Object({
37
- pat: Type.String({ description: "ast pattern", examples: ["oldFn($$$ARGS)"] }),
38
- out: Type.String({ description: "replacement template", examples: ["newFn($$$ARGS)"] }),
36
+ const astEditOpSchema = z.object({
37
+ pat: z.string().describe("ast pattern"),
38
+ out: z.string().describe("replacement template"),
39
39
  });
40
40
 
41
- const astEditSchema = Type.Object({
42
- ops: Type.Array(astEditOpSchema, {
43
- minItems: 1,
44
- description: "rewrite ops",
45
- }),
46
- paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to rewrite" }), {
47
- minItems: 1,
48
- description: "files, directories, globs, or internal URLs to rewrite",
49
- examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
50
- }),
41
+ const astEditSchema = z.object({
42
+ ops: z.array(astEditOpSchema).min(1).describe("rewrite ops"),
43
+ paths: z
44
+ .array(z.string().describe("file, directory, glob, or internal URL to rewrite"))
45
+ .min(1)
46
+ .describe("files, directories, globs, or internal URLs to rewrite"),
51
47
  });
52
48
 
53
49
  interface AstEditCallOptions {
@@ -174,7 +170,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
174
170
 
175
171
  async execute(
176
172
  _toolCallId: string,
177
- params: Static<typeof astEditSchema>,
173
+ params: z.infer<typeof astEditSchema>,
178
174
  signal?: AbortSignal,
179
175
  _onUpdate?: AgentToolUpdateCallback<AstEditToolDetails>,
180
176
  _context?: AgentToolContext,
@@ -4,7 +4,7 @@ import { type AstFindMatch, astGrep } 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
6
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
- import { type Static, Type } from "@sinclair/typebox";
7
+ import * as z from "zod/v4";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import type { Theme } from "../modes/theme/theme";
10
10
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
@@ -32,14 +32,13 @@ import {
32
32
  import { ToolError } from "./tool-errors";
33
33
  import { toolResult } from "./tool-result";
34
34
 
35
- const astGrepSchema = Type.Object({
36
- pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }),
37
- paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
38
- minItems: 1,
39
- description: "files, directories, globs, or internal URLs to search",
40
- examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
41
- }),
42
- skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
35
+ const astGrepSchema = z.object({
36
+ pat: z.string().describe("ast pattern"),
37
+ paths: z
38
+ .array(z.string().describe("file, directory, glob, or internal URL to search"))
39
+ .min(1)
40
+ .describe("files, directories, globs, or internal URLs to search"),
41
+ skip: z.number().default(0).describe("matches to skip").optional(),
43
42
  });
44
43
 
45
44
  async function runMultiTargetAstGrep(
@@ -129,7 +128,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
129
128
 
130
129
  async execute(
131
130
  _toolCallId: string,
132
- params: Static<typeof astGrepSchema>,
131
+ params: z.infer<typeof astGrepSchema>,
133
132
  signal?: AbortSignal,
134
133
  _onUpdate?: AgentToolUpdateCallback<AstGrepToolDetails>,
135
134
  _context?: AgentToolContext,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Conservative transforms applied to a bash command before execution.
3
+ *
4
+ * Two fixups are applied, each anchored to the end of a top-level segment
5
+ * (segments split on `;`, `&&`, `||`, and background `&`):
6
+ *
7
+ * 1. Trailing `| head [args]` / `| tail [args]` (and the `|&` variant) — these
8
+ * pipes exist purely to limit output length. The harness already truncates
9
+ * bash output and exposes the full result via an artifact, so they only
10
+ * hide content the agent wanted.
11
+ *
12
+ * 2. A redundant trailing `2>&1` left on a segment that has no remaining pipe
13
+ * or other redirect. The harness already merges stderr into stdout, so the
14
+ * duplication is purely cosmetic — and often a leftover after fixup (1)
15
+ * drops a downstream pipe.
16
+ *
17
+ * The heavy lifting (tokenization, quoting, heredoc handling, command
18
+ * substitution, nested compound commands) lives in Rust under
19
+ * `pi_shell::fixup`, driven by the real `brush-parser` AST. This module is a
20
+ * thin sync wrapper plus user-facing notice formatting.
21
+ */
22
+ import { applyBashFixups as nativeApplyBashFixups } from "@oh-my-pi/pi-natives";
23
+
24
+ export interface BashFixupResult {
25
+ /** Possibly-rewritten command. */
26
+ command: string;
27
+ /** Substrings that were stripped, in the order they were removed. */
28
+ stripped: string[];
29
+ }
30
+
31
+ /**
32
+ * Apply both fixups to a bash command. On any parse failure, multi-line input,
33
+ * or no-op transform, returns the input verbatim with `stripped: []`.
34
+ */
35
+ export function applyBashFixups(command: string): BashFixupResult {
36
+ return nativeApplyBashFixups(command);
37
+ }
38
+
39
+ /**
40
+ * Human-readable notice for the fixups that fired. Mirrors the shape of
41
+ * `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
42
+ */
43
+ export function formatBashFixupNotice(stripped: readonly string[]): string | undefined {
44
+ if (!stripped.length) return undefined;
45
+ const quoted = stripped.map(s => `\`${s}\``).join(", ");
46
+ return `<system-warning>Stripped redundant ${quoted} — bash output is already truncated and stderr is already merged into stdout. NEVER use these patterns.</system-warning>`;
47
+ }
package/src/tools/bash.ts CHANGED
@@ -3,7 +3,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
5
  import { $env, getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
6
- import { Type } from "@sinclair/typebox";
6
+ import * as z from "zod/v4";
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";
@@ -17,10 +17,11 @@ import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import { getSixelLineMask } from "../utils/sixel";
19
19
  import type { ToolSession } from ".";
20
+ import { applyBashFixups, formatBashFixupNotice } from "./bash-command-fixup";
20
21
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
21
22
  import { checkBashInterception } from "./bash-interceptor";
22
23
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
23
- import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
24
+ import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
24
25
  import { resolveToCwd } from "./path-utils";
25
26
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
26
27
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -43,30 +44,16 @@ async function saveBashOriginalArtifact(session: ToolSession, originalText: stri
43
44
  }
44
45
  }
45
46
 
46
- const bashSchemaBase = Type.Object({
47
- command: Type.String({ description: "command to execute", examples: ["ls -la", "echo hi"] }),
48
- env: Type.Optional(
49
- Type.Record(Type.String({ pattern: BASH_ENV_NAME_PATTERN.source }), Type.String(), {
50
- description: "extra env vars",
51
- }),
52
- ),
53
- timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 300 })),
54
- cwd: Type.Optional(Type.String({ description: "working directory", examples: ["src/", "/tmp"] })),
55
-
56
- pty: Type.Optional(
57
- Type.Boolean({
58
- description: "run in pty mode",
59
- }),
60
- ),
47
+ const bashSchemaBase = z.object({
48
+ command: z.string().describe("command to execute"),
49
+ env: z.record(z.string().regex(BASH_ENV_NAME_PATTERN), z.string()).optional().describe("extra env vars"),
50
+ timeout: z.number().default(300).describe("timeout in seconds").optional(),
51
+ cwd: z.string().describe("working directory").optional(),
52
+ pty: z.boolean().describe("run in pty mode").optional(),
61
53
  });
62
54
 
63
- const bashSchemaWithAsync = Type.Object({
64
- ...bashSchemaBase.properties,
65
- async: Type.Optional(
66
- Type.Boolean({
67
- description: "run in background",
68
- }),
69
- ),
55
+ const bashSchemaWithAsync = bashSchemaBase.extend({
56
+ async: z.boolean().describe("run in background").optional(),
70
57
  });
71
58
 
72
59
  type BashToolSchema = typeof bashSchemaBase | typeof bashSchemaWithAsync;
@@ -245,6 +232,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
245
232
  readonly #asyncEnabled: boolean;
246
233
  readonly #autoBackgroundEnabled: boolean;
247
234
  readonly #autoBackgroundThresholdMs: number;
235
+ #bashFixupNoticeEmitted = false;
248
236
 
249
237
  constructor(private readonly session: ToolSession) {
250
238
  this.#asyncEnabled = this.session.settings.get("async.enabled");
@@ -291,7 +279,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
291
279
  #buildCompletedResult(
292
280
  result: BashResult | BashInteractiveResult,
293
281
  timeoutSec: number,
294
- options: { requestedTimeoutSec?: number; notices?: string[]; terminalId?: string } = {},
282
+ options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
295
283
  ): AgentToolResult<BashToolDetails> {
296
284
  const outputLines = [this.#formatResultOutput(result)];
297
285
  const notices = options.notices?.filter(Boolean) ?? [];
@@ -314,7 +302,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
314
302
  label: string,
315
303
  previewText: string,
316
304
  timeoutSec: number,
317
- options: { requestedTimeoutSec?: number; notices?: string[] } = {},
305
+ options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
318
306
  ): AgentToolResult<BashToolDetails> {
319
307
  const details: BashToolDetails = {
320
308
  timeoutSeconds: timeoutSec,
@@ -352,7 +340,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
352
340
  timeoutMs: number;
353
341
  timeoutSec: number;
354
342
  requestedTimeoutSec?: number;
355
- timeoutClampNotice?: string;
343
+ notices?: readonly string[];
356
344
 
357
345
  resolvedEnv?: Record<string, string>;
358
346
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
@@ -392,7 +380,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
392
380
  });
393
381
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
394
382
  requestedTimeoutSec: options.requestedTimeoutSec,
395
- notices: [options.timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
383
+ notices: options.notices ?? [],
396
384
  });
397
385
  const finalText = this.#extractTextResult(finalResult);
398
386
  latestText = finalText;
@@ -483,6 +471,18 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
483
471
  let command = rawCommand;
484
472
  const env = normalizeBashEnv(rawEnv);
485
473
 
474
+ // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
475
+ // `2>&1`). The helper is single-line only and refuses anything that could
476
+ // change semantics.
477
+ let bashFixups: string[] = [];
478
+ if (this.session.settings.get("bash.stripTrailingHeadTail")) {
479
+ const fixup = applyBashFixups(command);
480
+ if (fixup.stripped.length > 0) {
481
+ command = fixup.command;
482
+ bashFixups = fixup.stripped;
483
+ }
484
+ }
485
+
486
486
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
487
487
  // Constrained to a single line so a `&&` that sits on a later line of a multiline
488
488
  // script can't pull the entire script into the "cwd" capture.
@@ -558,7 +558,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
558
558
  const requestedTimeoutSec = rawTimeout;
559
559
  const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
560
560
  const timeoutMs = timeoutSec * 1000;
561
+ const pendingNotices: string[] = [];
561
562
  const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
563
+ if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
564
+ const bashFixupNotice = this.#bashFixupNoticeEmitted ? undefined : formatBashFixupNotice(bashFixups);
565
+ if (bashFixupNotice) {
566
+ pendingNotices.push(bashFixupNotice);
567
+ this.#bashFixupNoticeEmitted = true;
568
+ }
562
569
 
563
570
  if (asyncRequested) {
564
571
  if (!AsyncJobManager.instance()) {
@@ -570,7 +577,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
570
577
  timeoutMs,
571
578
  timeoutSec,
572
579
  requestedTimeoutSec,
573
- timeoutClampNotice,
580
+ notices: pendingNotices,
574
581
 
575
582
  resolvedEnv,
576
583
  onUpdate,
@@ -578,7 +585,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
578
585
  });
579
586
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
580
587
  requestedTimeoutSec,
581
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
588
+ notices: pendingNotices,
582
589
  });
583
590
  }
584
591
 
@@ -592,7 +599,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
592
599
  timeoutMs,
593
600
  timeoutSec,
594
601
  requestedTimeoutSec,
595
- timeoutClampNotice,
602
+ notices: pendingNotices,
596
603
 
597
604
  resolvedEnv,
598
605
  onUpdate,
@@ -601,7 +608,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
601
608
  if (startBackgrounded) {
602
609
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
603
610
  requestedTimeoutSec,
604
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
611
+ notices: pendingNotices,
605
612
  });
606
613
  }
607
614
  const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
@@ -621,7 +628,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
621
628
  job.setBackgrounded(true);
622
629
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
623
630
  requestedTimeoutSec,
624
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
631
+ notices: pendingNotices,
625
632
  });
626
633
  }
627
634
 
@@ -722,7 +729,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
722
729
  };
723
730
  return this.#buildCompletedResult(timedOutResult, timeoutSec, {
724
731
  requestedTimeoutSec,
725
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
732
+ notices: pendingNotices,
726
733
  terminalId: handle.terminalId,
727
734
  });
728
735
  }
@@ -778,7 +785,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
778
785
 
779
786
  const bridgeNotices: string[] = [];
780
787
  if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
781
- if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice);
788
+ for (const notice of pendingNotices) bridgeNotices.push(notice);
782
789
 
783
790
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
784
791
  requestedTimeoutSec,
@@ -833,7 +840,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
833
840
  }
834
841
  return this.#buildCompletedResult(result, timeoutSec, {
835
842
  requestedTimeoutSec,
836
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
843
+ notices: pendingNotices,
837
844
  });
838
845
  }
839
846
  }
@@ -960,8 +967,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
960
967
  const expanded = renderContext?.expanded ?? options.expanded;
961
968
  const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
962
969
 
963
- // Get output from context (preferred) or fall back to result content
964
- const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
970
+ // Get output from context (preferred) or fall back to result content.
971
+ // Strip the LLM-facing notice appended by wrappedExecute so we don't
972
+ // double-print it alongside the styled warning line below.
973
+ const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
974
+ const output = stripOutputNotice(rawOutput, details?.meta);
965
975
  const displayOutput = output.trimEnd();
966
976
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
967
977
 
@@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../../extensibility/custom-tools/types
11
11
  import type { Theme } from "../../modes/theme/theme";
12
12
  import { Hasher, renderCodeCell, renderStatusLine } from "../../tui";
13
13
  import type { BrowserToolDetails } from "../browser";
14
- import { formatStyledTruncationWarning } from "../output-meta";
14
+ import { formatStyledTruncationWarning, stripOutputNotice } from "../output-meta";
15
15
  import { replaceTabs, shortenPath } from "../render-utils";
16
16
 
17
17
  const BROWSER_DEFAULT_PREVIEW_LINES = 10;
@@ -195,7 +195,7 @@ export const browserToolRenderer = {
195
195
  const details = result.details;
196
196
  const action = details?.action ?? argsObj.action;
197
197
  const isError = result.isError === true;
198
- const output = extractTextOutput(result.content);
198
+ const output = stripOutputNotice(extractTextOutput(result.content), details?.meta);
199
199
 
200
200
  if (action === "run") {
201
201
  let component = renderRunCell(argsObj, details, options, output, isError, theme);