@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.3
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 +38 -0
- package/dist/cli.js +3221 -3208
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/renderers.d.ts +13 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/settings-schema.ts +10 -0
- package/src/dap/client.ts +134 -36
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +74 -8
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/controllers/event-controller.ts +7 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/session/agent-session.ts +166 -30
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/tools/bash.ts +30 -1
- package/src/tools/grep.ts +21 -1
- package/src/tools/read.ts +14 -4
- package/src/tools/renderers.ts +13 -0
- package/src/tools/ssh.ts +8 -0
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- package/src/web/search/providers/gemini.ts +23 -6
package/src/main.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
writeLastChangelogVersion,
|
|
85
85
|
} from "./utils/changelog";
|
|
86
86
|
import { EventBus } from "./utils/event-bus";
|
|
87
|
+
import { withTimeoutSignal } from "./utils/fetch-timeout";
|
|
87
88
|
|
|
88
89
|
type RunAcpMode = (createSession: AcpSessionFactory) => Promise<never>;
|
|
89
90
|
type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promise<void>;
|
|
@@ -102,7 +103,9 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
|
|
|
102
103
|
return;
|
|
103
104
|
}
|
|
104
105
|
try {
|
|
105
|
-
const response = await fetch("https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest"
|
|
106
|
+
const response = await fetch("https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest", {
|
|
107
|
+
signal: withTimeoutSignal(5_000),
|
|
108
|
+
});
|
|
106
109
|
if (!response.ok) return undefined;
|
|
107
110
|
|
|
108
111
|
const data = (await response.json()) as { version?: string };
|
|
@@ -1383,6 +1386,9 @@ export async function runRootCommand(
|
|
|
1383
1386
|
}
|
|
1384
1387
|
|
|
1385
1388
|
if (!isInteractive && !session.model) {
|
|
1389
|
+
if (modelRegistryError) {
|
|
1390
|
+
process.stderr.write(`${chalk.red(modelRegistryError.message)}\n\n`);
|
|
1391
|
+
}
|
|
1386
1392
|
if (modelFallbackMessage) {
|
|
1387
1393
|
process.stderr.write(`${chalk.red(modelFallbackMessage)}\n`);
|
|
1388
1394
|
} else {
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -79,6 +79,31 @@ function hasOAuthScope(scopes: string | null | undefined, scope: string): boolea
|
|
|
79
79
|
return !!scopes && scopes.split(/\s+/).includes(scope);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Trim a DCR failure body / thrown error message to a single, short line the
|
|
84
|
+
* caller can splice into an error string. `undefined` when nothing salvageable
|
|
85
|
+
* remains after stripping whitespace.
|
|
86
|
+
*/
|
|
87
|
+
function truncateDetail(raw: string | undefined): string | undefined {
|
|
88
|
+
if (!raw) return undefined;
|
|
89
|
+
const firstLine = raw.split(/\r?\n/, 1)[0]?.trim();
|
|
90
|
+
if (!firstLine) return undefined;
|
|
91
|
+
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the response body of a rejected DCR request as a short diagnostic
|
|
96
|
+
* string. Never throws — the caller is already building an error and cannot
|
|
97
|
+
* afford to trade the actual failure for a "read body" one.
|
|
98
|
+
*/
|
|
99
|
+
async function readRegistrationFailureDetail(response: Response): Promise<string | undefined> {
|
|
100
|
+
try {
|
|
101
|
+
return truncateDetail(await response.text());
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
function isLoopbackHostname(hostname: string): boolean {
|
|
83
108
|
return hostname === "localhost" || hostname === "127.0.0.1";
|
|
84
109
|
}
|
|
@@ -294,6 +319,22 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
294
319
|
#codeVerifier?: string;
|
|
295
320
|
#fetch: FetchImpl;
|
|
296
321
|
#resource?: string;
|
|
322
|
+
/**
|
|
323
|
+
* Details of a rejected dynamic client-registration attempt. Populated by
|
|
324
|
+
* {@link #tryRegisterClient} when the provider advertises a registration
|
|
325
|
+
* endpoint but returns a non-2xx / throws (e.g. Figma's DCR endpoint 403s
|
|
326
|
+
* every request because only catalog-approved clients may connect). Reused
|
|
327
|
+
* by {@link #missingClientIdError} to explain why the fallback probe now
|
|
328
|
+
* requires a manually configured `oauth.clientId`, replacing the opaque
|
|
329
|
+
* "OAuth provider requires client_id" message.
|
|
330
|
+
*/
|
|
331
|
+
#registrationFailure?: {
|
|
332
|
+
endpoint: string;
|
|
333
|
+
/** HTTP status returned by the endpoint; `0` when the request threw. */
|
|
334
|
+
status: number;
|
|
335
|
+
/** First line of the response body (or thrown error message), trimmed. */
|
|
336
|
+
detail?: string;
|
|
337
|
+
};
|
|
297
338
|
|
|
298
339
|
constructor(
|
|
299
340
|
private config: MCPOAuthConfig,
|
|
@@ -507,6 +548,13 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
507
548
|
|
|
508
549
|
/**
|
|
509
550
|
* Try OAuth dynamic client registration when provider requires a client_id.
|
|
551
|
+
*
|
|
552
|
+
* Records rejection details on {@link #registrationFailure} so that when
|
|
553
|
+
* DCR is intentionally closed (Figma's `mcp:connect` endpoint returns 403 to
|
|
554
|
+
* every unlisted client — see https://developers.figma.com/docs/figma-mcp-server/,
|
|
555
|
+
* "Only clients listed in the Figma MCP Catalog can connect"), the fallback
|
|
556
|
+
* probe surfaces a message that names the endpoint and status instead of
|
|
557
|
+
* the historical opaque "OAuth provider requires client_id".
|
|
510
558
|
*/
|
|
511
559
|
async #tryRegisterClient(redirectUri: string): Promise<void> {
|
|
512
560
|
const registrationEndpoint = await this.#resolveRegistrationEndpoint();
|
|
@@ -530,7 +578,14 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
530
578
|
}),
|
|
531
579
|
});
|
|
532
580
|
|
|
533
|
-
if (!response.ok)
|
|
581
|
+
if (!response.ok) {
|
|
582
|
+
this.#registrationFailure = {
|
|
583
|
+
endpoint: registrationEndpoint,
|
|
584
|
+
status: response.status,
|
|
585
|
+
detail: await readRegistrationFailureDetail(response),
|
|
586
|
+
};
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
534
589
|
|
|
535
590
|
const data = (await response.json()) as {
|
|
536
591
|
client_id?: string;
|
|
@@ -543,8 +598,14 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
543
598
|
if (data.client_secret && data.client_secret.trim() !== "") {
|
|
544
599
|
this.#registeredClientSecret = data.client_secret;
|
|
545
600
|
}
|
|
546
|
-
} catch {
|
|
547
|
-
//
|
|
601
|
+
} catch (error) {
|
|
602
|
+
// Distinguish real transport/parse failures from a benign no-DCR
|
|
603
|
+
// response so #missingClientIdError can surface what went wrong.
|
|
604
|
+
this.#registrationFailure = {
|
|
605
|
+
endpoint: registrationEndpoint,
|
|
606
|
+
status: 0,
|
|
607
|
+
detail: error instanceof Error ? truncateDetail(error.message) : undefined,
|
|
608
|
+
};
|
|
548
609
|
}
|
|
549
610
|
}
|
|
550
611
|
|
|
@@ -609,7 +670,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
609
670
|
if (response.status < 400) return;
|
|
610
671
|
const body = await response.text();
|
|
611
672
|
if (/client[_-]?id/i.test(body) && /(required|missing|invalid)/i.test(body)) {
|
|
612
|
-
throw
|
|
673
|
+
throw this.#missingClientIdError();
|
|
613
674
|
}
|
|
614
675
|
} catch (error) {
|
|
615
676
|
if (error instanceof Error && /client[_-]?id/i.test(error.message)) {
|
|
@@ -618,6 +679,34 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
618
679
|
// Ignore network/probe failures to avoid blocking flows that still work.
|
|
619
680
|
}
|
|
620
681
|
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Build the error thrown when the authorize probe confirms the provider
|
|
685
|
+
* demands a `client_id`. When dynamic client registration was attempted and
|
|
686
|
+
* rejected (e.g. Figma's 403 for unlisted clients), fold the endpoint + HTTP
|
|
687
|
+
* status into the message and point the user at the manual `oauth.clientId`
|
|
688
|
+
* workaround. Refs issue #4307.
|
|
689
|
+
*/
|
|
690
|
+
#missingClientIdError(): Error {
|
|
691
|
+
const failure = this.#registrationFailure;
|
|
692
|
+
const manualHint =
|
|
693
|
+
"Configure `oauth.clientId` (and `oauth.clientSecret` if the flow needs one) on the MCP server entry in mcp.json.";
|
|
694
|
+
if (!failure) {
|
|
695
|
+
return new Error(
|
|
696
|
+
`OAuth provider requires client_id, and no dynamic-client-registration endpoint was advertised. ${manualHint}`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
const outcome =
|
|
700
|
+
failure.status > 0
|
|
701
|
+
? `HTTP ${failure.status}${failure.detail ? ` — ${failure.detail}` : ""}`
|
|
702
|
+
: failure.detail
|
|
703
|
+
? `network error — ${failure.detail}`
|
|
704
|
+
: "network error";
|
|
705
|
+
return new Error(
|
|
706
|
+
`OAuth provider requires client_id, and dynamic client registration was rejected ` +
|
|
707
|
+
`(POST ${failure.endpoint} → ${outcome}). The server likely restricts registration to pre-approved clients. ${manualHint}`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
621
710
|
}
|
|
622
711
|
|
|
623
712
|
/**
|
package/src/mcp/smithery-auth.ts
CHANGED
|
@@ -2,9 +2,11 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
|
|
5
|
+
import { withTimeoutSignal } from "../utils/fetch-timeout";
|
|
5
6
|
|
|
6
7
|
const SMITHERY_AUTH_FILENAME = "smithery.json";
|
|
7
8
|
const SMITHERY_URL = process.env.SMITHERY_URL || "https://smithery.ai";
|
|
9
|
+
const SMITHERY_AUTH_TIMEOUT_MS = 10_000;
|
|
8
10
|
|
|
9
11
|
type SmitheryCliAuthSession = {
|
|
10
12
|
sessionId: string;
|
|
@@ -38,6 +40,7 @@ export function getSmitheryLoginUrl(): string {
|
|
|
38
40
|
export async function createSmitheryCliAuthSession(): Promise<SmitheryCliAuthSession> {
|
|
39
41
|
const response = await fetch(`${SMITHERY_URL}/api/auth/cli/session`, {
|
|
40
42
|
method: "POST",
|
|
43
|
+
signal: withTimeoutSignal(SMITHERY_AUTH_TIMEOUT_MS),
|
|
41
44
|
});
|
|
42
45
|
if (!response.ok) {
|
|
43
46
|
throw new Error(`Failed to create Smithery auth session: ${response.status} ${response.statusText}`);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { withTimeoutSignal } from "../utils/fetch-timeout";
|
|
2
|
+
|
|
1
3
|
const SMITHERY_API_BASE_URL = (process.env.SMITHERY_API_URL || "https://api.smithery.ai").replace(/\/+$/, "");
|
|
4
|
+
const SMITHERY_CONNECT_TIMEOUT_MS = 10_000;
|
|
2
5
|
|
|
3
6
|
export class SmitheryConnectError extends Error {
|
|
4
7
|
status: number;
|
|
@@ -62,6 +65,7 @@ export function getSmitheryApiBaseUrl(): string {
|
|
|
62
65
|
export async function listSmitheryNamespaces(apiKey: string): Promise<SmitheryNamespace[]> {
|
|
63
66
|
const response = await fetch(toApiUrl("/namespaces"), {
|
|
64
67
|
headers: buildAuthHeaders(apiKey),
|
|
68
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
65
69
|
});
|
|
66
70
|
await expectOk(response, "Failed to list Smithery namespaces");
|
|
67
71
|
const payload = (await response.json()) as SmitheryNamespacesResponse;
|
|
@@ -72,6 +76,7 @@ export async function createSmitheryNamespace(apiKey: string): Promise<SmitheryN
|
|
|
72
76
|
const response = await fetch(toApiUrl("/namespaces"), {
|
|
73
77
|
method: "POST",
|
|
74
78
|
headers: buildAuthHeaders(apiKey),
|
|
79
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
75
80
|
});
|
|
76
81
|
await expectOk(response, "Failed to create Smithery namespace");
|
|
77
82
|
return (await response.json()) as SmitheryNamespace;
|
|
@@ -95,6 +100,7 @@ export async function listSmitheryConnectionsByUrl(
|
|
|
95
100
|
endpoint.searchParams.set("mcpUrl", mcpUrl);
|
|
96
101
|
const response = await fetch(endpoint.toString(), {
|
|
97
102
|
headers: buildAuthHeaders(apiKey),
|
|
103
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
98
104
|
});
|
|
99
105
|
await expectOk(response, "Failed to list Smithery connections");
|
|
100
106
|
const payload = (await response.json()) as SmitheryConnectionsResponse;
|
|
@@ -109,6 +115,7 @@ export async function createSmitheryConnection(
|
|
|
109
115
|
const response = await fetch(toApiUrl(`/connect/${encodeURIComponent(namespace)}`), {
|
|
110
116
|
method: "POST",
|
|
111
117
|
headers: buildAuthHeaders(apiKey),
|
|
118
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
112
119
|
body: JSON.stringify({
|
|
113
120
|
mcpUrl: params.mcpUrl,
|
|
114
121
|
name: params.name,
|
|
@@ -127,6 +134,7 @@ export async function getSmitheryConnection(
|
|
|
127
134
|
toApiUrl(`/connect/${encodeURIComponent(namespace)}/${encodeURIComponent(connectionId)}`),
|
|
128
135
|
{
|
|
129
136
|
headers: buildAuthHeaders(apiKey),
|
|
137
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
130
138
|
},
|
|
131
139
|
);
|
|
132
140
|
await expectOk(response, "Failed to get Smithery connection");
|
|
@@ -139,6 +147,7 @@ export async function deleteSmitheryConnection(apiKey: string, namespace: string
|
|
|
139
147
|
{
|
|
140
148
|
method: "DELETE",
|
|
141
149
|
headers: buildAuthHeaders(apiKey),
|
|
150
|
+
signal: withTimeoutSignal(SMITHERY_CONNECT_TIMEOUT_MS),
|
|
142
151
|
},
|
|
143
152
|
);
|
|
144
153
|
await expectOk(response, "Failed to delete Smithery connection");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { searchSmitheryRegistry } from "./smithery-registry";
|
|
3
|
+
|
|
4
|
+
type FetchInput = string | URL | Request;
|
|
5
|
+
type FetchInit = RequestInit | BunFetchRequestInit;
|
|
6
|
+
|
|
7
|
+
describe("searchSmitheryRegistry fetch cancellation", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("adds timeout signals to search and detail requests", async () => {
|
|
13
|
+
const signals: AbortSignal[] = [];
|
|
14
|
+
const fetchStub = Object.assign(
|
|
15
|
+
async (input: FetchInput, init?: FetchInit) => {
|
|
16
|
+
if (init?.signal instanceof AbortSignal) signals.push(init.signal);
|
|
17
|
+
const url = String(input);
|
|
18
|
+
if (url.includes("?")) {
|
|
19
|
+
return Response.json({
|
|
20
|
+
servers: [
|
|
21
|
+
{
|
|
22
|
+
id: "srv_1",
|
|
23
|
+
namespace: "smithery-ai",
|
|
24
|
+
slug: "filesystem",
|
|
25
|
+
qualifiedName: "@smithery-ai/filesystem",
|
|
26
|
+
displayName: "Filesystem",
|
|
27
|
+
description: "File access",
|
|
28
|
+
useCount: 1,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return Response.json({
|
|
34
|
+
qualifiedName: "@smithery-ai/filesystem",
|
|
35
|
+
displayName: "Filesystem",
|
|
36
|
+
description: "File access",
|
|
37
|
+
connections: [{ type: "http", deploymentUrl: "https://mcp.example" }],
|
|
38
|
+
tools: [],
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
{ preconnect: globalThis.fetch.preconnect },
|
|
42
|
+
);
|
|
43
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(fetchStub);
|
|
44
|
+
|
|
45
|
+
const results = await searchSmitheryRegistry("filesystem", { limit: 1 });
|
|
46
|
+
|
|
47
|
+
expect(results[0]?.name).toBe("smithery-ai/filesystem");
|
|
48
|
+
expect(signals).toHaveLength(2);
|
|
49
|
+
expect(signals.every(signal => signal instanceof AbortSignal)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
|
|
2
3
|
import type { MCPServerConfig } from "./types";
|
|
3
4
|
|
|
4
5
|
const SMITHERY_REGISTRY_BASE_URL = "https://registry.smithery.ai";
|
|
6
|
+
const SMITHERY_REGISTRY_TIMEOUT_MS = 10_000;
|
|
5
7
|
|
|
6
8
|
type SmitherySearchEntry = {
|
|
7
9
|
id?: string;
|
|
@@ -106,6 +108,7 @@ export interface SmitherySearchOptions {
|
|
|
106
108
|
limit?: number;
|
|
107
109
|
apiKey?: string;
|
|
108
110
|
includeSemantic?: boolean;
|
|
111
|
+
signal?: AbortSignal;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export class SmitheryRegistryError extends Error {
|
|
@@ -307,13 +310,17 @@ function createConfig(
|
|
|
307
310
|
};
|
|
308
311
|
}
|
|
309
312
|
|
|
310
|
-
async function fetchServerDetails(
|
|
313
|
+
async function fetchServerDetails(
|
|
314
|
+
path: string,
|
|
315
|
+
options?: { apiKey?: string; signal?: AbortSignal },
|
|
316
|
+
): Promise<SmitheryServerDetails | null> {
|
|
311
317
|
const headers = new Headers();
|
|
312
318
|
if (options?.apiKey) {
|
|
313
319
|
headers.set("Authorization", `Bearer ${options.apiKey}`);
|
|
314
320
|
}
|
|
315
321
|
const response = await fetch(`${SMITHERY_REGISTRY_BASE_URL}/servers/${path}`, {
|
|
316
322
|
headers,
|
|
323
|
+
signal: withTimeoutSignal(SMITHERY_REGISTRY_TIMEOUT_MS, options?.signal),
|
|
317
324
|
});
|
|
318
325
|
if (!response.ok) return null;
|
|
319
326
|
return (await response.json()) as SmitheryServerDetails;
|
|
@@ -321,7 +328,7 @@ async function fetchServerDetails(path: string, options?: { apiKey?: string }):
|
|
|
321
328
|
|
|
322
329
|
async function fetchServerDetailsFromEntry(
|
|
323
330
|
entry: SmitherySearchEntry,
|
|
324
|
-
options?: { apiKey?: string },
|
|
331
|
+
options?: { apiKey?: string; signal?: AbortSignal },
|
|
325
332
|
): Promise<SmitheryServerDetails | null> {
|
|
326
333
|
const candidates = resolveDetailPathCandidates(entry);
|
|
327
334
|
for (const candidate of candidates) {
|
|
@@ -329,6 +336,7 @@ async function fetchServerDetailsFromEntry(
|
|
|
329
336
|
const details = await fetchServerDetails(candidate, options);
|
|
330
337
|
if (details) return details;
|
|
331
338
|
} catch (error) {
|
|
339
|
+
if (options?.signal?.aborted) throw error;
|
|
332
340
|
logger.debug("Smithery detail fetch candidate failed", { candidate, error: String(error) });
|
|
333
341
|
}
|
|
334
342
|
}
|
|
@@ -411,7 +419,18 @@ export async function searchSmitheryRegistry(
|
|
|
411
419
|
url.searchParams.set("q", query);
|
|
412
420
|
url.searchParams.set("pageSize", String(pageSize));
|
|
413
421
|
if (page > 1) url.searchParams.set("page", String(page));
|
|
414
|
-
|
|
422
|
+
let response: Response;
|
|
423
|
+
try {
|
|
424
|
+
response = await fetch(url.toString(), {
|
|
425
|
+
headers,
|
|
426
|
+
signal: withTimeoutSignal(SMITHERY_REGISTRY_TIMEOUT_MS, options?.signal),
|
|
427
|
+
});
|
|
428
|
+
} catch (err) {
|
|
429
|
+
if (isTimeoutError(err)) {
|
|
430
|
+
throw new SmitheryRegistryError("Smithery search timed out after 10s", 0);
|
|
431
|
+
}
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
415
434
|
if (!response.ok) {
|
|
416
435
|
throw new SmitheryRegistryError(`Smithery search failed with status ${response.status}`, response.status);
|
|
417
436
|
}
|
|
@@ -448,10 +467,14 @@ export async function searchSmitheryRegistry(
|
|
|
448
467
|
const results = await Promise.all(
|
|
449
468
|
uniqueEntries.map(async entry => {
|
|
450
469
|
try {
|
|
451
|
-
const details = await fetchServerDetailsFromEntry(entry, {
|
|
470
|
+
const details = await fetchServerDetailsFromEntry(entry, {
|
|
471
|
+
apiKey: options?.apiKey,
|
|
472
|
+
signal: options?.signal,
|
|
473
|
+
});
|
|
452
474
|
if (!details) return null;
|
|
453
475
|
return toSearchResult(entry, details);
|
|
454
476
|
} catch (error) {
|
|
477
|
+
if (options?.signal?.aborted) throw error;
|
|
455
478
|
detailFailures.push({
|
|
456
479
|
identity: getEntryIdentityKey(entry) ?? entry.id ?? "unknown",
|
|
457
480
|
error: String(error),
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ToolUIColor,
|
|
8
8
|
wrapTextWithAnsi,
|
|
9
9
|
} from "../../tools/render-utils";
|
|
10
|
-
import { Ellipsis,
|
|
10
|
+
import { Ellipsis, truncateToWidth } from "../../tui";
|
|
11
11
|
import type { Theme } from "../theme/theme";
|
|
12
12
|
|
|
13
13
|
const COLLAPSED_NOTES = 3;
|
|
@@ -42,8 +42,10 @@ function severityColor(severity: AdvisorSeverity | undefined): ToolUIColor {
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Display-only transcript card for advisor notes injected into the primary
|
|
45
|
-
* session.
|
|
46
|
-
*
|
|
45
|
+
* session. Styled as a distinct voice so notes never blend into thinking
|
|
46
|
+
* output (whose `thinkingText` color equals `toolOutput` in most themes):
|
|
47
|
+
* a bold `customMessageLabel` header tag (skill-card convention), a heavy
|
|
48
|
+
* rail tinted per-note severity, and the note body on the default text color.
|
|
47
49
|
*/
|
|
48
50
|
export function createAdvisorMessageCard(
|
|
49
51
|
details: AdvisorMessageDetails | undefined,
|
|
@@ -58,9 +60,9 @@ export function createAdvisorMessageCard(
|
|
|
58
60
|
return createCachedComponent(
|
|
59
61
|
getExpanded,
|
|
60
62
|
(width, expanded) => {
|
|
61
|
-
const
|
|
62
|
-
const lines = [
|
|
63
|
-
const
|
|
63
|
+
const tag = uiTheme.fg("customMessageLabel", uiTheme.bold(`${uiTheme.status.info} Advisor`));
|
|
64
|
+
const lines = [`${tag} ${uiTheme.fg("dim", meta.join(uiTheme.sep.dot))}`];
|
|
65
|
+
const railGlyph = uiTheme.symbol("advisor.rail");
|
|
64
66
|
const shown = expanded ? notes : notes.slice(0, COLLAPSED_NOTES);
|
|
65
67
|
for (const entry of shown) {
|
|
66
68
|
const badge = entry.severity
|
|
@@ -72,8 +74,8 @@ export function createAdvisorMessageCard(
|
|
|
72
74
|
entry.advisor && entry.advisor !== "default"
|
|
73
75
|
? `${uiTheme.fg("dim", `[${replaceTabs(entry.advisor)}]`)} `
|
|
74
76
|
: "";
|
|
75
|
-
const
|
|
76
|
-
const quoteWidth = visibleWidth(
|
|
77
|
+
const rail = uiTheme.fg(severityColor(entry.severity), railGlyph);
|
|
78
|
+
const quoteWidth = visibleWidth(` ${railGlyph} `);
|
|
77
79
|
const badgeWidth = visibleWidth(badge);
|
|
78
80
|
const whoWidth = visibleWidth(who);
|
|
79
81
|
const w1 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth - badgeWidth - whoWidth);
|
|
@@ -92,12 +94,13 @@ export function createAdvisorMessageCard(
|
|
|
92
94
|
|
|
93
95
|
bodyLines.forEach((line, index) => {
|
|
94
96
|
const prefix = index === 0 ? `${badge}${who}` : "";
|
|
95
|
-
lines.push(` ${
|
|
97
|
+
lines.push(` ${rail} ${prefix}${uiTheme.fg("customMessageText", replaceTabs(line))}`);
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
const hidden = notes.length - shown.length;
|
|
99
101
|
if (hidden > 0) {
|
|
100
|
-
|
|
102
|
+
const rail = uiTheme.fg("dim", railGlyph);
|
|
103
|
+
lines.push(` ${rail} ${uiTheme.fg("dim", `… +${hidden} more ${hidden === 1 ? "note" : "notes"}`)}`);
|
|
101
104
|
}
|
|
102
105
|
return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
|
|
103
106
|
},
|
|
@@ -4,7 +4,6 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
|
4
4
|
import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
5
5
|
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { getProjectDir } from "@oh-my-pi/pi-utils";
|
|
7
|
-
import { $ } from "bun";
|
|
8
7
|
import { settings } from "../../../config/settings";
|
|
9
8
|
import type { AgentSession } from "../../../session/agent-session";
|
|
10
9
|
import type { OAuthAccountIdentity } from "../../../session/auth-storage";
|
|
@@ -707,14 +706,22 @@ export class StatusLineComponent implements Component {
|
|
|
707
706
|
}
|
|
708
707
|
};
|
|
709
708
|
try {
|
|
710
|
-
//
|
|
711
|
-
|
|
709
|
+
// Route through the shared `gh` helper so the child inherits
|
|
710
|
+
// `GH_NON_INTERACTIVE_ENV` (disables terminal/keychain prompts) and
|
|
711
|
+
// hard-terminates on the git command deadline instead of stalling
|
|
712
|
+
// the status-line indefinitely (#4234). Requires `gh repo set-default`;
|
|
713
|
+
// non-zero exit still falls through to the null cache below.
|
|
714
|
+
const result = await git.github.run(
|
|
715
|
+
lookupCwd,
|
|
716
|
+
["pr", "view", "--json", "number,url"],
|
|
717
|
+
AbortSignal.timeout(git.GIT_COMMAND_TIMEOUT_MS),
|
|
718
|
+
);
|
|
712
719
|
if (this.#disposed) return;
|
|
713
720
|
if (result.exitCode !== 0) {
|
|
714
721
|
setCachedPr(null);
|
|
715
722
|
return;
|
|
716
723
|
}
|
|
717
|
-
const pr = JSON.parse(result.stdout
|
|
724
|
+
const pr = JSON.parse(result.stdout) as { number: number; url: string };
|
|
718
725
|
if (typeof pr.number === "number") {
|
|
719
726
|
setCachedPr({ number: pr.number, url: pr.url });
|
|
720
727
|
} else {
|
|
@@ -182,12 +182,14 @@ export interface ToolExecutionHandle {
|
|
|
182
182
|
setExpanded(expanded: boolean): void;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
185
|
+
/** Redraw live tool blocks at the spinner's glyph-advance rate. Rendering more
|
|
186
|
+
* often produced identical frames — the previous 30fps cadence emitted ~2.4
|
|
187
|
+
* paints per glyph step, and although the terminal I/O layer dedupes those, the
|
|
188
|
+
* compose pipeline still ran end-to-end per frame (issue #4353). Matching the
|
|
189
|
+
* render tick to the glyph tick halves the paints during tool execution with no
|
|
190
|
+
* visible change. */
|
|
191
|
+
export const SPINNER_RENDER_INTERVAL_MS = 80;
|
|
192
|
+
/** Advance the spinner glyph at its classic ~12.5fps step (mirrors `Loader`). */
|
|
191
193
|
export const SPINNER_GLYPH_ADVANCE_MS = 80;
|
|
192
194
|
|
|
193
195
|
/** Phase-locked spinner glyph index shared by every live tool block so parallel
|
|
@@ -281,6 +283,14 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
281
283
|
// history, so progress renders static gray and further partial snapshots are
|
|
282
284
|
// dropped (see #maybeFreezeBackgroundTask).
|
|
283
285
|
#backgroundTaskFrozen = false;
|
|
286
|
+
// Set on each `render()` when the last painted shape carried the streamed
|
|
287
|
+
// SSH-style placeholder / partial-result chrome. Reset gates key off these
|
|
288
|
+
// so a topology-changing update that lands before the shape reaches the
|
|
289
|
+
// terminal never triggers a full-viewport replay (which on direct terminals
|
|
290
|
+
// wipes native scrollback and flashes the user's history — reviewer note on
|
|
291
|
+
// PR #4315).
|
|
292
|
+
#placeholderShapePainted = false;
|
|
293
|
+
#partialResultShapePainted = false;
|
|
284
294
|
#renderState: {
|
|
285
295
|
spinnerFrame?: number;
|
|
286
296
|
expanded: boolean;
|
|
@@ -485,6 +495,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
485
495
|
if (isPartial && this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) {
|
|
486
496
|
return;
|
|
487
497
|
}
|
|
498
|
+
const hadNoResult = this.#result === undefined;
|
|
499
|
+
const wasPartialResult = this.#result !== undefined && this.#isPartial;
|
|
500
|
+
const placeholderPainted = this.#placeholderShapePainted;
|
|
501
|
+
const partialResultPainted = this.#partialResultShapePainted;
|
|
502
|
+
this.#placeholderShapePainted = false;
|
|
503
|
+
this.#partialResultShapePainted = false;
|
|
488
504
|
this.#result = result;
|
|
489
505
|
this.#resultVersion++;
|
|
490
506
|
this.#isPartial = isPartial;
|
|
@@ -496,6 +512,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
496
512
|
this.#updateSpinnerAnimation();
|
|
497
513
|
this.#updateTodoStrikeAnimation();
|
|
498
514
|
this.#updateDisplay();
|
|
515
|
+
this.#resetDisplayForResultTopologyChange(
|
|
516
|
+
hadNoResult && placeholderPainted,
|
|
517
|
+
wasPartialResult && partialResultPainted,
|
|
518
|
+
isPartial,
|
|
519
|
+
);
|
|
499
520
|
// Convert non-PNG images to PNG for Kitty protocol (async)
|
|
500
521
|
this.#maybeConvertImagesForKitty();
|
|
501
522
|
}
|
|
@@ -830,6 +851,49 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
830
851
|
this.#displayBuilt = true;
|
|
831
852
|
}
|
|
832
853
|
|
|
854
|
+
#rendererFlag(name: "forceFirstResultViewportRepaint" | "forceResultViewportRepaintOnSettle"): boolean {
|
|
855
|
+
const toolValue = (this.#tool as Record<string, unknown> | undefined)?.[name];
|
|
856
|
+
const rendererValue = toolRenderers[this.#toolName]?.[name];
|
|
857
|
+
return toolValue === true || (toolValue === undefined && rendererValue === true);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* True while the last painted shape uses the streamed placeholder path
|
|
862
|
+
* (`⏳ SSH: […]` / `$ …`) — the render call ran with `__partialJson` args
|
|
863
|
+
* and no result. Kept as a per-paint fact so a topology-changing update
|
|
864
|
+
* that lands before the placeholder reaches the terminal skips the reset.
|
|
865
|
+
*/
|
|
866
|
+
#isPlaceholderShapeAtRender(): boolean {
|
|
867
|
+
if (this.#result !== undefined) return false;
|
|
868
|
+
if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return false;
|
|
869
|
+
return partialJsonOf(this.#args) !== undefined;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
#resetDisplayForResultTopologyChange(
|
|
873
|
+
firstResultAfterPlaceholderPaint: boolean,
|
|
874
|
+
partialResultPaintedBeforeSettle: boolean,
|
|
875
|
+
isPartial: boolean,
|
|
876
|
+
): void {
|
|
877
|
+
const firstResultReplacesStreamedPlaceholder =
|
|
878
|
+
firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint");
|
|
879
|
+
const provisionalResultSettled =
|
|
880
|
+
partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle");
|
|
881
|
+
if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) {
|
|
882
|
+
this.#ui.resetDisplay();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
override render(width: number): readonly string[] {
|
|
887
|
+
const lines = super.render(width);
|
|
888
|
+
// Update the paint-tracking flags after `super.render(width)` — the
|
|
889
|
+
// override runs on every compose the parent Container performs, so a
|
|
890
|
+
// frame that never gets composed leaves the flags false and prevents a
|
|
891
|
+
// spurious `resetDisplay()`.
|
|
892
|
+
this.#placeholderShapePainted = this.#isPlaceholderShapeAtRender();
|
|
893
|
+
this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial;
|
|
894
|
+
return lines;
|
|
895
|
+
}
|
|
896
|
+
|
|
833
897
|
// Viewport-/settings-dependent image sizing folded into the memo key only when
|
|
834
898
|
// the last rebuild actually emitted images, so a terminal resize re-shapes an
|
|
835
899
|
// image-bearing result (to rescale it) without re-shaping every image-free
|
|
@@ -873,7 +937,8 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
873
937
|
if (shouldRenderCall) {
|
|
874
938
|
if (tool.renderCall) {
|
|
875
939
|
try {
|
|
876
|
-
const
|
|
940
|
+
const callArgs = this.#getCallArgsForRender();
|
|
941
|
+
const callComponent = tool.renderCall(callArgs, this.#renderState, theme);
|
|
877
942
|
if (callComponent) this.#contentBox.addChild(callComponent as Component);
|
|
878
943
|
} catch (err) {
|
|
879
944
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|
|
@@ -1007,7 +1072,8 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
1007
1072
|
if (shouldRenderCall) {
|
|
1008
1073
|
// Render call component
|
|
1009
1074
|
try {
|
|
1010
|
-
const
|
|
1075
|
+
const callArgs = this.#getCallArgsForRender();
|
|
1076
|
+
const callComponent = renderer.renderCall(callArgs, this.#renderState, theme);
|
|
1011
1077
|
if (callComponent) this.#contentBox.addChild(callComponent);
|
|
1012
1078
|
} catch (err) {
|
|
1013
1079
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|