@zswarm/pi 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zSwarm contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # @zswarm/pi
2
+
3
+ Bridge stdio MCP servers into [pi](https://pi.dev) as native tools. Defaults to zSwarm; point it at any stdio MCP server with `ZSWARM_MCP_SERVERS`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@zswarm/pi
9
+ ```
10
+
11
+ From a checkout of this repo:
12
+
13
+ ```bash
14
+ pi install -l ./packages/pi
15
+ ```
16
+
17
+ `-l` installs into the project (`.pi/settings.json`) rather than your home
18
+ directory, and is shareable — pi installs it for anyone who opens the project.
19
+
20
+ Restart pi after installing. After that `/reload` is enough, since the extension
21
+ registers on every bind. On success:
22
+
23
+ ```
24
+ [Extensions]
25
+ zswarm-mcp.ts
26
+ MCP zswarm: 1 tool(s) registered
27
+ ```
28
+
29
+ ## Configure
30
+
31
+ With nothing set, it looks for zSwarm's own server in the only two places it can
32
+ be, in order:
33
+
34
+ 1. `<cwd>/bin/launch-mcp.mjs` — a checkout of the zswarm repo, pi started at its root
35
+ 2. `@zswarm/mcp` — resolved by name, wherever your package manager put it
36
+
37
+ `@zswarm/mcp` is a dependency of this package, so a plain
38
+ `pi install npm:@zswarm/pi` pulls the server in and step 2 finds it. Nothing to
39
+ configure.
40
+
41
+ Found in neither, it registers nothing and says so. It does not guess a relative
42
+ path: a wrong guess becomes a spawn error you have to decode, whereas finding
43
+ nothing names the variable that fixes it.
44
+
45
+ Set `ZSWARM_MCP_SERVERS` to bridge a **different** server — JSON, same shape as
46
+ other MCP hosts:
47
+
48
+ ```bash
49
+ ZSWARM_MCP_SERVERS='{
50
+ "zswarm": {"command": "/abs/path/to/node", "args": ["/abs/path/to/launch-mcp.mjs"]},
51
+ "other": {"command": "/abs/path/to/other-server"}
52
+ }'
53
+ ```
54
+
55
+ Use absolute paths throughout. Hosts that spawn servers outside your shell
56
+ inherit no `PATH`, so a bare `node` fails with
57
+ `exec: "node": executable file not found in $PATH`. The built-in defaults use
58
+ `process.execPath` — the interpreter already running pi — for exactly this
59
+ reason.
60
+
61
+ Tools register as `<server>_<tool>`, or `<tool>` when the names match. A failed server is skipped. Set `ZSWARM_SESSION` if more than one Zellij session is live.
62
+
63
+ ## How it works
64
+
65
+ On load: spawn each server → `initialize` → `tools/list` → `pi.registerTool()`. A call is `tools/call`. JSON-RPC, no SDK; schemas pass through as-is. Tools may not be ready in the same instant pi starts.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Bridge MCP tools into pi.
3
+ *
4
+ * pi ships no MCP client on purpose — its position is that a CLI with a README
5
+ * is the better interface. That holds right up until you want one surface that
6
+ * every harness in a crew shares, which is what an MCP server already is. This
7
+ * extension spawns a stdio MCP server, asks it for its tools, and registers
8
+ * each one with pi, so the same tool call works here and in the MCP hosts.
9
+ *
10
+ * It speaks the protocol directly rather than depending on an SDK: an extension
11
+ * is loaded from the project tree and cannot rely on the repo's node_modules
12
+ * being resolvable from wherever pi was started.
13
+ *
14
+ * Configure with ZSWARM_MCP_SERVERS (JSON, same shape as any MCP host config):
15
+ *
16
+ * {"zswarm":{"command":"/abs/path/to/node","args":["/abs/path/to/server.js"]}}
17
+ *
18
+ * With nothing set it looks for zSwarm's own server in the two places it can
19
+ * actually be, and registers nothing if it is in neither — see defaultServers.
20
+ */
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { createRequire } from "node:module";
25
+ import { resolve } from "node:path";
26
+
27
+ type ServerSpec = {
28
+ command: string;
29
+ args?: string[];
30
+ env?: Record<string, string>;
31
+ };
32
+
33
+ type McpTool = {
34
+ name: string;
35
+ description?: string;
36
+ inputSchema?: Record<string, unknown>;
37
+ };
38
+
39
+ /**
40
+ * Where zSwarm's own server might be, in order of confidence.
41
+ *
42
+ * `process.execPath` rather than `"node"`: a host that spawns servers outside
43
+ * your shell inherits no PATH, and the interpreter already running this code is
44
+ * both absolute and known-good.
45
+ *
46
+ * There is deliberately no fallback to a bare relative path. Guessing produces
47
+ * a spawn failure the user has to decode; finding nothing produces a message
48
+ * that names the environment variable that fixes it.
49
+ */
50
+ function resolveDependencyServer(): string | null {
51
+ try {
52
+ return createRequire(import.meta.url).resolve("@zswarm/mcp");
53
+ } catch {
54
+ return null; // Not installed; the checkout path or an override covers it.
55
+ }
56
+ }
57
+
58
+ function defaultServers(): Record<string, ServerSpec> {
59
+ const candidates = [
60
+ // A checkout of the zswarm repo, with pi started at its root.
61
+ resolve(process.cwd(), "bin", "launch-mcp.mjs"),
62
+ // Installed as a dependency: @zswarm/mcp exports its server entry, so
63
+ // node resolution finds it wherever the package manager put it.
64
+ resolveDependencyServer(),
65
+ ].filter((entry): entry is string => entry !== null);
66
+ for (const entry of candidates) {
67
+ if (existsSync(entry)) {
68
+ return { zswarm: { command: process.execPath, args: [entry] } };
69
+ }
70
+ }
71
+ return {};
72
+ }
73
+
74
+ /** How long to wait for a server to answer initialize/tools-list before giving up. */
75
+ const HANDSHAKE_MS = 20_000;
76
+ /** Tool calls can legitimately block — `wait` holds until a pane goes idle. */
77
+ const CALL_MS = 15 * 60_000;
78
+
79
+ function readServers(): Record<string, ServerSpec> {
80
+ const raw = process.env.ZSWARM_MCP_SERVERS?.trim();
81
+ if (!raw) return defaultServers();
82
+ try {
83
+ const parsed: unknown = JSON.parse(raw);
84
+ if (parsed && typeof parsed === "object") {
85
+ return parsed as Record<string, ServerSpec>;
86
+ }
87
+ } catch {
88
+ // A malformed override should not cost you the default server.
89
+ }
90
+ return defaultServers();
91
+ }
92
+
93
+ /**
94
+ * One stdio MCP server, kept alive for the session.
95
+ *
96
+ * Framing is newline-delimited JSON-RPC. Responses are matched by id because
97
+ * a server may interleave notifications, and stdout can split a message across
98
+ * chunk boundaries — so bytes are buffered until a newline lands.
99
+ */
100
+ class McpClient {
101
+ private child: ChildProcessWithoutNullStreams | null = null;
102
+ private buffer = "";
103
+ private nextId = 1;
104
+ private readonly pending = new Map<
105
+ number,
106
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
107
+ >();
108
+
109
+ constructor(
110
+ private readonly name: string,
111
+ private readonly spec: ServerSpec,
112
+ private readonly cwd: string,
113
+ ) {}
114
+
115
+ async start(): Promise<McpTool[]> {
116
+ const child = spawn(this.spec.command, this.spec.args ?? [], {
117
+ cwd: this.cwd,
118
+ env: { ...process.env, ...(this.spec.env ?? {}) },
119
+ stdio: ["pipe", "pipe", "pipe"],
120
+ windowsHide: true,
121
+ });
122
+ this.child = child;
123
+
124
+ child.stdout.setEncoding("utf8");
125
+ child.stdout.on("data", (chunk: string) => this.consume(chunk));
126
+ child.on("error", (err) => this.failAll(err));
127
+ child.on("close", () => this.failAll(new Error(`${this.name} exited`)));
128
+
129
+ await this.request(
130
+ "initialize",
131
+ {
132
+ protocolVersion: "2024-11-05",
133
+ capabilities: {},
134
+ clientInfo: { name: "pi", version: "1" },
135
+ },
136
+ HANDSHAKE_MS,
137
+ );
138
+ this.notify("notifications/initialized", {});
139
+
140
+ const listed = (await this.request("tools/list", {}, HANDSHAKE_MS)) as {
141
+ tools?: McpTool[];
142
+ };
143
+ return listed.tools ?? [];
144
+ }
145
+
146
+ private consume(chunk: string): void {
147
+ this.buffer += chunk;
148
+ let newline = this.buffer.indexOf("\n");
149
+ while (newline !== -1) {
150
+ const line = this.buffer.slice(0, newline).trim();
151
+ this.buffer = this.buffer.slice(newline + 1);
152
+ if (line) this.dispatch(line);
153
+ newline = this.buffer.indexOf("\n");
154
+ }
155
+ }
156
+
157
+ private dispatch(line: string): void {
158
+ let message: { id?: number; result?: unknown; error?: { message?: string } };
159
+ try {
160
+ message = JSON.parse(line);
161
+ } catch {
162
+ return; // Servers may log to stdout; ignore anything that is not a message.
163
+ }
164
+ if (typeof message.id !== "number") return;
165
+ const waiter = this.pending.get(message.id);
166
+ if (!waiter) return;
167
+ this.pending.delete(message.id);
168
+ if (message.error) {
169
+ waiter.reject(new Error(message.error.message ?? "MCP error"));
170
+ } else {
171
+ waiter.resolve(message.result);
172
+ }
173
+ }
174
+
175
+ private failAll(error: Error): void {
176
+ for (const waiter of this.pending.values()) waiter.reject(error);
177
+ this.pending.clear();
178
+ }
179
+
180
+ private send(payload: Record<string, unknown>): void {
181
+ this.child?.stdin.write(`${JSON.stringify(payload)}\n`);
182
+ }
183
+
184
+ private notify(method: string, params: unknown): void {
185
+ this.send({ jsonrpc: "2.0", method, params });
186
+ }
187
+
188
+ private request(
189
+ method: string,
190
+ params: unknown,
191
+ timeoutMs: number,
192
+ ): Promise<unknown> {
193
+ const id = this.nextId++;
194
+ return new Promise<unknown>((resolve, reject) => {
195
+ const timer = setTimeout(() => {
196
+ this.pending.delete(id);
197
+ reject(new Error(`${this.name} ${method} timed out`));
198
+ }, timeoutMs);
199
+ this.pending.set(id, {
200
+ resolve: (value) => {
201
+ clearTimeout(timer);
202
+ resolve(value);
203
+ },
204
+ reject: (error) => {
205
+ clearTimeout(timer);
206
+ reject(error);
207
+ },
208
+ });
209
+ this.send({ jsonrpc: "2.0", id, method, params });
210
+ });
211
+ }
212
+
213
+ async call(tool: string, args: Record<string, unknown>): Promise<string> {
214
+ const result = (await this.request(
215
+ "tools/call",
216
+ { name: tool, arguments: args },
217
+ CALL_MS,
218
+ )) as { content?: Array<{ type?: string; text?: string }> };
219
+ const text = (result.content ?? [])
220
+ .filter((part) => part.type === "text" && typeof part.text === "string")
221
+ .map((part) => part.text)
222
+ .join("\n");
223
+ return text || JSON.stringify(result);
224
+ }
225
+
226
+ stop(): void {
227
+ this.child?.kill();
228
+ this.child = null;
229
+ }
230
+ }
231
+
232
+ export default function (pi: ExtensionAPI) {
233
+ const clients: McpClient[] = [];
234
+ /** What each server reported at connect time, for the session_start notice. */
235
+ const registered: Array<{ name: string; count: number; error?: string }> = [];
236
+
237
+ /**
238
+ * Connect and register at module load rather than from `session_start`.
239
+ *
240
+ * `/reload` rebinds extensions, but only a session switch, fork, or new
241
+ * session is documented to re-emit `session_start` — so a handler is the
242
+ * wrong place to put the one thing that must happen on every bind. A fresh
243
+ * module instance is created per bind, so this closure runs exactly once
244
+ * each time and needs no idempotence guard.
245
+ *
246
+ * Registration is async because a server's tools are unknown until it has
247
+ * been asked, and the default export cannot await. Tools therefore appear a
248
+ * moment after load; `session_start` reports what landed.
249
+ */
250
+ const connecting = (async () => {
251
+ for (const [name, spec] of Object.entries(readServers())) {
252
+ // No ExtensionContext at load time, so the project root comes from the
253
+ // process — pi is started in the directory it operates on.
254
+ const client = new McpClient(name, spec, process.cwd());
255
+ let tools: McpTool[];
256
+ try {
257
+ tools = await client.start();
258
+ } catch (err) {
259
+ // A missing server is a missing capability, never a broken session.
260
+ const message = err instanceof Error ? err.message : String(err);
261
+ registered.push({ name, count: 0, error: message });
262
+ client.stop();
263
+ continue;
264
+ }
265
+ clients.push(client);
266
+ registered.push({ name, count: tools.length });
267
+
268
+ for (const tool of tools) {
269
+ // `server_tool`, unless the server named its tool after itself.
270
+ const toolName = tool.name === name ? name : `${name}_${tool.name}`;
271
+ pi.registerTool({
272
+ name: toolName,
273
+ label: toolName,
274
+ description: tool.description ?? `${tool.name} via the ${name} MCP server`,
275
+ promptSnippet: (tool.description ?? toolName).split(".")[0],
276
+ // An MCP inputSchema is already JSON Schema, which is what the
277
+ // TypeBox helpers produce — so it is handed over unchanged.
278
+ parameters: (tool.inputSchema ?? {
279
+ type: "object",
280
+ properties: {},
281
+ }) as never,
282
+ async execute(_toolCallId, params, signal) {
283
+ if (signal?.aborted) {
284
+ return { content: [{ type: "text", text: "Cancelled" }], details: {} };
285
+ }
286
+ try {
287
+ const text = await client.call(
288
+ tool.name,
289
+ (params ?? {}) as Record<string, unknown>,
290
+ );
291
+ return { content: [{ type: "text", text }], details: {} };
292
+ } catch (err) {
293
+ const message = err instanceof Error ? err.message : String(err);
294
+ return {
295
+ content: [{ type: "text", text: `MCP ${name} error: ${message}` }],
296
+ details: { error: message },
297
+ };
298
+ }
299
+ },
300
+ });
301
+ }
302
+ }
303
+ })();
304
+
305
+ // Surface the outcome once there is a UI to surface it to. Awaiting the same
306
+ // promise means this reports rather than repeats the work.
307
+ pi.on("session_start", async (_event, ctx) => {
308
+ await connecting;
309
+ if (registered.length === 0) {
310
+ ctx.ui.notify(
311
+ "No MCP server found. Set ZSWARM_MCP_SERVERS to point at one.",
312
+ "error",
313
+ );
314
+ return;
315
+ }
316
+ for (const entry of registered) {
317
+ if (entry.error) {
318
+ ctx.ui.notify(`MCP ${entry.name} unavailable: ${entry.error}`, "error");
319
+ } else {
320
+ ctx.ui.notify(`MCP ${entry.name}: ${entry.count} tool(s) registered`, "info");
321
+ }
322
+ }
323
+ });
324
+
325
+ // Idempotent by construction: the list is emptied as it is drained.
326
+ pi.on("session_shutdown", async () => {
327
+ for (const client of clients) client.stop();
328
+ clients.length = 0;
329
+ });
330
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@zswarm/pi",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Bridge stdio MCP servers into pi as native tools",
6
+ "keywords": [
7
+ "pi-package",
8
+ "mcp",
9
+ "zellij",
10
+ "zswarm"
11
+ ],
12
+ "license": "MIT",
13
+ "pi": {
14
+ "extensions": [
15
+ "./extensions"
16
+ ]
17
+ },
18
+ "files": [
19
+ "extensions",
20
+ "README.md"
21
+ ],
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "@earendil-works/pi-coding-agent": {
27
+ "optional": true
28
+ }
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@zswarm/mcp": "0.1.0"
38
+ },
39
+ "scripts": {}
40
+ }