@oh-my-pi/pi-coding-agent 13.13.2 → 13.14.2
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.
- package/CHANGELOG.md +26 -0
- package/package.json +7 -7
- package/src/config/model-registry.ts +10 -3
- package/src/config/settings-schema.ts +3 -0
- package/src/discovery/helpers.ts +9 -2
- package/src/exec/bash-executor.ts +7 -5
- package/src/mcp/client.ts +29 -4
- package/src/mcp/manager.ts +256 -57
- package/src/mcp/tool-bridge.ts +189 -106
- package/src/mcp/transports/http.ts +8 -3
- package/src/modes/components/bash-execution.ts +40 -11
- package/src/modes/components/python-execution.ts +2 -3
- package/src/modes/components/tool-execution.ts +4 -5
- package/src/modes/controllers/command-controller.ts +0 -2
- package/src/modes/controllers/mcp-command-controller.ts +45 -0
- package/src/modes/interactive-mode.ts +9 -5
- package/src/patch/index.ts +15 -7
- package/src/prompts/agents/explore.md +4 -67
- package/src/session/agent-session.ts +7 -16
- package/src/session/streaming-output.ts +87 -37
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/tools/bash-interactive.ts +2 -6
- package/src/tools/python.ts +2 -2
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
355
|
+
const connection = await untilAborted(signal, () => this.getConnection());
|
|
286
356
|
throwIfAborted(signal);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -6,13 +6,17 @@ import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
|
6
6
|
import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
|
8
8
|
import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
|
|
9
|
-
import { getSixelLineMask, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
|
9
|
+
import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
|
10
10
|
import { DynamicBorder } from "./dynamic-border";
|
|
11
11
|
import { truncateToVisualLines } from "./visual-truncate";
|
|
12
12
|
|
|
13
13
|
// Preview line limit when not expanded (matches tool execution behavior)
|
|
14
14
|
const PREVIEW_LINES = 20;
|
|
15
|
+
const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
|
|
15
16
|
const MAX_DISPLAY_LINE_CHARS = 4000;
|
|
17
|
+
// Minimum interval between processing incoming chunks for display (ms).
|
|
18
|
+
// Chunks arriving faster than this are accumulated and processed in one batch.
|
|
19
|
+
const CHUNK_THROTTLE_MS = 50;
|
|
16
20
|
|
|
17
21
|
export class BashExecutionComponent extends Container {
|
|
18
22
|
#outputLines: string[] = [];
|
|
@@ -21,7 +25,10 @@ export class BashExecutionComponent extends Container {
|
|
|
21
25
|
#loader: Loader;
|
|
22
26
|
#truncation?: TruncationMeta;
|
|
23
27
|
#expanded = false;
|
|
28
|
+
#displayDirty = false;
|
|
29
|
+
#chunkGate = false;
|
|
24
30
|
#contentContainer: Container;
|
|
31
|
+
#headerText: Text;
|
|
25
32
|
|
|
26
33
|
constructor(
|
|
27
34
|
private readonly command: string,
|
|
@@ -45,8 +52,8 @@ export class BashExecutionComponent extends Container {
|
|
|
45
52
|
this.addChild(this.#contentContainer);
|
|
46
53
|
|
|
47
54
|
// Command header
|
|
48
|
-
|
|
49
|
-
this.#contentContainer.addChild(
|
|
55
|
+
this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
|
|
56
|
+
this.#contentContainer.addChild(this.#headerText);
|
|
50
57
|
|
|
51
58
|
// Loader
|
|
52
59
|
this.#loader = new Loader(
|
|
@@ -72,14 +79,22 @@ export class BashExecutionComponent extends Container {
|
|
|
72
79
|
|
|
73
80
|
override invalidate(): void {
|
|
74
81
|
super.invalidate();
|
|
82
|
+
this.#displayDirty = false;
|
|
75
83
|
this.#updateDisplay();
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
appendOutput(chunk: string): void {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
87
|
+
// During high-throughput output (e.g. seq 1 500M), processing every
|
|
88
|
+
// chunk would saturate the event loop. Instead, accept one chunk per
|
|
89
|
+
// throttle window and drop the rest — the OutputSink captures everything
|
|
90
|
+
// for the artifact, and setComplete() replaces with the final output.
|
|
91
|
+
if (this.#chunkGate) return;
|
|
92
|
+
this.#chunkGate = true;
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
this.#chunkGate = false;
|
|
95
|
+
}, CHUNK_THROTTLE_MS);
|
|
96
|
+
|
|
97
|
+
const incomingLines = chunk.split("\n");
|
|
83
98
|
if (this.#outputLines.length > 0 && incomingLines.length > 0) {
|
|
84
99
|
const lastIndex = this.#outputLines.length - 1;
|
|
85
100
|
const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)];
|
|
@@ -90,7 +105,12 @@ export class BashExecutionComponent extends Container {
|
|
|
90
105
|
this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines));
|
|
91
106
|
}
|
|
92
107
|
|
|
93
|
-
|
|
108
|
+
// Cap stored lines during streaming to avoid unbounded memory growth
|
|
109
|
+
if (this.#outputLines.length > STREAMING_LINE_CAP) {
|
|
110
|
+
this.#outputLines = this.#outputLines.slice(-STREAMING_LINE_CAP);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.#displayDirty = true;
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
setComplete(
|
|
@@ -115,6 +135,14 @@ export class BashExecutionComponent extends Container {
|
|
|
115
135
|
this.#updateDisplay();
|
|
116
136
|
}
|
|
117
137
|
|
|
138
|
+
override render(width: number): string[] {
|
|
139
|
+
if (this.#displayDirty) {
|
|
140
|
+
this.#displayDirty = false;
|
|
141
|
+
this.#updateDisplay();
|
|
142
|
+
}
|
|
143
|
+
return super.render(width);
|
|
144
|
+
}
|
|
145
|
+
|
|
118
146
|
#updateDisplay(): void {
|
|
119
147
|
const availableLines = this.#outputLines;
|
|
120
148
|
|
|
@@ -122,15 +150,16 @@ export class BashExecutionComponent extends Container {
|
|
|
122
150
|
const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
|
|
123
151
|
const hiddenLineCount = availableLines.length - previewLogicalLines.length;
|
|
124
152
|
const sixelLineMask =
|
|
125
|
-
TERMINAL.imageProtocol === ImageProtocol.Sixel
|
|
153
|
+
TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
|
|
154
|
+
? getSixelLineMask(availableLines)
|
|
155
|
+
: undefined;
|
|
126
156
|
const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
|
|
127
157
|
|
|
128
158
|
// Rebuild content container
|
|
129
159
|
this.#contentContainer.clear();
|
|
130
160
|
|
|
131
161
|
// Command header
|
|
132
|
-
|
|
133
|
-
this.#contentContainer.addChild(header);
|
|
162
|
+
this.#contentContainer.addChild(this.#headerText);
|
|
134
163
|
|
|
135
164
|
// Output
|
|
136
165
|
if (availableLines.length > 0) {
|
|
@@ -72,9 +72,8 @@ export class PythonExecutionComponent extends Container {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
appendOutput(chunk: string): void {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
|
|
75
|
+
// Chunk is pre-sanitized by OutputSink.push() — no need to sanitize again.
|
|
76
|
+
const newLines = chunk.split("\n").map(line => this.#clampDisplayLine(line));
|
|
78
77
|
if (this.#outputLines.length > 0 && newLines.length > 0) {
|
|
79
78
|
this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
|
|
80
79
|
`${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
|
|
@@ -105,17 +105,16 @@ export class ToolExecutionComponent extends Container {
|
|
|
105
105
|
// Cached converted images for Kitty protocol (which requires PNG), keyed by index
|
|
106
106
|
#convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
|
|
107
107
|
// Spinner animation for partial task results
|
|
108
|
-
#spinnerFrame
|
|
108
|
+
#spinnerFrame?: number;
|
|
109
109
|
#spinnerInterval?: NodeJS.Timeout;
|
|
110
110
|
// Track if args are still being streamed (for edit/write spinner)
|
|
111
111
|
#argsComplete = false;
|
|
112
112
|
#renderState: {
|
|
113
|
-
spinnerFrame
|
|
113
|
+
spinnerFrame?: number;
|
|
114
114
|
expanded: boolean;
|
|
115
115
|
isPartial: boolean;
|
|
116
116
|
renderContext?: Record<string, unknown>;
|
|
117
117
|
} = {
|
|
118
|
-
spinnerFrame: 0,
|
|
119
118
|
expanded: false,
|
|
120
119
|
isPartial: true,
|
|
121
120
|
};
|
|
@@ -328,10 +327,9 @@ export class ToolExecutionComponent extends Container {
|
|
|
328
327
|
this.#spinnerInterval = setInterval(() => {
|
|
329
328
|
const frameCount = theme.spinnerFrames.length;
|
|
330
329
|
if (frameCount === 0) return;
|
|
331
|
-
this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
|
|
330
|
+
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
|
|
332
331
|
this.#renderState.spinnerFrame = this.#spinnerFrame;
|
|
333
332
|
this.#ui.requestRender();
|
|
334
|
-
// NO updateDisplay() — existing component closures read from renderState
|
|
335
333
|
}, 80);
|
|
336
334
|
} else if (!needsSpinner && this.#spinnerInterval) {
|
|
337
335
|
clearInterval(this.#spinnerInterval);
|
|
@@ -346,6 +344,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
346
344
|
if (this.#spinnerInterval) {
|
|
347
345
|
clearInterval(this.#spinnerInterval);
|
|
348
346
|
this.#spinnerInterval = undefined;
|
|
347
|
+
this.#spinnerFrame = undefined;
|
|
349
348
|
}
|
|
350
349
|
}
|
|
351
350
|
|
|
@@ -690,7 +690,6 @@ export class CommandController {
|
|
|
690
690
|
chunk => {
|
|
691
691
|
if (this.ctx.bashComponent) {
|
|
692
692
|
this.ctx.bashComponent.appendOutput(chunk);
|
|
693
|
-
this.ctx.ui.requestRender();
|
|
694
693
|
}
|
|
695
694
|
},
|
|
696
695
|
{ excludeFromContext },
|
|
@@ -732,7 +731,6 @@ export class CommandController {
|
|
|
732
731
|
chunk => {
|
|
733
732
|
if (this.ctx.pythonComponent) {
|
|
734
733
|
this.ctx.pythonComponent.appendOutput(chunk);
|
|
735
|
-
this.ctx.ui.requestRender();
|
|
736
734
|
}
|
|
737
735
|
},
|
|
738
736
|
{ excludeFromContext },
|
|
@@ -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
|
*/
|