one-code-extension 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * mcp extension — Model Context Protocol client, which pi deliberately omits.
3
+ *
4
+ * Servers are configured in Claude Code's format (`.mcp.json`, `~/.claude.json`)
5
+ * and their tools are registered as `mcp__<server>__<tool>`, so existing Claude
6
+ * Code MCP setups and permission rules work unchanged.
7
+ *
8
+ * Every MCP tool is registered deferred: a handful of servers can contribute
9
+ * dozens of tools, and putting all those schemas in the system prompt is exactly
10
+ * what `tool_search` exists to avoid.
11
+ *
12
+ * Servers are connected once per session (their tool lists are needed to
13
+ * register anything) and closed on `session_shutdown`. In the interactive main
14
+ * session the connect runs in the background so remote servers cannot delay
15
+ * the prompt; one-shots and subagent children await it (findings §15).
16
+ */
17
+
18
+ import os from "node:os";
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+ import { DEFER_CHANNEL } from "../lib/deferred.ts";
22
+ import { persistIfLarge } from "../lib/persisted-output.ts";
23
+ import { ccToolRenderers } from "../lib/tui-render.ts";
24
+ import {
25
+ callTool,
26
+ close,
27
+ type Connection,
28
+ connect,
29
+ type FailedConnection,
30
+ mcpInstructionsReminder,
31
+ readResource,
32
+ readResourceDir,
33
+ } from "./client.ts";
34
+ import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
35
+ import { join } from "node:path";
36
+ import { discoverPlugins } from "../lib/plugins.ts";
37
+ import { loadServers, type McpServer } from "./config.ts";
38
+ import {
39
+ describeContent,
40
+ describeResourceContents,
41
+ jsonSchemaToTypeBox,
42
+ type McpContentBlock,
43
+ type McpResourceContents,
44
+ namespacedToolName,
45
+ } from "./schema.ts";
46
+
47
+ /** Shared "server name not found" tool result for the resource read/list-dir tools. */
48
+ function unknownServerError(name: string) {
49
+ return {
50
+ content: [{ type: "text" as const, text: `No connected MCP server named "${name}".` }],
51
+ details: {} as { server?: string; uri?: string },
52
+ isError: true,
53
+ };
54
+ }
55
+
56
+ export default function mcpExtension(pi: ExtensionAPI) {
57
+ const connections = new Map<string, Connection>();
58
+ const failures: FailedConnection[] = [];
59
+ const registered = new Set<string>();
60
+ let servers: McpServer[] = [];
61
+
62
+ /**
63
+ * Where oversized results are persisted — the session dir, matching Claude
64
+ * Code's `<session-dir>/tool-results/<id>.txt`. A session-less run (`--no-session`)
65
+ * has no such dir, so a temp folder serves; the model gets the path either way.
66
+ */
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
+ };
76
+
77
+ const registerToolsFor = (connection: Connection) => {
78
+ for (const tool of connection.tools) {
79
+ const name = namespacedToolName(connection.server.name, tool.name);
80
+ if (registered.has(name)) continue;
81
+ registered.add(name);
82
+
83
+ pi.registerTool({
84
+ name,
85
+ label: `${connection.server.name}: ${tool.name}`,
86
+ ...ccToolRenderers(`${connection.server.name}: ${tool.name}`),
87
+ description: tool.description ?? `MCP tool "${tool.name}" from server "${connection.server.name}".`,
88
+ parameters: jsonSchemaToTypeBox(tool.inputSchema),
89
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
90
+ const live = connections.get(connection.server.name);
91
+ if (!live) {
92
+ return {
93
+ content: [{ type: "text", text: `MCP server "${connection.server.name}" is not connected.` }],
94
+ details: {} as Record<string, unknown>,
95
+ isError: true,
96
+ };
97
+ }
98
+ try {
99
+ const result = await callTool(live, tool.name, (params ?? {}) as Record<string, unknown>);
100
+ const { text, images } = describeContent(result.content as McpContentBlock[] | undefined);
101
+ return {
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text: text ? persistIfLarge(text, { dir: resultsDir(ctx), id: toolCallId }) : "(no output)",
106
+ },
107
+ ...images.map((image) => ({ type: "image" as const, data: image.data, mimeType: image.mimeType })),
108
+ ],
109
+ details: { server: connection.server.name, tool: tool.name } as Record<string, unknown>,
110
+ isError: result.isError === true,
111
+ };
112
+ } catch (error) {
113
+ return {
114
+ content: [{ type: "text", text: `MCP call failed: ${(error as Error).message}` }],
115
+ details: { server: connection.server.name, tool: tool.name } as Record<string, unknown>,
116
+ isError: true,
117
+ };
118
+ }
119
+ },
120
+ });
121
+
122
+ // Deferred: the model discovers these through tool_search.
123
+ pi.events.emit(DEFER_CHANNEL, {
124
+ name,
125
+ keywords: [connection.server.name, tool.name.replace(/[_-]/g, " "), "mcp"],
126
+ });
127
+ }
128
+ };
129
+
130
+ /** Resolves when every configured server has connected or failed. */
131
+ let connecting: Promise<void> | undefined;
132
+ 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;
138
+
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)) {
142
+ failures.push({
143
+ server,
144
+ error: `not started — ${server.missingEnv?.join(", ")} not set in the environment`,
145
+ });
146
+ }
147
+
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
+ );
159
+
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)));
164
+ return;
165
+ }
166
+
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
+ }
177
+ }
178
+
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",
183
+ );
184
+ }
185
+
186
+ const resourceCount = [...connections.values()].reduce((n, c) => n + c.resources.length, 0);
187
+ if (resourceCount > 0) {
188
+ registerResourceTools();
189
+ }
190
+
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
+ });
203
+ }
204
+ };
205
+
206
+ let resourceToolsRegistered = false;
207
+ const registerResourceTools = () => {
208
+ if (resourceToolsRegistered) return;
209
+ resourceToolsRegistered = true;
210
+
211
+ pi.registerTool({
212
+ name: "list_mcp_resources",
213
+ label: "MCP Resources",
214
+ ...ccToolRenderers("MCP Resources"),
215
+ description:
216
+ "List resources exposed by connected MCP servers. Resources are readable documents or data the server offers, addressed by uri.",
217
+ parameters: Type.Object({
218
+ server: Type.Optional(Type.String({ description: "Limit to one server by name" })),
219
+ }),
220
+ async execute(_toolCallId, params) {
221
+ // A bad `server` name must not read back as "this server has zero
222
+ // resources" — validate it like read_mcp_resource does, else a typo
223
+ // looks like an empty (but real) server.
224
+ if (params.server && !connections.has(params.server)) {
225
+ return {
226
+ content: [
227
+ {
228
+ type: "text",
229
+ text: `No connected MCP server named "${params.server}". Connected servers: ${[...connections.keys()].join(", ") || "none"}.`,
230
+ },
231
+ ],
232
+ details: { count: 0 },
233
+ isError: true,
234
+ };
235
+ }
236
+ const lines: string[] = [];
237
+ for (const connection of connections.values()) {
238
+ if (params.server && connection.server.name !== params.server) continue;
239
+ for (const resource of connection.resources) {
240
+ lines.push(
241
+ `${connection.server.name} ${resource.uri}${resource.name ? ` — ${resource.name}` : ""}${resource.description ? `: ${resource.description}` : ""}`,
242
+ );
243
+ }
244
+ }
245
+ return {
246
+ content: [{ type: "text", text: lines.length ? lines.join("\n") : "No MCP resources available." }],
247
+ details: { count: lines.length },
248
+ };
249
+ },
250
+ });
251
+
252
+ pi.registerTool({
253
+ name: "read_mcp_resource",
254
+ label: "Read MCP Resource",
255
+ ...ccToolRenderers("Read MCP Resource"),
256
+ description: "Read one resource from an MCP server by uri. Use list_mcp_resources to find uris.",
257
+ parameters: Type.Object({
258
+ server: Type.String({ description: "Server name that owns the resource" }),
259
+ uri: Type.String({ description: "Resource uri" }),
260
+ }),
261
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
262
+ const connection = connections.get(params.server);
263
+ if (!connection) return unknownServerError(params.server);
264
+ try {
265
+ const result = await readResource(connection, params.uri);
266
+ const text = describeResourceContents(result.contents as McpResourceContents[] | undefined);
267
+ return {
268
+ content: [
269
+ {
270
+ type: "text",
271
+ text: text ? persistIfLarge(text, { dir: resultsDir(ctx), id: toolCallId }) : "(empty resource)",
272
+ },
273
+ ],
274
+ details: { server: params.server, uri: params.uri },
275
+ };
276
+ } catch (error) {
277
+ return {
278
+ content: [{ type: "text", text: `Could not read ${params.uri}: ${(error as Error).message}` }],
279
+ details: { server: params.server, uri: params.uri },
280
+ isError: true,
281
+ };
282
+ }
283
+ },
284
+ });
285
+
286
+ pi.registerTool({
287
+ name: "read_mcp_resource_dir",
288
+ label: "List MCP Resource Directory",
289
+ ...ccToolRenderers("List MCP Resource Directory"),
290
+ description:
291
+ 'List the direct children of a directory resource on an MCP server (resources/directory/read). Not recursive: each entry carries its own uri, and subdirectories appear with mimeType "inode/directory" — call again on a subdirectory uri to descend. Only servers that support directory listing accept this; others return an error.',
292
+ parameters: Type.Object({
293
+ server: Type.String({ description: "Server name that owns the directory" }),
294
+ uri: Type.String({ description: "The directory resource uri to list" }),
295
+ }),
296
+ async execute(_toolCallId, params) {
297
+ const connection = connections.get(params.server);
298
+ if (!connection) return unknownServerError(params.server);
299
+ try {
300
+ const result = await readResourceDir(connection, params.uri);
301
+ const entries = (result.resources ?? result.entries ?? []) as Array<{ uri?: string; name?: string; mimeType?: string }>;
302
+ const lines = entries.map((e) => `${e.uri ?? "(no uri)"}${e.name ? ` — ${e.name}` : ""}${e.mimeType ? ` (${e.mimeType})` : ""}`);
303
+ return {
304
+ content: [{ type: "text", text: lines.length ? lines.join("\n") : "(empty directory)" }],
305
+ details: { server: params.server, uri: params.uri },
306
+ };
307
+ } catch (error) {
308
+ return {
309
+ content: [{ type: "text", text: `Could not list ${params.uri}: ${(error as Error).message}` }],
310
+ details: { server: params.server, uri: params.uri },
311
+ isError: true,
312
+ };
313
+ }
314
+ },
315
+ });
316
+
317
+ for (const name of ["list_mcp_resources", "read_mcp_resource", "read_mcp_resource_dir"]) {
318
+ pi.events.emit(DEFER_CHANNEL, { name, keywords: ["mcp", "resource", "document", "uri", "directory"] });
319
+ }
320
+ };
321
+
322
+ pi.on("session_start", async (_event, ctx) => {
323
+ // pi awaits session_start handlers serially before the prompt opens, and
324
+ // remote servers take seconds to answer — awaiting here was the entire
325
+ // "slow startup" (4.9s → 0.24s measured, findings §15). In the interactive
326
+ // main session the connect runs in the background: tools register as each
327
+ // server answers (tool_search handles late defers), and /mcp shows
328
+ // "connecting" until it settles. Print-mode one-shots and subagent RPC
329
+ // children still await — their single turn starts immediately and would
330
+ // race past tools that are not registered yet.
331
+ connecting = connectAll(ctx).catch((error) => {
332
+ // A rejection here would otherwise be an unhandled-promise crash in
333
+ // the background path; per-server failures are already collected in
334
+ // `failures`, so this only catches setup bugs — surface them loud.
335
+ if (ctx.hasUI) ctx.ui.notify(`MCP startup failed: ${(error as Error).message}`, "error");
336
+ });
337
+ connecting.finally(() => {
338
+ connectSettled = true;
339
+ });
340
+ if (!ctx.hasUI || process.env.PI_SUBAGENT_CHILD) {
341
+ await connecting;
342
+ }
343
+ });
344
+
345
+ pi.on("session_shutdown", async () => {
346
+ shuttingDown = true;
347
+ await Promise.all([...connections.values()].map((connection) => close(connection)));
348
+ connections.clear();
349
+ });
350
+
351
+ pi.registerCommand("mcp", {
352
+ description: "Show MCP server status",
353
+ handler: async (_args, ctx) => {
354
+ if (servers.length === 0) {
355
+ ctx.ui.notify("No MCP servers configured. Add them to .mcp.json or ~/.claude.json.", "info");
356
+ return;
357
+ }
358
+ const lines = servers.map((server) => {
359
+ const connection = connections.get(server.name);
360
+ 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}`;
363
+ }
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})`;
369
+ });
370
+ ctx.ui.notify(lines.join("\n"), "info");
371
+ },
372
+ });
373
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tool naming and JSON Schema → TypeBox conversion (pure).
3
+ *
4
+ * MCP servers describe their tools with plain JSON Schema. pi expects TypeBox
5
+ * schemas, so rather than relying on a raw schema object happening to satisfy
6
+ * TypeBox's runtime checks, the common subset is converted explicitly and
7
+ * anything exotic degrades to a permissive object that passes arguments through.
8
+ */
9
+
10
+ import { type TSchema, Type } from "typebox";
11
+
12
+ /** Claude Code's namespacing: mcp__<server>__<tool>, sanitised for tool-name rules. */
13
+ export function namespacedToolName(server: string, tool: string): string {
14
+ const clean = (part: string) => part.replace(/[^a-zA-Z0-9_]/g, "_");
15
+ return `mcp__${clean(server)}__${clean(tool)}`;
16
+ }
17
+
18
+ export function parseNamespacedToolName(name: string): { server: string; tool: string } | undefined {
19
+ if (!name.startsWith("mcp__")) return undefined;
20
+ const rest = name.slice("mcp__".length);
21
+ const separator = rest.indexOf("__");
22
+ if (separator === -1) return undefined;
23
+ return { server: rest.slice(0, separator), tool: rest.slice(separator + 2) };
24
+ }
25
+
26
+ interface JsonSchema {
27
+ type?: string | string[];
28
+ description?: string;
29
+ properties?: Record<string, JsonSchema>;
30
+ required?: string[];
31
+ items?: JsonSchema;
32
+ enum?: unknown[];
33
+ const?: unknown;
34
+ anyOf?: JsonSchema[];
35
+ oneOf?: JsonSchema[];
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ function convertLeaf(schema: JsonSchema): TSchema | undefined {
40
+ const options = schema.description ? { description: schema.description } : {};
41
+ const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type;
42
+
43
+ switch (type) {
44
+ case "string":
45
+ return Type.String(options);
46
+ case "number":
47
+ return Type.Number(options);
48
+ case "integer":
49
+ return Type.Integer(options);
50
+ case "boolean":
51
+ return Type.Boolean(options);
52
+ case "array":
53
+ return Type.Array(schema.items ? jsonSchemaToTypeBox(schema.items) : Type.Unknown(), options);
54
+ case "object":
55
+ return jsonSchemaToTypeBox(schema);
56
+ default:
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Converts a JSON Schema to a TypeBox schema. Enums become literal unions;
63
+ * unrecognised constructs become `Unknown`, which validates anything, so an
64
+ * unusual server schema degrades to passing arguments through rather than
65
+ * rejecting every call.
66
+ */
67
+ export function jsonSchemaToTypeBox(schema: JsonSchema | undefined): TSchema {
68
+ if (!schema || typeof schema !== "object") return Type.Unknown();
69
+
70
+ const options = schema.description ? { description: schema.description } : {};
71
+
72
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
73
+ const literals = schema.enum
74
+ .filter((value): value is string | number | boolean => ["string", "number", "boolean"].includes(typeof value))
75
+ .map((value) => Type.Literal(value));
76
+ if (literals.length === schema.enum.length) {
77
+ return literals.length === 1 ? literals[0] : Type.Union(literals, options);
78
+ }
79
+ return Type.Unknown();
80
+ }
81
+
82
+ if (schema.properties || schema.type === "object") {
83
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
84
+ const properties: Record<string, TSchema> = {};
85
+ for (const [key, value] of Object.entries(schema.properties ?? {})) {
86
+ const converted = jsonSchemaToTypeBox(value);
87
+ properties[key] = required.has(key) ? converted : Type.Optional(converted);
88
+ }
89
+ // additionalProperties stays open: servers commonly accept more than they declare.
90
+ return Type.Object(properties, { ...options, additionalProperties: true });
91
+ }
92
+
93
+ const leaf = convertLeaf(schema);
94
+ if (leaf) return leaf;
95
+
96
+ const variants = schema.anyOf ?? schema.oneOf;
97
+ if (Array.isArray(variants) && variants.length > 0) {
98
+ const converted = variants.map((variant) => jsonSchemaToTypeBox(variant));
99
+ return converted.length === 1 ? converted[0] : Type.Union(converted, options);
100
+ }
101
+
102
+ return Type.Unknown();
103
+ }
104
+
105
+ /** MCP tool results carry typed content blocks; map the ones pi can display. */
106
+ export interface McpContentBlock {
107
+ type: string;
108
+ text?: string;
109
+ data?: string;
110
+ mimeType?: string;
111
+ resource?: { text?: string; uri?: string };
112
+ }
113
+
114
+ /**
115
+ * `resources/read` returns resource *contents*, not typed content blocks: each
116
+ * entry carries `text` or base64 `blob` plus `uri`/`mimeType`, with no `type`
117
+ * field. They need their own formatter — running them through describeContent
118
+ * yields "[undefined content]".
119
+ */
120
+ export interface McpResourceContents {
121
+ uri?: string;
122
+ mimeType?: string;
123
+ text?: string;
124
+ blob?: string;
125
+ }
126
+
127
+ export function describeResourceContents(contents: McpResourceContents[] | undefined): string {
128
+ const parts: string[] = [];
129
+ for (const entry of contents ?? []) {
130
+ if (typeof entry.text === "string") {
131
+ parts.push(entry.text);
132
+ } else if (typeof entry.blob === "string") {
133
+ const size = Math.round((entry.blob.length * 3) / 4 / 1024);
134
+ parts.push(`[binary resource ${entry.uri ?? ""} ${entry.mimeType ?? "unknown type"}, ~${size} KB]`);
135
+ }
136
+ }
137
+ return parts.join("\n").trim();
138
+ }
139
+
140
+ export function describeContent(blocks: McpContentBlock[] | undefined): {
141
+ text: string;
142
+ images: Array<{ data: string; mimeType: string }>;
143
+ } {
144
+ const texts: string[] = [];
145
+ const images: Array<{ data: string; mimeType: string }> = [];
146
+
147
+ for (const block of blocks ?? []) {
148
+ if (block.type === "text" && typeof block.text === "string") {
149
+ texts.push(block.text);
150
+ } else if (block.type === "image" && block.data) {
151
+ images.push({ data: block.data, mimeType: block.mimeType ?? "image/png" });
152
+ } else if (block.type === "resource" && block.resource) {
153
+ const { text, uri } = block.resource;
154
+ texts.push(text ?? `[resource ${uri ?? "(no uri)"}]`);
155
+ } else {
156
+ texts.push(`[${block.type} content]`);
157
+ }
158
+ }
159
+
160
+ return { text: texts.join("\n").trim(), images };
161
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * memory extension — filesystem side of the auto-memory system (see
3
+ * extensions/lib/memory.ts for the design and the prompt side).
4
+ *
5
+ * - session start: create the per-project memory directory (the system prompt
6
+ * promises the model "this directory already exists") and inject the
7
+ * MEMORY.md index — capped like Claude Code caps it — via the reminder queue.
8
+ * - write into the memory dir: stamp bookkeeping frontmatter (node_type,
9
+ * originSessionId, modified) into the tool input *before* execution, so the
10
+ * file lands stamped and file-tracker observes the same bytes that are on
11
+ * disk. Edits are not stamped: Claude Code ties the stamp to writes, and an
12
+ * edit's input carries diffs, not the file.
13
+ * - write/edit of MEMORY.md: check the load limits afterwards; near-limit
14
+ * nudges over the reminder queue, over-limit turns the result into an error
15
+ * (the write itself succeeded — the message says so), matching Claude Code.
16
+ */
17
+
18
+ import { mkdirSync, readFileSync } from "node:fs";
19
+ import { isAbsolute, join, resolve, sep } from "node:path";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import {
22
+ INDEX_NEAR_LIMIT_REMINDER,
23
+ INDEX_OVER_LIMIT_ERROR,
24
+ indexLimitStatus,
25
+ projectMemoryDir,
26
+ stampFrontmatter,
27
+ } from "../lib/memory.ts";
28
+ import { REMINDER_CHANNEL } from "../lib/reminders.ts";
29
+
30
+ function inputPath(input: unknown, cwd: string): string | undefined {
31
+ const raw = (input as { path?: unknown })?.path;
32
+ if (typeof raw !== "string" || !raw.trim()) return undefined;
33
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
34
+ }
35
+
36
+ export default function memoryExtension(pi: ExtensionAPI) {
37
+ let dir: string | undefined;
38
+
39
+ pi.on("session_start", (_event, ctx) => {
40
+ // Like Claude Code: one memory dir per git repo (shared by worktrees and
41
+ // subdirectories); outside a repo, per cwd.
42
+ const candidate = projectMemoryDir(ctx.cwd);
43
+ try {
44
+ mkdirSync(candidate, { recursive: true });
45
+ } catch {
46
+ // Unwritable home (sandboxes, CI): the prompt section still renders,
47
+ // and writes will surface their own errors to the model.
48
+ return;
49
+ }
50
+ dir = candidate;
51
+
52
+ // The MEMORY.md index is injected by the claude-context extension, folded
53
+ // into the `# claudeMd` block exactly as Claude Code does — not as a
54
+ // separate reminder here. This extension owns the directory and the
55
+ // write-time stamping / limit checks below.
56
+ });
57
+
58
+ pi.on("tool_call", (event, ctx) => {
59
+ if (event.toolName !== "write" || !dir) return undefined;
60
+ const path = inputPath(event.input, ctx.cwd);
61
+ if (!path?.startsWith(dir + sep)) return undefined;
62
+ const input = event.input as { content?: unknown };
63
+ if (typeof input.content !== "string") return undefined;
64
+ input.content = stampFrontmatter(input.content, ctx.sessionManager.getSessionId(), new Date().toISOString());
65
+ return undefined;
66
+ });
67
+
68
+ pi.on("tool_result", (event, ctx) => {
69
+ if (event.isError || !dir) return undefined;
70
+ if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
71
+ if (inputPath(event.input, ctx.cwd) !== join(dir, "MEMORY.md")) return undefined;
72
+
73
+ let index: string;
74
+ try {
75
+ index = readFileSync(join(dir, "MEMORY.md"), "utf8");
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ const status = indexLimitStatus(index);
80
+ if (status === "near") {
81
+ pi.events.emit(REMINDER_CHANNEL, { text: INDEX_NEAR_LIMIT_REMINDER });
82
+ } else if (status === "over") {
83
+ return {
84
+ content: [...(event.content ?? []), { type: "text", text: INDEX_OVER_LIMIT_ERROR }],
85
+ isError: true,
86
+ };
87
+ }
88
+ return undefined;
89
+ });
90
+ }