@oh-my-pi/pi-coding-agent 13.13.2 → 13.14.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.
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
7
  import { sanitizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
8
+ import { untilAborted } from "@oh-my-pi/pi-utils";
8
9
  import type { TSchema } from "@sinclair/typebox";
9
10
  import type { SourceMeta } from "../capability/types";
10
11
  import type {
@@ -17,22 +18,43 @@ import type { Theme } from "../modes/theme/theme";
17
18
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
18
19
  import { callTool } from "./client";
19
20
  import { renderMCPCall, renderMCPResult } from "./render";
20
- import type { MCPContent, MCPServerConnection, MCPToolDefinition } from "./types";
21
+ import type { MCPContent, MCPServerConnection, MCPToolCallParams, MCPToolCallResult, MCPToolDefinition } from "./types";
21
22
 
22
- function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
23
- if (!signal) return promise;
24
- if (signal.aborted) {
25
- return Promise.reject(signal.reason instanceof Error ? signal.reason : new ToolAbortError());
26
- }
23
+ /** Reconnect callback: tears down stale connection, returns new one or null. */
24
+ export type MCPReconnect = () => Promise<MCPServerConnection | null>;
27
25
 
28
- const { promise: wrapped, resolve, reject } = Promise.withResolvers<T>();
29
- const onAbort = () => {
30
- reject(signal.reason instanceof Error ? signal.reason : new ToolAbortError());
31
- };
26
+ /**
27
+ * Network-level and stale-session errors that warrant a reconnect + single retry.
28
+ * Conservative: only catches errors where the server is likely alive but the
29
+ * connection object is stale (dead SSE, expired session, refused after restart).
30
+ */
31
+ const RETRIABLE_PATTERNS = [
32
+ "econnrefused",
33
+ "econnreset",
34
+ "epipe",
35
+ "enetunreach",
36
+ "ehostunreach",
37
+ "fetch failed",
38
+ "transport not connected",
39
+ "transport closed",
40
+ "network error",
41
+ ];
42
+
43
+ export function isRetriableConnectionError(error: unknown): boolean {
44
+ if (!(error instanceof Error)) return false;
45
+ const msg = error.message.toLowerCase();
46
+ // Stale session (server restarted, old session ID is gone)
47
+ if (/^http (404|502|503):/.test(msg)) return true;
48
+ return RETRIABLE_PATTERNS.some(p => msg.includes(p));
49
+ }
32
50
 
33
- signal.addEventListener("abort", onAbort, { once: true });
34
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
35
- return wrapped;
51
+ type MCPToolArgs = NonNullable<MCPToolCallParams["arguments"]>;
52
+
53
+ function normalizeToolArgs(value: unknown): MCPToolArgs {
54
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
55
+ return {};
56
+ }
57
+ return value as MCPToolArgs;
36
58
  }
37
59
 
38
60
  /** Details included in MCP tool results for rendering */
@@ -77,6 +99,60 @@ function formatMCPContent(content: MCPContent[]): string {
77
99
  return parts.join("\n\n");
78
100
  }
79
101
 
102
+ /** Build a CustomToolResult from a callTool response. */
103
+ function buildResult(
104
+ result: MCPToolCallResult,
105
+ serverName: string,
106
+ mcpToolName: string,
107
+ provider?: string,
108
+ providerName?: string,
109
+ ): CustomToolResult<MCPToolDetails> {
110
+ const text = formatMCPContent(result.content);
111
+ const details: MCPToolDetails = {
112
+ serverName,
113
+ mcpToolName,
114
+ isError: result.isError,
115
+ rawContent: result.content,
116
+ provider,
117
+ providerName,
118
+ };
119
+ if (result.isError) {
120
+ return { content: [{ type: "text", text: `Error: ${text}` }], details };
121
+ }
122
+ return { content: [{ type: "text", text }], details };
123
+ }
124
+
125
+ /** Build an error CustomToolResult from a caught exception. */
126
+ function buildErrorResult(
127
+ error: unknown,
128
+ serverName: string,
129
+ mcpToolName: string,
130
+ provider?: string,
131
+ providerName?: string,
132
+ ): CustomToolResult<MCPToolDetails> {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ return {
135
+ content: [{ type: "text", text: `MCP error: ${message}` }],
136
+ details: { serverName, mcpToolName, isError: true, provider, providerName },
137
+ };
138
+ }
139
+
140
+ /** Re-throw abort-related errors so they bypass error-result handling. */
141
+ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
142
+ if (error instanceof ToolAbortError) throw error;
143
+ if (error instanceof Error && error.name === "AbortError") throw new ToolAbortError();
144
+ if (signal?.aborted) throw new ToolAbortError();
145
+ }
146
+
147
+ async function reconnectWithAbort(reconnect: MCPReconnect, signal?: AbortSignal): Promise<MCPServerConnection | null> {
148
+ try {
149
+ return await untilAborted(signal, reconnect);
150
+ } catch (error) {
151
+ rethrowIfAborted(error, signal);
152
+ return null;
153
+ }
154
+ }
155
+
80
156
  /**
81
157
  * Create a unique tool name for an MCP tool.
82
158
  *
@@ -143,13 +219,14 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
143
219
  readonly mcpServerName: string;
144
220
 
145
221
  /** Create MCPTool instances for all tools from an MCP server connection */
146
- static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[]): MCPTool[] {
147
- return tools.map(tool => new MCPTool(connection, tool));
222
+ static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[], reconnect?: MCPReconnect): MCPTool[] {
223
+ return tools.map(tool => new MCPTool(connection, tool, reconnect));
148
224
  }
149
225
 
150
226
  constructor(
151
- private readonly connection: MCPServerConnection,
227
+ private connection: MCPServerConnection,
152
228
  private readonly tool: MCPToolDefinition,
229
+ private readonly reconnect?: MCPReconnect,
153
230
  ) {
154
231
  this.name = createMCPToolName(connection.name, tool.name);
155
232
  this.label = `${connection.name}/${tool.name}`;
@@ -160,11 +237,11 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
160
237
  }
161
238
 
162
239
  renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
163
- return renderMCPCall((args ?? {}) as Record<string, unknown>, theme, this.label);
240
+ return renderMCPCall(normalizeToolArgs(args), theme, this.label);
164
241
  }
165
242
 
166
243
  renderResult(result: CustomToolResult<MCPToolDetails>, options: RenderResultOptions, theme: Theme, args?: unknown) {
167
- return renderMCPResult(result, options, theme, (args ?? {}) as Record<string, unknown>);
244
+ return renderMCPResult(result, options, theme, normalizeToolArgs(args));
168
245
  }
169
246
 
170
247
  async execute(
@@ -175,51 +252,38 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
175
252
  signal?: AbortSignal,
176
253
  ): Promise<CustomToolResult<MCPToolDetails>> {
177
254
  throwIfAborted(signal);
178
- try {
179
- const result = await callTool(this.connection, this.tool.name, params as Record<string, unknown>, { signal });
180
-
181
- const text = formatMCPContent(result.content);
182
- const details: MCPToolDetails = {
183
- serverName: this.connection.name,
184
- mcpToolName: this.tool.name,
185
- isError: result.isError,
186
- rawContent: result.content,
187
- provider: this.connection._source?.provider,
188
- providerName: this.connection._source?.providerName,
189
- };
190
-
191
- if (result.isError) {
192
- return {
193
- content: [{ type: "text", text: `Error: ${text}` }],
194
- details,
195
- };
196
- }
255
+ const args = normalizeToolArgs(params);
256
+ const provider = this.connection._source?.provider;
257
+ const providerName = this.connection._source?.providerName;
197
258
 
198
- return {
199
- content: [{ type: "text", text }],
200
- details,
201
- };
259
+ try {
260
+ const result = await callTool(this.connection, this.tool.name, args, { signal });
261
+ return buildResult(result, this.connection.name, this.tool.name, provider, providerName);
202
262
  } catch (error) {
203
- if (error instanceof ToolAbortError) {
204
- throw error;
205
- }
206
- if (error instanceof Error && error.name === "AbortError") {
207
- throw new ToolAbortError();
208
- }
209
- if (signal?.aborted) {
210
- throw new ToolAbortError();
263
+ rethrowIfAborted(error, signal);
264
+ if (this.reconnect && isRetriableConnectionError(error)) {
265
+ const newConn = await reconnectWithAbort(this.reconnect, signal);
266
+ if (newConn) {
267
+ // Rebind so subsequent calls on this instance use the fresh connection
268
+ this.connection = newConn;
269
+ const retryProvider = newConn._source?.provider ?? provider;
270
+ const retryProviderName = newConn._source?.providerName ?? providerName;
271
+ try {
272
+ const result = await callTool(newConn, this.tool.name, args, { signal });
273
+ return buildResult(result, newConn.name, this.tool.name, retryProvider, retryProviderName);
274
+ } catch (retryError) {
275
+ rethrowIfAborted(retryError, signal);
276
+ return buildErrorResult(
277
+ retryError,
278
+ this.connection.name,
279
+ this.tool.name,
280
+ retryProvider,
281
+ retryProviderName,
282
+ );
283
+ }
284
+ }
211
285
  }
212
- const message = error instanceof Error ? error.message : String(error);
213
- return {
214
- content: [{ type: "text", text: `MCP error: ${message}` }],
215
- details: {
216
- serverName: this.connection.name,
217
- mcpToolName: this.tool.name,
218
- isError: true,
219
- provider: this.connection._source?.provider,
220
- providerName: this.connection._source?.providerName,
221
- },
222
- };
286
+ return buildErrorResult(error, this.connection.name, this.tool.name, provider, providerName);
223
287
  }
224
288
  }
225
289
  }
@@ -245,8 +309,9 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
245
309
  tools: MCPToolDefinition[],
246
310
  getConnection: () => Promise<MCPServerConnection>,
247
311
  source?: SourceMeta,
312
+ reconnect?: MCPReconnect,
248
313
  ): DeferredMCPTool[] {
249
- return tools.map(tool => new DeferredMCPTool(serverName, tool, getConnection, source));
314
+ return tools.map(tool => new DeferredMCPTool(serverName, tool, getConnection, source, reconnect));
250
315
  }
251
316
 
252
317
  constructor(
@@ -254,6 +319,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
254
319
  private readonly tool: MCPToolDefinition,
255
320
  private readonly getConnection: () => Promise<MCPServerConnection>,
256
321
  source?: SourceMeta,
322
+ private readonly reconnect?: MCPReconnect,
257
323
  ) {
258
324
  this.name = createMCPToolName(serverName, tool.name);
259
325
  this.label = `${serverName}/${tool.name}`;
@@ -266,11 +332,11 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
266
332
  }
267
333
 
268
334
  renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
269
- return renderMCPCall((args ?? {}) as Record<string, unknown>, theme, this.label);
335
+ return renderMCPCall(normalizeToolArgs(args), theme, this.label);
270
336
  }
271
337
 
272
338
  renderResult(result: CustomToolResult<MCPToolDetails>, options: RenderResultOptions, theme: Theme, args?: unknown) {
273
- return renderMCPResult(result, options, theme, (args ?? {}) as Record<string, unknown>);
339
+ return renderMCPResult(result, options, theme, normalizeToolArgs(args));
274
340
  }
275
341
 
276
342
  async execute(
@@ -281,53 +347,70 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
281
347
  signal?: AbortSignal,
282
348
  ): Promise<CustomToolResult<MCPToolDetails>> {
283
349
  throwIfAborted(signal);
350
+ const args = normalizeToolArgs(params);
351
+ const provider = this.#fallbackProvider;
352
+ const providerName = this.#fallbackProviderName;
353
+
284
354
  try {
285
- const connection = await withAbort(this.getConnection(), signal);
355
+ const connection = await untilAborted(signal, () => this.getConnection());
286
356
  throwIfAborted(signal);
287
- const result = await callTool(connection, this.tool.name, params as Record<string, unknown>, { signal });
288
-
289
- const text = formatMCPContent(result.content);
290
- const details: MCPToolDetails = {
291
- serverName: this.serverName,
292
- mcpToolName: this.tool.name,
293
- isError: result.isError,
294
- rawContent: result.content,
295
- provider: connection._source?.provider ?? this.#fallbackProvider,
296
- providerName: connection._source?.providerName ?? this.#fallbackProviderName,
297
- };
298
-
299
- if (result.isError) {
300
- return {
301
- content: [{ type: "text", text: `Error: ${text}` }],
302
- details,
303
- };
304
- }
305
-
306
- return {
307
- content: [{ type: "text", text }],
308
- details,
309
- };
310
- } catch (error) {
311
- if (error instanceof ToolAbortError) {
312
- throw error;
313
- }
314
- if (error instanceof Error && error.name === "AbortError") {
315
- throw new ToolAbortError();
357
+ try {
358
+ const result = await callTool(connection, this.tool.name, args, { signal });
359
+ return buildResult(
360
+ result,
361
+ this.serverName,
362
+ this.tool.name,
363
+ connection._source?.provider ?? provider,
364
+ connection._source?.providerName ?? providerName,
365
+ );
366
+ } catch (callError) {
367
+ rethrowIfAborted(callError, signal);
368
+ if (this.reconnect && isRetriableConnectionError(callError)) {
369
+ const newConn = await reconnectWithAbort(this.reconnect, signal);
370
+ if (newConn) {
371
+ const retryProvider = newConn._source?.provider ?? provider;
372
+ const retryProviderName = newConn._source?.providerName ?? providerName;
373
+ try {
374
+ const result = await callTool(newConn, this.tool.name, args, { signal });
375
+ return buildResult(result, this.serverName, this.tool.name, retryProvider, retryProviderName);
376
+ } catch (retryError) {
377
+ rethrowIfAborted(retryError, signal);
378
+ return buildErrorResult(
379
+ retryError,
380
+ this.serverName,
381
+ this.tool.name,
382
+ retryProvider,
383
+ retryProviderName,
384
+ );
385
+ }
386
+ }
387
+ }
388
+ return buildErrorResult(callError, this.serverName, this.tool.name, provider, providerName);
316
389
  }
317
- if (signal?.aborted) {
318
- throw new ToolAbortError();
390
+ } catch (connError) {
391
+ // getConnection() failed — server never connected or connection lost.
392
+ // This is always worth a reconnect attempt for deferred tools, since the
393
+ // error ("MCP server not connected") isn't a network error from callTool.
394
+ rethrowIfAborted(connError, signal);
395
+ if (this.reconnect) {
396
+ const newConn = await reconnectWithAbort(this.reconnect, signal);
397
+ if (newConn) {
398
+ try {
399
+ const result = await callTool(newConn, this.tool.name, args, { signal });
400
+ return buildResult(
401
+ result,
402
+ this.serverName,
403
+ this.tool.name,
404
+ newConn._source?.provider ?? provider,
405
+ newConn._source?.providerName ?? providerName,
406
+ );
407
+ } catch (retryError) {
408
+ rethrowIfAborted(retryError, signal);
409
+ return buildErrorResult(retryError, this.serverName, this.tool.name, provider, providerName);
410
+ }
411
+ }
319
412
  }
320
- const message = error instanceof Error ? error.message : String(error);
321
- return {
322
- content: [{ type: "text", text: `MCP error: ${message}` }],
323
- details: {
324
- serverName: this.serverName,
325
- mcpToolName: this.tool.name,
326
- isError: true,
327
- provider: this.#fallbackProvider,
328
- providerName: this.#fallbackProviderName,
329
- },
330
- };
413
+ return buildErrorResult(connError, this.serverName, this.tool.name, provider, providerName);
331
414
  }
332
415
  }
333
416
  }
@@ -4,7 +4,7 @@
4
4
  * Implements JSON-RPC 2.0 over HTTP POST with optional SSE streaming.
5
5
  * Based on MCP spec 2025-03-26.
6
6
  */
7
- import { readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
7
+ import { logger, readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
8
8
  import type {
9
9
  JsonRpcError,
10
10
  JsonRpcMessage,
@@ -91,13 +91,16 @@ export class HttpTransport implements MCPTransport {
91
91
  return;
92
92
  }
93
93
 
94
- // Connection established — read messages in background, reset sseConnection when done
94
+ // Connection established — read messages in background.
95
+ // If the stream ends unexpectedly (server restart, network drop),
96
+ // fire onClose so the manager can trigger reconnection.
95
97
  const signal = this.#sseConnection.signal;
96
98
  void this.#readSSEStream(response.body!, signal).finally(() => {
99
+ const wasConnected = this.#connected;
97
100
  this.#sseConnection = null;
101
+ if (wasConnected) this.onClose?.();
98
102
  });
99
103
  }
100
-
101
104
  async #readSSEStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
102
105
  try {
103
106
  for await (const message of readSseJson<JsonRpcMessage>(body, signal)) {
@@ -106,6 +109,7 @@ export class HttpTransport implements MCPTransport {
106
109
  }
107
110
  } catch (error) {
108
111
  if (error instanceof Error && error.name !== "AbortError") {
112
+ logger.debug("HTTP SSE stream error", { url: this.config.url, error: error.message });
109
113
  this.onError?.(error);
110
114
  }
111
115
  }
@@ -457,6 +461,7 @@ export class HttpTransport implements MCPTransport {
457
461
  }
458
462
 
459
463
  this.onClose?.();
464
+ this.onClose = undefined;
460
465
  }
461
466
  }
462
467
 
@@ -127,6 +127,9 @@ export class MCPCommandController {
127
127
  case "smithery-logout":
128
128
  await this.#handleSmitheryLogout();
129
129
  break;
130
+ case "reconnect":
131
+ await this.#handleReconnect(parts[2]);
132
+ break;
130
133
  case "reload":
131
134
  await this.#handleReload();
132
135
  break;
@@ -159,6 +162,7 @@ export class MCPCommandController {
159
162
  " Search Smithery registry and deploy from picker",
160
163
  " /mcp smithery-login Login to Smithery and cache API key",
161
164
  " /mcp smithery-logout Remove cached Smithery API key",
165
+ " /mcp reconnect <name> Reconnect to a specific MCP server",
162
166
  " /mcp reload Force reload and rediscover MCP runtime tools",
163
167
  " /mcp resources List available resources from connected servers",
164
168
  " /mcp prompts List available prompts from connected servers",
@@ -1372,6 +1376,47 @@ export class MCPCommandController {
1372
1376
  }
1373
1377
  }
1374
1378
 
1379
+ /**
1380
+ * Handle /mcp reconnect <name> - Reconnect to a specific server.
1381
+ */
1382
+ async #handleReconnect(name: string | undefined): Promise<void> {
1383
+ if (!name) {
1384
+ this.ctx.showError("Server name required. Usage: /mcp reconnect <name>");
1385
+ return;
1386
+ }
1387
+ if (!this.ctx.mcpManager) {
1388
+ this.ctx.showError("MCP manager not available.");
1389
+ return;
1390
+ }
1391
+
1392
+ this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
1393
+
1394
+ try {
1395
+ const connection = await this.ctx.mcpManager.reconnectServer(name);
1396
+ if (connection) {
1397
+ // refreshMCPTools re-registers tools and preserves the user's prior
1398
+ // MCP tool selection. No need to call activateDiscoveredMCPTools —
1399
+ // that would broaden the selection to all server tools.
1400
+ await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1401
+ const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name);
1402
+ this.#showMessage(
1403
+ [
1404
+ "\n",
1405
+ theme.fg("success", `\u2713 Reconnected to "${name}"`),
1406
+ ` Tools: ${serverTools.length}`,
1407
+ "\n",
1408
+ ].join("\n"),
1409
+ );
1410
+ } else {
1411
+ this.ctx.showError(`Failed to reconnect to "${name}". Check server status and logs.`);
1412
+ }
1413
+ } catch (error) {
1414
+ this.ctx.showError(
1415
+ `Failed to reconnect to "${name}": ${error instanceof Error ? error.message : String(error)}`,
1416
+ );
1417
+ }
1418
+ }
1419
+
1375
1420
  /**
1376
1421
  * Reload MCP manager with new configs
1377
1422
  */
@@ -6,7 +6,7 @@ import * as path from "node:path";
6
6
  import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
7
  import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
8
8
  import type { Component, SlashCommand } from "@oh-my-pi/pi-tui";
9
- import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
9
+ import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
10
10
  import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
11
11
  import chalk from "chalk";
12
12
  import { KeybindingsManager } from "../config/keybindings";
@@ -1111,8 +1111,7 @@ export class InteractiveMode implements InteractiveModeContext {
1111
1111
  this.#startMicAnimation();
1112
1112
  } else if (state === "transcribing") {
1113
1113
  this.#stopMicAnimation();
1114
- this.editor.cursorOverride = `\x1b[38;2;200;200;200m${theme.icon.mic}\x1b[0m`;
1115
- this.editor.cursorOverrideWidth = 1;
1114
+ this.#setMicCursor({ r: 200, g: 200, b: 200 });
1116
1115
  } else {
1117
1116
  this.#cleanupMicAnimation();
1118
1117
  }
@@ -1122,10 +1121,15 @@ export class InteractiveMode implements InteractiveModeContext {
1122
1121
  });
1123
1122
  }
1124
1123
 
1124
+ #setMicCursor(color: { r: number; g: number; b: number }): void {
1125
+ this.editor.cursorOverride = `\x1b[38;2;${color.r};${color.g};${color.b}m${theme.icon.mic}\x1b[0m`;
1126
+ // Theme symbols can be wide (for example, 🎤), so measure the rendered override.
1127
+ this.editor.cursorOverrideWidth = visibleWidth(this.editor.cursorOverride);
1128
+ }
1129
+
1125
1130
  #updateMicIcon(): void {
1126
1131
  const { r, g, b } = hsvToRgb({ h: this.#voiceHue, s: 0.9, v: 1.0 });
1127
- this.editor.cursorOverride = `\x1b[38;2;${r};${g};${b}m${theme.icon.mic}\x1b[0m`;
1128
- this.editor.cursorOverrideWidth = 1;
1132
+ this.#setMicCursor({ r, g, b });
1129
1133
  }
1130
1134
 
1131
1135
  #startMicAnimation(): void {
@@ -97,8 +97,12 @@ const patchEditSchema = Type.Object({
97
97
  export type ReplaceParams = Static<typeof replaceEditSchema>;
98
98
  export type PatchParams = Static<typeof patchEditSchema>;
99
99
 
100
- /** Pattern matching hashline display format prefixes: `LINE#ID:CONTENT` and `#ID:CONTENT` */
101
- const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[ZPMQVRWSNKTXJBYH]{2}:/;
100
+ /**
101
+ * Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`.
102
+ * A plus-prefixed form appears in diff-like output and should be treated as hashline metadata too.
103
+ */
104
+ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
105
+ const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
102
106
 
103
107
  /** Pattern matching a unified-diff added-line `+` prefix (but not `++`). Does NOT match `-` to avoid corrupting Markdown list items. */
104
108
  const DIFF_PLUS_RE = /^[+](?![+])/;
@@ -111,27 +115,31 @@ const DIFF_PLUS_RE = /^[+](?![+])/;
111
115
  * output file. This strips them heuristically before application.
112
116
  */
113
117
  export function stripNewLinePrefixes(lines: string[]): string[] {
114
- // Hashline prefixes are highly specific to read output and should only be
115
- // stripped when *every* non-empty line carries one.
116
- // Diff '+' markers can be legitimate content less often, so keep majority mode.
118
+ // Hashline prefixes are highly specific to read output and are usually stripped only when
119
+ // *every* non-empty line carries one. If a line is prefixed as `+ID:`, strip that
120
+ // prefix while leaving other `+` lines untouched to avoid corrupting mixed snippets.
117
121
  let hashPrefixCount = 0;
122
+ let diffPlusHashPrefixCount = 0;
118
123
  let diffPlusCount = 0;
119
124
  let nonEmpty = 0;
120
125
  for (const l of lines) {
121
126
  if (l.length === 0) continue;
122
127
  nonEmpty++;
123
128
  if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
129
+ if (HASHLINE_PREFIX_PLUS_RE.test(l)) diffPlusHashPrefixCount++;
124
130
  if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
125
131
  }
126
132
  if (nonEmpty === 0) return lines;
127
133
 
128
134
  const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
129
- const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
130
- if (!stripHash && !stripPlus) return lines;
135
+ const stripPlus =
136
+ !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
137
+ if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
131
138
 
132
139
  return lines.map(l => {
133
140
  if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
134
141
  if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
142
+ if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(l)) return l.replace(HASHLINE_PREFIX_RE, "");
135
143
  return l;
136
144
  });
137
145
  }
@@ -3,7 +3,7 @@ name: explore
3
3
  description: Fast read-only codebase scout returning compressed context for handoff
4
4
  tools: read, grep, find, fetch, web_search
5
5
  model: pi/smol
6
- thinking-level: off
6
+ thinking-level: med
7
7
  output:
8
8
  properties:
9
9
  summary:
@@ -12,84 +12,21 @@ output:
12
12
  type: string
13
13
  files:
14
14
  metadata:
15
- description: Files examined with exact line ranges
15
+ description: Files examined with relevant code references
16
16
  elements:
17
17
  properties:
18
- path:
18
+ ref:
19
19
  metadata:
20
- description: Absolute path to file
20
+ description: Project-relative path or paths to the most relevant code reference(s), optionally suffixed with line ranges like `:12-34` when relevant
21
21
  type: string
22
- line_start:
23
- metadata:
24
- description: First line read (1-indexed)
25
- type: number
26
- line_end:
27
- metadata:
28
- description: Last line read (1-indexed)
29
- type: number
30
22
  description:
31
23
  metadata:
32
24
  description: Section contents
33
25
  type: string
34
- code:
35
- metadata:
36
- description: Critical types/interfaces/functions extracted verbatim
37
- elements:
38
- properties:
39
- path:
40
- metadata:
41
- description: Absolute path to source file
42
- type: string
43
- line_start:
44
- metadata:
45
- description: Excerpt first line (1-indexed)
46
- type: number
47
- line_end:
48
- metadata:
49
- description: Excerpt last line (1-indexed)
50
- type: number
51
- language:
52
- metadata:
53
- description: Language id for syntax highlighting
54
- type: string
55
- content:
56
- metadata:
57
- description: Verbatim code excerpt
58
- type: string
59
26
  architecture:
60
27
  metadata:
61
28
  description: Brief explanation of how pieces connect
62
29
  type: string
63
- dependencies:
64
- metadata:
65
- description: Key internal and external dependencies relevant to the task
66
- elements:
67
- properties:
68
- name:
69
- metadata:
70
- description: Package or module name
71
- type: string
72
- role:
73
- metadata:
74
- description: What it provides in context of the task
75
- type: string
76
- risks:
77
- metadata:
78
- description: Gotchas, edge cases, or constraints the receiving agent should know
79
- elements:
80
- type: string
81
- start_here:
82
- metadata:
83
- description: Recommended entry point for receiving agent
84
- properties:
85
- path:
86
- metadata:
87
- description: Absolute path to start reading
88
- type: string
89
- reason:
90
- metadata:
91
- description: Why this file best starting point
92
- type: string
93
30
  ---
94
31
 
95
32
  You are a file search specialist and a codebase scout.