pi-codemcp 0.1.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.
package/src/chains.ts ADDED
@@ -0,0 +1,452 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { type ExtensionAPI, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import type { TSchema } from "typebox";
6
+ import { summarizeError } from "./errors.js";
7
+ import { previewExecutionValue, renderExecutionResult } from "./execution-rendering.js";
8
+ import type { CodeMcpLifecycle } from "./lifecycle.js";
9
+ import { formatCodeMcpOutput } from "./output.js";
10
+
11
+ export interface ChainJsonSchema extends TSchema {
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface SavedChainDependency {
16
+ kind: "mcp_tool" | "saved_chain";
17
+ name: string;
18
+ call: string;
19
+ server: string;
20
+ schemaFingerprint: string;
21
+ }
22
+
23
+ export interface SavedChainManifest {
24
+ version: 1;
25
+ id: string;
26
+ name: string;
27
+ description: string;
28
+ code: string;
29
+ inputSchema: ChainJsonSchema;
30
+ outputSchema: ChainJsonSchema;
31
+ enabled: boolean;
32
+ dependencies: SavedChainDependency[];
33
+ schemaFingerprint: string;
34
+ createdAt: number;
35
+ updatedAt: number;
36
+ validatedAt: number;
37
+ }
38
+
39
+ export type ChainScope = "global" | "project";
40
+
41
+ export interface SavedChainView {
42
+ chain: SavedChainManifest;
43
+ scope: ChainScope;
44
+ status: "ready" | "disabled" | "stale" | "shadowed";
45
+ staleDependencies: string[];
46
+ calledBy: string[];
47
+ }
48
+
49
+ export interface ChainEnabledChange {
50
+ name: string;
51
+ scope: ChainScope;
52
+ enabled: boolean;
53
+ }
54
+
55
+ export interface ManagerApplyResult {
56
+ chains: SavedChainView[];
57
+ status: Record<string, unknown>;
58
+ }
59
+
60
+ export interface SaveChainInput {
61
+ scope: ChainScope;
62
+ name: string;
63
+ description: string;
64
+ code: string;
65
+ inputSchema: ChainJsonSchema;
66
+ outputSchema: ChainJsonSchema;
67
+ }
68
+
69
+ interface ScopedSavedChain {
70
+ scope: ChainScope;
71
+ chain: SavedChainManifest;
72
+ }
73
+
74
+ interface LoadedChains {
75
+ chains: ScopedSavedChain[];
76
+ errors: string[];
77
+ }
78
+
79
+ const CHAIN_NAME = /^[a-z][a-z0-9_]{0,63}$/;
80
+
81
+ export class SavedChainManager {
82
+ readonly startupErrors: string[] = [];
83
+ private readonly manifests = new Map<string, ScopedSavedChain>();
84
+ private readonly registered = new Set<string>();
85
+ private projectChainsPath: string | undefined;
86
+
87
+ constructor(
88
+ private readonly pi: ExtensionAPI,
89
+ private readonly lifecycle: CodeMcpLifecycle,
90
+ ) {
91
+ this.reloadPersisted();
92
+ }
93
+
94
+ configureProject(path: string | undefined): void {
95
+ if (path === this.projectChainsPath) return;
96
+ this.projectChainsPath = path;
97
+ this.reloadPersisted();
98
+ }
99
+
100
+ activatePersisted(): void {
101
+ try {
102
+ this.refreshNativeTools();
103
+ } catch (error) {
104
+ this.startupErrors.push(summarizeError(error));
105
+ }
106
+ }
107
+
108
+ async save(input: SaveChainInput, signal?: AbortSignal): Promise<SavedChainView> {
109
+ assertChainName(input.name);
110
+ this.assertToolNameAvailable(input.name);
111
+ const result = await this.lifecycle.request(
112
+ "save_chain",
113
+ {
114
+ scope: input.scope,
115
+ name: input.name,
116
+ description: input.description,
117
+ code: input.code,
118
+ input_schema: input.inputSchema,
119
+ output_schema: input.outputSchema,
120
+ },
121
+ signal,
122
+ );
123
+ const root = requireRecord(result.chain, "save_chain.chain");
124
+ const view = parseSavedChainView(root, "save_chain.chain");
125
+ this.upsertView(view);
126
+ this.refreshNativeTools();
127
+ return view;
128
+ }
129
+
130
+ async list(signal?: AbortSignal): Promise<SavedChainView[]> {
131
+ const result = await this.lifecycle.request("list_chains", {}, signal);
132
+ const views = parseViewList(result.chains, "list_chains.chains");
133
+ this.synchronizeViews(views);
134
+ return views;
135
+ }
136
+
137
+ async applyEnabled(
138
+ changes: readonly ChainEnabledChange[],
139
+ signal?: AbortSignal,
140
+ ): Promise<ManagerApplyResult> {
141
+ const result = await this.lifecycle.request(
142
+ "apply_manager_changes",
143
+ {
144
+ changes: changes.map((change) => ({ ...change })),
145
+ },
146
+ signal,
147
+ );
148
+ const views = parseViewList(result.chains, "apply_manager_changes.chains");
149
+ const status = requireRecord(result.status, "apply_manager_changes.status");
150
+ this.synchronizeViews(views);
151
+ return { chains: views, status };
152
+ }
153
+
154
+ async revalidate(name: string, scope: ChainScope, signal?: AbortSignal): Promise<SavedChainView> {
155
+ const result = await this.lifecycle.request("revalidate_chain", { name, scope }, signal);
156
+ const view = parseSavedChainView(result, "revalidate_chain");
157
+ this.upsertView(view);
158
+ this.refreshNativeTools();
159
+ return view;
160
+ }
161
+
162
+ async delete(name: string, scope: ChainScope, signal?: AbortSignal): Promise<SavedChainView[]> {
163
+ const result = await this.lifecycle.request("delete_chain", { name, scope }, signal);
164
+ const views = parseViewList(result.chains, "delete_chain.chains");
165
+ this.synchronizeViews(views);
166
+ return views;
167
+ }
168
+
169
+ private register(chain: SavedChainManifest): void {
170
+ this.assertToolNameAvailable(chain.name);
171
+ const manager = this;
172
+ this.pi.registerTool({
173
+ name: nativeChainToolName(chain.name),
174
+ label: chain.name,
175
+ description: chain.description,
176
+ parameters: chain.inputSchema,
177
+ async execute(_toolCallId, params, signal, onUpdate) {
178
+ onUpdate?.({
179
+ content: [{ type: "text", text: `Running saved MCP chain ${chain.name}...` }],
180
+ details: undefined,
181
+ });
182
+ const arguments_ = requireRecord(params, `${chain.name} arguments`);
183
+ const result = await manager.lifecycle.request(
184
+ "execute_chain",
185
+ { name: chain.name, arguments: arguments_ },
186
+ signal,
187
+ );
188
+ if (result.ok !== true) {
189
+ const error =
190
+ typeof result.error === "string" ? result.error : `Saved chain ${chain.name} failed`;
191
+ throw new Error(error);
192
+ }
193
+ const settings = manager.lifecycle.loadSettings();
194
+ const output = formatCodeMcpOutput(result, {
195
+ maxBytes: settings.outputLimitKiB * 1024,
196
+ maxLines: settings.outputLineLimit,
197
+ });
198
+ return {
199
+ content: [{ type: "text", text: output.text }],
200
+ details: {
201
+ ...output.details,
202
+ chain: chain.name,
203
+ ok: true,
204
+ callsMade: Number(result.calls_made ?? 0),
205
+ chainCalls: Number(result.chain_calls ?? 0),
206
+ preview: previewExecutionValue(result.result),
207
+ },
208
+ };
209
+ },
210
+ renderCall(args, theme, context) {
211
+ return renderSavedChainCall(
212
+ chain,
213
+ requireRecord(args, `${chain.name} arguments`),
214
+ theme,
215
+ context.expanded,
216
+ );
217
+ },
218
+ renderResult(result, state, theme) {
219
+ return renderExecutionResult(result, state, theme, {
220
+ partialText: `Running saved MCP chain ${chain.name}...`,
221
+ expandDescription: "arguments and full output",
222
+ });
223
+ },
224
+ });
225
+ this.registered.add(chain.name);
226
+ }
227
+
228
+ private refreshNativeTools(): void {
229
+ const effective = this.effectiveManifests();
230
+ for (const item of effective) this.register(item.chain);
231
+ const managedNames = new Set([...this.registered].map((name) => nativeChainToolName(name)));
232
+ const active = this.pi.getActiveTools().filter((name) => !managedNames.has(name));
233
+ for (const item of effective) {
234
+ if (item.chain.enabled) active.push(nativeChainToolName(item.chain.name));
235
+ }
236
+ this.pi.setActiveTools([...new Set(active)]);
237
+ }
238
+
239
+ private effectiveManifests(): ScopedSavedChain[] {
240
+ const effective = new Map<string, ScopedSavedChain>();
241
+ for (const item of this.manifests.values()) {
242
+ const current = effective.get(item.chain.name);
243
+ if (!current || item.scope === "project") effective.set(item.chain.name, item);
244
+ }
245
+ return [...effective.values()].sort((left, right) =>
246
+ left.chain.name.localeCompare(right.chain.name),
247
+ );
248
+ }
249
+
250
+ private synchronizeViews(views: SavedChainView[]): void {
251
+ this.manifests.clear();
252
+ for (const view of views) this.upsertView(view);
253
+ this.refreshNativeTools();
254
+ }
255
+
256
+ private upsertView(view: SavedChainView): void {
257
+ this.manifests.set(scopeKey(view.scope, view.chain.name), {
258
+ scope: view.scope,
259
+ chain: view.chain,
260
+ });
261
+ }
262
+
263
+ private reloadPersisted(): void {
264
+ this.startupErrors.length = 0;
265
+ this.manifests.clear();
266
+ const global = loadSavedChains(this.lifecycle.chainsPath, "global");
267
+ this.startupErrors.push(...global.errors);
268
+ for (const item of global.chains) {
269
+ this.manifests.set(scopeKey(item.scope, item.chain.name), item);
270
+ }
271
+ if (this.projectChainsPath === undefined) return;
272
+ const project = loadSavedChains(this.projectChainsPath, "project");
273
+ this.startupErrors.push(...project.errors);
274
+ for (const item of project.chains) {
275
+ this.manifests.set(scopeKey(item.scope, item.chain.name), item);
276
+ }
277
+ }
278
+
279
+ private assertToolNameAvailable(name: string): void {
280
+ if (this.registered.has(name)) return;
281
+ const nativeName = nativeChainToolName(name);
282
+ if (this.pi.getAllTools().some((tool) => tool.name === nativeName)) {
283
+ throw new Error(
284
+ `Cannot register saved chain ${name}: native tool ${nativeName} already exists`,
285
+ );
286
+ }
287
+ }
288
+ }
289
+
290
+ function renderSavedChainCall(
291
+ chain: SavedChainManifest,
292
+ args: Record<string, unknown>,
293
+ theme: Theme,
294
+ expanded: boolean,
295
+ ): Text {
296
+ const title = theme.fg("toolTitle", theme.bold("MCP Chain"));
297
+ const name = theme.fg("accent", theme.bold(chain.name));
298
+ const count = Object.keys(args).length;
299
+ const argumentLabel = `${count} ${count === 1 ? "argument" : "arguments"}`;
300
+ if (expanded) {
301
+ const serialized = JSON.stringify(args, null, 2);
302
+ return new Text(
303
+ `${title} ${name}\n${theme.fg("accent", theme.bold(`Arguments · ${argumentLabel}`))}\n${highlightCode(serialized, "json").join("\n")}`,
304
+ 0,
305
+ 0,
306
+ );
307
+ }
308
+ return new Text(
309
+ `${title} ${name} ${theme.fg("muted", "·")} ${theme.fg("muted", argumentLabel)}`,
310
+ 0,
311
+ 0,
312
+ );
313
+ }
314
+
315
+ export function nativeChainToolName(name: string): string {
316
+ return `mcp_chain_${name}`;
317
+ }
318
+
319
+ export function parseSavedChainView(value: unknown, label: string): SavedChainView {
320
+ const root = requireRecord(value, label);
321
+ const status = root.status;
322
+ if (status !== "ready" && status !== "disabled" && status !== "stale" && status !== "shadowed") {
323
+ throw new TypeError(`${label}.status must be ready, disabled, stale, or shadowed`);
324
+ }
325
+ const scope = root.scope;
326
+ if (scope !== "global" && scope !== "project") {
327
+ throw new TypeError(`${label}.scope must be global or project`);
328
+ }
329
+ return {
330
+ chain: parseSavedChainManifest(root.chain, `${label}.chain`),
331
+ scope,
332
+ status,
333
+ staleDependencies: stringArray(root.stale_dependencies, `${label}.stale_dependencies`),
334
+ calledBy: stringArray(root.called_by, `${label}.called_by`),
335
+ };
336
+ }
337
+
338
+ export function parseSavedChainManifest(value: unknown, label: string): SavedChainManifest {
339
+ const root = requireRecord(value, label);
340
+ if (root.version !== 1) throw new TypeError(`${label}.version must be 1`);
341
+ const name = requiredString(root.name, `${label}.name`);
342
+ assertChainName(name);
343
+ const dependencies = Array.isArray(root.dependencies)
344
+ ? root.dependencies.map((dependency, index) =>
345
+ parseDependency(dependency, `${label}.dependencies[${index}]`),
346
+ )
347
+ : [];
348
+ return {
349
+ version: 1,
350
+ id: requiredString(root.id, `${label}.id`),
351
+ name,
352
+ description: requiredString(root.description, `${label}.description`),
353
+ code: requiredString(root.code, `${label}.code`),
354
+ inputSchema: requireSchema(root.input_schema, `${label}.input_schema`, true),
355
+ outputSchema: requireSchema(root.output_schema, `${label}.output_schema`, false),
356
+ enabled: requiredBoolean(root.enabled, `${label}.enabled`),
357
+ dependencies,
358
+ schemaFingerprint: requiredString(root.schema_fingerprint, `${label}.schema_fingerprint`),
359
+ createdAt: requiredNumber(root.created_at, `${label}.created_at`),
360
+ updatedAt: requiredNumber(root.updated_at, `${label}.updated_at`),
361
+ validatedAt: requiredNumber(root.validated_at, `${label}.validated_at`),
362
+ };
363
+ }
364
+
365
+ function loadSavedChains(directory: string, scope: ChainScope): LoadedChains {
366
+ if (!existsSync(directory)) return { chains: [], errors: [] };
367
+ const chains: ScopedSavedChain[] = [];
368
+ const errors: string[] = [];
369
+ for (const filename of readdirSync(directory)
370
+ .filter((name) => name.endsWith(".json"))
371
+ .sort()) {
372
+ const path = join(directory, filename);
373
+ try {
374
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
375
+ chains.push({ scope, chain: parseSavedChainManifest(parsed, path) });
376
+ } catch (error) {
377
+ errors.push(`Saved ${scope} chain ${filename} failed to load: ${summarizeError(error)}`);
378
+ }
379
+ }
380
+ return { chains, errors };
381
+ }
382
+
383
+ function parseViewList(value: unknown, label: string): SavedChainView[] {
384
+ const values = Array.isArray(value) ? value : [];
385
+ return values.map((item, index) => parseSavedChainView(item, `${label}[${index}]`));
386
+ }
387
+
388
+ function scopeKey(scope: ChainScope, name: string): string {
389
+ return `${scope}:${name}`;
390
+ }
391
+
392
+ function parseDependency(value: unknown, label: string): SavedChainDependency {
393
+ const root = requireRecord(value, label);
394
+ const kind = root.kind;
395
+ if (kind !== "mcp_tool" && kind !== "saved_chain") {
396
+ throw new TypeError(`${label}.kind must be mcp_tool or saved_chain`);
397
+ }
398
+ return {
399
+ kind,
400
+ name: requiredString(root.name, `${label}.name`),
401
+ call: requiredString(root.call, `${label}.call`),
402
+ server: requiredString(root.server, `${label}.server`),
403
+ schemaFingerprint: requiredString(root.schema_fingerprint, `${label}.schema_fingerprint`),
404
+ };
405
+ }
406
+
407
+ function requireSchema(value: unknown, label: string, requireObject: boolean): ChainJsonSchema {
408
+ const schema = requireRecord(value, label);
409
+ if (requireObject && schema.type !== "object") {
410
+ throw new TypeError(`${label}.type must be object`);
411
+ }
412
+ return schema;
413
+ }
414
+
415
+ function assertChainName(name: string): void {
416
+ if (!CHAIN_NAME.test(name)) {
417
+ throw new TypeError(
418
+ "Saved chain name must start with a lowercase letter and contain only lowercase letters, digits, and underscores (maximum 64 characters)",
419
+ );
420
+ }
421
+ }
422
+
423
+ function requiredString(value: unknown, label: string): string {
424
+ if (typeof value !== "string" || !value) throw new TypeError(`${label} must be a string`);
425
+ return value;
426
+ }
427
+
428
+ function requiredBoolean(value: unknown, label: string): boolean {
429
+ if (typeof value !== "boolean") throw new TypeError(`${label} must be a boolean`);
430
+ return value;
431
+ }
432
+
433
+ function requiredNumber(value: unknown, label: string): number {
434
+ if (typeof value !== "number" || !Number.isFinite(value)) {
435
+ throw new TypeError(`${label} must be a number`);
436
+ }
437
+ return value;
438
+ }
439
+
440
+ function stringArray(value: unknown, label: string): string[] {
441
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
442
+ throw new TypeError(`${label} must be an array of strings`);
443
+ }
444
+ return [...new Set(value)];
445
+ }
446
+
447
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
448
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
449
+ throw new TypeError(`${label} must be an object`);
450
+ }
451
+ return Object.fromEntries(Object.entries(value));
452
+ }
package/src/config.ts ADDED
@@ -0,0 +1,58 @@
1
+ import {
2
+ type JsonRecord,
3
+ readJsonObject,
4
+ requireJsonObject,
5
+ writeJsonObjectAtomically,
6
+ } from "./json-file.js";
7
+
8
+ export interface McpServerEnabledChange {
9
+ name: string;
10
+ enabled: boolean;
11
+ }
12
+
13
+ export function setMcpServerEnabled(configPath: string, name: string, enabled: boolean): void {
14
+ setMcpServersEnabled(configPath, [{ name, enabled }]);
15
+ }
16
+
17
+ export function setMcpServersEnabled(
18
+ configPath: string,
19
+ changes: readonly McpServerEnabledChange[],
20
+ ): void {
21
+ if (changes.length === 0) return;
22
+ const duplicate = duplicateName(changes.map((change) => change.name));
23
+ if (duplicate) throw new Error(`Duplicate MCP server change: ${JSON.stringify(duplicate)}`);
24
+
25
+ const root = readJsonObject(configPath, "mcp.json root");
26
+ const hasServerBlock = Object.hasOwn(root, "mcpServers");
27
+ const servers = hasServerBlock ? requireJsonObject(root.mcpServers, "mcp.json mcpServers") : root;
28
+ const updatedServers: JsonRecord = { ...servers };
29
+
30
+ for (const change of changes) {
31
+ const server = requireJsonObject(
32
+ servers[change.name],
33
+ `MCP server ${JSON.stringify(change.name)}`,
34
+ );
35
+ const updatedServer: JsonRecord = { ...server };
36
+ if (typeof server.enabled === "boolean") {
37
+ updatedServer.enabled = change.enabled;
38
+ delete updatedServer.disabled;
39
+ } else {
40
+ updatedServer.disabled = !change.enabled;
41
+ }
42
+ updatedServers[change.name] = updatedServer;
43
+ }
44
+
45
+ const updatedRoot: JsonRecord = hasServerBlock
46
+ ? { ...root, mcpServers: updatedServers }
47
+ : updatedServers;
48
+ writeJsonObjectAtomically(configPath, updatedRoot);
49
+ }
50
+
51
+ function duplicateName(names: readonly string[]): string | undefined {
52
+ const seen = new Set<string>();
53
+ for (const name of names) {
54
+ if (seen.has(name)) return name;
55
+ seen.add(name);
56
+ }
57
+ return undefined;
58
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,9 @@
1
+ export function summarizeError(error: unknown): string {
2
+ const lines = (error instanceof Error ? error.message : String(error))
3
+ .split("\n")
4
+ .map((line) => line.trim())
5
+ .filter(Boolean);
6
+ const first = lines[0] ?? "Unknown error";
7
+ const last = lines.at(-1);
8
+ return last && last !== first ? `${first} — ${last}` : first;
9
+ }
@@ -0,0 +1,183 @@
1
+ import { highlightCode, keyHint, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { CodeMcpOutputDetails } from "./output.js";
4
+
5
+ export interface ExecutionRenderDetails extends CodeMcpOutputDetails {
6
+ ok: boolean;
7
+ failureStage?: string;
8
+ callsMade: number;
9
+ chainCalls: number;
10
+ preview: string[];
11
+ }
12
+
13
+ interface RenderResult {
14
+ content: readonly unknown[];
15
+ details?: unknown;
16
+ }
17
+
18
+ interface ExecutionRendererOptions {
19
+ partialText?: string;
20
+ expandDescription?: string;
21
+ }
22
+
23
+ export function renderExecutionResult(
24
+ result: RenderResult,
25
+ state: { expanded: boolean; isPartial: boolean },
26
+ theme: Theme,
27
+ options: ExecutionRendererOptions = {},
28
+ ): Text {
29
+ if (state.isPartial) {
30
+ return new Text(
31
+ `\n${theme.fg("warning", options.partialText ?? "Preflight check, then execution...")}`,
32
+ 0,
33
+ 0,
34
+ );
35
+ }
36
+ const details = result.details as ExecutionRenderDetails | undefined;
37
+ if (state.expanded) return renderExpandedResult(result.content, details, theme);
38
+ const calls = details?.callsMade ?? 0;
39
+ const chainCalls = details?.chainCalls ?? 0;
40
+ const outputTokens = details?.outputTokens ?? 0;
41
+ let text = details?.ok
42
+ ? `\n${theme.fg("success", `✓ Output · ${formatExecutionCalls(calls, chainCalls)} · ${formatTokenEstimate(outputTokens)}`)}`
43
+ : `\n${renderCompactFailure(details?.failureStage, calls, chainCalls, theme)}`;
44
+ for (const line of details?.preview ?? []) {
45
+ text += `\n${theme.fg("dim", ` ${line}`)}`;
46
+ }
47
+ if (details?.truncated) text += `\n${theme.fg("warning", " output truncated")}`;
48
+ text += `\n${theme.fg(
49
+ "muted",
50
+ keyHint("app.tools.expand", options.expandDescription ?? "code and full output"),
51
+ )}`;
52
+ return new Text(text, 0, 0);
53
+ }
54
+
55
+ export function previewExecutionValue(value: unknown): string[] {
56
+ if (isRecord(value)) {
57
+ return Object.entries(value)
58
+ .slice(0, 3)
59
+ .map(([key, entry]) => `${key}: ${summarizeValue(entry)}`);
60
+ }
61
+ if (Array.isArray(value)) {
62
+ return value.slice(0, 3).map((entry, index) => `[${index}]: ${summarizeValue(entry)}`);
63
+ }
64
+ if (value === null || value === undefined) return [];
65
+ return [summarizeValue(value)];
66
+ }
67
+
68
+ export function getTextContent(content: readonly unknown[]): string {
69
+ return content
70
+ .flatMap((item) => {
71
+ if (!isRecord(item) || item.type !== "text" || typeof item.text !== "string") return [];
72
+ return [item.text];
73
+ })
74
+ .join("\n");
75
+ }
76
+
77
+ function renderExpandedResult(
78
+ content: readonly unknown[],
79
+ details: ExecutionRenderDetails | undefined,
80
+ theme: Theme,
81
+ ): Text {
82
+ const response = parseJsonObject(getTextContent(content));
83
+ const calls = details?.callsMade ?? 0;
84
+ const chainCalls = details?.chainCalls ?? 0;
85
+ if (details?.ok) {
86
+ const output = response ? formatJson(response.result) : getTextContent(content);
87
+ const highlighted = highlightCode(output, "json").join("\n");
88
+ return new Text(
89
+ `\n${theme.fg("success", theme.bold(`Output · ${formatExecutionCalls(calls, chainCalls)}`))}\n${highlighted}`,
90
+ 0,
91
+ 0,
92
+ );
93
+ }
94
+
95
+ const stage = details?.failureStage ?? "runtime";
96
+ const error =
97
+ response && typeof response.error === "string" ? response.error : getTextContent(content);
98
+ const heading = failureHeading(stage, calls, chainCalls, theme);
99
+ const coloredError =
100
+ stage === "preflight" ? theme.fg("warning", error) : theme.fg("error", error);
101
+ return new Text(`\n${heading}\n${coloredError}`, 0, 0);
102
+ }
103
+
104
+ function renderCompactFailure(
105
+ stage: string | undefined,
106
+ calls: number,
107
+ chainCalls: number,
108
+ theme: Theme,
109
+ ): string {
110
+ const summary = formatExecutionCalls(calls, chainCalls);
111
+ if (stage === "preflight") {
112
+ return theme.fg("warning", `✗ Preflight · code not run · ${summary}`);
113
+ }
114
+ if (stage === "timeout") {
115
+ return theme.fg("error", `✗ Timeout · stopped after ${summary}`);
116
+ }
117
+ if (stage === "cancelled") {
118
+ return theme.fg("warning", `✗ Cancelled · stopped after ${summary}`);
119
+ }
120
+ if (stage === "result") {
121
+ return theme.fg("warning", `✗ Result too large · ${summary} completed`);
122
+ }
123
+ return theme.fg("error", `✗ Runtime · failed after ${summary}`);
124
+ }
125
+
126
+ function failureHeading(stage: string, calls: number, chainCalls: number, theme: Theme): string {
127
+ const summary = formatExecutionCalls(calls, chainCalls);
128
+ if (stage === "preflight") {
129
+ return `${theme.fg("warning", theme.bold("Preflight failed"))}\n${theme.fg("muted", "Code was not executed; no upstream side effects")}`;
130
+ }
131
+ if (stage === "timeout") {
132
+ return `${theme.fg("error", theme.bold("Execution timed out"))}\n${theme.fg("muted", `Stopped after ${summary}`)}`;
133
+ }
134
+ if (stage === "cancelled") {
135
+ return `${theme.fg("warning", theme.bold("Execution cancelled"))}\n${theme.fg("muted", `Stopped after ${summary}`)}`;
136
+ }
137
+ if (stage === "result") {
138
+ return `${theme.fg("warning", theme.bold("Result too large"))}\n${theme.fg("muted", `Call graph completed (${summary}); return a smaller value`)}`;
139
+ }
140
+ return `${theme.fg("error", theme.bold("Runtime failed"))}\n${theme.fg("muted", `Failure occurred after ${summary}`)}`;
141
+ }
142
+
143
+ function formatExecutionCalls(mcpCalls: number, chainCalls: number): string {
144
+ const mcp = `${mcpCalls} MCP ${mcpCalls === 1 ? "call" : "calls"}`;
145
+ return chainCalls > 0
146
+ ? `${mcp} · ${chainCalls} chain ${chainCalls === 1 ? "call" : "calls"}`
147
+ : mcp;
148
+ }
149
+
150
+ function formatTokenEstimate(tokens: number): string {
151
+ return `~${tokens.toLocaleString("en-US")} tokens`;
152
+ }
153
+
154
+ function formatJson(value: unknown): string {
155
+ return JSON.stringify(value, null, 2) ?? String(value);
156
+ }
157
+
158
+ function parseJsonObject(value: string): Record<string, unknown> | undefined {
159
+ try {
160
+ const parsed: unknown = JSON.parse(value);
161
+ return isRecord(parsed) ? parsed : undefined;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ function summarizeValue(value: unknown): string {
168
+ if (Array.isArray(value)) return `[${value.length} items]`;
169
+ if (isRecord(value)) {
170
+ const keys = Object.keys(value);
171
+ return `{${keys.slice(0, 4).join(", ")}${keys.length > 4 ? ", …" : ""}}`;
172
+ }
173
+ if (typeof value === "string") return truncate(value.replace(/\s+/g, " "), 100);
174
+ return String(value);
175
+ }
176
+
177
+ function truncate(value: string, maxLength: number): string {
178
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
179
+ }
180
+
181
+ function isRecord(value: unknown): value is Record<string, unknown> {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }