@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.4

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +3553 -3540
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  10. package/dist/types/edit/hashline/execute.d.ts +1 -1
  11. package/dist/types/edit/hashline/params.d.ts +3 -6
  12. package/dist/types/edit/renderer.d.ts +1 -0
  13. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  14. package/dist/types/hindsight/client.d.ts +15 -11
  15. package/dist/types/hindsight/client.test.d.ts +1 -0
  16. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  17. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  18. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  19. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  20. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  23. package/dist/types/session/session-storage.d.ts +31 -3
  24. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  25. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  26. package/dist/types/ssh/connection-manager.d.ts +19 -0
  27. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  28. package/dist/types/subprocess/worker-client.d.ts +20 -6
  29. package/dist/types/tools/bash.d.ts +27 -0
  30. package/dist/types/tools/renderers.d.ts +13 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  33. package/dist/types/web/search/providers/base.d.ts +1 -0
  34. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  35. package/package.json +12 -12
  36. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  37. package/src/cli/models-cli.ts +19 -0
  38. package/src/cli/update-cli.test.ts +28 -0
  39. package/src/cli/update-cli.ts +35 -8
  40. package/src/cli/usage-cli.ts +34 -5
  41. package/src/commands/update.ts +8 -2
  42. package/src/config/config-file.ts +6 -6
  43. package/src/config/settings-schema.ts +10 -0
  44. package/src/dap/client.ts +134 -36
  45. package/src/edit/file-snapshot-store.ts +12 -1
  46. package/src/edit/hashline/diff.ts +4 -13
  47. package/src/edit/hashline/execute.ts +1 -1
  48. package/src/edit/hashline/params.ts +5 -12
  49. package/src/edit/renderer.ts +4 -2
  50. package/src/edit/streaming.ts +15 -5
  51. package/src/export/html/tool-views.generated.js +2 -2
  52. package/src/extensibility/plugins/installer.ts +12 -3
  53. package/src/extensibility/plugins/manager.ts +32 -8
  54. package/src/extensibility/plugins/parser.ts +7 -5
  55. package/src/extensibility/tool-event-input.ts +1 -1
  56. package/src/hindsight/client.test.ts +33 -0
  57. package/src/hindsight/client.ts +42 -22
  58. package/src/internal-urls/docs-index.generated.txt +1 -1
  59. package/src/main.ts +7 -1
  60. package/src/mcp/oauth-flow.ts +93 -4
  61. package/src/mcp/smithery-auth.ts +3 -0
  62. package/src/mcp/smithery-connect.ts +9 -0
  63. package/src/mcp/smithery-registry.test.ts +51 -0
  64. package/src/mcp/smithery-registry.ts +27 -4
  65. package/src/modes/components/advisor-message.ts +13 -10
  66. package/src/modes/components/status-line/component.ts +11 -4
  67. package/src/modes/components/tool-execution.ts +74 -8
  68. package/src/modes/components/transcript-container.ts +26 -0
  69. package/src/modes/controllers/event-controller.ts +7 -3
  70. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  71. package/src/modes/interactive-mode.ts +1 -0
  72. package/src/modes/rpc/rpc-client.ts +29 -16
  73. package/src/modes/theme/shimmer.ts +49 -15
  74. package/src/modes/theme/theme.ts +7 -0
  75. package/src/session/agent-session.ts +166 -30
  76. package/src/session/indexed-session-storage.ts +40 -3
  77. package/src/session/session-manager.ts +83 -14
  78. package/src/session/session-storage.ts +112 -26
  79. package/src/slash-commands/helpers/usage-report.ts +6 -1
  80. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  81. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  82. package/src/ssh/connection-manager.ts +44 -11
  83. package/src/ssh/sshfs-mount.ts +27 -4
  84. package/src/subprocess/worker-client.ts +161 -10
  85. package/src/tools/bash.ts +30 -1
  86. package/src/tools/grep.ts +21 -1
  87. package/src/tools/read.ts +14 -4
  88. package/src/tools/renderers.ts +13 -0
  89. package/src/tools/ssh.ts +8 -0
  90. package/src/utils/clipboard.ts +49 -12
  91. package/src/utils/fetch-timeout.ts +10 -0
  92. package/src/web/search/index.ts +8 -0
  93. package/src/web/search/providers/base.ts +1 -0
  94. 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 {
@@ -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) return;
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
- // Ignore registration failures and continue without client registration.
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 new Error("OAuth provider requires client_id");
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
  /**
@@ -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(path: string, options?: { apiKey?: string }): Promise<SmitheryServerDetails | null> {
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
- const response = await fetch(url.toString(), { headers });
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, { apiKey: options?.apiKey });
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, renderStatusLine, truncateToWidth } from "../../tui";
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. Mirrors the IRC card's glyph + quote-border conventions so passive
46
- * advice reads as a distinct, non-interrupting aside rather than a user turn.
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 glyph = uiTheme.styledSymbol("status.info", "accent");
62
- const lines = [renderStatusLine({ iconOverride: glyph, title: "Advisor", meta }, uiTheme)];
63
- const quote = uiTheme.fg("dim", uiTheme.md.quoteBorder);
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 quotePrefix = ` ${quote} `;
76
- const quoteWidth = visibleWidth(quotePrefix);
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(` ${quote} ${prefix}${uiTheme.fg("toolOutput", replaceTabs(line))}`);
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
- lines.push(` ${quote} ${uiTheme.fg("dim", `… +${hidden} more ${hidden === 1 ? "note" : "notes"}`)}`);
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
- // Requires `gh repo set-default` to be configured; fails gracefully if not
711
- const result = await $`gh pr view --json number,url`.cwd(lookupCwd).quiet().nothrow();
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.toString()) as { number: number; url: string };
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
- /** Drive pending-tool redraws at 30fps for live tool headers and displaceable
186
- * poll blocks. The TUI throttles at the same cadence, and static frames diff to
187
- * a no-op redraw at ~zero cost. */
188
- export const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
189
- /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
190
- * render cadence (mirrors `Loader`). */
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 callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
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 callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
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) });