@oh-my-pi/pi-coding-agent 15.4.3 → 15.5.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 (136) hide show
  1. package/CHANGELOG.md +81 -5
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/auth-broker-cli.d.ts +1 -1
  4. package/dist/types/commands/launch.d.ts +8 -0
  5. package/dist/types/config/settings-schema.d.ts +42 -1
  6. package/dist/types/edit/index.d.ts +2 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +8 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  9. package/dist/types/hashline/executor.d.ts +6 -3
  10. package/dist/types/lsp/index.d.ts +9 -1
  11. package/dist/types/mcp/client.d.ts +2 -1
  12. package/dist/types/mcp/oauth-discovery.d.ts +4 -3
  13. package/dist/types/mcp/timeout.d.ts +9 -0
  14. package/dist/types/mcp/types.d.ts +1 -1
  15. package/dist/types/sdk.d.ts +2 -0
  16. package/dist/types/session/streaming-output.d.ts +1 -1
  17. package/dist/types/task/index.d.ts +2 -0
  18. package/dist/types/task/types.d.ts +4 -0
  19. package/dist/types/tools/approval.d.ts +46 -0
  20. package/dist/types/tools/ask.d.ts +1 -0
  21. package/dist/types/tools/ast-edit.d.ts +2 -0
  22. package/dist/types/tools/ast-grep.d.ts +1 -0
  23. package/dist/types/tools/bash.d.ts +11 -1
  24. package/dist/types/tools/browser.d.ts +2 -0
  25. package/dist/types/tools/calculator.d.ts +1 -0
  26. package/dist/types/tools/checkpoint.d.ts +2 -0
  27. package/dist/types/tools/debug.d.ts +9 -1
  28. package/dist/types/tools/eval.d.ts +2 -0
  29. package/dist/types/tools/find.d.ts +10 -0
  30. package/dist/types/tools/gh.d.ts +2 -1
  31. package/dist/types/tools/hindsight-recall.d.ts +1 -0
  32. package/dist/types/tools/hindsight-reflect.d.ts +1 -0
  33. package/dist/types/tools/hindsight-retain.d.ts +1 -0
  34. package/dist/types/tools/inspect-image.d.ts +1 -0
  35. package/dist/types/tools/irc.d.ts +1 -0
  36. package/dist/types/tools/job.d.ts +1 -0
  37. package/dist/types/tools/read.d.ts +1 -0
  38. package/dist/types/tools/recipe/index.d.ts +1 -0
  39. package/dist/types/tools/render-mermaid.d.ts +1 -0
  40. package/dist/types/tools/resolve.d.ts +1 -0
  41. package/dist/types/tools/search-tool-bm25.d.ts +1 -0
  42. package/dist/types/tools/search.d.ts +1 -0
  43. package/dist/types/tools/ssh.d.ts +2 -0
  44. package/dist/types/tools/todo-write.d.ts +1 -0
  45. package/dist/types/tools/write.d.ts +2 -0
  46. package/dist/types/tools/yield.d.ts +1 -0
  47. package/dist/types/web/search/index.d.ts +1 -0
  48. package/package.json +7 -7
  49. package/src/cli/args.ts +14 -0
  50. package/src/cli/auth-broker-cli.ts +171 -22
  51. package/src/commands/auth-broker.ts +3 -0
  52. package/src/commands/launch.ts +16 -0
  53. package/src/config/mcp-schema.json +2 -2
  54. package/src/config/model-registry.ts +19 -4
  55. package/src/config/prompt-templates.ts +0 -125
  56. package/src/config/settings-schema.ts +59 -1
  57. package/src/config/settings.ts +2 -1
  58. package/src/dap/session.ts +35 -2
  59. package/src/discovery/builtin.ts +2 -2
  60. package/src/discovery/mcp-json.ts +1 -1
  61. package/src/edit/index.ts +26 -0
  62. package/src/edit/modes/patch.ts +1 -1
  63. package/src/edit/streaming.ts +12 -2
  64. package/src/exec/bash-executor.ts +6 -2
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +18 -14
  66. package/src/extensibility/custom-tools/types.ts +16 -2
  67. package/src/extensibility/extensions/wrapper.ts +36 -1
  68. package/src/extensibility/hooks/types.ts +8 -1
  69. package/src/hashline/apply.ts +47 -2
  70. package/src/hashline/executor.ts +46 -24
  71. package/src/internal-urls/docs-index.generated.ts +8 -7
  72. package/src/lsp/edits.ts +82 -29
  73. package/src/lsp/index.ts +38 -1
  74. package/src/lsp/utils.ts +1 -1
  75. package/src/main.ts +6 -0
  76. package/src/mcp/client.ts +8 -6
  77. package/src/mcp/oauth-discovery.ts +120 -32
  78. package/src/mcp/oauth-flow.ts +34 -6
  79. package/src/mcp/timeout.ts +59 -0
  80. package/src/mcp/transports/http.ts +42 -44
  81. package/src/mcp/transports/stdio.ts +8 -5
  82. package/src/mcp/types.ts +1 -1
  83. package/src/modes/components/hook-editor.ts +11 -3
  84. package/src/modes/components/mcp-add-wizard.ts +6 -2
  85. package/src/modes/components/model-selector.ts +33 -11
  86. package/src/modes/controllers/command-controller.ts +6 -4
  87. package/src/modes/controllers/mcp-command-controller.ts +8 -4
  88. package/src/prompts/review-custom-request.md +22 -0
  89. package/src/prompts/review-headless-request.md +16 -0
  90. package/src/prompts/review-request.md +2 -3
  91. package/src/prompts/system/project-prompt.md +4 -0
  92. package/src/prompts/tools/debug.md +1 -0
  93. package/src/prompts/tools/find.md +4 -2
  94. package/src/prompts/tools/hashline.md +43 -93
  95. package/src/sdk.ts +47 -73
  96. package/src/session/agent-session.ts +93 -27
  97. package/src/session/streaming-output.ts +1 -1
  98. package/src/slash-commands/helpers/usage-report.ts +3 -1
  99. package/src/task/executor.ts +11 -0
  100. package/src/task/index.ts +19 -0
  101. package/src/task/render.ts +12 -2
  102. package/src/task/types.ts +4 -0
  103. package/src/tools/approval.ts +185 -0
  104. package/src/tools/ask.ts +1 -0
  105. package/src/tools/ast-edit.ts +25 -1
  106. package/src/tools/ast-grep.ts +1 -0
  107. package/src/tools/bash.ts +69 -1
  108. package/src/tools/browser/tab-supervisor.ts +1 -1
  109. package/src/tools/browser.ts +15 -0
  110. package/src/tools/calculator.ts +1 -0
  111. package/src/tools/checkpoint.ts +2 -0
  112. package/src/tools/debug.ts +38 -0
  113. package/src/tools/eval.ts +15 -0
  114. package/src/tools/find.ts +17 -8
  115. package/src/tools/gh.ts +21 -1
  116. package/src/tools/hindsight-recall.ts +1 -0
  117. package/src/tools/hindsight-reflect.ts +1 -0
  118. package/src/tools/hindsight-retain.ts +1 -0
  119. package/src/tools/image-gen.ts +1 -0
  120. package/src/tools/inspect-image.ts +1 -0
  121. package/src/tools/irc.ts +1 -0
  122. package/src/tools/job.ts +1 -0
  123. package/src/tools/path-utils.ts +14 -1
  124. package/src/tools/read.ts +1 -0
  125. package/src/tools/recipe/index.ts +1 -0
  126. package/src/tools/render-mermaid.ts +1 -0
  127. package/src/tools/report-tool-issue.ts +1 -0
  128. package/src/tools/resolve.ts +1 -0
  129. package/src/tools/review.ts +1 -0
  130. package/src/tools/search-tool-bm25.ts +1 -0
  131. package/src/tools/search.ts +1 -0
  132. package/src/tools/ssh.ts +8 -0
  133. package/src/tools/todo-write.ts +1 -0
  134. package/src/tools/write.ts +12 -1
  135. package/src/tools/yield.ts +1 -0
  136. package/src/web/search/index.ts +2 -0
@@ -532,6 +532,11 @@ function createSubagentSettings(baseSettings: Settings): Settings {
532
532
  ...snapshot,
533
533
  "async.enabled": false,
534
534
  "bash.autoBackground.enabled": false,
535
+ // Subagents run headless — there is no UI to confirm prompts against, so
536
+ // the parent task approval is the authorization boundary. Use yolo mode
537
+ // to preserve unattended subagent execution while still honoring any
538
+ // tool-level safety override that can be handled before execution.
539
+ "tools.approvalMode": "yolo",
535
540
  });
536
541
  }
537
542
 
@@ -1148,6 +1153,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1148
1153
  if (model?.contextWindow && model.contextWindow > 0) {
1149
1154
  progress.contextWindow = model.contextWindow;
1150
1155
  }
1156
+ if (model) {
1157
+ progress.resolvedModel = explicitThinkingLevel
1158
+ ? `${model.provider}/${model.id}:${resolvedThinkingLevel}`
1159
+ : `${model.provider}/${model.id}`;
1160
+ }
1151
1161
  const effectiveThinkingLevel = explicitThinkingLevel
1152
1162
  ? resolvedThinkingLevel
1153
1163
  : (thinkingLevel ?? resolvedThinkingLevel);
@@ -1606,6 +1616,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1606
1616
  contextTokens: progress.contextTokens,
1607
1617
  contextWindow: progress.contextWindow,
1608
1618
  modelOverride,
1619
+ resolvedModel: progress.resolvedModel,
1609
1620
  error: exitCode !== 0 && stderr ? stderr : undefined,
1610
1621
  aborted: wasAborted,
1611
1622
  abortReason: finalAbortReason,
package/src/task/index.ts CHANGED
@@ -27,6 +27,7 @@ import planModeSubagentPrompt from "../prompts/system/plan-mode-subagent.md" wit
27
27
  import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
28
28
  import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
29
29
  import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
30
+ import { truncateForPrompt } from "../tools/approval";
30
31
  import { formatBytes, formatDuration } from "../tools/render-utils";
31
32
  import {
32
33
  type AgentDefinition,
@@ -214,6 +215,24 @@ function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams):
214
215
  */
215
216
  export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetails, Theme> {
216
217
  readonly name = "task";
218
+ readonly approval = "exec" as const;
219
+ readonly formatApprovalDetails = (args: unknown): string[] => {
220
+ const params = args as Partial<TaskParams>;
221
+ const lines: string[] = [];
222
+ if (typeof params.agent === "string") {
223
+ lines.push(`Agent: ${truncateForPrompt(params.agent)}`);
224
+ }
225
+ const tasks = Array.isArray(params.tasks) ? params.tasks : [];
226
+ const firstTask = tasks[0];
227
+ if (firstTask) {
228
+ lines.push(`Task: ${truncateForPrompt(firstTask.id)}`);
229
+ lines.push(`Assignment:\n${truncateForPrompt(firstTask.assignment)}`);
230
+ if (tasks.length > 1) {
231
+ lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`);
232
+ }
233
+ }
234
+ return lines;
235
+ };
217
236
  readonly label = "Task";
218
237
  readonly summary = "Spawn a subagent to complete a parallel task";
219
238
  readonly strict = true;
@@ -8,6 +8,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
10
  import { formatNumber } from "@oh-my-pi/pi-utils";
11
+ import { settings } from "../config/settings";
11
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
13
  import type { Theme } from "../modes/theme/theme";
13
14
  import {
@@ -59,6 +60,8 @@ function appendAgentStats(
59
60
  contextTokens?: number;
60
61
  contextWindow?: number;
61
62
  cost: number;
63
+ resolvedModel?: string;
64
+ showResolvedModelBadge?: boolean;
62
65
  },
63
66
  theme: Theme,
64
67
  ): string {
@@ -83,6 +86,9 @@ function appendAgentStats(
83
86
  if (opts.cost > 0) {
84
87
  line += `${theme.sep.dot}${theme.fg("statusLineCost", `$${opts.cost.toFixed(2)}`)}`;
85
88
  }
89
+ if (opts.resolvedModel && opts.showResolvedModelBadge) {
90
+ line += `${theme.sep.dot}${theme.fg("dim", truncateToWidth(replaceTabs(opts.resolvedModel), 30))}`;
91
+ }
86
92
  return line;
87
93
  }
88
94
 
@@ -564,14 +570,15 @@ function renderAgentProgress(
564
570
  statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`;
565
571
  }
566
572
 
573
+ const showBadge = settings.get("task.showResolvedModelBadge");
567
574
  if (progress.status === "running") {
568
575
  if (!description) {
569
576
  const taskPreview = truncateToWidth(progress.assignment ?? progress.task, 40);
570
577
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
571
578
  }
572
- statusLine = appendAgentStats(statusLine, progress, theme);
579
+ statusLine = appendAgentStats(statusLine, { ...progress, showResolvedModelBadge: showBadge }, theme);
573
580
  } else if (progress.status === "completed") {
574
- statusLine = appendAgentStats(statusLine, progress, theme);
581
+ statusLine = appendAgentStats(statusLine, { ...progress, showResolvedModelBadge: showBadge }, theme);
575
582
  }
576
583
 
577
584
  lines.push(statusLine);
@@ -838,6 +845,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
838
845
  iconColor,
839
846
  theme,
840
847
  )}`;
848
+ const showBadge = settings.get("task.showResolvedModelBadge");
841
849
  statusLine = appendAgentStats(
842
850
  statusLine,
843
851
  {
@@ -845,6 +853,8 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
845
853
  contextTokens: result.contextTokens,
846
854
  contextWindow: result.contextWindow,
847
855
  cost: result.usage?.cost.total ?? 0,
856
+ resolvedModel: result.resolvedModel,
857
+ showResolvedModelBadge: showBadge,
848
858
  },
849
859
  theme,
850
860
  );
package/src/task/types.ts CHANGED
@@ -210,6 +210,8 @@ export interface AgentProgress {
210
210
  cost: number;
211
211
  durationMs: number;
212
212
  modelOverride?: string | string[];
213
+ /** Resolved model display string in the form `<provider>/<id>`, optionally suffixed with `:<thinkingLevel>` when the level was set explicitly. Undefined when the model could not be resolved. */
214
+ resolvedModel?: string;
213
215
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
214
216
  extractedToolData?: Record<string, unknown[]>;
215
217
  /**
@@ -268,6 +270,8 @@ export interface SingleResult {
268
270
  /** Model's context window in tokens, when known. */
269
271
  contextWindow?: number;
270
272
  modelOverride?: string | string[];
273
+ /** Resolved model display string in the form `<provider>/<id>`, optionally suffixed with `:<thinkingLevel>` when the level was set explicitly. Omitted from tool-result JSON when undefined to keep wire payloads small. */
274
+ resolvedModel?: string;
271
275
  error?: string;
272
276
  aborted?: boolean;
273
277
  abortReason?: string;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Tool approval resolution.
3
+ *
4
+ * Approval policy is declared by each tool. This module only knows how to:
5
+ * - normalize user `tools.approval.<tool>: allow | deny | prompt` overrides,
6
+ * - compare a tool capability tier against the active approval mode,
7
+ * - format the generic approval prompt body.
8
+ */
9
+ import type { AgentTool, ToolApprovalDecision, ToolTier } from "@oh-my-pi/pi-agent-core";
10
+
11
+ export type { ToolApproval, ToolApprovalDecision, ToolTier } from "@oh-my-pi/pi-agent-core";
12
+
13
+ export type ApprovalPolicy = "allow" | "deny" | "prompt";
14
+ export type ApprovalMode = "always-ask" | "write" | "yolo";
15
+
16
+ type ApprovalSubject = Pick<AgentTool, "name" | "approval" | "formatApprovalDetails">;
17
+
18
+ export interface ResolvedApproval {
19
+ policy: ApprovalPolicy;
20
+ tier: ToolTier;
21
+ reason?: string;
22
+ override: boolean;
23
+ }
24
+
25
+ const POLICY_VALUES: ReadonlySet<ApprovalPolicy> = new Set(["allow", "deny", "prompt"]);
26
+ const TIER_VALUES: ReadonlySet<ToolTier> = new Set(["read", "write", "exec"]);
27
+
28
+ const TIER_RANK: Record<ToolTier, number> = {
29
+ read: 0,
30
+ write: 1,
31
+ exec: 2,
32
+ };
33
+
34
+ const APPROVAL_MODE_MAX_TIER: Record<ApprovalMode, ToolTier> = {
35
+ "always-ask": "read",
36
+ write: "write",
37
+ yolo: "exec",
38
+ };
39
+
40
+ const DEFAULT_PROMPT_TRUNCATE_CHARS = 2000;
41
+
42
+ /** Best-effort conversion of an arbitrary user-supplied value to a policy. */
43
+ function normalizePolicy(value: unknown): ApprovalPolicy | undefined {
44
+ if (typeof value !== "string") return undefined;
45
+ const lowered = value.trim().toLowerCase();
46
+ return POLICY_VALUES.has(lowered as ApprovalPolicy) ? (lowered as ApprovalPolicy) : undefined;
47
+ }
48
+
49
+ function isToolTier(value: unknown): value is ToolTier {
50
+ return typeof value === "string" && TIER_VALUES.has(value as ToolTier);
51
+ }
52
+
53
+ function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> {
54
+ if (isToolTier(value)) {
55
+ return { tier: value, override: false };
56
+ }
57
+
58
+ if (value && typeof value === "object" && !Array.isArray(value)) {
59
+ const record = value as Record<string, unknown>;
60
+ const tier = isToolTier(record.tier) ? record.tier : "exec";
61
+ const reason = typeof record.reason === "string" && record.reason.length > 0 ? record.reason : undefined;
62
+ return {
63
+ tier,
64
+ override: record.override === true,
65
+ ...(reason ? { reason } : {}),
66
+ };
67
+ }
68
+
69
+ return { tier: "exec", override: false };
70
+ }
71
+
72
+ function getToolDecision(tool: ApprovalSubject, args: unknown): Omit<ResolvedApproval, "policy"> {
73
+ const approval = tool.approval;
74
+ const decision: ToolApprovalDecision | undefined = typeof approval === "function" ? approval(args) : approval;
75
+ return normalizeDecision(decision);
76
+ }
77
+
78
+ function modeApprovesTier(mode: ApprovalMode, tier: ToolTier): boolean {
79
+ return TIER_RANK[tier] <= TIER_RANK[APPROVAL_MODE_MAX_TIER[mode]];
80
+ }
81
+
82
+ /**
83
+ * Resolve approval policy for a tool call.
84
+ *
85
+ * Resolution order:
86
+ * 1. Tool `approval(args)` decision, defaulting to tier "exec" when omitted.
87
+ * 2. User per-tool override, if set and valid.
88
+ * 3. Active mode tier comparison.
89
+ *
90
+ * Tool decisions with `override: true` force a prompt in every mode unless the
91
+ * user explicitly denies the tool; deny remains the strongest policy.
92
+ */
93
+ export function resolveApproval(
94
+ tool: ApprovalSubject,
95
+ args: unknown,
96
+ mode: ApprovalMode,
97
+ userConfig: Record<string, unknown> = {},
98
+ ): ResolvedApproval {
99
+ const decision = getToolDecision(tool, args);
100
+ const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined;
101
+
102
+ if (decision.override) {
103
+ if (userPolicy === "deny") {
104
+ return { policy: "deny", tier: decision.tier, override: true };
105
+ }
106
+ return {
107
+ policy: "prompt",
108
+ tier: decision.tier,
109
+ override: true,
110
+ ...(decision.reason ? { reason: decision.reason } : {}),
111
+ };
112
+ }
113
+
114
+ if (userPolicy) {
115
+ return { policy: userPolicy, tier: decision.tier, override: false };
116
+ }
117
+
118
+ if (modeApprovesTier(mode, decision.tier)) {
119
+ return { policy: "allow", tier: decision.tier, override: false };
120
+ }
121
+
122
+ return {
123
+ policy: "prompt",
124
+ tier: decision.tier,
125
+ override: false,
126
+ ...(decision.reason ? { reason: decision.reason } : {}),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Check if a tool call requires user approval.
132
+ *
133
+ * @throws Error if policy is 'deny'
134
+ * @returns Object with required flag and optional reason for the prompt
135
+ */
136
+ export function requiresApproval(
137
+ tool: ApprovalSubject,
138
+ args: unknown,
139
+ mode: ApprovalMode,
140
+ userConfig: Record<string, unknown> = {},
141
+ ): { required: boolean; reason?: string } {
142
+ const { policy, reason } = resolveApproval(tool, args, mode, userConfig);
143
+
144
+ if (policy === "deny") {
145
+ throw new Error(
146
+ `Tool "${tool.name}" is blocked by user policy.\n` +
147
+ `To allow: remove "tools.approval.${tool.name}: deny" from config.`,
148
+ );
149
+ }
150
+
151
+ if (policy === "prompt") return { required: true, reason };
152
+ return { required: false };
153
+ }
154
+
155
+ export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string {
156
+ if (value.length <= maxChars) return value;
157
+ const omitted = value.length - maxChars;
158
+ return `${value.slice(0, maxChars)}… (${omitted} chars truncated)`;
159
+ }
160
+
161
+ /**
162
+ * Format the approval prompt body shown to the user.
163
+ */
164
+ export function formatApprovalPrompt(tool: ApprovalSubject, args: unknown, reason?: string): string {
165
+ const lines = [`Allow tool: ${tool.name}`];
166
+
167
+ if (tool.name.startsWith("mcp__") && tool.approval === undefined) {
168
+ lines.push("Origin: MCP server tool");
169
+ }
170
+
171
+ if (reason) {
172
+ lines.push(`Reason: ${reason}`);
173
+ }
174
+
175
+ const details = tool.formatApprovalDetails?.(args);
176
+ if (typeof details === "string") {
177
+ if (details.length > 0) lines.push(details);
178
+ } else if (Array.isArray(details)) {
179
+ for (const detail of details) {
180
+ if (detail.length > 0) lines.push(detail);
181
+ }
182
+ }
183
+
184
+ return lines.join("\n");
185
+ }
package/src/tools/ask.ts CHANGED
@@ -378,6 +378,7 @@ type AskParams = AskToolInput;
378
378
  */
379
379
  export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
380
380
  readonly name = "ask";
381
+ readonly approval = "read" as const;
381
382
  readonly label = "Ask";
382
383
  readonly summary = "Ask the user a clarifying question";
383
384
  readonly description: string;
@@ -12,10 +12,11 @@ import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text
12
12
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
13
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
14
  import type { ToolSession } from ".";
15
+ import { truncateForPrompt } from "./approval";
15
16
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
17
  import { formatGroupedFiles } from "./grouped-file-output";
17
18
  import type { OutputMeta } from "./output-meta";
18
- import { resolveToolSearchScope } from "./path-utils";
19
+ import { isInternalUrlPath, resolveToolSearchScope } from "./path-utils";
19
20
  import {
20
21
  appendParseErrorsBulletList,
21
22
  capParseErrors,
@@ -162,6 +163,29 @@ export interface AstEditToolDetails {
162
163
 
163
164
  export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
164
165
  readonly name = "ast_edit";
166
+ readonly approval = (args: unknown) => {
167
+ const paths = Array.isArray((args as Partial<z.infer<typeof astEditSchema>>).paths)
168
+ ? ((args as Partial<z.infer<typeof astEditSchema>>).paths as string[])
169
+ : [];
170
+ return paths.length > 0 && paths.every(path => isInternalUrlPath(path)) ? "read" : "write";
171
+ };
172
+ readonly formatApprovalDetails = (args: unknown): string[] => {
173
+ const params = args as Partial<z.infer<typeof astEditSchema>>;
174
+ const lines: string[] = [];
175
+ const ops = Array.isArray(params.ops) ? params.ops : [];
176
+ const firstOp = ops[0];
177
+ if (firstOp) {
178
+ lines.push(`Pattern: ${truncateForPrompt(firstOp.pat)}`);
179
+ lines.push(`Replacement: ${truncateForPrompt(firstOp.out)}`);
180
+ if (ops.length > 1) {
181
+ lines.push(`+${ops.length - 1} more op${ops.length === 2 ? "" : "s"}`);
182
+ }
183
+ }
184
+ if (Array.isArray(params.paths) && params.paths.length > 0) {
185
+ lines.push(`Paths: ${truncateForPrompt(params.paths.join(", "))}`);
186
+ }
187
+ return lines;
188
+ };
165
189
  readonly label = "AST Edit";
166
190
  readonly summary = "Perform AST-aware code edits (structural refactoring)";
167
191
  readonly description: string;
@@ -122,6 +122,7 @@ export interface AstGrepToolDetails {
122
122
 
123
123
  export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
124
124
  readonly name = "ast_grep";
125
+ readonly approval = "read" as const;
125
126
  readonly label = "AST Grep";
126
127
  readonly summary = "Search code with AST patterns (structural grep)";
127
128
  readonly description: string;
package/src/tools/bash.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import * as fs from "node:fs";
2
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import type {
3
+ AgentTool,
4
+ AgentToolContext,
5
+ AgentToolResult,
6
+ AgentToolUpdateCallback,
7
+ ToolApprovalDecision,
8
+ } from "@oh-my-pi/pi-agent-core";
3
9
  import type { Component } from "@oh-my-pi/pi-tui";
4
10
  import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
11
  import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -17,6 +23,7 @@ import { renderStatusLine } from "../tui";
17
23
  import { CachedOutputBlock } from "../tui/output-block";
18
24
  import { getSixelLineMask } from "../utils/sixel";
19
25
  import type { ToolSession } from ".";
26
+ import { truncateForPrompt } from "./approval";
20
27
  import { applyBashFixups } from "./bash-command-fixup";
21
28
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
22
29
  import { checkBashInterception } from "./bash-interceptor";
@@ -34,6 +41,54 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
34
41
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
42
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
36
43
 
44
+ /**
45
+ * Bash patterns that force an approval prompt even in yolo mode.
46
+ *
47
+ * Kept intentionally tight — the cost of a false positive is one extra prompt;
48
+ * the cost of a false negative is data loss or a compromised host. New patterns
49
+ * should target shapes that are virtually never legitimate in automation.
50
+ */
51
+ export const CRITICAL_BASH_PATTERNS = [
52
+ // Recursive destruction.
53
+ /\brm\s+-[a-z]*[rRfF][a-z]*\s+\//i, // rm -rf /, rm -fr /, rm -r /, rm -f /…
54
+ /\bsudo\s+rm\b/i, // any `sudo rm`.
55
+ /\bchmod\s+-R\s+[0-7]+\s+\//i, // `chmod -R 777 /`.
56
+ /\bchmod\s+-R\s+[ugoa+\-=rwxXst,]+\s+\//, // `chmod -R u+x /`, `chmod -R u+rwx,o+w /etc` (symbolic mode, root target).
57
+ /\bchown\s+-R\s+\S+\s+\//i, // `chown -R user /`.
58
+
59
+ // Fork bomb (a few common spacings).
60
+ /:\(\)\s*\{\s*:\s*\|\s*:/i,
61
+
62
+ // Disk / filesystem destruction.
63
+ />\s*\/dev\/sd[a-z]/i, // write to disk device.
64
+ /\bmkfs(\.|\b)/i, // format filesystem.
65
+ /\bdd\s+if=.+of=\/dev\//i, // dd to a device.
66
+ /\bshred\s+\/dev\//i,
67
+ /\bcryptsetup\b/i,
68
+
69
+ // System-config destruction.
70
+ />\s*\/etc\/(?:passwd|shadow|sudoers)\b/i,
71
+ /\btee\s+(?:-a\s+)?\/etc\/(?:passwd|shadow|sudoers)\b/i, // `tee /etc/passwd`, `tee -a /etc/sudoers`.
72
+
73
+ // Remote-fetch-then-execute (curl/wget piped to a shell or process-subbed).
74
+ /\b(?:curl|wget|fetch)\b[^|]*\|\s*(?:bash|sh|zsh|fish)\b/i,
75
+ // Process-sub variants — `bash <(curl …)`, `source <(curl …)`, `. <(curl …)`. `.` and `source` are
76
+ // anchored to a command boundary so `find . -name` and similar don't false-positive.
77
+ /(?:^|[\s;&|(])(?:bash|sh|zsh|source|\.)\s+<\(\s*(?:curl|wget|fetch)\b/i,
78
+ // `eval "$(curl …)"` / `eval $(curl …)` / `eval \`curl …\``.
79
+ /\beval\s+["'`]?\$\(\s*(?:curl|wget|fetch)\b|\beval\s+`\s*(?:curl|wget|fetch)\b/i,
80
+
81
+ // Process/host control.
82
+ /\bkill\s+-9\s+1\b/, // kill PID 1.
83
+ // Process/host control — must sit at command position so `npm run reboot-tests`
84
+ // or `echo 'shutdown the queue'` don't false-positive.
85
+ /(?:^|[\s;&|(])(?:shutdown|poweroff|reboot|halt)(?:\s|$|[;|&])/i,
86
+ /(?:^|[\s;&|(])init\s+0\b/i,
87
+
88
+ // Network-shell exfil.
89
+ /\bnc\b[^|;]*\s-[a-zA-Z]*[ec][a-zA-Z]*\s/i, // `nc -e` / `nc -c`.
90
+ ] as const;
91
+
37
92
  async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
38
93
  try {
39
94
  const alloc = await session.allocateOutputArtifact?.("bash-original");
@@ -224,6 +279,19 @@ function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutS
224
279
  */
225
280
  export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
226
281
  readonly name = "bash";
282
+ readonly approval = (args: unknown): ToolApprovalDecision => {
283
+ const rawCommand = (args as Partial<BashToolInput>).command;
284
+ const command = typeof rawCommand === "string" ? rawCommand : "";
285
+ if (command !== "" && CRITICAL_BASH_PATTERNS.some(pattern => pattern.test(command))) {
286
+ return { tier: "exec", override: true, reason: "Critical pattern detected" };
287
+ }
288
+ return "exec";
289
+ };
290
+ readonly formatApprovalDetails = (args: unknown): string[] => {
291
+ const rawCommand = (args as Partial<BashToolInput>).command;
292
+ const command = typeof rawCommand === "string" ? rawCommand : "(missing)";
293
+ return [`Command: ${truncateForPrompt(command)}`];
294
+ };
227
295
  readonly label = "Bash";
228
296
  readonly loadMode = "essential";
229
297
  readonly description: string;
@@ -105,7 +105,7 @@ export async function acquireTab(
105
105
  await runInTabWithSnapshot(
106
106
  name,
107
107
  {
108
- code: `await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "networkidle2")} });`,
108
+ code: `await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "load")} });`,
109
109
  timeoutMs: opts.timeoutMs,
110
110
  signal: opts.signal,
111
111
  },
@@ -3,6 +3,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import * as z from "zod/v4";
4
4
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
5
5
  import type { ToolSession } from "../sdk";
6
+ import { truncateForPrompt } from "./approval";
6
7
  import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
7
8
  import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
8
9
  import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor";
@@ -87,6 +88,20 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
87
88
  */
88
89
  export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
89
90
  readonly name = "browser";
91
+ readonly approval = "exec" as const;
92
+ readonly formatApprovalDetails = (args: unknown): string[] => {
93
+ const params = args as Partial<BrowserParams>;
94
+ const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
95
+ const tabName = typeof params.name === "string" ? params.name : DEFAULT_TAB_NAME;
96
+ lines.push(`Tab: ${truncateForPrompt(tabName)}`);
97
+ if (typeof params.url === "string" && params.url.length > 0) {
98
+ lines.push(`URL: ${truncateForPrompt(params.url)}`);
99
+ }
100
+ if (typeof params.code === "string" && params.code.length > 0) {
101
+ lines.push(`Code:\n${truncateForPrompt(params.code)}`);
102
+ }
103
+ return lines;
104
+ };
90
105
  readonly label = "Browser";
91
106
  readonly loadMode = "discoverable";
92
107
  readonly summary = "Control a headless browser to navigate and interact with web pages";
@@ -396,6 +396,7 @@ type CalculatorParams = z.infer<typeof calculatorSchema>;
396
396
  */
397
397
  export class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
398
398
  readonly name = "calc";
399
+ readonly approval = "read" as const;
399
400
  readonly label = "Calc";
400
401
  readonly summary = "Evaluate a mathematical expression";
401
402
  readonly loadMode = "discoverable";
@@ -48,6 +48,7 @@ function isTopLevelSession(session: ToolSession): boolean {
48
48
 
49
49
  export class CheckpointTool implements AgentTool<typeof checkpointSchema, CheckpointToolDetails> {
50
50
  readonly name = "checkpoint";
51
+ readonly approval = "read" as const;
51
52
  readonly label = "Checkpoint";
52
53
  readonly summary = "Create a git-based checkpoint to save and restore session state";
53
54
  readonly description: string;
@@ -93,6 +94,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
93
94
 
94
95
  export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDetails> {
95
96
  readonly name = "rewind";
97
+ readonly approval = "read" as const;
96
98
  readonly label = "Rewind";
97
99
  readonly summary = "Rewind to a previously created checkpoint";
98
100
  readonly description: string;
@@ -5,6 +5,7 @@ import type {
5
5
  AgentToolResult,
6
6
  AgentToolUpdateCallback,
7
7
  RenderResultOptions,
8
+ ToolApprovalDecision,
8
9
  } from "@oh-my-pi/pi-agent-core";
9
10
  import { type Component, Text } from "@oh-my-pi/pi-tui";
10
11
  import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
@@ -37,6 +38,7 @@ import debugDescription from "../prompts/tools/debug.md" with { type: "text" };
37
38
  import { renderStatusLine } from "../tui";
38
39
  import { CachedOutputBlock } from "../tui/output-block";
39
40
  import type { ToolSession } from ".";
41
+ import { truncateForPrompt } from "./approval";
40
42
  import type { OutputMeta } from "./output-meta";
41
43
  import { formatPathRelativeToCwd, resolveToCwd } from "./path-utils";
42
44
  import {
@@ -51,6 +53,23 @@ import { ToolError } from "./tool-errors";
51
53
  import { toolResult } from "./tool-result";
52
54
  import { clampTimeout } from "./tool-timeouts";
53
55
 
56
+ /**
57
+ * DAP debug actions that only read program state (no mutation, no execution).
58
+ * Execution-side actions (`launch`, `attach`, `continue`, `step_*`, `pause`,
59
+ * `evaluate`, breakpoint mutations, memory writes) are exec-tier.
60
+ */
61
+ export const DEBUG_READONLY_ACTIONS: ReadonlySet<string> = new Set([
62
+ "output",
63
+ "threads",
64
+ "stack_trace",
65
+ "scopes",
66
+ "variables",
67
+ "disassemble",
68
+ "read_memory",
69
+ "loaded_sources",
70
+ "modules",
71
+ "sessions",
72
+ ]);
54
73
  const debugSchema = z.object({
55
74
  action: z.enum([
56
75
  "launch",
@@ -609,6 +628,19 @@ export const debugToolRenderer = {
609
628
 
610
629
  export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails> {
611
630
  readonly name = "debug";
631
+ readonly approval = (args: unknown): ToolApprovalDecision => {
632
+ const rawAction = (args as Partial<DebugParams>).action;
633
+ const action = typeof rawAction === "string" ? rawAction.toLowerCase() : "";
634
+ return DEBUG_READONLY_ACTIONS.has(action) ? "read" : "exec";
635
+ };
636
+ readonly formatApprovalDetails = (args: unknown): string[] => {
637
+ const params = args as Partial<DebugParams>;
638
+ const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
639
+ if (typeof params.program === "string" && params.program.length > 0) {
640
+ lines.push(`Program: ${truncateForPrompt(params.program)}`);
641
+ }
642
+ return lines;
643
+ };
612
644
  readonly label = "Debug";
613
645
  readonly summary = "Debug a running process with DAP (debugger adapter protocol)";
614
646
  readonly description: string;
@@ -647,6 +679,9 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
647
679
  await validateLaunchProgram(program, commandCwd);
648
680
  const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
649
681
  if (!adapter) {
682
+ if (params.adapter === "debugpy") {
683
+ throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
684
+ }
650
685
  throw new ToolError(
651
686
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
652
687
  );
@@ -667,6 +702,9 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
667
702
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
668
703
  const adapter = selectAttachAdapter(commandCwd, params.adapter, params.port);
669
704
  if (!adapter) {
705
+ if (params.adapter === "debugpy") {
706
+ throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
707
+ }
670
708
  throw new ToolError(
671
709
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
672
710
  );
package/src/tools/eval.ts CHANGED
@@ -15,6 +15,7 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
15
15
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
16
16
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
17
17
  import { resolveEvalBackends, type ToolSession } from ".";
18
+ import { truncateForPrompt } from "./approval";
18
19
  import {
19
20
  formatStyledTruncationWarning,
20
21
  resolveOutputMaxColumns,
@@ -202,6 +203,20 @@ async function resolveBackend(session: ToolSession, language: EvalLanguage): Pro
202
203
 
203
204
  export class EvalTool implements AgentTool<typeof evalSchema> {
204
205
  readonly name = "eval";
206
+ readonly approval = "exec" as const;
207
+ readonly formatApprovalDetails = (args: unknown): string[] => {
208
+ const params = args as Partial<EvalToolParams>;
209
+ const cells = Array.isArray(params.cells) ? params.cells : [];
210
+ const firstCell = cells[0] as Partial<EvalCellInput> | undefined;
211
+ if (!firstCell) return [];
212
+ const language = typeof firstCell.language === "string" ? firstCell.language : "(missing)";
213
+ const code = typeof firstCell.code === "string" ? firstCell.code : "";
214
+ const lines = [`Language: ${language}`, `Code:\n${truncateForPrompt(code)}`];
215
+ if (cells.length > 1) {
216
+ lines.push(`+${cells.length - 1} more cell${cells.length === 2 ? "" : "s"}`);
217
+ }
218
+ return lines;
219
+ };
205
220
  readonly summary = "Execute Python or JavaScript code in an in-process eval backend";
206
221
  readonly loadMode = "discoverable";
207
222
  readonly label = "Eval";