@oh-my-pi/pi-coding-agent 17.0.8 → 17.0.9
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 +33 -1
- package/dist/cli.js +4850 -4774
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +27 -0
- package/dist/types/config/settings-schema.d.ts +34 -3
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/settings-defs.d.ts +1 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +8 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +13 -12
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +1 -1
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +40 -0
- package/src/config/model-resolver.ts +0 -1
- package/src/config/settings-schema.ts +41 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/lsp/client.ts +23 -0
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +25 -7
- package/src/main.ts +1 -0
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/render.ts +94 -35
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/modes/components/agent-hub.ts +26 -9
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/settings-defs.ts +5 -2
- package/src/modes/components/settings-selector.ts +9 -5
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/controllers/command-controller.ts +14 -7
- package/src/modes/controllers/mcp-command-controller.ts +70 -40
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/tools/task.md +12 -2
- package/src/sdk.ts +45 -3
- package/src/session/agent-session.ts +63 -32
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +41 -2
- package/src/session/session-loader.ts +10 -5
- package/src/slash-commands/helpers/usage-report.ts +3 -1
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +52 -8
- package/src/task/structured-subagent.ts +3 -1
- package/src/task/types.ts +16 -0
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/index.ts +3 -0
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/web/search/providers/firecrawl.ts +46 -13
- package/src/web/search/types.ts +5 -1
|
@@ -90,3 +90,43 @@ export async function sessionFilesFromDisk(): Promise<Map<string, string>> {
|
|
|
90
90
|
for (const dir of artifactsDirsFromRegistry()) await scan(dir, 0);
|
|
91
91
|
return found;
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Availability half of the `history://` resolution semantics: true when a
|
|
96
|
+
* transcript for `agentId` can be served from a registered ref's live session
|
|
97
|
+
* or retained session file, or from an on-disk `.jsonl` under a known
|
|
98
|
+
* artifacts dir. Hint surfaces use this so they only advertise
|
|
99
|
+
* `history://<agentId>` links that `HistoryProtocolHandler` can actually
|
|
100
|
+
* resolve. A retained sessionFile path is verified on disk before it counts,
|
|
101
|
+
* and probing never throws: a stale path or unreadable artifacts subtree
|
|
102
|
+
* reads as unavailable instead of disturbing the caller's delivery path.
|
|
103
|
+
*/
|
|
104
|
+
export async function hasResolvableTranscript(agentId: string): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const registry = AgentRegistry.global();
|
|
107
|
+
const lower = agentId.toLowerCase();
|
|
108
|
+
let ref = registry.get(agentId);
|
|
109
|
+
if (ref?.kind === "advisor") ref = undefined;
|
|
110
|
+
ref ??= registry.list().find(candidate => candidate.kind !== "advisor" && candidate.id.toLowerCase() === lower);
|
|
111
|
+
if (ref?.session) return true;
|
|
112
|
+
if (ref?.sessionFile && (await isReadableFile(ref.sessionFile))) return true;
|
|
113
|
+
const files = await sessionFilesFromDisk();
|
|
114
|
+
for (const id of files.keys()) {
|
|
115
|
+
if (id.toLowerCase() === lower) return true;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Availability probing is advisory; any filesystem failure means the
|
|
119
|
+
// transcript cannot be promised, never that delivery should fail.
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when `file` exists and is a regular file; never throws. */
|
|
125
|
+
async function isReadableFile(file: string): Promise<boolean> {
|
|
126
|
+
try {
|
|
127
|
+
const stat = await fs.stat(file);
|
|
128
|
+
return stat.isFile();
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -846,6 +846,29 @@ export async function getOrCreateClient(
|
|
|
846
846
|
return clientPromise;
|
|
847
847
|
}
|
|
848
848
|
|
|
849
|
+
/** Return an active or already-starting client without starting a language server. */
|
|
850
|
+
export async function getActiveOrPendingClient(
|
|
851
|
+
config: ServerConfig,
|
|
852
|
+
cwd: string,
|
|
853
|
+
signal?: AbortSignal,
|
|
854
|
+
): Promise<LspClient | undefined> {
|
|
855
|
+
throwIfAborted(signal);
|
|
856
|
+
const client = clients.get(`${config.command}:${cwd}`);
|
|
857
|
+
if (client) {
|
|
858
|
+
client.lastActivity = Date.now();
|
|
859
|
+
return client;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const pending = clientLocks.get(`${config.command}:${cwd}`);
|
|
863
|
+
if (!pending) return undefined;
|
|
864
|
+
try {
|
|
865
|
+
return await untilAborted(signal, pending);
|
|
866
|
+
} catch {
|
|
867
|
+
throwIfAborted(signal);
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
849
872
|
/**
|
|
850
873
|
* Ensure a file is opened in the LSP client.
|
|
851
874
|
* Sends didOpen notification if the file is not already tracked.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Biome CLI-based linter client.
|
|
3
3
|
* Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues).
|
|
4
4
|
*/
|
|
5
|
-
import path from "node:path";
|
|
5
|
+
import * as path from "node:path";
|
|
6
6
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../../lsp/types";
|
|
8
8
|
|
|
@@ -141,14 +141,13 @@ export class BiomeClient implements LinterClient {
|
|
|
141
141
|
) {}
|
|
142
142
|
|
|
143
143
|
async format(filePath: string, content: string): Promise<string> {
|
|
144
|
-
//
|
|
144
|
+
// Keep the standalone LinterClient contract: callers supply the content to
|
|
145
|
+
// format, regardless of what is currently on disk.
|
|
145
146
|
await Bun.write(filePath, content);
|
|
146
147
|
|
|
147
|
-
// Run biome format --write
|
|
148
148
|
const result = await runBiome(["format", "--write", filePath], this.cwd, this.config.resolvedCommand);
|
|
149
149
|
|
|
150
150
|
if (result.success) {
|
|
151
|
-
// Read back formatted content
|
|
152
151
|
return await Bun.file(filePath).text();
|
|
153
152
|
}
|
|
154
153
|
|
package/src/lsp/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
ensureFileOpen,
|
|
21
21
|
FileChangeType,
|
|
22
22
|
getActiveClients,
|
|
23
|
+
getActiveOrPendingClient,
|
|
23
24
|
getOrCreateClient,
|
|
24
25
|
type LspServerStatus,
|
|
25
26
|
notifySaved,
|
|
@@ -211,6 +212,7 @@ async function syncFileContent(
|
|
|
211
212
|
cwd: string,
|
|
212
213
|
servers: Array<[string, ServerConfig]>,
|
|
213
214
|
signal?: AbortSignal,
|
|
215
|
+
createMissing = true,
|
|
214
216
|
): Promise<void> {
|
|
215
217
|
throwIfAborted(signal);
|
|
216
218
|
await Promise.allSettled(
|
|
@@ -219,11 +221,15 @@ async function syncFileContent(
|
|
|
219
221
|
if (serverConfig.createClient) {
|
|
220
222
|
return;
|
|
221
223
|
}
|
|
222
|
-
const client =
|
|
224
|
+
const client = createMissing
|
|
225
|
+
? await getOrCreateClient(serverConfig, cwd, undefined, signal)
|
|
226
|
+
: await getActiveOrPendingClient(serverConfig, cwd, signal);
|
|
227
|
+
if (!client) return;
|
|
223
228
|
throwIfAborted(signal);
|
|
224
229
|
await syncContent(client, absolutePath, content, signal);
|
|
225
230
|
}),
|
|
226
231
|
);
|
|
232
|
+
throwIfAborted(signal);
|
|
227
233
|
}
|
|
228
234
|
|
|
229
235
|
/**
|
|
@@ -239,6 +245,7 @@ async function notifyFileSaved(
|
|
|
239
245
|
cwd: string,
|
|
240
246
|
servers: Array<[string, ServerConfig]>,
|
|
241
247
|
signal?: AbortSignal,
|
|
248
|
+
createMissing = true,
|
|
242
249
|
): Promise<void> {
|
|
243
250
|
throwIfAborted(signal);
|
|
244
251
|
await Promise.allSettled(
|
|
@@ -247,10 +254,14 @@ async function notifyFileSaved(
|
|
|
247
254
|
if (serverConfig.createClient) {
|
|
248
255
|
return;
|
|
249
256
|
}
|
|
250
|
-
const client =
|
|
257
|
+
const client = createMissing
|
|
258
|
+
? await getOrCreateClient(serverConfig, cwd, undefined, signal)
|
|
259
|
+
: await getActiveOrPendingClient(serverConfig, cwd, signal);
|
|
260
|
+
if (!client) return;
|
|
251
261
|
await notifySaved(client, absolutePath, signal);
|
|
252
262
|
}),
|
|
253
263
|
);
|
|
264
|
+
throwIfAborted(signal);
|
|
254
265
|
}
|
|
255
266
|
|
|
256
267
|
// Cache config per cwd to avoid repeated file I/O
|
|
@@ -1333,7 +1344,8 @@ async function runLspWritethrough(
|
|
|
1333
1344
|
// Capture diagnostic versions BEFORE syncing to detect stale diagnostics
|
|
1334
1345
|
// Bound client creation by the writethrough budget: a hung/broken server
|
|
1335
1346
|
// must not add its full init wait (30s default) to every edit.
|
|
1336
|
-
const
|
|
1347
|
+
const minVersionsPromise = enableDiagnostics ? captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
|
|
1348
|
+
let minVersions = useCustomFormatter ? undefined : await minVersionsPromise;
|
|
1337
1349
|
let expectedDocumentVersions: ServerVersionMap | undefined;
|
|
1338
1350
|
|
|
1339
1351
|
let formatter: FileFormatResult | undefined;
|
|
@@ -1353,13 +1365,19 @@ async function runLspWritethrough(
|
|
|
1353
1365
|
operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
1354
1366
|
await untilAborted(operationSignal, async () => {
|
|
1355
1367
|
if (useCustomFormatter) {
|
|
1356
|
-
// Custom linters
|
|
1368
|
+
// Custom linters operate on on-disk input; the shared pre-write also
|
|
1369
|
+
// supports implementations that inspect the file before formatting.
|
|
1357
1370
|
await writeContent(content);
|
|
1358
|
-
|
|
1371
|
+
const [formattedContent, capturedVersions] = await Promise.all([
|
|
1372
|
+
formatContent(dst, content, cwd, customLinterServers, operationSignal),
|
|
1373
|
+
minVersionsPromise,
|
|
1374
|
+
]);
|
|
1375
|
+
finalContent = formattedContent;
|
|
1376
|
+
minVersions = capturedVersions;
|
|
1359
1377
|
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
|
|
1360
1378
|
await writeContent(finalContent);
|
|
1361
1379
|
await notifyWriteCommitted(operationSignal);
|
|
1362
|
-
await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
|
|
1380
|
+
await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
|
|
1363
1381
|
} else {
|
|
1364
1382
|
// 1. Sync original content to LSP servers
|
|
1365
1383
|
await syncFileContent(dst, content, cwd, lspServers, operationSignal);
|
|
@@ -1385,7 +1403,7 @@ async function runLspWritethrough(
|
|
|
1385
1403
|
}
|
|
1386
1404
|
|
|
1387
1405
|
// 5. Notify saved to LSP servers
|
|
1388
|
-
await notifyFileSaved(dst, cwd, lspServers, operationSignal);
|
|
1406
|
+
await notifyFileSaved(dst, cwd, lspServers, operationSignal, !useCustomFormatter || enableDiagnostics);
|
|
1389
1407
|
});
|
|
1390
1408
|
synced = true;
|
|
1391
1409
|
} catch {
|
package/src/main.ts
CHANGED
|
@@ -134,6 +134,7 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
|
|
|
134
134
|
// embedders need project-level opt-outs for reminder/prelude prompt injection.
|
|
135
135
|
const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
|
|
136
136
|
"task.isolation.mode",
|
|
137
|
+
"task.isolation.apply",
|
|
137
138
|
"task.isolation.merge",
|
|
138
139
|
"task.isolation.commits",
|
|
139
140
|
"task.eager",
|
package/src/mcp/manager.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type { MCPToolDetails } from "./tool-bridge";
|
|
|
38
38
|
import { DeferredMCPTool, MCPTool } from "./tool-bridge";
|
|
39
39
|
import type { MCPToolCache } from "./tool-cache";
|
|
40
40
|
import type {
|
|
41
|
+
MCPAuthChallenge,
|
|
41
42
|
MCPGetPromptResult,
|
|
42
43
|
MCPPrompt,
|
|
43
44
|
MCPRequestOptions,
|
|
@@ -160,6 +161,9 @@ export interface MCPDiscoverOptions {
|
|
|
160
161
|
onStatus?: (event: McpConnectionStatusEvent) => void;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/** Handles an MCP `WWW-Authenticate` challenge and returns refreshed config. */
|
|
165
|
+
export type MCPAuthHandler = (serverName: string, challenge: MCPAuthChallenge) => Promise<MCPServerConfig | undefined>;
|
|
166
|
+
|
|
163
167
|
/**
|
|
164
168
|
* MCP Server Manager.
|
|
165
169
|
*
|
|
@@ -189,6 +193,7 @@ export class MCPManager {
|
|
|
189
193
|
#pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
|
|
190
194
|
#sources = new Map<string, SourceMeta>();
|
|
191
195
|
#authStorage: AuthStorage | null = null;
|
|
196
|
+
#authHandler?: MCPAuthHandler;
|
|
192
197
|
#onNotification?: (serverName: string, method: string, params: unknown) => void;
|
|
193
198
|
#onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
|
|
194
199
|
#onResourcesChanged?: (serverName: string, uri: string) => void;
|
|
@@ -201,7 +206,7 @@ export class MCPManager {
|
|
|
201
206
|
/** Preserved configs for reconnection after connection loss. */
|
|
202
207
|
#serverConfigs = new Map<string, MCPServerConfig>();
|
|
203
208
|
/**
|
|
204
|
-
* Timestamps of recent
|
|
209
|
+
* Timestamps of recent reconnectServer invocations per server, used by the
|
|
205
210
|
* crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
|
|
206
211
|
*/
|
|
207
212
|
#reconnectHistory = new Map<string, number[]>();
|
|
@@ -312,6 +317,11 @@ export class MCPManager {
|
|
|
312
317
|
this.#authStorage = authStorage;
|
|
313
318
|
}
|
|
314
319
|
|
|
320
|
+
/** Set the callback used to complete OAuth after a tool-level auth challenge. */
|
|
321
|
+
setAuthHandler(handler: MCPAuthHandler | undefined): void {
|
|
322
|
+
this.#authHandler = handler;
|
|
323
|
+
}
|
|
324
|
+
|
|
315
325
|
/**
|
|
316
326
|
* Discover and connect to all MCP servers from .mcp.json files.
|
|
317
327
|
* Returns tools and any connection errors.
|
|
@@ -474,7 +484,8 @@ export class MCPManager {
|
|
|
474
484
|
.then(async ({ connection, serverTools }) => {
|
|
475
485
|
if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
|
|
476
486
|
this.#pendingToolLoads.delete(name);
|
|
477
|
-
const reconnect = () =>
|
|
487
|
+
const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) =>
|
|
488
|
+
this.reconnectServer(name, options);
|
|
478
489
|
const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
|
|
479
490
|
this.#replaceServerTools(name, customTools);
|
|
480
491
|
this.#onToolsChanged?.(this.#tools);
|
|
@@ -810,13 +821,15 @@ export class MCPManager {
|
|
|
810
821
|
* the same server share one reconnection attempt. Returns the new
|
|
811
822
|
* connection, or `null` if reconnection failed or the per-server crash
|
|
812
823
|
* burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
|
|
813
|
-
*
|
|
814
824
|
* @param options.manual - When `true`, resets the crash-burst window so a
|
|
815
825
|
* user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
|
|
816
826
|
* earlier storm. Defaults to `false`; the transport `onClose` callback
|
|
817
827
|
* and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
|
|
818
828
|
*/
|
|
819
|
-
async reconnectServer(
|
|
829
|
+
async reconnectServer(
|
|
830
|
+
name: string,
|
|
831
|
+
options?: { manual?: boolean; authChallenge?: MCPAuthChallenge },
|
|
832
|
+
): Promise<MCPServerConnection | null> {
|
|
820
833
|
if (options?.manual) {
|
|
821
834
|
this.#reconnectHistory.delete(name);
|
|
822
835
|
}
|
|
@@ -828,7 +841,7 @@ export class MCPManager {
|
|
|
828
841
|
return null;
|
|
829
842
|
}
|
|
830
843
|
|
|
831
|
-
const attempt = this.#doReconnect(name);
|
|
844
|
+
const attempt = this.#doReconnect(name, options?.authChallenge);
|
|
832
845
|
this.#pendingReconnections.set(name, attempt);
|
|
833
846
|
return attempt.finally(() => this.#pendingReconnections.delete(name));
|
|
834
847
|
}
|
|
@@ -873,12 +886,30 @@ export class MCPManager {
|
|
|
873
886
|
return false;
|
|
874
887
|
}
|
|
875
888
|
|
|
876
|
-
async #doReconnect(name: string): Promise<MCPServerConnection | null> {
|
|
889
|
+
async #doReconnect(name: string, authChallenge?: MCPAuthChallenge): Promise<MCPServerConnection | null> {
|
|
877
890
|
const oldConnection = this.#connections.get(name);
|
|
878
|
-
|
|
891
|
+
let config = oldConnection?.config ?? this.#serverConfigs.get(name);
|
|
879
892
|
const source = this.#sources.get(name) ?? oldConnection?._source;
|
|
880
893
|
if (!config) return null;
|
|
881
894
|
|
|
895
|
+
if (authChallenge) {
|
|
896
|
+
if (!this.#authHandler) {
|
|
897
|
+
logger.error("MCP auth challenge cannot be handled; no auth handler is configured", {
|
|
898
|
+
path: `mcp:${name}`,
|
|
899
|
+
});
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
const refreshedConfig = await this.#authHandler(name, authChallenge);
|
|
904
|
+
if (!refreshedConfig) return null;
|
|
905
|
+
config = refreshedConfig;
|
|
906
|
+
this.#serverConfigs.set(name, config);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
logger.error("MCP auth challenge handling failed", { path: `mcp:${name}`, error });
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
882
913
|
logger.debug("MCP reconnecting", { path: `mcp:${name}` });
|
|
883
914
|
|
|
884
915
|
// Close the old transport without removing tools or notifying consumers.
|
|
@@ -987,7 +1018,7 @@ export class MCPManager {
|
|
|
987
1018
|
};
|
|
988
1019
|
try {
|
|
989
1020
|
const serverTools = await listTools(connection);
|
|
990
|
-
const reconnect = () => this.reconnectServer(name);
|
|
1021
|
+
const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) => this.reconnectServer(name, options);
|
|
991
1022
|
const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
|
|
992
1023
|
void this.toolCache?.set(name, config, serverTools);
|
|
993
1024
|
this.#replaceServerTools(name, customTools);
|
package/src/mcp/render.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Provides structured display of MCP tool calls and results,
|
|
5
5
|
* showing args and output in JSON tree format similar to task tool.
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
7
|
+
import { type Component, Markdown } from "@oh-my-pi/pi-tui";
|
|
8
|
+
import { settings } from "../config/settings";
|
|
8
9
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
9
|
-
import type
|
|
10
|
+
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
|
|
10
11
|
import {
|
|
11
12
|
formatArgsInline,
|
|
12
13
|
JSON_TREE_MAX_DEPTH_COLLAPSED,
|
|
@@ -48,6 +49,61 @@ export function renderMCPCall(args: Record<string, unknown>, theme: Theme, label
|
|
|
48
49
|
);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** Render an MCP status/args prefix followed by Markdown-aware text output. */
|
|
53
|
+
function renderMarkdownMCPResult(
|
|
54
|
+
result: { details?: MCPToolDetails; isError?: boolean },
|
|
55
|
+
trimmedOutput: string,
|
|
56
|
+
truncationWarning: string | null,
|
|
57
|
+
options: RenderResultOptions,
|
|
58
|
+
theme: Theme,
|
|
59
|
+
args?: Record<string, unknown>,
|
|
60
|
+
): Component {
|
|
61
|
+
const markdown = new Markdown(trimmedOutput, 0, 0, getMarkdownTheme(), {
|
|
62
|
+
color: text => theme.fg("toolOutput", text),
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
render(contentWidth: number): readonly string[] {
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
const isError = result.isError ?? result.details?.isError ?? false;
|
|
68
|
+
const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
|
|
69
|
+
lines.push(
|
|
70
|
+
renderStatusLine(
|
|
71
|
+
isError ? { icon: "error", title } : { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title },
|
|
72
|
+
theme,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (options.expanded && args && Object.keys(args).length > 0) {
|
|
77
|
+
lines.push(theme.fg("dim", "Args"));
|
|
78
|
+
const tree = renderJsonTreeLines(
|
|
79
|
+
args,
|
|
80
|
+
theme,
|
|
81
|
+
JSON_TREE_MAX_DEPTH_EXPANDED,
|
|
82
|
+
JSON_TREE_MAX_LINES_EXPANDED,
|
|
83
|
+
JSON_TREE_SCALAR_LEN_EXPANDED,
|
|
84
|
+
);
|
|
85
|
+
lines.push(...tree.lines);
|
|
86
|
+
if (tree.truncated) lines.push(theme.fg("dim", "…"));
|
|
87
|
+
lines.push("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rendered = markdown.render(Math.max(1, contentWidth));
|
|
91
|
+
const maxOutputLines = options.expanded ? 12 : 4;
|
|
92
|
+
lines.push(...rendered.slice(0, maxOutputLines));
|
|
93
|
+
if (rendered.length > maxOutputLines) {
|
|
94
|
+
lines.push(
|
|
95
|
+
`${theme.fg("dim", `… ${rendered.length - maxOutputLines} more lines`)} ${formatExpandHint(theme, options.expanded, true)}`,
|
|
96
|
+
);
|
|
97
|
+
} else if (!options.expanded) {
|
|
98
|
+
lines.push(formatExpandHint(theme, options.expanded, true));
|
|
99
|
+
}
|
|
100
|
+
if (truncationWarning) lines.push(truncationWarning);
|
|
101
|
+
return lines;
|
|
102
|
+
},
|
|
103
|
+
invalidate(): void {},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
51
107
|
/**
|
|
52
108
|
* Render MCP tool result.
|
|
53
109
|
*/
|
|
@@ -58,6 +114,24 @@ export function renderMCPResult(
|
|
|
58
114
|
args?: Record<string, unknown>,
|
|
59
115
|
): Component {
|
|
60
116
|
const { expanded } = options;
|
|
117
|
+
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
118
|
+
const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
|
|
119
|
+
const truncationWarning = result.details?.meta?.truncation
|
|
120
|
+
? formatStyledTruncationWarning(result.details.meta, theme)
|
|
121
|
+
: null;
|
|
122
|
+
let parsedOutput: unknown;
|
|
123
|
+
let isJsonOutput = false;
|
|
124
|
+
if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
|
|
125
|
+
try {
|
|
126
|
+
parsedOutput = JSON.parse(trimmedOutput);
|
|
127
|
+
isJsonOutput = true;
|
|
128
|
+
} catch {
|
|
129
|
+
// Non-JSON text beginning with a bracket is still eligible for Markdown.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (trimmedOutput && settings.get("mcp.renderMarkdownResults") && !isJsonOutput) {
|
|
133
|
+
return renderMarkdownMCPResult(result, trimmedOutput, truncationWarning, options, theme, args);
|
|
134
|
+
}
|
|
61
135
|
return new WidthAwareText(
|
|
62
136
|
contentWidth => {
|
|
63
137
|
const lines: string[] = [];
|
|
@@ -86,46 +160,31 @@ export function renderMCPResult(
|
|
|
86
160
|
lines.push(""); // Blank line before output
|
|
87
161
|
}
|
|
88
162
|
|
|
89
|
-
// Output section
|
|
90
|
-
|
|
91
|
-
// Strip the LLM-facing spill notice before parsing/rendering: a spilled
|
|
92
|
-
// result appends `[Showing… artifact://N]` to the body, which would break
|
|
93
|
-
// JSON detection and bury the recovery link. Surface it as a styled warning
|
|
94
|
-
// instead, mirroring the built-in read/bash/ssh/browser renderers.
|
|
95
|
-
const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
|
|
96
|
-
const truncationWarning = result.details?.meta?.truncation
|
|
97
|
-
? formatStyledTruncationWarning(result.details.meta, theme)
|
|
98
|
-
: null;
|
|
163
|
+
// Output section. The body and spill metadata are normalized before
|
|
164
|
+
// component selection so the opt-in Markdown path can use its own renderer.
|
|
99
165
|
|
|
100
166
|
if (!trimmedOutput) {
|
|
101
167
|
lines.push(theme.fg("dim", "(no output)"));
|
|
102
168
|
return lines.join("\n");
|
|
103
169
|
}
|
|
104
170
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (!expanded) {
|
|
120
|
-
lines.push(formatExpandHint(theme, expanded, true));
|
|
121
|
-
} else if (tree.truncated) {
|
|
122
|
-
lines.push(theme.fg("dim", "…"));
|
|
123
|
-
}
|
|
124
|
-
if (truncationWarning) lines.push(truncationWarning);
|
|
125
|
-
return lines.join("\n");
|
|
171
|
+
// Preserve the existing structured JSON renderer regardless of the
|
|
172
|
+
// Markdown preference; JSON trees remain more useful than styled source.
|
|
173
|
+
if (isJsonOutput) {
|
|
174
|
+
const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
|
|
175
|
+
const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
|
|
176
|
+
const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
|
|
177
|
+
const tree = renderJsonTreeLines(parsedOutput, theme, maxDepth, maxLines, maxScalarLen);
|
|
178
|
+
|
|
179
|
+
if (tree.lines.length > 0) {
|
|
180
|
+
lines.push(...tree.lines);
|
|
181
|
+
if (!expanded) {
|
|
182
|
+
lines.push(formatExpandHint(theme, expanded, true));
|
|
183
|
+
} else if (tree.truncated) {
|
|
184
|
+
lines.push(theme.fg("dim", "…"));
|
|
126
185
|
}
|
|
127
|
-
|
|
128
|
-
|
|
186
|
+
if (truncationWarning) lines.push(truncationWarning);
|
|
187
|
+
return lines.join("\n");
|
|
129
188
|
}
|
|
130
189
|
}
|
|
131
190
|
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -22,10 +22,17 @@ import { normalizeLocalScheme } from "../tools/path-utils";
|
|
|
22
22
|
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
|
|
23
23
|
import { callTool } from "./client";
|
|
24
24
|
import { renderMCPCall, renderMCPResult } from "./render";
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
MCPAuthChallenge,
|
|
27
|
+
MCPContent,
|
|
28
|
+
MCPServerConnection,
|
|
29
|
+
MCPToolCallParams,
|
|
30
|
+
MCPToolCallResult,
|
|
31
|
+
MCPToolDefinition,
|
|
32
|
+
} from "./types";
|
|
26
33
|
|
|
27
|
-
/** Reconnect callback: tears down stale connection,
|
|
28
|
-
export type MCPReconnect = () => Promise<MCPServerConnection | null>;
|
|
34
|
+
/** Reconnect callback: tears down a stale connection, optionally authorizing first. */
|
|
35
|
+
export type MCPReconnect = (options?: { authChallenge?: MCPAuthChallenge }) => Promise<MCPServerConnection | null>;
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Network-level and stale-session errors that warrant a reconnect + single retry.
|
|
@@ -174,6 +181,8 @@ export interface MCPToolDetails {
|
|
|
174
181
|
isError?: boolean;
|
|
175
182
|
/** Raw content from MCP response */
|
|
176
183
|
rawContent?: MCPContent[];
|
|
184
|
+
/** Structured metadata from the MCP response */
|
|
185
|
+
mcpMeta?: Record<string, unknown>;
|
|
177
186
|
/** Provider ID (e.g., "claude", "mcp-json") */
|
|
178
187
|
provider?: string;
|
|
179
188
|
/** Provider display name (e.g., "Claude Code", "MCP Config") */
|
|
@@ -222,6 +231,7 @@ function buildResult(
|
|
|
222
231
|
mcpToolName,
|
|
223
232
|
isError: result.isError,
|
|
224
233
|
rawContent: result.content,
|
|
234
|
+
mcpMeta: result._meta,
|
|
225
235
|
provider,
|
|
226
236
|
providerName,
|
|
227
237
|
};
|
|
@@ -249,6 +259,51 @@ function buildErrorResult(
|
|
|
249
259
|
};
|
|
250
260
|
}
|
|
251
261
|
|
|
262
|
+
type MCPToolCallAttempt = {
|
|
263
|
+
connection: MCPServerConnection;
|
|
264
|
+
result?: MCPToolCallResult;
|
|
265
|
+
error?: unknown;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function getMcpAuthChallenge(result: MCPToolCallResult): MCPAuthChallenge | undefined {
|
|
269
|
+
if (!result.isError) return undefined;
|
|
270
|
+
const values = result._meta?.["mcp/www_authenticate"];
|
|
271
|
+
if (!Array.isArray(values)) return undefined;
|
|
272
|
+
const wwwAuthenticate = values.filter((value): value is string => typeof value === "string" && value.trim() !== "");
|
|
273
|
+
return wwwAuthenticate.length > 0 ? { wwwAuthenticate } : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function callToolWithAuthRetry(
|
|
277
|
+
connection: MCPServerConnection,
|
|
278
|
+
toolName: string,
|
|
279
|
+
args: MCPToolArgs,
|
|
280
|
+
reconnect: MCPReconnect | undefined,
|
|
281
|
+
signal?: AbortSignal,
|
|
282
|
+
): Promise<MCPToolCallAttempt> {
|
|
283
|
+
const result = await callTool(connection, toolName, args, { signal });
|
|
284
|
+
const authChallenge = getMcpAuthChallenge(result);
|
|
285
|
+
if (!authChallenge || !reconnect) return { connection, result };
|
|
286
|
+
|
|
287
|
+
let newConnection: MCPServerConnection | null;
|
|
288
|
+
try {
|
|
289
|
+
newConnection = await reconnectWithAbort(reconnect, signal, { authChallenge });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
rethrowIfAborted(error, signal);
|
|
292
|
+
return { connection, error };
|
|
293
|
+
}
|
|
294
|
+
if (!newConnection) return { connection, result };
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
return {
|
|
298
|
+
connection: newConnection,
|
|
299
|
+
result: await callTool(newConnection, toolName, args, { signal }),
|
|
300
|
+
};
|
|
301
|
+
} catch (error) {
|
|
302
|
+
rethrowIfAborted(error, signal);
|
|
303
|
+
return { connection: newConnection, error };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
252
307
|
/** Re-throw abort-related errors so they bypass error-result handling. */
|
|
253
308
|
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
|
254
309
|
if (error instanceof ToolAbortError) throw error;
|
|
@@ -256,9 +311,13 @@ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
|
|
256
311
|
if (signal?.aborted) throw new ToolAbortError();
|
|
257
312
|
}
|
|
258
313
|
|
|
259
|
-
async function reconnectWithAbort(
|
|
314
|
+
async function reconnectWithAbort(
|
|
315
|
+
reconnect: MCPReconnect,
|
|
316
|
+
signal?: AbortSignal,
|
|
317
|
+
options?: { authChallenge?: MCPAuthChallenge },
|
|
318
|
+
): Promise<MCPServerConnection | null> {
|
|
260
319
|
try {
|
|
261
|
-
return await untilAborted(signal, reconnect);
|
|
320
|
+
return await untilAborted(signal, () => reconnect(options));
|
|
262
321
|
} catch (error) {
|
|
263
322
|
rethrowIfAborted(error, signal);
|
|
264
323
|
return null;
|
|
@@ -379,8 +438,27 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
379
438
|
const providerName = this.connection._source?.providerName;
|
|
380
439
|
|
|
381
440
|
try {
|
|
382
|
-
const
|
|
383
|
-
|
|
441
|
+
const attempt = await callToolWithAuthRetry(this.connection, this.tool.name, args, this.reconnect, signal);
|
|
442
|
+
if (attempt.error !== undefined) {
|
|
443
|
+
return buildErrorResult(attempt.error, this.connection.name, this.tool.name, provider, providerName);
|
|
444
|
+
}
|
|
445
|
+
if (!attempt.result) {
|
|
446
|
+
return buildErrorResult(
|
|
447
|
+
new Error("MCP tool call returned no result"),
|
|
448
|
+
this.connection.name,
|
|
449
|
+
this.tool.name,
|
|
450
|
+
provider,
|
|
451
|
+
providerName,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
this.connection = attempt.connection;
|
|
455
|
+
return buildResult(
|
|
456
|
+
attempt.result,
|
|
457
|
+
attempt.connection.name,
|
|
458
|
+
this.tool.name,
|
|
459
|
+
attempt.connection._source?.provider ?? provider,
|
|
460
|
+
attempt.connection._source?.providerName ?? providerName,
|
|
461
|
+
);
|
|
384
462
|
} catch (error) {
|
|
385
463
|
rethrowIfAborted(error, signal);
|
|
386
464
|
if (this.reconnect && isRetriableConnectionError(error)) {
|
|
@@ -483,13 +561,31 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
483
561
|
const connection = await untilAborted(signal, () => this.getConnection());
|
|
484
562
|
throwIfAborted(signal);
|
|
485
563
|
try {
|
|
486
|
-
const
|
|
564
|
+
const attempt = await callToolWithAuthRetry(connection, this.tool.name, args, this.reconnect, signal);
|
|
565
|
+
if (attempt.error !== undefined) {
|
|
566
|
+
return buildErrorResult(
|
|
567
|
+
attempt.error,
|
|
568
|
+
this.serverName,
|
|
569
|
+
this.tool.name,
|
|
570
|
+
attempt.connection._source?.provider ?? provider,
|
|
571
|
+
attempt.connection._source?.providerName ?? providerName,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (!attempt.result) {
|
|
575
|
+
return buildErrorResult(
|
|
576
|
+
new Error("MCP tool call returned no result"),
|
|
577
|
+
this.serverName,
|
|
578
|
+
this.tool.name,
|
|
579
|
+
provider,
|
|
580
|
+
providerName,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
487
583
|
return buildResult(
|
|
488
|
-
result,
|
|
584
|
+
attempt.result,
|
|
489
585
|
this.serverName,
|
|
490
586
|
this.tool.name,
|
|
491
|
-
connection._source?.provider ?? provider,
|
|
492
|
-
connection._source?.providerName ?? providerName,
|
|
587
|
+
attempt.connection._source?.provider ?? provider,
|
|
588
|
+
attempt.connection._source?.providerName ?? providerName,
|
|
493
589
|
);
|
|
494
590
|
} catch (callError) {
|
|
495
591
|
rethrowIfAborted(callError, signal);
|