one-code-extension 0.1.4 → 0.3.1

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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +222 -177
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +128 -0
  8. package/docs/guide/configuration.md +173 -0
  9. package/docs/guide/differences-from-claude-code.md +152 -0
  10. package/docs/guide/doctor.md +88 -0
  11. package/docs/guide/hooks.md +111 -0
  12. package/docs/guide/installation.md +126 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
  14. package/docs/guide/providers-and-models.md +196 -0
  15. package/docs/guide/reference.md +226 -0
  16. package/docs/guide/sessions-and-context.md +192 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +178 -0
  18. package/docs/guide/subagents-and-workflows.md +195 -0
  19. package/docs/guide/tasks-and-background-work.md +113 -0
  20. package/docs/guide/terminal-interface.md +157 -0
  21. package/docs/guide/tools.md +177 -0
  22. package/docs/guide/troubleshooting.md +177 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -16,10 +16,11 @@
16
16
  */
17
17
 
18
18
  import os from "node:os";
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
21
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
22
- import { persistIfLarge } from "../lib/persisted-output.ts";
22
+ import { MCP_TOOLS_CHANNEL } from "../lib/mcp-share.ts";
23
+ import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
23
24
  import { ccToolRenderers } from "../lib/tui-render.ts";
24
25
  import {
25
26
  callTool,
@@ -27,15 +28,35 @@ import {
27
28
  type Connection,
28
29
  connect,
29
30
  type FailedConnection,
31
+ isUnauthorized,
32
+ mcpFailuresReminder,
30
33
  mcpInstructionsReminder,
31
34
  readResource,
32
35
  readResourceDir,
33
36
  } from "./client.ts";
34
37
  import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
35
38
  import { join } from "node:path";
36
- import { discoverPlugins } from "../lib/plugins.ts";
39
+ import {
40
+ MCP_STATUS_CHANNEL,
41
+ MCP_STATUS_REQUEST_CHANNEL,
42
+ type McpServerStatus,
43
+ type McpStatusEvent,
44
+ } from "../lib/mcp-status.ts";
45
+ import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
46
+ import { readDisabledMcpServers, setMcpServerDisabled } from "../lib/mcp-overrides.ts";
47
+ import { boundedDockHeight, safeThemeBold, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
48
+ import { authenticate as runOAuthFlow, silentProvider } from "./oauth/flow.ts";
49
+ import { hasStoredTokens } from "./oauth/store.ts";
50
+ import { announcedFrom, applyMcpDelta, emptyAnnounced, mcpChangeNotices, mcpDelta, type McpSnapshot } from "./announce.ts";
51
+ import { decodeMcpKey } from "./panel/keys.ts";
52
+ import { type McpEntry, type McpEntryStatus } from "./panel/model.ts";
53
+ import { renderMcpPanel, type McpPaint } from "./panel/render.ts";
54
+ import { applyMcpKey, initialMcpState, type McpEffect } from "./panel/state.ts";
55
+ import { sessionAlive } from "../lib/session-lifecycle.ts";
37
56
  import { loadServers, type McpServer } from "./config.ts";
57
+ import { approveMcpServers, persistApproval, projectRootOf } from "./trust.ts";
38
58
  import {
59
+ capDescription,
39
60
  describeContent,
40
61
  describeResourceContents,
41
62
  jsonSchemaToTypeBox,
@@ -44,6 +65,9 @@ import {
44
65
  namespacedToolName,
45
66
  } from "./schema.ts";
46
67
 
68
+ /** Anthropic's hard limit on a tool name; a longer one fails the whole request. */
69
+ const MAX_TOOL_NAME_LENGTH = 128;
70
+
47
71
  /** Shared "server name not found" tool result for the resource read/list-dir tools. */
48
72
  function unknownServerError(name: string) {
49
73
  return {
@@ -55,36 +79,72 @@ function unknownServerError(name: string) {
55
79
 
56
80
  export default function mcpExtension(pi: ExtensionAPI) {
57
81
  const connections = new Map<string, Connection>();
58
- const failures: FailedConnection[] = [];
82
+ let failures: FailedConnection[] = [];
59
83
  const registered = new Set<string>();
60
84
  let servers: McpServer[] = [];
85
+ // http servers that returned a 401 with no stored tokens — authNeeded via OAuth,
86
+ // distinct from a missing-env authNeeded (which Authenticate cannot fix).
87
+ const oauthNeeded = new Set<string>();
88
+ // Servers the user disabled (persisted in ~/.onecode) — discovered but not connected.
89
+ let disabledNames = new Set<string>();
90
+ // Project .mcp.json servers withheld this session (no consent yet, declined,
91
+ // or disabled via Claude Code's disabledMcpjsonServers) — listed, not connected.
92
+ const withheldNames = new Map<string, string>();
93
+ // Config file paths contributed by plugins, so the panel can group them.
94
+ let pluginConfigPaths = new Set<string>();
95
+ const home = os.homedir();
96
+ // Live tool definitions, shared with in-process subagents so they reach MCP
97
+ // through these same open connections instead of connecting their own.
98
+ const sharedTools: ToolDefinition[] = [];
99
+ // Message 1 carries the MCP instructions and failed-servers reminders. Once a
100
+ // request has gone out it is part of every later request's cached prefix, so
101
+ // those blocks are frozen and later changes ride one-shot notices (./announce.ts).
102
+ let requestSent = false;
103
+ let announced = emptyAnnounced();
104
+ pi.on("context", () => {
105
+ requestSent = true;
106
+ });
61
107
 
62
108
  /**
63
109
  * Where oversized results are persisted — the session dir, matching Claude
64
110
  * Code's `<session-dir>/tool-results/<id>.txt`. A session-less run (`--no-session`)
65
111
  * has no such dir, so a temp folder serves; the model gets the path either way.
66
112
  */
67
- const resultsDir = (ctx: ExtensionContext | undefined): string => {
68
- try {
69
- const dir = ctx?.sessionManager.getSessionDir();
70
- if (dir) return dir;
71
- } catch {
72
- // fall through to the temp folder
73
- }
74
- return join(os.tmpdir(), "one-code");
75
- };
113
+ const resultsDir = (ctx: ExtensionContext | undefined): string => sessionResultsDir(ctx);
76
114
 
77
115
  const registerToolsFor = (connection: Connection) => {
78
116
  for (const tool of connection.tools) {
79
117
  const name = namespacedToolName(connection.server.name, tool.name);
80
- if (registered.has(name)) continue;
118
+ // Anthropic caps a tool name at 128 characters and rejects the whole
119
+ // request when one is longer — and a deferred definition rides every
120
+ // request from request 1, so one over-long name would fail every turn
121
+ // of the session with an opaque `tools.N.custom.name` error. Drop it
122
+ // here with a clear reason instead.
123
+ if (name.length > MAX_TOOL_NAME_LENGTH) {
124
+ failures.push({
125
+ server: connection.server,
126
+ error: `tool "${tool.name}" skipped: its namespaced name is ${name.length} characters, over the ${MAX_TOOL_NAME_LENGTH}-character limit the provider enforces`,
127
+ });
128
+ continue;
129
+ }
130
+ if (registered.has(name)) {
131
+ // Two tools sanitise to one name (`a-b` and `a_b`, or a server
132
+ // reconnecting under a stale registration). Skipping silently would
133
+ // make the second tool unreachable with no trace; record it where
134
+ // the connection summary reports it.
135
+ failures.push({
136
+ server: connection.server,
137
+ error: `tool "${tool.name}" skipped: its registered name "${name}" is already taken by another tool`,
138
+ });
139
+ continue;
140
+ }
81
141
  registered.add(name);
82
142
 
83
- pi.registerTool({
143
+ const def: ToolDefinition = {
84
144
  name,
85
145
  label: `${connection.server.name}: ${tool.name}`,
86
146
  ...ccToolRenderers(`${connection.server.name}: ${tool.name}`),
87
- description: tool.description ?? `MCP tool "${tool.name}" from server "${connection.server.name}".`,
147
+ description: capDescription(tool.description) ?? `MCP tool "${tool.name}" from server "${connection.server.name}".`,
88
148
  parameters: jsonSchemaToTypeBox(tool.inputSchema),
89
149
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
90
150
  const live = connections.get(connection.server.name);
@@ -97,7 +157,7 @@ export default function mcpExtension(pi: ExtensionAPI) {
97
157
  }
98
158
  try {
99
159
  const result = await callTool(live, tool.name, (params ?? {}) as Record<string, unknown>);
100
- const { text, images } = describeContent(result.content as McpContentBlock[] | undefined);
160
+ const { text, images } = describeContent(result.content as McpContentBlock[] | undefined, connection.server.name);
101
161
  return {
102
162
  content: [
103
163
  {
@@ -117,7 +177,9 @@ export default function mcpExtension(pi: ExtensionAPI) {
117
177
  };
118
178
  }
119
179
  },
120
- });
180
+ };
181
+ pi.registerTool(def);
182
+ sharedTools.push(def);
121
183
 
122
184
  // Deferred: the model discovers these through tool_search.
123
185
  pi.events.emit(DEFER_CHANNEL, {
@@ -125,82 +187,195 @@ export default function mcpExtension(pi: ExtensionAPI) {
125
187
  keywords: [connection.server.name, tool.name.replace(/[_-]/g, " "), "mcp"],
126
188
  });
127
189
  }
190
+ // Publish the live set so in-process subagents can share these connections.
191
+ if (sharedTools.length > 0) pi.events.emit(MCP_TOOLS_CHANNEL, { tools: [...sharedTools] });
128
192
  };
129
193
 
130
194
  /** Resolves when every configured server has connected or failed. */
131
195
  let connecting: Promise<void> | undefined;
132
196
  let connectSettled = false;
133
- let shuttingDown = false;
134
-
135
- const connectAll = async (ctx: ExtensionContext) => {
136
- servers = loadServers(ctx.cwd, os.homedir(), process.env, discoverPlugins(join(os.homedir(), ".claude")).mcpConfigs);
137
- if (servers.length === 0) return;
197
+ // False after session_shutdown: every pi.* call throws from then on (lib/session-lifecycle.ts).
198
+ const alive = sessionAlive(pi);
138
199
 
139
- // A server whose credentials are unset would connect with an empty token and
140
- // fail with a confusing protocol error; skip it and say why instead.
141
- for (const server of servers.filter((s) => s.missingEnv?.length)) {
200
+ /**
201
+ * Connect one server, updating the live maps. Prior failure/authNeeded state
202
+ * for the server is cleared first, so this doubles as the reconnect path.
203
+ * - An http server with stored OAuth tokens connects with the silent
204
+ * provider (refreshing as needed); without tokens it connects with NO
205
+ * provider so a 401 throws instead of popping a browser at startup.
206
+ * - A 401 from an http server marks it authNeeded (OAuth); any other error
207
+ * is a hard failure.
208
+ */
209
+ /**
210
+ * Record a freshly established connection as the live one for its server:
211
+ * clear stale failure/authNeeded state, register its tools (and resource
212
+ * tools), and surface any listTools warnings. The single "now connected"
213
+ * path — both connectOne and the OAuth flow route through it, so neither can
214
+ * drift (e.g. drop the warnings step).
215
+ */
216
+ const adoptConnection = (server: McpServer, connection: Connection): void => {
217
+ failures = failures.filter((f) => f.server.name !== server.name);
218
+ oauthNeeded.delete(server.name);
219
+ connections.set(server.name, connection);
220
+ // A server that dies (or drops the HTTP session) mid-session must not keep
221
+ // reading "connected" with a stale tool count; its tools stay registered
222
+ // and answer "not connected" until Reconnect in /mcp.
223
+ connection.client.onclose = () => {
224
+ if (connection.closing || !alive()) return;
225
+ if (connections.get(server.name) !== connection) return;
226
+ connections.delete(server.name);
227
+ const tail = connection.stderrTail();
142
228
  failures.push({
143
229
  server,
144
- error: `not started — ${server.missingEnv?.join(", ")} not set in the environment`,
230
+ error: `connection closed by the server${tail ? `\nserver stderr: ${tail.slice(-1_000)}` : ""}`,
145
231
  });
232
+ emitInstructions();
233
+ };
234
+ registerToolsFor(connection);
235
+ for (const warning of connection.warnings) failures.push({ server, error: warning });
236
+ if (connection.resources.length > 0) registerResourceTools();
237
+ };
238
+
239
+ const connectOne = async (server: McpServer): Promise<void> => {
240
+ failures = failures.filter((f) => f.server.name !== server.name);
241
+ oauthNeeded.delete(server.name);
242
+ const provider = server.kind === "http" && hasStoredTokens(server.name) ? silentProvider(server.name) : undefined;
243
+ try {
244
+ const connection = await connect(server, provider);
245
+ if (!alive()) {
246
+ await close(connection);
247
+ return;
248
+ }
249
+ adoptConnection(server, connection);
250
+ } catch (error) {
251
+ if (!alive()) return;
252
+ if (server.kind === "http" && isUnauthorized(error)) oauthNeeded.add(server.name);
253
+ else failures.push({ server, error: (error as Error).message });
146
254
  }
255
+ };
147
256
 
148
- const results = await Promise.all(
149
- servers
150
- .filter((server) => !server.missingEnv?.length)
151
- .map(async (server) => {
152
- try {
153
- return { connection: await connect(server) };
154
- } catch (error) {
155
- return { failure: { server, error: (error as Error).message } };
156
- }
157
- }),
158
- );
257
+ /**
258
+ * Tell the MODEL about the servers, the way Claude Code does (findings §14):
259
+ * each connected server's own usage instructions (its initialize result), and
260
+ * which servers failed — otherwise it concludes the tools do not exist or the
261
+ * user has no access instead of reporting a connection failure the user can
262
+ * fix (review M4). Re-run on every connect, close, reconnect, authenticate,
263
+ * enable and disable. Before the first request the two first-prepend blocks on
264
+ * message 1 are (re)written; after it they are frozen and only the changes are
265
+ * announced (./announce.ts).
266
+ */
267
+ const emitInstructions = () => {
268
+ const live = [...connections.values()];
269
+ const snapshot: McpSnapshot = {
270
+ connected: live.map((c) => ({ name: c.server.name, instructions: c.instructions })),
271
+ failed: failures.filter((f) => !connections.has(f.server.name)).map((f) => ({ name: f.server.name, error: f.error })),
272
+ };
159
273
 
160
- // The session may have shut down while a slow server was still answering;
161
- // a connection landing now would leak its transport, so close it instead.
162
- if (shuttingDown) {
163
- await Promise.all(results.map((result) => (result.connection ? close(result.connection) : undefined)));
274
+ if (!requestSent) {
275
+ // Nothing is cached yet: (re)write the standing message-1 blocks.
276
+ if (snapshot.failed.length === 0) {
277
+ pi.events.emit(REMINDER_CHANNEL, { scope: "every-turn", key: "mcp-failures", text: "", remove: true });
278
+ } else {
279
+ pi.events.emit(REMINDER_CHANNEL, {
280
+ scope: "every-turn",
281
+ key: "mcp-failures",
282
+ text: mcpFailuresReminder(snapshot.failed),
283
+ placement: "first-prepend",
284
+ order: CONTEXT_ORDER.mcp + 1,
285
+ });
286
+ }
287
+ const instructions = mcpInstructionsReminder(live);
288
+ if (instructions) {
289
+ pi.events.emit(REMINDER_CHANNEL, {
290
+ scope: "every-turn",
291
+ key: "mcp-instructions",
292
+ text: instructions,
293
+ placement: "first-prepend",
294
+ order: CONTEXT_ORDER.mcp,
295
+ });
296
+ } else {
297
+ // No server has instructions anymore (e.g. the last one was disabled or
298
+ // disconnected) — drop the stale every-turn reminder so the model stops
299
+ // being told to use tools that are gone.
300
+ pi.events.emit(REMINDER_CHANNEL, { scope: "every-turn", key: "mcp-instructions", text: "", remove: true });
301
+ }
302
+ announced = announcedFrom(snapshot);
164
303
  return;
165
304
  }
166
305
 
167
- for (const result of results) {
168
- if (result.connection) {
169
- connections.set(result.connection.server.name, result.connection);
170
- registerToolsFor(result.connection);
171
- for (const warning of result.connection.warnings) {
172
- failures.push({ server: result.connection.server, error: warning });
173
- }
174
- } else if (result.failure) {
175
- failures.push(result.failure);
176
- }
306
+ // Message 1 is frozen (rewriting it would re-cache the whole conversation):
307
+ // tell the model only what changed, as one-shots where it reads next.
308
+ const delta = mcpDelta(announced, snapshot);
309
+ for (const text of mcpChangeNotices(delta)) pi.events.emit(REMINDER_CHANNEL, { text });
310
+ applyMcpDelta(announced, delta);
311
+ };
312
+
313
+ /** Config files that exist but failed to parse (review M11) — shown at startup and in /mcp. */
314
+ let configErrors: string[] = [];
315
+
316
+ const connectAll = async (ctx: ExtensionContext) => {
317
+ const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), ctx.cwd));
318
+ pluginConfigPaths = new Set(plugins.mcpConfigs);
319
+ configErrors = [];
320
+ servers = loadServers(ctx.cwd, home, process.env, [...pluginConfigPaths], {
321
+ pluginNames: plugins.mcpConfigPlugins,
322
+ onError: (path, message) => configErrors.push(`${path}: ${message}`),
323
+ });
324
+ if (configErrors.length > 0) {
325
+ const text = `MCP config could not be parsed (ignored): ${configErrors.join("; ")}`;
326
+ if (ctx.hasUI) ctx.ui.notify(text, "warning");
327
+ else process.stderr.write(`${text}\n`);
177
328
  }
329
+ disabledNames = readDisabledMcpServers(ctx.cwd, home);
330
+ if (servers.length === 0) return;
178
331
 
179
- if (failures.length > 0 && ctx.hasUI) {
180
- ctx.ui.notify(
181
- failures.map((f) => `MCP server "${f.server.name}" failed: ${f.error}`).join("\n") + " (/mcp for status)",
182
- "warning",
332
+ // Disabled servers are listed but never connected; a server with an unset
333
+ // credential env var stays "needs authentication" and is not attempted
334
+ // (connecting with an empty token fails confusingly). A project .mcp.json
335
+ // server needs the user's consent first (trust.ts — a cloned repo must not
336
+ // run commands at startup). Everything else connects.
337
+ const candidates = servers.filter((server) => !disabledNames.has(server.name) && !server.missingEnv?.length);
338
+ const consent = await approveMcpServers(candidates, pluginConfigPaths, ctx.cwd, home, {
339
+ hasUI: ctx.hasUI,
340
+ select: (title, options) => ctx.ui.select(title, options),
341
+ notify: (message) => {
342
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
343
+ else process.stderr.write(`${message}\n`);
344
+ },
345
+ disable: (server) => {
346
+ setMcpServerDisabled(server.name, true, "project", ctx.cwd, home);
347
+ disabledNames.add(server.name);
348
+ },
349
+ });
350
+ if (!alive()) return;
351
+ withheldNames.clear();
352
+ for (const { server, reason } of consent.withheld) {
353
+ withheldNames.set(
354
+ server.name,
355
+ reason === "disabled-by-claude-settings"
356
+ ? "disabled by disabledMcpjsonServers in Claude Code settings"
357
+ : reason === "declined"
358
+ ? "not approved this session (project .mcp.json)"
359
+ : "not approved (project .mcp.json; approve in an interactive session)",
183
360
  );
184
361
  }
362
+ await Promise.all(consent.approved.map(connectOne));
185
363
 
186
- const resourceCount = [...connections.values()].reduce((n, c) => n + c.resources.length, 0);
187
- if (resourceCount > 0) {
188
- registerResourceTools();
189
- }
364
+ if (!alive()) return;
190
365
 
191
- // Servers' own usage instructions (initialize result) ride an every-turn
192
- // reminder, as Claude Code injects them (findings §14) — without this the
193
- // field is silently dropped and servers can't teach the model their tools.
194
- const instructions = mcpInstructionsReminder([...connections.values()]);
195
- if (instructions) {
196
- pi.events.emit(REMINDER_CHANNEL, {
197
- scope: "every-turn",
198
- key: "mcp-instructions",
199
- text: instructions,
200
- placement: "first-prepend",
201
- order: CONTEXT_ORDER.mcp,
202
- });
366
+ if (failures.length > 0) {
367
+ const text = failures.map((f) => `MCP server "${f.server.name}" failed: ${f.error}`).join("\n") + " (/mcp for status)";
368
+ if (ctx.hasUI) ctx.ui.notify(text, "warning");
369
+ else process.stderr.write(`${text}\n`);
370
+ }
371
+ if (oauthNeeded.size > 0 && ctx.hasUI) {
372
+ ctx.ui.notify(
373
+ `${oauthNeeded.size} MCP server${oauthNeeded.size === 1 ? "" : "s"} need authentication — run /mcp to authenticate.`,
374
+ "warning",
375
+ );
203
376
  }
377
+
378
+ emitInstructions();
204
379
  };
205
380
 
206
381
  let resourceToolsRegistered = false;
@@ -217,7 +392,7 @@ export default function mcpExtension(pi: ExtensionAPI) {
217
392
  parameters: Type.Object({
218
393
  server: Type.Optional(Type.String({ description: "Limit to one server by name" })),
219
394
  }),
220
- async execute(_toolCallId, params) {
395
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
221
396
  // A bad `server` name must not read back as "this server has zero
222
397
  // resources" — validate it like read_mcp_resource does, else a typo
223
398
  // looks like an empty (but real) server.
@@ -243,7 +418,12 @@ export default function mcpExtension(pi: ExtensionAPI) {
243
418
  }
244
419
  }
245
420
  return {
246
- content: [{ type: "text", text: lines.length ? lines.join("\n") : "No MCP resources available." }],
421
+ content: [
422
+ {
423
+ type: "text",
424
+ text: lines.length ? persistIfLarge(lines.join("\n"), { dir: resultsDir(ctx), id: toolCallId }) : "No MCP resources available.",
425
+ },
426
+ ],
247
427
  details: { count: lines.length },
248
428
  };
249
429
  },
@@ -293,7 +473,7 @@ export default function mcpExtension(pi: ExtensionAPI) {
293
473
  server: Type.String({ description: "Server name that owns the directory" }),
294
474
  uri: Type.String({ description: "The directory resource uri to list" }),
295
475
  }),
296
- async execute(_toolCallId, params) {
476
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
297
477
  const connection = connections.get(params.server);
298
478
  if (!connection) return unknownServerError(params.server);
299
479
  try {
@@ -301,7 +481,12 @@ export default function mcpExtension(pi: ExtensionAPI) {
301
481
  const entries = (result.resources ?? result.entries ?? []) as Array<{ uri?: string; name?: string; mimeType?: string }>;
302
482
  const lines = entries.map((e) => `${e.uri ?? "(no uri)"}${e.name ? ` — ${e.name}` : ""}${e.mimeType ? ` (${e.mimeType})` : ""}`);
303
483
  return {
304
- content: [{ type: "text", text: lines.length ? lines.join("\n") : "(empty directory)" }],
484
+ content: [
485
+ {
486
+ type: "text",
487
+ text: lines.length ? persistIfLarge(lines.join("\n"), { dir: resultsDir(ctx), id: toolCallId }) : "(empty directory)",
488
+ },
489
+ ],
305
490
  details: { server: params.server, uri: params.uri },
306
491
  };
307
492
  } catch (error) {
@@ -320,6 +505,10 @@ export default function mcpExtension(pi: ExtensionAPI) {
320
505
  };
321
506
 
322
507
  pi.on("session_start", async (_event, ctx) => {
508
+ // A new session (/clear, resume in a new process) has no cached prefix yet:
509
+ // its message-1 blocks are written fresh (same reset as tool-search).
510
+ requestSent = false;
511
+ announced = emptyAnnounced();
323
512
  // pi awaits session_start handlers serially before the prompt opens, and
324
513
  // remote servers take seconds to answer — awaiting here was the entire
325
514
  // "slow startup" (4.9s → 0.24s measured, findings §15). In the interactive
@@ -336,36 +525,347 @@ export default function mcpExtension(pi: ExtensionAPI) {
336
525
  });
337
526
  connecting.finally(() => {
338
527
  connectSettled = true;
528
+ // The session may be gone by now (/clear while a remote server was still
529
+ // answering): pi's API throws from assertActive after session_shutdown,
530
+ // and from a promise continuation that is an uncaught exception that
531
+ // killed the process (STEERING-REVIEW-2026-09-05 H3, measured).
532
+ if (!alive()) return;
533
+ // Final publish, marked settled — emitted even with zero servers/tools so
534
+ // a consumer that spawns children early (subagents) can stop waiting for
535
+ // late-connecting servers instead of snapshotting an empty set forever.
536
+ pi.events.emit(MCP_TOOLS_CHANNEL, { tools: [...sharedTools], settled: true });
339
537
  });
340
- if (!ctx.hasUI || process.env.PI_SUBAGENT_CHILD) {
538
+ if (!ctx.hasUI) {
341
539
  await connecting;
342
540
  }
343
541
  });
344
542
 
345
543
  pi.on("session_shutdown", async () => {
346
- shuttingDown = true;
347
544
  await Promise.all([...connections.values()].map((connection) => close(connection)));
348
545
  connections.clear();
349
546
  });
350
547
 
548
+ // Single source of truth for a server's status, shared by the /plugins status
549
+ // snapshot, the /mcp panel entries, and the text fallback.
550
+ const deriveStatus = (server: McpServer): { status: McpEntryStatus; detail?: string; toolCount?: number } => {
551
+ if (disabledNames.has(server.name)) return { status: "disabled" };
552
+ const withheld = withheldNames.get(server.name);
553
+ if (withheld) return { status: "disabled", detail: withheld };
554
+ const connection = connections.get(server.name);
555
+ if (connection) {
556
+ // A server can connect yet warn (e.g. listTools/listResources failed);
557
+ // adoptConnection files those under `failures`. Surface it so the warning
558
+ // isn't collected-but-invisible.
559
+ const warning = failures.find((f) => f.server.name === server.name);
560
+ return { status: "connected", toolCount: connection.tools.length, detail: warning?.error };
561
+ }
562
+ if (oauthNeeded.has(server.name)) return { status: "authNeeded" };
563
+ if (server.missingEnv?.length) {
564
+ return { status: "authNeeded", detail: `${server.missingEnv.join(", ")} not set in the environment` };
565
+ }
566
+ const failure = failures.find((f) => f.server.name === server.name);
567
+ if (failure) return { status: "failed", detail: failure.error };
568
+ return connectSettled ? { status: "failed", detail: "not connected" } : { status: "connecting" };
569
+ };
570
+
571
+ // The /plugins panel can't import this extension's state (jiti gives every
572
+ // extension its own module instance), and the bus doesn't replay — so it
573
+ // asks: a request event answered with a status snapshot.
574
+ const buildStatusSnapshot = (): McpServerStatus[] =>
575
+ servers.map((server) => {
576
+ const { status, detail, toolCount } = deriveStatus(server);
577
+ return { name: server.name, status, detail, toolCount, source: server.source };
578
+ });
579
+
580
+ pi.events.on(MCP_STATUS_REQUEST_CHANNEL, () => {
581
+ pi.events.emit(MCP_STATUS_CHANNEL, { servers: buildStatusSnapshot(), settled: connectSettled } satisfies McpStatusEvent);
582
+ });
583
+
584
+ // --- /mcp panel ------------------------------------------------------------
585
+
586
+ const shorten = (p: string) => (p.startsWith(home) ? `~${p.slice(home.length)}` : p);
587
+
588
+ // One place that turns a server's config source into its display group, sort
589
+ // rank, config-location label, and disable-persistence scope — so the four
590
+ // can't drift apart. Plugin servers persist at project scope (mcp-overrides
591
+ // has no plugin scope; per-repo is the right default for a plugin).
592
+ interface Provenance {
593
+ rank: number;
594
+ group: string;
595
+ configLocation: string;
596
+ scope: "user" | "project";
597
+ }
598
+ const classify = (server: McpServer): Provenance => {
599
+ if (pluginConfigPaths.has(server.source)) {
600
+ return { rank: 3, group: "Plugin MCPs", configLocation: "Plugin configuration", scope: "project" };
601
+ }
602
+ if (server.source === join(home, ".claude.json")) {
603
+ return { rank: 0, group: `User MCPs (${shorten(server.source)})`, configLocation: shorten(server.source), scope: "user" };
604
+ }
605
+ if (server.source.endsWith(join(".claude", "settings.local.json"))) {
606
+ return { rank: 2, group: "Project MCPs (.claude/settings.local.json)", configLocation: shorten(server.source), scope: "project" };
607
+ }
608
+ return { rank: 1, group: `Project MCPs (${shorten(server.source)})`, configLocation: shorten(server.source), scope: "project" };
609
+ };
610
+
611
+ const buildEntries = (): McpEntry[] =>
612
+ servers
613
+ .map((server) => {
614
+ const { status, detail, toolCount } = deriveStatus(server);
615
+ const { rank, group, configLocation } = classify(server);
616
+ const canAuthenticate = server.kind === "http" && !server.missingEnv?.length;
617
+ // Issue line for hard failures, for an env-missing authNeeded (so the user
618
+ // sees which variable to set), and for a connected-but-warning server; an
619
+ // OAuth authNeeded needs no issue — the Authenticate action speaks for itself.
620
+ const issue =
621
+ status === "failed" || status === "connected" || status === "disabled" || (status === "authNeeded" && !canAuthenticate)
622
+ ? detail
623
+ : undefined;
624
+ // Auth line shown on a failed http server (as Claude Code does), read
625
+ // honestly from whether tokens are stored — not just "failed http".
626
+ const authState =
627
+ status === "failed" && server.kind === "http"
628
+ ? hasStoredTokens(server.name)
629
+ ? ("authenticated" as const)
630
+ : ("notAuthenticated" as const)
631
+ : undefined;
632
+ const entry: McpEntry = {
633
+ name: server.name,
634
+ group,
635
+ status,
636
+ toolCount,
637
+ issue,
638
+ url: server.kind === "http" ? server.url : undefined,
639
+ configLocation,
640
+ authState,
641
+ canAuthenticate,
642
+ };
643
+ return { entry, rank };
644
+ })
645
+ .sort((a, b) =>
646
+ a.rank !== b.rank
647
+ ? a.rank - b.rank
648
+ : a.entry.group === b.entry.group
649
+ ? a.entry.name.localeCompare(b.entry.name)
650
+ : a.entry.group.localeCompare(b.entry.group),
651
+ )
652
+ .map((e) => e.entry);
653
+
654
+ /** Bounded dock like /skills and /plugins — keeps the transcript visible above. */
655
+ const MCP_PANEL_MAX_HEIGHT = 22;
656
+
657
+ const openMcpPanel = async (ctx: ExtensionContext): Promise<void> => {
658
+ await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
659
+ const paint: McpPaint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
660
+ const state = initialMcpState();
661
+ let notices: string[] = [];
662
+ const busy = new Set<string>();
663
+ let cache: { width: number; lines: string[] } | undefined;
664
+ // The entry list is derived from live connection state; build it once and
665
+ // rebuild only when that state changes (an action lands, or the ticker
666
+ // sees connects still settling) — never per render frame or per keystroke.
667
+ let entries = buildEntries();
668
+ const repaint = () => {
669
+ cache = undefined;
670
+ tui.requestRender();
671
+ };
672
+ const syncRepaint = () => {
673
+ entries = buildEntries();
674
+ repaint();
675
+ };
676
+ // Animate while connecting or an action is in flight; real changes repaint explicitly.
677
+ const ticker = setInterval(() => {
678
+ if (busy.size > 0 || !connectSettled) syncRepaint();
679
+ }, 500);
680
+ ticker.unref?.();
681
+
682
+ // Message after a (re)connect attempt: silent on success, else name the
683
+ // reason from the freshly derived status (auth vs a hard failure).
684
+ const outcomeNotice = (server: McpServer): string[] => {
685
+ const status = deriveStatus(server).status;
686
+ if (status === "connected") return [];
687
+ if (status === "authNeeded") return [`"${server.name}" needs authentication.`];
688
+ return [`"${server.name}" could not connect.`];
689
+ };
690
+
691
+ const runReconnect = async (entry: McpEntry) => {
692
+ const server = servers.find((s) => s.name === entry.name);
693
+ if (!server || busy.has(entry.name)) return;
694
+ busy.add(entry.name);
695
+ notices = [`Reconnecting to "${entry.name}"…`];
696
+ syncRepaint();
697
+ const existing = connections.get(entry.name);
698
+ if (existing) {
699
+ connections.delete(entry.name);
700
+ await close(existing);
701
+ }
702
+ await connectOne(server);
703
+ busy.delete(entry.name);
704
+ // The session may have been replaced during the connect (an RPC
705
+ // new_session while the panel was open): every pi.* call throws
706
+ // then, and from a void'ed continuation that is an unhandled
707
+ // rejection (LIFECYCLE-REVIEW-2026-09-06 L2).
708
+ if (!alive()) return;
709
+ emitInstructions();
710
+ notices = outcomeNotice(server);
711
+ syncRepaint();
712
+ };
713
+
714
+ const runDisable = (entry: McpEntry) => {
715
+ const server = servers.find((s) => s.name === entry.name);
716
+ if (!server || busy.has(entry.name)) return; // don't cut across an in-flight connect
717
+ setMcpServerDisabled(entry.name, true, classify(server).scope, ctx.cwd, home);
718
+ disabledNames.add(entry.name);
719
+ const existing = connections.get(entry.name);
720
+ if (existing) {
721
+ connections.delete(entry.name);
722
+ void close(existing);
723
+ }
724
+ state.detail = undefined;
725
+ emitInstructions(); // drops the disabled server's instructions reminder
726
+ notices = [`Disabled "${entry.name}". Its tools stay unavailable until re-enabled.`];
727
+ syncRepaint();
728
+ };
729
+
730
+ const runEnable = async (entry: McpEntry) => {
731
+ const server = servers.find((s) => s.name === entry.name);
732
+ if (!server || busy.has(entry.name)) return;
733
+ setMcpServerDisabled(entry.name, false, classify(server).scope, ctx.cwd, home);
734
+ disabledNames.delete(entry.name);
735
+ // Enabling a withheld project .mcp.json server by hand IS the consent.
736
+ if (withheldNames.delete(entry.name)) persistApproval(projectRootOf(server), server);
737
+ busy.add(entry.name);
738
+ notices = [`Enabling "${entry.name}"…`];
739
+ syncRepaint();
740
+ await connectOne(server);
741
+ busy.delete(entry.name);
742
+ if (!alive()) return; // see runReconnect
743
+ emitInstructions();
744
+ notices = outcomeNotice(server);
745
+ syncRepaint();
746
+ };
747
+
748
+ const runAuthenticate = async (entry: McpEntry) => {
749
+ const server = servers.find((s) => s.name === entry.name);
750
+ if (!server || busy.has(entry.name)) return;
751
+ if (server.kind !== "http") {
752
+ notices = [`"${entry.name}" is not an http server; OAuth is unavailable.`];
753
+ repaint();
754
+ return;
755
+ }
756
+ busy.add(entry.name);
757
+ notices = [`Starting authorization for "${entry.name}"…`];
758
+ syncRepaint();
759
+ try {
760
+ const connection = await runOAuthFlow({
761
+ server,
762
+ home,
763
+ onPrompt: (message) => {
764
+ notices = message.split("\n");
765
+ repaint();
766
+ },
767
+ });
768
+ if (!alive()) {
769
+ await close(connection); // see runReconnect
770
+ return;
771
+ }
772
+ adoptConnection(server, connection);
773
+ emitInstructions();
774
+ notices = [`Authenticated "${entry.name}".`];
775
+ } catch (error) {
776
+ notices = [`Authentication failed: ${(error as Error).message}`];
777
+ } finally {
778
+ busy.delete(entry.name);
779
+ syncRepaint();
780
+ }
781
+ };
782
+
783
+ // Each action is a void'ed promise. A throw after the session was
784
+ // replaced is expected (every pi.* call throws then) and dropped; any
785
+ // other failure is shown in the panel, never an unhandled rejection.
786
+ const guarded = (work: Promise<void>) =>
787
+ work.catch((error) => {
788
+ if (!alive()) return;
789
+ notices = [`Action failed: ${(error as Error).message}`];
790
+ syncRepaint();
791
+ });
792
+
793
+ const runEffect = (effect: McpEffect) => {
794
+ switch (effect.kind) {
795
+ case "close":
796
+ clearInterval(ticker);
797
+ done(null);
798
+ return;
799
+ case "reconnect":
800
+ void guarded(runReconnect(effect.entry));
801
+ return;
802
+ case "disable":
803
+ runDisable(effect.entry);
804
+ return;
805
+ case "enable":
806
+ void guarded(runEnable(effect.entry));
807
+ return;
808
+ case "authenticate":
809
+ void guarded(runAuthenticate(effect.entry));
810
+ return;
811
+ }
812
+ };
813
+
814
+ return {
815
+ render: (width: number) => {
816
+ if (cache?.width === width) return cache.lines;
817
+ const termRows = (tui as { terminal: { rows: number } }).terminal.rows;
818
+ const height = boundedDockHeight(termRows, MCP_PANEL_MAX_HEIGHT);
819
+ const lines = renderMcpPanel(
820
+ { state, entries, width, height, notices, settled: connectSettled },
821
+ paint,
822
+ ).map((line) => truncateLine(line, width));
823
+ cache = { width, lines };
824
+ return lines;
825
+ },
826
+ handleInput: (data: string) => {
827
+ const key = decodeMcpKey(data);
828
+ if (!key) return;
829
+ // Navigation reads the cached entries; effects (which change the
830
+ // underlying state) rebuild them via syncRepaint inside their handlers.
831
+ const effect = applyMcpKey(state, key, entries);
832
+ if (effect) runEffect(effect);
833
+ repaint();
834
+ },
835
+ invalidate: () => {
836
+ cache = undefined;
837
+ },
838
+ dispose: () => {
839
+ clearInterval(ticker);
840
+ },
841
+ };
842
+ });
843
+ };
844
+
351
845
  pi.registerCommand("mcp", {
352
- description: "Show MCP server status",
846
+ description: "Manage MCP servers (status, reconnect, authenticate, enable/disable)",
353
847
  handler: async (_args, ctx) => {
354
848
  if (servers.length === 0) {
355
849
  ctx.ui.notify("No MCP servers configured. Add them to .mcp.json or ~/.claude.json.", "info");
356
850
  return;
357
851
  }
852
+ if (ctx.hasUI) {
853
+ await openMcpPanel(ctx);
854
+ return;
855
+ }
856
+ // Non-interactive fallback: a flat status listing, with tool/resource
857
+ // counts and any warning for connected servers.
358
858
  const lines = servers.map((server) => {
859
+ const { status, detail } = deriveStatus(server);
359
860
  const connection = connections.get(server.name);
861
+ let suffix = "";
360
862
  if (connection) {
361
- const warning = connection.warnings.length ? ` — WARNING: ${connection.warnings.join("; ")}` : "";
362
- return `connected ${server.name} — ${connection.tools.length} tools, ${connection.resources.length} resources (${server.source})${warning}`;
863
+ suffix = ` — ${connection.tools.length} tools, ${connection.resources.length} resources`;
864
+ if (detail) suffix += ` — WARNING: ${detail}`;
865
+ } else if (detail) {
866
+ suffix = ` — ${detail}`;
363
867
  }
364
- const failure = failures.find((f) => f.server.name === server.name);
365
- if (failure) return `failed ${server.name} — ${failure.error} (${server.source})`;
366
- return connectSettled
367
- ? `failed ${server.name} — not connected (${server.source})`
368
- : `… ${server.name} — still connecting (${server.source})`;
868
+ return `${status.padEnd(12)} ${server.name}${suffix} (${server.source})`;
369
869
  });
370
870
  ctx.ui.notify(lines.join("\n"), "info");
371
871
  },