@dyyz1993/pi-coding-agent 0.74.25 → 0.74.28

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 (29) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/core/agent-session.d.ts +3 -0
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +45 -0
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/extensions/types.d.ts +17 -1
  7. package/dist/core/extensions/types.d.ts.map +1 -1
  8. package/dist/core/extensions/types.js.map +1 -1
  9. package/dist/core/session-manager.d.ts +5 -0
  10. package/dist/core/session-manager.d.ts.map +1 -1
  11. package/dist/core/session-manager.js +8 -0
  12. package/dist/core/session-manager.js.map +1 -1
  13. package/dist/extensions/hooks-engine/index.ts +104 -16
  14. package/dist/extensions/lsp/lsp/index.ts +40 -37
  15. package/dist/extensions/output-guard/index.ts +126 -64
  16. package/dist/extensions/rules-engine/index.js +64 -22
  17. package/dist/extensions/rules-engine/index.ts +86 -16
  18. package/dist/extensions/rules-engine/types.d.ts +12 -2
  19. package/dist/extensions/rules-engine/types.d.ts.map +1 -1
  20. package/dist/extensions/rules-engine/types.js.map +1 -1
  21. package/dist/extensions/rules-engine/types.ts +13 -2
  22. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  23. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  24. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  25. package/examples/extensions/sandbox/package-lock.json +2 -2
  26. package/examples/extensions/sandbox/package.json +1 -1
  27. package/examples/extensions/with-deps/package-lock.json +2 -2
  28. package/examples/extensions/with-deps/package.json +1 -1
  29. package/package.json +4 -4
@@ -7,7 +7,7 @@
7
7
  * Supported events: tool_call, tool_result, agent_start, agent_end
8
8
  * Supported hook types: command (spawn process), prompt (inject text)
9
9
  *
10
- * Command hooks: exit code 2 = block operation, 0 = allow
10
+ * Command hooks: exit code 2 = block operation, 0 = allow, 3 = ask user
11
11
  * Prompt hooks: text injected into the conversation
12
12
  */
13
13
 
@@ -23,7 +23,18 @@ const EVENT_MAP: Record<string, string> = {
23
23
  session_shutdown: "on_session_end",
24
24
  };
25
25
 
26
- function parseHooks(raw: string | undefined): AgentHooks | null {
26
+ // Export types for testing
27
+ export type { HookResult };
28
+
29
+ interface HookResult {
30
+ action: "allow" | "deny" | "ask";
31
+ reason?: string;
32
+ question?: string;
33
+ options?: string[];
34
+ message?: string;
35
+ }
36
+
37
+ export function parseHooks(raw: string | undefined): AgentHooks | null {
27
38
  if (!raw) return null;
28
39
  try {
29
40
  return JSON.parse(raw);
@@ -32,21 +43,38 @@ function parseHooks(raw: string | undefined): AgentHooks | null {
32
43
  }
33
44
  }
34
45
 
35
- function matchesCondition(condition: string | undefined, event: Record<string, unknown>): boolean {
46
+ export function matchesCondition(condition: string | undefined, event: Record<string, unknown>): boolean {
36
47
  if (!condition) return true;
37
48
  const toolName = (event.toolName as string) ?? "";
38
- const parts = condition.split("|").map(s => s.trim());
49
+ const parts = condition.split("|").map((s) => s.trim());
39
50
  return parts.includes(toolName);
40
51
  }
41
52
 
42
- async function executeCommand(command: string, event: Record<string, unknown>, timeout = 5000): Promise<number> {
53
+ export async function executeCommand(
54
+ command: string,
55
+ event: Record<string, unknown>,
56
+ timeout = 5000,
57
+ ): Promise<{ exitCode: number; stdout: string }> {
43
58
  return new Promise((resolve) => {
44
59
  const toolName = (event.toolName as string) ?? "";
60
+ const toolCallId = (event.toolCallId as string) ?? "";
45
61
  const input = event.input ?? {};
62
+ const vars = event.variables as Record<string, string> | undefined;
63
+
46
64
  const env: Record<string, string> = {
47
65
  ...process.env as Record<string, string>,
66
+ // Tool context
48
67
  PI_HOOK_TOOL: toolName,
49
- PI_HOOK_EVENT: JSON.stringify(input),
68
+ PI_HOOK_TOOL_CALL_ID: toolCallId,
69
+ PI_HOOK_INPUT: JSON.stringify(input),
70
+ // Agent context (from event.variables)
71
+ PI_HOOK_AGENT_NAME: vars?.agentName ?? "",
72
+ PI_HOOK_PERMISSION_MODE: vars?.permissionMode ?? "",
73
+ PI_HOOK_ALLOWED_TOOLS: vars?.allowedTools ?? "",
74
+ PI_HOOK_DISALLOWED_TOOLS: vars?.disallowedTools ?? "",
75
+ // Session context (if available)
76
+ PI_HOOK_SESSION_ID: (event as any).sessionId ?? "",
77
+ PI_HOOK_CWD: (event as any).cwd ?? "",
50
78
  };
51
79
 
52
80
  const proc = spawn("sh", ["-c", command], {
@@ -54,29 +82,52 @@ async function executeCommand(command: string, event: Record<string, unknown>, t
54
82
  stdio: ["pipe", "pipe", "pipe"],
55
83
  });
56
84
 
85
+ let stdout = "";
86
+ let stderr = "";
87
+
88
+ proc.stdout?.on("data", (data) => {
89
+ stdout += String(data);
90
+ });
91
+
92
+ proc.stderr?.on("data", (data) => {
93
+ stderr += String(data);
94
+ });
95
+
57
96
  const timer = setTimeout(() => {
58
97
  proc.kill("SIGTERM");
59
- resolve(0);
98
+ resolve({ exitCode: 0, stdout: "" });
60
99
  }, timeout);
61
100
 
62
101
  proc.on("close", (code) => {
63
102
  clearTimeout(timer);
64
- resolve(code ?? 0);
103
+ resolve({ exitCode: code ?? 0, stdout });
65
104
  });
66
105
 
67
106
  proc.on("error", () => {
68
107
  clearTimeout(timer);
69
- resolve(0);
108
+ resolve({ exitCode: 0, stdout: "" });
70
109
  });
71
110
  });
72
111
  }
73
112
 
113
+ export function parseStdout(stdout: string): HookResult | null {
114
+ if (!stdout.trim()) {
115
+ return null;
116
+ }
117
+
118
+ try {
119
+ return JSON.parse(stdout.trim()) as HookResult;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
74
125
  export default function hooksEngine(pi: ExtensionAPI): void {
75
126
  const subscribe = (eventName: string) => {
76
127
  const hookKey = EVENT_MAP[eventName];
77
128
  if (!hookKey) return;
78
129
 
79
- pi.on(eventName, async (event: Record<string, unknown>) => {
130
+ pi.on(eventName, async (event: Record<string, unknown>, ctx: any) => {
80
131
  const vars = event.variables as Record<string, string> | undefined;
81
132
  if (!vars?.agentHooks) return undefined;
82
133
 
@@ -86,25 +137,62 @@ export default function hooksEngine(pi: ExtensionAPI): void {
86
137
  const eventHooks = hooks[hookKey] ?? hooks["*"] ?? [];
87
138
  if (eventHooks.length === 0) return undefined;
88
139
 
89
- const results: string[] = [];
140
+ const promptResults: string[] = [];
90
141
 
91
142
  for (const hook of eventHooks) {
92
143
  if (!matchesCondition(hook.if, event)) continue;
93
144
 
94
145
  if (hook.type === "command") {
95
- const code = await executeCommand(hook.command, event);
96
- if (code === 2) {
146
+ const { exitCode, stdout } = await executeCommand(hook.command, event);
147
+
148
+ if (exitCode === 0 && stdout.trim()) {
149
+ const parsed = parseStdout(stdout);
150
+ if (parsed?.action === "allow" && parsed.message) {
151
+ console.log("[hook] Context injection:", parsed.message);
152
+ } else if (!parsed && stdout.trim()) {
153
+ console.log("[hook] Message:", stdout.trim());
154
+ }
155
+ continue;
156
+ }
157
+
158
+ if (exitCode === 2) {
159
+ const parsed = parseStdout(stdout);
160
+ const reason = parsed?.reason ?? stdout.trim() ?? `[hook] Operation blocked by hook: ${hook.command}`;
97
161
  return {
98
162
  block: true,
99
- reason: `[hook] Operation blocked by hook: ${hook.command}`,
163
+ reason,
100
164
  };
101
165
  }
166
+
167
+ if (exitCode === 3) {
168
+ const parsed = parseStdout(stdout);
169
+ const question = parsed?.question ?? stdout.trim() ?? "Confirm this operation?";
170
+
171
+ if (ctx?.ui?.confirm) {
172
+ const confirmed = await ctx.ui.confirm(question, "no");
173
+ if (!confirmed) {
174
+ return {
175
+ block: true,
176
+ reason: "[hook] User denied the operation",
177
+ };
178
+ }
179
+ continue;
180
+ } else {
181
+ return {
182
+ block: true,
183
+ reason: "[hook] Ask confirmation not supported in this context",
184
+ };
185
+ }
186
+ }
187
+
188
+ continue;
102
189
  } else if (hook.type === "prompt") {
103
- results.push(hook.prompt);
190
+ promptResults.push(hook.prompt);
104
191
  }
105
192
  }
106
193
 
107
- if (results.length > 0) {
194
+ if (promptResults.length > 0) {
195
+ console.log("[hook] Prompts to inject:", promptResults);
108
196
  }
109
197
 
110
198
  return undefined;
@@ -96,46 +96,49 @@ export default function lspExtension(pi: ExtensionAPI): void {
96
96
  }
97
97
 
98
98
  pi.on("session_start", async (_event: any, ctx: any) => {
99
- const raw = pi.registerChannel("lsp");
99
+ try {
100
+ const raw = pi.registerChannel("lsp");
101
+ if (raw) {
102
+ lspChannel = createTypedChannel<LspChannelContract>(raw).server;
100
103
 
101
- if (raw) {
102
- lspChannel = createTypedChannel<LspChannelContract>(raw).server;
103
-
104
- lspChannel.handle("lsp.setMode", (params) => {
105
- const { mode: newMode } = params;
106
- const validModes: DiagnosticsModeName[] = ["agent_end", "edit_write", "disabled"];
107
- if (!validModes.includes(newMode as DiagnosticsModeName)) return { ok: false };
108
- mode.set(newMode as DiagnosticsModeName);
109
- const modeData = {
110
- event: "mode_changed" as const,
111
- timestamp: Date.now(),
112
- mode: mode.get(),
113
- };
114
- lspChannel?.emit("mode_changed", modeData);
115
- pi.appendEntry("lsp", modeData);
116
- return { ok: true, mode: mode.get() };
117
- });
104
+ lspChannel.handle("lsp.setMode", (params) => {
105
+ const { mode: newMode } = params;
106
+ const validModes: DiagnosticsModeName[] = ["agent_end", "edit_write", "disabled"];
107
+ if (!validModes.includes(newMode as DiagnosticsModeName)) return { ok: false };
108
+ mode.set(newMode as DiagnosticsModeName);
109
+ const modeData = {
110
+ event: "mode_changed" as const,
111
+ timestamp: Date.now(),
112
+ mode: mode.get(),
113
+ };
114
+ lspChannel?.emit("mode_changed", modeData);
115
+ pi.appendEntry("lsp", modeData);
116
+ return { ok: true, mode: mode.get() };
117
+ });
118
118
 
119
- lspChannel.handle("getActiveLanguages", () => {
120
- return { languages: getActiveLanguages() };
121
- });
119
+ lspChannel.handle("getActiveLanguages", () => {
120
+ return { languages: getActiveLanguages() };
121
+ });
122
122
 
123
- lspChannel.handle("getStatus", () => {
124
- const s = runtime.getStatus();
125
- return {
126
- state: s.state,
127
- servers: s.servers.map((srv) => ({
128
- name: srv.name,
129
- fileTypes: srv.fileTypes,
130
- state: srv.status.state,
131
- reason: srv.status.reason,
132
- transport: srv.status.transport,
133
- activeCommand: srv.status.activeCommand,
134
- configuredCommand: srv.status.configuredCommand,
135
- })),
136
- mode: mode.get(),
137
- };
138
- });
123
+ lspChannel.handle("getStatus", () => {
124
+ const s = runtime.getStatus();
125
+ return {
126
+ state: s.state,
127
+ servers: s.servers.map((srv) => ({
128
+ name: srv.name,
129
+ fileTypes: srv.fileTypes,
130
+ state: srv.status.state,
131
+ reason: srv.status.reason,
132
+ transport: srv.status.transport,
133
+ activeCommand: srv.status.activeCommand,
134
+ configuredCommand: srv.status.configuredCommand,
135
+ })),
136
+ mode: mode.get(),
137
+ };
138
+ });
139
+ }
140
+ } catch {
141
+ // registerChannel is only available in RPC mode; gracefully degrade in TUI/print mode
139
142
  }
140
143
 
141
144
  const config = configResolver.resolve();
@@ -1,31 +1,45 @@
1
1
  /**
2
2
  * Output Guard Extension - Global fallback truncation + tool limit optimization.
3
3
  *
4
- * Provides three capabilities:
4
+ * Aligns pi-momo-fork's truncation strategy with OpenCode's approach:
5
5
  *
6
- * 1. **Global truncation fallback**: Hooks into `tool_result` events. When a tool
7
- * (especially extension/plugin/MCP tools) returns output exceeding limits
8
- * without self-managing truncation, this extension truncates the output and
9
- * saves the full content to a temp file.
6
+ * OpenCode has a global truncation layer in `Tool.define()` that checks
7
+ * `metadata.truncated` - if undefined, applies 50KB/2000-line truncation
8
+ * and saves full output to disk. Plugin/MCP tools are wrapped in
9
+ * `fromPlugin()` with `Truncate.output()` built in.
10
10
  *
11
- * 2. **Tool limit optimization**: Hooks into `tool_call` events to enforce lower
12
- * result limits on find (1000 -> 100) and ls (500 -> 100), matching OpenCode's
13
- * defaults. Reduces unnecessary context consumption.
11
+ * Pi lacks this global layer. This extension fills the gap via `tool_result`
12
+ * event hooks, providing equivalent protection for:
13
+ * - Extension/plugin tools (no built-in truncation)
14
+ * - MCP tools (no built-in truncation)
15
+ * - Any future tool that forgets to self-manage
14
16
  *
15
- * 3. **PDF text extraction**: Registers a `pdf_read` tool that extracts text content
16
- * from PDF files using pdf-parse, since the built-in read tool does not support PDFs.
17
+ * Three capabilities:
17
18
  *
18
- * Configuration (via .pi/settings.json or global settings):
19
- * outputGuard.maxLines: number (default: 2000)
20
- * outputGuard.maxBytes: number (default: 51200 = 50KB)
21
- * outputGuard.findLimit: number (default: 100)
22
- * outputGuard.lsLimit: number (default: 100)
23
- * outputGuard.saveToFile: boolean (default: true - save truncated output to disk)
19
+ * 1. **Global truncation fallback**: Intercepts `tool_result` for tools that
20
+ * don't self-manage truncation. Applies 50KB/2000-line limit, saves full
21
+ * output to `<sessionDataDir>/tool-output/`, returns truncated preview
22
+ * with actionable file path hint.
23
+ *
24
+ * 2. **Tool limit optimization**: Intercepts `tool_call` to enforce lower
25
+ * result limits on find (1000 -> 100) and ls (500 -> 100), matching
26
+ * OpenCode's glob/ls defaults. Reduces unnecessary context consumption.
27
+ *
28
+ * 3. **PDF text extraction**: Registers a `pdf_read` tool that extracts text
29
+ * from PDF files. OpenCode sends PDFs as raw base64 to the model; Pi's
30
+ * read tool doesn't support PDFs at all. This tool uses pdf-parse for
31
+ * text extraction, which is more token-efficient than base64 encoding.
32
+ *
33
+ * Configuration (via .pi/settings.json `outputGuard` key):
34
+ * maxLines: number (default: 2000)
35
+ * maxBytes: number (default: 51200 = 50KB)
36
+ * findLimit: number (default: 100)
37
+ * lsLimit: number (default: 100)
38
+ * saveToFile: boolean (default: true)
24
39
  */
25
40
 
26
41
  import { randomBytes } from "node:crypto";
27
- import { createWriteStream, mkdirSync, existsSync } from "node:fs";
28
- import { writeFile } from "node:fs/promises";
42
+ import { mkdirSync, existsSync, writeFileSync as fsWriteFileSync } from "node:fs";
29
43
  import { tmpdir } from "node:os";
30
44
  import { join } from "node:path";
31
45
  import { Type } from "typebox";
@@ -39,14 +53,30 @@ import type {
39
53
  } from "@dyyz1993/pi-coding-agent";
40
54
 
41
55
  // ============================================================================
42
- // Configuration
56
+ // Constants
43
57
  // ============================================================================
44
58
 
59
+ /** Matches OpenCode's MAX_LINES */
45
60
  const DEFAULT_MAX_LINES = 2000;
61
+ /** Matches OpenCode's MAX_BYTES */
46
62
  const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
63
+ /** Matches OpenCode's glob limit of 100 */
47
64
  const DEFAULT_FIND_LIMIT = 100;
65
+ /** Matches OpenCode's ls limit of 100 */
48
66
  const DEFAULT_LS_LIMIT = 100;
49
67
 
68
+ /**
69
+ * Built-in tools that self-manage truncation.
70
+ * These tools set details.truncation and handle their own size limits,
71
+ * so the global fallback must skip them (matches OpenCode's
72
+ * `metadata.truncated !== undefined` check).
73
+ */
74
+ const SELF_MANAGED_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
75
+
76
+ // ============================================================================
77
+ // Configuration
78
+ // ============================================================================
79
+
50
80
  interface OutputGuardConfig {
51
81
  maxLines: number;
52
82
  maxBytes: number;
@@ -68,7 +98,7 @@ function loadConfig(ctx: ExtensionContext): OutputGuardConfig {
68
98
  }
69
99
 
70
100
  // ============================================================================
71
- // Truncation Logic
101
+ // Truncation Logic (mirrors OpenCode's Truncate.output)
72
102
  // ============================================================================
73
103
 
74
104
  interface TruncationInfo {
@@ -76,13 +106,17 @@ interface TruncationInfo {
76
106
  content: string;
77
107
  totalLines: number;
78
108
  totalBytes: number;
109
+ outputLines: number;
110
+ outputBytes: number;
79
111
  truncatedBy: "lines" | "bytes" | null;
80
112
  fullOutputPath?: string;
81
113
  }
82
114
 
83
115
  /**
84
- * Truncate text content from the tail (keep the end - more useful for tool output).
85
- * Saves full content to a temp file when truncation occurs.
116
+ * Truncate text content, keeping the tail (last N lines).
117
+ * Mirrors OpenCode's `Truncate.output()` with direction="tail".
118
+ * Saves full content to `<sessionDataDir>/tool-output/` when truncated
119
+ * (matches OpenCode's `<data-dir>/tool-output/` pattern).
86
120
  */
87
121
  function truncateOutput(
88
122
  content: string,
@@ -100,36 +134,39 @@ function truncateOutput(
100
134
  content,
101
135
  totalLines,
102
136
  totalBytes,
137
+ outputLines: totalLines,
138
+ outputBytes: totalBytes,
103
139
  truncatedBy: null,
104
140
  };
105
141
  }
106
142
 
107
- // Collect lines from the end
108
- const outputLines: string[] = [];
109
- let outputBytes = 0;
143
+ // Collect lines from the end (tail direction)
144
+ const outputLinesArr: string[] = [];
145
+ let outputBytesCount = 0;
110
146
  let truncatedBy: "lines" | "bytes" = "lines";
111
147
 
112
- for (let i = lines.length - 1; i >= 0 && outputLines.length < config.maxLines; i--) {
148
+ for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < config.maxLines; i--) {
113
149
  const line = lines[i];
114
- const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLines.length > 0 ? 1 : 0);
150
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
115
151
 
116
- if (outputBytes + lineBytes > config.maxBytes) {
152
+ if (outputBytesCount + lineBytes > config.maxBytes) {
117
153
  truncatedBy = "bytes";
118
154
  break;
119
155
  }
120
156
 
121
- outputLines.unshift(line);
122
- outputBytes += lineBytes;
157
+ outputLinesArr.unshift(line);
158
+ outputBytesCount += lineBytes;
123
159
  }
124
160
 
125
- if (outputLines.length >= config.maxLines && outputBytes <= config.maxBytes) {
161
+ if (outputLinesArr.length >= config.maxLines && outputBytesCount <= config.maxBytes) {
126
162
  truncatedBy = "lines";
127
163
  }
128
164
 
129
- const truncatedContent = outputLines.join("\n");
165
+ const truncatedContent = outputLinesArr.join("\n");
166
+ const finalOutputBytes = Buffer.byteLength(truncatedContent, "utf-8");
130
167
  let fullOutputPath: string | undefined;
131
168
 
132
- // Save full output to disk
169
+ // Save full output to disk (matches OpenCode's behavior)
133
170
  if (config.saveToFile) {
134
171
  fullOutputPath = saveFullOutput(content, ctx);
135
172
  }
@@ -139,38 +176,42 @@ function truncateOutput(
139
176
  content: truncatedContent,
140
177
  totalLines,
141
178
  totalBytes,
179
+ outputLines: outputLinesArr.length,
180
+ outputBytes: finalOutputBytes,
142
181
  truncatedBy,
143
182
  fullOutputPath,
144
183
  };
145
184
  }
146
185
 
147
186
  /**
148
- * Save full output content to a temp file.
187
+ * Save full output to disk.
188
+ * Uses sessionDataDir/tool-output/ to match OpenCode's <data-dir>/tool-output/.
189
+ * Falls back to tmpdir if sessionDataDir is unavailable.
149
190
  */
150
191
  function saveFullOutput(content: string, ctx: ExtensionContext): string | undefined {
151
192
  try {
152
- const id = randomBytes(8).toString("hex");
153
- const dir = join(tmpdir(), "pi-output-guard");
193
+ const id = `output-${Date.now()}-${randomBytes(4).toString("hex")}`;
194
+ // Prefer sessionDataDir if it's an absolute path (production),
195
+ // otherwise fall back to tmpdir (works reliably in tests too)
196
+ const rawBaseDir = ctx.sessionDataDir;
197
+ let baseDir: string;
198
+ if (rawBaseDir && rawBaseDir.startsWith("/")) {
199
+ baseDir = rawBaseDir;
200
+ } else {
201
+ baseDir = join(tmpdir(), "pi-output-guard");
202
+ }
203
+ const dir = join(baseDir, "tool-output");
154
204
  if (!existsSync(dir)) {
155
205
  mkdirSync(dir, { recursive: true });
156
206
  }
157
- const filePath = join(dir, `output-${id}.log`);
158
- writeFileSync(filePath, content);
207
+ const filePath = join(dir, `${id}.log`);
208
+ fsWriteFileSync(filePath, content);
159
209
  return filePath;
160
210
  } catch {
161
211
  return undefined;
162
212
  }
163
213
  }
164
214
 
165
- /**
166
- * Synchronous write for saveFullOutput.
167
- */
168
- function writeFileSync(filePath: string, content: string): void {
169
- const stream = createWriteStream(filePath);
170
- stream.write(content);
171
- stream.end();
172
- }
173
-
174
215
  // ============================================================================
175
216
  // Extension Entry Point
176
217
  // ============================================================================
@@ -178,6 +219,15 @@ function writeFileSync(filePath: string, content: string): void {
178
219
  export default function outputGuard(pi: ExtensionAPI) {
179
220
  // ------------------------------------------------------------------
180
221
  // 1. Global truncation fallback via tool_result hook
222
+ //
223
+ // Mirrors OpenCode's Tool.define() wrapper:
224
+ // if (result.metadata.truncated === undefined) {
225
+ // result.output = Truncate.output(result.output)
226
+ // }
227
+ //
228
+ // In pi, the equivalent is: if a tool's details doesn't have a
229
+ // truncation field AND the tool isn't a known self-managing tool,
230
+ // apply truncation.
181
231
  // ------------------------------------------------------------------
182
232
  pi.on("tool_result", async (event: ToolResultEvent, ctx: ExtensionContext): Promise<ToolResultEventResult | void> => {
183
233
  const config = loadConfig(ctx);
@@ -186,10 +236,12 @@ export default function outputGuard(pi: ExtensionAPI) {
186
236
  const textParts = event.content.filter((p): p is { type: "text"; text: string } => p.type === "text");
187
237
  if (textParts.length === 0) return;
188
238
 
189
- // Check if the tool already self-managed truncation via details
239
+ // Skip tools that self-manage truncation
240
+ // (matches OpenCode's `metadata.truncated !== undefined` check)
190
241
  if (hasSelfManagedTruncation(event)) return;
191
242
 
192
- // Check if image content is present - images have their own size management
243
+ // Skip image content - images have their own size management
244
+ // (matches OpenCode's `metadata.truncated = false` for images)
193
245
  const hasImages = event.content.some((p) => p.type === "image");
194
246
  if (hasImages) return;
195
247
 
@@ -217,6 +269,10 @@ export default function outputGuard(pi: ExtensionAPI) {
217
269
 
218
270
  // ------------------------------------------------------------------
219
271
  // 2. Tool limit optimization via tool_call hook
272
+ //
273
+ // OpenCode: glob=100, ls=100
274
+ // Pi default: find=1000, ls=500
275
+ // This hook reduces Pi's limits to match OpenCode.
220
276
  // ------------------------------------------------------------------
221
277
  pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
222
278
  const config = loadConfig(ctx);
@@ -240,12 +296,16 @@ export default function outputGuard(pi: ExtensionAPI) {
240
296
 
241
297
  // ------------------------------------------------------------------
242
298
  // 3. PDF text extraction tool
299
+ //
300
+ // OpenCode sends PDFs as raw base64 attachments (no text extraction).
301
+ // Pi's read tool doesn't support PDFs at all (outputs binary garbage).
302
+ // This tool uses pdf-parse to extract text, which is more token-efficient.
243
303
  // ------------------------------------------------------------------
244
304
  pi.registerTool({
245
305
  name: "pdf_read",
246
306
  description:
247
307
  "Read and extract text content from a PDF file. " +
248
- "Returns the text content of the PDF, paginated with page markers. " +
308
+ "Returns the text content of the PDF with metadata. " +
249
309
  "Use this instead of the read tool for PDF files.",
250
310
  parameters: Type.Object({
251
311
  path: Type.String({ description: "Path to the PDF file" }),
@@ -337,14 +397,15 @@ export default function outputGuard(pi: ExtensionAPI) {
337
397
  // ============================================================================
338
398
 
339
399
  /**
340
- * Check if a tool already self-manages truncation via its details field.
341
- * Built-in tools (read, bash, grep, find, ls) set details.truncation,
342
- * so we skip them and only catch unprotected tools.
400
+ * Check if a tool already self-manages truncation.
401
+ *
402
+ * Mirrors OpenCode's check: `result.metadata.truncated !== undefined`.
403
+ * In pi, built-in tools set `details.truncation`, and any tool can opt in
404
+ * by including a `truncation` field in its details.
343
405
  */
344
406
  function hasSelfManagedTruncation(event: ToolResultEvent): boolean {
345
407
  // Built-in tools that self-manage truncation
346
- const selfManagedTools = new Set(["read", "bash", "grep", "find", "ls"]);
347
- if (selfManagedTools.has(event.toolName)) return true;
408
+ if (SELF_MANAGED_TOOLS.has(event.toolName)) return true;
348
409
 
349
410
  // Check if details has a truncation field (any tool can opt in)
350
411
  const details = event.details as Record<string, unknown> | undefined;
@@ -354,27 +415,28 @@ function hasSelfManagedTruncation(event: ToolResultEvent): boolean {
354
415
  }
355
416
 
356
417
  /**
357
- * Build a human-readable truncation notice with actionable instructions.
418
+ * Build a truncation notice with actionable file path hint.
419
+ * Matches OpenCode's output format which tells the model where to find
420
+ * the full output and suggests using read/grep tools.
358
421
  */
359
422
  function buildTruncationNotice(info: TruncationInfo, config: OutputGuardConfig): string {
360
423
  const parts: string[] = [];
361
424
 
362
425
  if (info.truncatedBy === "lines") {
363
- parts.push(
364
- `Output truncated: ${info.totalLines} lines exceeded limit of ${config.maxLines}.`,
365
- );
426
+ const omitted = info.totalLines - info.outputLines;
427
+ parts.push(`...${omitted} lines truncated.`);
428
+ parts.push(`Output exceeded ${config.maxLines} line limit (${info.totalLines} total lines).`);
366
429
  } else if (info.truncatedBy === "bytes") {
367
- parts.push(
368
- `Output truncated: ${formatBytes(info.totalBytes)} exceeded limit of ${formatBytes(config.maxBytes)}.`,
369
- );
430
+ parts.push(`...output truncated at ${formatBytes(info.outputBytes)}.`);
431
+ parts.push(`Output exceeded ${formatBytes(config.maxBytes)} byte limit (${formatBytes(info.totalBytes)} total).`);
370
432
  }
371
433
 
372
434
  if (info.fullOutputPath) {
373
435
  parts.push(`Full output saved to: ${info.fullOutputPath}`);
374
- parts.push(`Use the read tool to view the full output.`);
436
+ parts.push("Use the read tool to view the full output.");
375
437
  }
376
438
 
377
- return parts.join(" ");
439
+ return parts.join("\n");
378
440
  }
379
441
 
380
442
  function formatBytes(bytes: number): string {