@oh-my-pi/pi-coding-agent 16.3.0 → 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.
Files changed (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  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 +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
package/src/irc/bus.ts CHANGED
@@ -8,10 +8,11 @@
8
8
  * agents receive the message as a non-interrupting aside at the next step
9
9
  * boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
10
10
  * the recipient, observed via `wait` — with one exception: when the sender
11
- * awaits a reply and the recipient is mid-turn with async execution
12
- * disabled, the recipient session generates an ephemeral side-channel
13
- * auto-reply (it may be blocked in a synchronous task spawn whose batch
14
- * includes the sender, so a real turn could never happen in time).
11
+ * awaits a reply and the recipient cannot run a real reply turn in time
12
+ * (mid-turn with async execution disabled possibly blocked in a
13
+ * synchronous task spawn whose batch includes the sender or idle in plan
14
+ * mode, where autonomous wake turns are suppressed), the recipient session
15
+ * generates an ephemeral side-channel auto-reply.
15
16
  */
16
17
 
17
18
  import { logger, Snowflake } from "@oh-my-pi/pi-utils";
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),
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
2
  import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
4
  import * as os from "node:os";
@@ -163,4 +163,75 @@ describe("MoveOverlay", () => {
163
163
  expect(result).toBeDefined();
164
164
  expect(result!.directory).toBe(path.join(cwd, "alpha"));
165
165
  });
166
+
167
+ it("does not stat every directory entry per keystroke", async () => {
168
+ // Regression: `readDirCached` used to cache names and re-`statSync` every
169
+ // entry per keystroke. Populate with mostly files so the old code path
170
+ // would have stat'd all of them; the fix classifies via `Dirent` and only
171
+ // falls back to `statSync` on symlink entries.
172
+ const bulk = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-bulk-"));
173
+ try {
174
+ for (let i = 0; i < 60; i++) {
175
+ fs.writeFileSync(path.join(bulk, `f_${String(i).padStart(3, "0")}.txt`), "x");
176
+ }
177
+ for (let i = 0; i < 10; i++) fs.mkdirSync(path.join(bulk, `sub_${i}`));
178
+
179
+ const statSpy = spyOn(fs, "statSync");
180
+ try {
181
+ const overlay = new MoveOverlay(bulk, () => {});
182
+ statSpy.mockClear();
183
+ overlay.handleInput("s");
184
+ overlay.handleInput("u");
185
+ overlay.handleInput("b");
186
+ // Each keystroke may `statSync` at most once — the
187
+ // `resolveExistingDirectory` probe on the typed prefix. Entries
188
+ // are classified via `Dirent`, not one stat apiece.
189
+ expect(statSpy.mock.calls.length).toBeLessThanOrEqual(3);
190
+ } finally {
191
+ statSpy.mockRestore();
192
+ }
193
+ } finally {
194
+ await fsp.rm(bulk, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ it("classifies unknown-type Dirents via statSync fallback", async () => {
199
+ // Regression: filesystems that report UV_DIRENT_UNKNOWN return Dirents
200
+ // whose isDirectory()/isFile()/isSymbolicLink() all report false. Those
201
+ // entries must fall back to statSync so /move still lists real
202
+ // directories on NFS/FUSE/older SMB.
203
+ const unknownFs = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-unknown-"));
204
+ try {
205
+ const realDir = path.join(unknownFs, "real-dir");
206
+ const realFile = path.join(unknownFs, "real-file.txt");
207
+ fs.mkdirSync(realDir);
208
+ fs.writeFileSync(realFile, "x");
209
+
210
+ const fakeDirent = (name: string): fs.Dirent =>
211
+ ({
212
+ name,
213
+ isDirectory: () => false,
214
+ isFile: () => false,
215
+ isSymbolicLink: () => false,
216
+ isBlockDevice: () => false,
217
+ isCharacterDevice: () => false,
218
+ isFIFO: () => false,
219
+ isSocket: () => false,
220
+ }) as fs.Dirent;
221
+ const readdirSpy = spyOn(fs, "readdirSync").mockReturnValue([
222
+ fakeDirent("real-dir"),
223
+ fakeDirent("real-file.txt"),
224
+ ] as never);
225
+ try {
226
+ const overlay = new MoveOverlay(unknownFs, () => {});
227
+ const text = strip(overlay.render(80));
228
+ expect(text).toContain("real-dir/");
229
+ expect(text).not.toContain("real-file.txt");
230
+ } finally {
231
+ readdirSpy.mockRestore();
232
+ }
233
+ } finally {
234
+ await fsp.rm(unknownFs, { recursive: true, force: true });
235
+ }
236
+ });
166
237
  });
@@ -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
  },
@@ -708,7 +708,7 @@ export class AssistantMessageComponent extends Container {
708
708
  );
709
709
 
710
710
  // Fast path: reuse Markdown children when shape is stable during streaming
711
- if (this.#tryFastPathUpdate(message)) return;
711
+ if (this.#tryFastPathUpdate(message, opts)) return;
712
712
 
713
713
  // Clear content container
714
714
  this.#contentContainer.clear();
@@ -29,14 +29,14 @@ const OVERLAY_WIDTH = 68;
29
29
 
30
30
  /** TTL for the directory listing cache (ms). */
31
31
  const DIR_CACHE_TTL = 500;
32
- const dirCache = new Map<string, { time: number; entries: string[] }>();
32
+ const dirCache = new Map<string, { time: number; entries: fs.Dirent[] }>();
33
33
 
34
- function readDirCached(dir: string): string[] {
34
+ function readDirCached(dir: string): fs.Dirent[] {
35
35
  const now = Date.now();
36
36
  const cached = dirCache.get(dir);
37
37
  if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
38
38
  try {
39
- const entries = fs.readdirSync(dir);
39
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
40
40
  dirCache.set(dir, { time: now, entries });
41
41
  return entries;
42
42
  } catch {
@@ -44,6 +44,27 @@ function readDirCached(dir: string): string[] {
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * `Dirent.isDirectory()` reports the entry type, not the link target, so a
49
+ * `statSync` fallback is still needed for symlinks that point at a directory.
50
+ * Some filesystems (NFS, FUSE, older SMB) report `UV_DIRENT_UNKNOWN` — every
51
+ * `isX()` returns false — so those entries also fall back to `statSync` rather
52
+ * than being silently dropped from the results.
53
+ */
54
+ function entryIsDirectory(dir: string, entry: fs.Dirent): boolean {
55
+ if (entry.isDirectory()) return true;
56
+ // Fast reject only for entry types we can confidently identify as non-directory.
57
+ if (entry.isFile() || entry.isBlockDevice() || entry.isCharacterDevice() || entry.isFIFO() || entry.isSocket()) {
58
+ return false;
59
+ }
60
+ // Symlink (need target type) or unknown (filesystem didn't provide a type) — stat to find out.
61
+ try {
62
+ return fs.statSync(path.join(dir, entry.name)).isDirectory();
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
47
68
  function printableInput(data: string): string {
48
69
  const withoutPasteEnvelope = data.replaceAll("\x1b[200~", "").replaceAll("\x1b[201~", "");
49
70
  if (withoutPasteEnvelope.includes("\x1b")) return "";
@@ -76,17 +97,13 @@ export function resolveExistingDirectory(input: string, cwd: string): string | n
76
97
 
77
98
  function listChildDirectories(dirPath: string, max: number, includeHidden = false): DirEntry[] {
78
99
  const results: DirEntry[] = [];
79
- const names = readDirCached(dirPath);
80
- for (const name of names) {
100
+ const entries = readDirCached(dirPath);
101
+ for (const entry of entries) {
81
102
  if (results.length >= max) break;
103
+ const { name } = entry;
82
104
  if (!includeHidden && name.startsWith(".")) continue;
83
- const full = path.join(dirPath, name);
84
- try {
85
- if (!fs.statSync(full).isDirectory()) continue;
86
- } catch {
87
- continue;
88
- }
89
- results.push({ value: full, label: `${name}/` });
105
+ if (!entryIsDirectory(dirPath, entry)) continue;
106
+ results.push({ value: path.join(dirPath, name), label: `${name}/` });
90
107
  }
91
108
  results.sort((a, b) => a.label.localeCompare(b.label));
92
109
  return results;
@@ -118,19 +135,14 @@ function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[]
118
135
 
119
136
  const lower = query.toLowerCase();
120
137
  const results: DirEntry[] = [];
121
- const names = readDirCached(baseDir);
122
- for (const name of names) {
138
+ const entries = readDirCached(baseDir);
139
+ for (const entry of entries) {
123
140
  if (results.length >= max) break;
141
+ const { name } = entry;
124
142
  if (!includeHidden && name.startsWith(".")) continue;
125
- const full = path.join(baseDir, name);
126
- try {
127
- if (!fs.statSync(full).isDirectory()) continue;
128
- } catch {
129
- continue;
130
- }
131
- if (!query || name.toLowerCase().includes(lower)) {
132
- results.push({ value: full, label: `${name}/` });
133
- }
143
+ if (query && !name.toLowerCase().includes(lower)) continue;
144
+ if (!entryIsDirectory(baseDir, entry)) continue;
145
+ results.push({ value: path.join(baseDir, name), label: `${name}/` });
134
146
  }
135
147
  return results;
136
148
  }