6 Commits

Author SHA1 Message Date
joakimp 357fcc6eca AGENTS.md: documentation-drift sweep as explicit pre-commit step
Companion to the same addition in the cloud-init and ansible repos.
Caught real drift in those repos in a recent session only because
the user explicitly asked. Codify the sweep with concrete, repo-
specific drift hotspots rather than a vague 'watch for drift' rule
that gets ignored.

Each AGENTS.md addition lists the doc files most likely to fall
behind code changes here, plus a quick-triage one-liner using
'git diff --name-only HEAD | xargs grep -l ...' so the rule is
actionable not aspirational.
2026-05-20 23:12:03 +02:00
joakimp da34f41798 AGENTS: note that extension imports are coupled to host pi version
The 2026-05-09 rename of extension imports from @mariozechner/pi-* to
@earendil-works/pi-* surfaced a sequencing failure: extensions are
loaded by jiti at runtime, and their import statements resolve against
whatever node_modules the running pi binary bundles. A host pi still
on @mariozechner-bundle 0.73.1 cannot resolve @earendil-works/* and
fails extension load with 'Cannot find module'.

Bun build --external '*' does NOT catch this \u2014 it only validates the
bundle shape, not runtime module resolution. The actual gate is
running pi against the extensions on the target system.

Codify the rule in Conventions so future renames (or major-version
deps bumps) sequence the host upgrade alongside the import change.
2026-05-09 21:20:53 +02:00
joakimp 1381a37115 Rename @mariozechner/pi-* to @earendil-works/pi-*
Pi moved to its new home at earendil-works on 2026-05-07
(https://pi.dev/news/2026/5/7/pi-has-a-new-home). Affected packages:

  @mariozechner/pi-coding-agent  -> @earendil-works/pi-coding-agent
  @mariozechner/pi-tui           -> @earendil-works/pi-tui
  @mariozechner/pi-ai            -> @earendil-works/pi-ai
  @mariozechner/pi-agent-core    -> @earendil-works/pi-agent-core

The old @mariozechner/* packages are deprecated on npm with the
explicit message 'please use @earendil-works/pi-coding-agent instead
going forward', and the version stream has moved on (old top-out
0.73.1; new currently 0.74.0). Anyone npm-installing the old names
gets a deprecation warning + a stale binary.

Sweep:
- All 7 extension TypeScript files: import statements updated.
- README, AGENTS, install.sh: textual references and the github.com/
  mariozechner/pi-coding-agent URL pointed at github.com/earendil-works/
  pi (the new monorepo root; coding-agent now lives at
  packages/coding-agent inside it).
- Bun build of mcp-loader, ext-toggle, ssh-controlmaster verified clean.

Brew install references (`brew install pi-coding-agent`) left as-is:
the homebrew formula still works at 0.73.1 and a tap update is
tracked upstream at earendil-works/pi#2755. Historical CHANGELOG
entries are untouched.
2026-05-09 17:56:15 +02:00
joakimp feb7add717 ext-toggle: self-guard against disabling /ext
Disabling ext-toggle through its own /ext UI would rename the file to
ext-toggle.ts.off, which pi's auto-discovery skips, so the next /reload
silently drops the /ext command itself. The .ts.off rename also
persists across pi restarts and \u2014 in containerized setups
(opencode-devbox) where ~/.pi is mounted on the devbox-pi-config named
volume \u2014 across container recreate, so even nuking the container
doesn't recover the surface.

Add ext-toggle to the existing DISABLE_GUARDS map so the toggle is
refused at stage-time with an explanation pointing at the manual
recovery path:

    mv ~/.pi/agent/extensions/ext-toggle.ts.off \
       ~/.pi/agent/extensions/ext-toggle.ts

Same shape as the existing ssh-controlmaster guard. Sibling slash
commands (/mcp from mcp-loader) don't need this guard because
settings.json is their source of truth and remains editable by hand
even if the loader is disabled \u2014 only ext-toggle's own UI is the
single point of management.
2026-05-09 16:31:20 +02:00
joakimp 37cc49e06f mcp-loader v2: streamable-HTTP transport for remote MCP servers (context7)
- New RemoteMcpClient implementing MCP streamable-HTTP per spec 2025-03-26:
  POST JSON-RPC, parse application/json or text/event-stream responses,
  round-trip optional Mcp-Session-Id, optional auth via 'headers' config.
- Refactor StdioMcpClient to share an IMcpClient interface with the remote
  client; extension entry dispatches on cfg.type. Drops the v1 'remote
  skipped with warning' code path.
- Bump MCP_PROTOCOL_VERSION to 2025-11-25 (single constant, both clients).
- 404 self-heal: when a remote returns 404 to a request carrying our
  Mcp-Session-Id, drop the id, re-initialize, retry the request once
  (per spec 2025-11-25 \u00a72.2). allowReinitOn404=false on the retry path
  prevents recursion. Verified via mock-server smoke test.
- Sanitize pi-facing tool names to ^[A-Za-z][A-Za-z0-9_]{0,63}$. Anthropic
  allows hyphens but Bedrock's Anthropic shim rejects them, causing entire
  turns to 4xx silently when context7's hyphenated tools (resolve-library-id,
  query-docs) were registered. Original MCP-side names are preserved in the
  tool-execute closure, so sanitization is purely pi-facing.
- /mcp slash command: drop 'remote (skipped)' status label.
- Docs: README and AGENTS updated for transports, headers config, 404
  self-heal, tool-name sanitization rationale, OAuth limitation.

End-to-end verified: context7 connects through pi, returns useful docs
(Bun streaming/SSE example fetched successfully).
2026-05-09 15:26:36 +02:00
joakimp 7eec49b9b8 mcp-loader: add /mcp slash command for runtime status + toggle
Mirrors /ext UX (space=stage, enter=apply+reload, esc=cancel) but for
MCP servers in the settings.json `mcp` block. Tracks per-server runtime
state captured at extension load time so users can see at a glance
which servers are running / failed / disabled / remote-skipped /
invalid, with tool counts for the running ones.

Toggling writes back to settings.json — disabling sets enabled:false,
re-enabling removes the explicit key (default is true) to keep the
file tidy. Then ctx.reload() picks up the change.

Closes the visibility gap surfaced by 'searxng_search isn't in /ext':
MCP-provided tools are runtime-spawned, not file-based extensions, so
they need their own list view. /mcp fills that hole.
2026-05-08 21:05:09 +02:00
10 changed files with 634 additions and 88 deletions
+68 -23
View File
@@ -23,7 +23,7 @@ extensions/
notify.ts # Native terminal notification when agent finishes notify.ts # Native terminal notification when agent finishes
ext-toggle.ts # /ext slash command — list & toggle extensions at runtime ext-toggle.ts # /ext slash command — list & toggle extensions at runtime
todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example) todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example)
mcp-loader.ts # Generic MCP server loader — reads `mcp` block from settings.json mcp-loader.ts # Generic MCP server loader + /mcp slash command
install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
package.json # pi package manifest — enables `pi install /path` as an alternative package.json # pi package manifest — enables `pi install /path` as an alternative
README.md # User-facing docs. README.md # User-facing docs.
@@ -33,6 +33,8 @@ LICENSE # MIT.
## Conventions ## Conventions
- **Extension imports are coupled to the running pi version.** Extensions are loaded by jiti at runtime; their `import { ... } from "@earendil-works/pi-*"` statements resolve against whatever node_modules the running `pi` binary has bundled. **When extension imports change package names** (e.g. the 2026-05-09 rename from `@mariozechner/pi-*` to `@earendil-works/pi-*`), the host pi MUST be upgraded to the matching version *before or at the same time as* the import-rename push, otherwise users on the older pi see `Cannot find module '@earendil-works/...'` at extension load. Container path: bump `PI_VERSION` build-arg in opencode-devbox. Host path: `npm install -g @earendil-works/pi-coding-agent` (and `brew uninstall pi-coding-agent` if previously installed via brew at the old name). `bun build --external '*'` does NOT catch this regression — it only validates the bundle shape, not module resolution against the runtime pi's deps. Real test: run `pi --version` on the target system after the rename.
- **One `.ts` file per extension**, flat under `extensions/`. If an extension - **One `.ts` file per extension**, flat under `extensions/`. If an extension
grows multiple files, use a subdirectory with an `index.ts` entry point — grows multiple files, use a subdirectory with an `index.ts` entry point —
pi's auto-discovery handles both shapes. pi's auto-discovery handles both shapes.
@@ -73,8 +75,8 @@ untouched.
1. **Drop a `.ts` file into `extensions/`.** No other config needed — 1. **Drop a `.ts` file into `extensions/`.** No other config needed —
`install.sh` discovers all `.ts` files automatically. `install.sh` discovers all `.ts` files automatically.
2. **Export a default factory function** `(pi: ExtensionAPI) => void`. 2. **Export a default factory function** `(pi: ExtensionAPI) => void`.
See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) See the [pi extensions docs](https://github.com/earendil-works/pi/blob/main/docs/extensions.md)
and [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions). and [built-in examples](https://github.com/earendil-works/pi/tree/main/examples/extensions).
3. **Register any CLI flags** via `pi.registerFlag()` so they appear in 3. **Register any CLI flags** via `pi.registerFlag()` so they appear in
`pi --help`. `pi --help`.
4. **Keep the extension inert without its flag** (or equivalent trigger). Don't 4. **Keep the extension inert without its flag** (or equivalent trigger). Don't
@@ -242,20 +244,22 @@ upstream updates but gain reproducibility.
**Refresh from upstream when needed:** **Refresh from upstream when needed:**
```bash ```bash
cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@mariozechner/pi-coding-agent/examples/extensions/todo.ts \ cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@earendil-works/pi-coding-agent/examples/extensions/todo.ts \
~/src/src_local/pi-extensions/extensions/todo.ts ~/src/src_local/pi-extensions/extensions/todo.ts
# review diff, then commit # review diff, then commit
``` ```
### `mcp-loader.ts` ### `mcp-loader.ts`
Generic MCP stdio client — reads an `mcp` block from `~/.pi/agent/settings.json` Generic MCP client — reads an `mcp` block from `~/.pi/agent/settings.json`
and connects to each declared server, exposing the tools as namespaced pi tools. and connects to each declared server (stdio subprocess or streamable-HTTP
endpoint), exposing the tools as namespaced pi tools.
**Why this exists:** pi has no built-in MCP loader (unlike opencode and Claude **Why this exists:** pi has no built-in MCP loader (unlike opencode and Claude
Desktop). Adding each new MCP server as a hand-rolled extension (the way Desktop). Adding each new MCP server as a hand-rolled extension (the way
`mempalace.ts` does it) doesn't scale past 2-3 servers. This loader is the `mempalace.ts` does it) doesn't scale past 2-3 servers. This loader is the
config-driven generalization — one extension, any number of servers. config-driven generalization — one extension, any number of servers, two
transports.
**Key design decisions:** **Key design decisions:**
@@ -265,42 +269,72 @@ config-driven generalization — one extension, any number of servers.
multiple servers expose tools with the same short name. Tools that already multiple servers expose tools with the same short name. Tools that already
start with `<servername>_` (the convention some servers like mempalace start with `<servername>_` (the convention some servers like mempalace
follow internally) skip the prefix to avoid double-prefixing. follow internally) skip the prefix to avoid double-prefixing.
- **Tool names are sanitized to `^[A-Za-z][A-Za-z0-9_]{0,63}$`** before
registering with pi. This is the strictest regex we have to satisfy:
Anthropic's Messages API allows hyphens, but AWS Bedrock's Anthropic shim
rejects them outright — a single hyphenated tool name causes the whole
turn to 4xx silently, manifesting as "no output at all" once the offending
server is enabled. context7 surfaces this because its tools are named
`resolve-library-id` and `query-docs`. We replace any non-`[A-Za-z0-9_]`
char with `_`, prepend `t_` if the result doesn't start with a letter, and
truncate to 64 chars. The original MCP-side name is kept in the closure
used by `client.callTool`, so the sanitization is purely pi-facing.
- **Fail-soft per server.** A server that won't start (binary missing, init - **Fail-soft per server.** A server that won't start (binary missing, init
handshake fails) logs one stderr line and is skipped. Other servers handshake fails, remote 4xx/5xx) logs one stderr line and is skipped.
continue. Pi keeps working. Other servers continue. Pi keeps working.
- **Stdio transport only in v1.** Remote/streamable-HTTP servers are - **Two transports behind one interface.** `IMcpClient` (`start`, `tools`,
detected and skipped with a warning. Adding remote support is the `callTool`, `stop`) is implemented by `StdioMcpClient` (subprocess +
obvious v2 (context7 is the prime motivator). Avoided in v1 because newline-delimited JSON-RPC) and `RemoteMcpClient` (HTTP POST + JSON or
streamable-HTTP needs SSE parsing, session management, and reconnect SSE response). The extension entry dispatches on `cfg.type` and the
logic that triples the implementation size. rest of the code is transport-agnostic.
- **Does not replace `mempalace.ts`.** The mempalace bridge has bespoke - **Does not replace `mempalace.ts`.** The mempalace bridge has bespoke
agent-identity injection from `$MEMPALACE_AGENT_NAME` that's worth agent-identity injection from `$MEMPALACE_AGENT_NAME` that's worth
preserving. Loader and bridge coexist. Listing `mempalace` in the `mcp` preserving. Loader and bridge coexist. Listing `mempalace` in the `mcp`
block would produce duplicate tool registrations — don't. block would produce duplicate tool registrations — don't.
- **No reconnect on subprocess death.** If a server's subprocess crashes - **No stdio reconnect on death.** If a stdio subprocess crashes
mid-session, its tools become permanently unavailable until pi `/reload`s. mid-session, its tools become permanently unavailable until pi `/reload`s.
Same limitation as the mempalace bridge today; not worth complicating v1. Same limitation as the mempalace bridge today.
- **Remote sessions self-heal on 404.** Per spec 2025-11-25 §2.2, a server
that has terminated a session MUST respond with HTTP 404 to requests
carrying the stale `Mcp-Session-Id`. `RemoteMcpClient` catches this
condition (404 + sessionAtStart was non-null), drops the id, runs a fresh
`initialize` + `notifications/initialized`, and retries the original
request once. Persistent 404s after refresh surface as errors.
**API used:** **API used:**
- `pi.registerTool({ name, label, description, parameters, execute })` for - `pi.registerTool({ name, label, description, parameters, execute })` for
each MCP tool exposed by each connected server. each MCP tool exposed by each connected server.
- `pi.on("session_shutdown", ...)` to SIGTERM all subprocesses cleanly. - `pi.on("session_shutdown", ...)` to SIGTERM stdio subprocesses and DELETE
remote sessions cleanly.
- `Type.Unsafe<...>(inputSchema)` from typebox to pass the MCP server's - `Type.Unsafe<...>(inputSchema)` from typebox to pass the MCP server's
JSON schema through to pi without conversion (TypeBox schemas are JSON schema through to pi without conversion (TypeBox schemas are
plain JSON Schema at runtime). plain JSON Schema at runtime).
**Internal MCP client:** **Internal MCP clients:**
- `class StdioMcpClient` — spawn subprocess, write newline-delimited - `class StdioMcpClient` — spawn subprocess, write newline-delimited
JSON-RPC, parse newline-delimited responses, match by request `id`. JSON-RPC, parse newline-delimited responses, match by request `id`.
- Sends `initialize` handshake with `protocolVersion: 2024-11-05`, then Stderr drained silently unless `PI_MCP_LOADER_DEBUG=1`.
`notifications/initialized`, then `tools/list` to discover tools. - `class RemoteMcpClient` — streamable-HTTP per MCP spec 2025-03-26.
- Stderr drained silently unless `PI_MCP_LOADER_DEBUG=1`. POST JSON-RPC body to a single URL with `Accept: application/json,
text/event-stream`. Server replies either with one JSON response or an
SSE stream containing `event: message` / `data: <json>` blocks; we
consume the stream until the response with our `id` arrives, then
cancel the reader. Optional `Mcp-Session-Id` header is captured on
initialize and round-tripped on every subsequent request, then DELETEd
on stop. Per-server `headers` config (e.g. `Authorization`) is merged
into every request. No GET-stream subscription — server-initiated
notifications are not consumed.
- Both share `interface IMcpClient { serverName, tools, start, callTool, stop }`.
Both send `initialize` handshake with `protocolVersion: MCP_PROTOCOL_VERSION`
(currently `2025-11-25`, per the constant at the top of the file),
then `notifications/initialized`, then `tools/list` to discover tools.
**Future v2 extensions:** **Future extensions:**
- Streamable-HTTP transport for remote servers (context7). - OAuth flow for remote servers that require it (today: pre-issued bearer
tokens via `headers` only).
- Per-tool enable/disable in settings.json (e.g. - Per-tool enable/disable in settings.json (e.g.
`"mcp.searxng.tools": ["web_search"]` to expose only a subset). `"mcp.searxng.tools": ["web_search"]` to expose only a subset).
- Reconnect-on-crash with exponential backoff. - Reconnect-on-crash with exponential backoff.
@@ -366,3 +400,14 @@ pi --ssh user@host --ssh-ask-pass
— same pattern for agent skills rather than extensions. — same pattern for agent skills rather than extensions.
- [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox) - [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox)
— Docker containers; composes toolkits via independent install.sh calls. — Docker containers; composes toolkits via independent install.sh calls.
## Documentation drift sweep
Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo:
- `README.md` — the per-extension feature/usage descriptions, the install instructions, the dependency list. When an extension's flags or behaviour change, the README block for that extension is the first thing to update.
- `AGENTS.md` (this file) — the `Extension-specific notes` section is large and easy to leave stale. Each extension has its own subsection; if you change a flag, hook, or default, update the subsection here as well as the README.
- Extension files themselves — the `// description:` comment header pi parses for the extension list view must stay accurate.
- `install.sh` — `--only` / `--skip` defaults, the list of artifacts it knows about, and the symlink targets. If you add a new extension file, the installer's auto-discovery picks it up; if you add a new artifact type that needs special handling, the script must be updated and the README block describing what `install.sh does` along with it.
Quick triage: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md install.sh`. For an extension rename, also grep for the old name across the whole repo.
+22 -6
View File
@@ -1,6 +1,6 @@
# pi-extensions # pi-extensions
Custom and modified extensions for the [pi coding-agent](https://github.com/mariozechner/pi-coding-agent). Custom and modified extensions for the [pi coding-agent](https://github.com/earendil-works/pi).
This repo is the single source of truth for extensions that aren't suitable for general publishing — personal workflow tweaks, modified versions of built-in examples, and extensions written for specific infrastructure. Symlinked into `~/.pi/agent/extensions/` so pi loads them automatically. This repo is the single source of truth for extensions that aren't suitable for general publishing — personal workflow tweaks, modified versions of built-in examples, and extensions written for specific infrastructure. Symlinked into `~/.pi/agent/extensions/` so pi loads them automatically.
@@ -205,7 +205,7 @@ This is a verbatim copy of the upstream `examples/extensions/todo.ts` shipped wi
### `mcp-loader.ts` ### `mcp-loader.ts`
Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing all of their tools to pi as native tools — namespaced as `<server-name>_<tool-name>` to avoid collisions. Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing all of their tools to pi as native tools — namespaced as `<server-name>_<tool-name>` to avoid collisions, with non-`[A-Za-z0-9_]` characters replaced by `_` so the names pass the strictest provider tool-name regex (e.g. AWS Bedrock).
**Settings.json shape:** **Settings.json shape:**
@@ -239,18 +239,34 @@ Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json`
| `type` | `"local"` (stdio subprocess) or `"remote"` (streamable-http). Default `"local"`. | | `type` | `"local"` (stdio subprocess) or `"remote"` (streamable-http). Default `"local"`. |
| `command` | Argv array. First element is the executable, rest are args. Local servers only. | | `command` | Argv array. First element is the executable, rest are args. Local servers only. |
| `url` | Remote MCP endpoint URL. Remote servers only. | | `url` | Remote MCP endpoint URL. Remote servers only. |
| `headers` | Optional object of HTTP headers (e.g. `Authorization`, `X-API-Key`) sent with every request. Remote servers only. |
| `enabled` | Default `true`. Set `false` to disable a server without removing the entry. | | `enabled` | Default `true`. Set `false` to disable a server without removing the entry. |
| `env` | Optional object of env vars injected into the subprocess. Inherits parent env first, then overlays these keys. Local servers only. | | `env` | Optional object of env vars injected into the subprocess. Inherits parent env first, then overlays these keys. Local servers only. |
**Limitations (v1):** **Transports:**
- **Stdio only.** Remote/streamable-HTTP transport is detected and skipped with a warning. Server like `context7` configured as `"type": "remote"` will not load until v2. - `local` — stdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, mcp-server-time…).
- **No reconnect** if a subprocess dies mid-session — those tools become unavailable until `/reload` (same as `mempalace.ts`). - `remote` — streamable-HTTP per MCP spec 2025-03-26: POST JSON-RPC, server replies either `application/json` or `text/event-stream`. Optional `Mcp-Session-Id` round-trip if the server issues one. No GET subscription stream (server-initiated notifications are not consumed).
**Limitations:**
- **No stdio reconnect** if a subprocess dies mid-session — those tools become unavailable until `/reload` (same as `mempalace.ts`).
- **Remote sessions self-heal on 404.** If a streamable-HTTP server forgets our session id (e.g. server restart), the client transparently re-initializes and retries the request once.
- **No OAuth flow.** Remote servers requiring OAuth must be accessed with a pre-issued bearer token via `headers`.
- **Coexists with `mempalace.ts`** but does not replace it. The mempalace bridge has bespoke handling (agent identity injection) that's worth keeping. Don't list `mempalace` in the `mcp` block too — you'd get duplicate tool registrations. - **Coexists with `mempalace.ts`** but does not replace it. The mempalace bridge has bespoke handling (agent identity injection) that's worth keeping. Don't list `mempalace` in the `mcp` block too — you'd get duplicate tool registrations.
**Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs. **Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs.
Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions) for the API surface. **Slash command:** `/mcp` opens a multi-toggle overlay listing every server in the `mcp` block with its runtime status:
- `running · N tools` — connected, tools registered
- `failed: <message>` — start handshake threw
- `disabled in settings``enabled: false`
- `invalid: <message>` — malformed config (read-only row)
UX matches `/ext`: **space** stages a toggle, **enter** writes back to `settings.json` and reloads pi, **esc** cancels. Toggling re-enables a previously-disabled server by removing the explicit `enabled` key (the default is `true`).
Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/earendil-works/pi/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/earendil-works/pi/tree/main/examples/extensions) for the API surface.
--- ---
+1 -1
View File
@@ -10,7 +10,7 @@
* /fork so you don't accidentally throw away work. * /fork so you don't accidentally throw away work.
*/ */
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@earendil-works/pi-coding-agent";
// ── Dangerous bash patterns ─────────────────────────────────────────────────── // ── Dangerous bash patterns ───────────────────────────────────────────────────
+14 -2
View File
@@ -30,14 +30,14 @@ import * as os from "node:os";
import { import {
type ExtensionAPI, type ExtensionAPI,
getSettingsListTheme, getSettingsListTheme,
} from "@mariozechner/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { import {
Container, Container,
Key, Key,
matchesKey, matchesKey,
type SettingItem, type SettingItem,
SettingsList, SettingsList,
} from "@mariozechner/pi-tui"; } from "@earendil-works/pi-tui";
const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions"); const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions");
@@ -58,6 +58,18 @@ const DISABLE_GUARDS: Record<string, () => string | null> = {
if (!hasSshFlag) return null; if (!hasSshFlag) return null;
return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh."; return "Active --ssh session — disabling would silently revert read/write/edit/bash to local. Exit pi and relaunch without --ssh.";
}, },
// ext-toggle owns the /ext slash command. Disabling it removes the
// only TUI surface for re-enabling itself: pi auto-discovers `*.ts`
// only, so once renamed to `.ts.off` the file is invisible to pi and
// the next /reload silently drops the command. The disabled state
// also persists — in containerized setups (opencode-devbox) the
// ~/.pi volume keeps the `.ts.off` rename across container recreate,
// so even nuking the container doesn't recover the surface. Recovery
// path is manual: shell into the container (or open a host shell),
// run `mv ~/.pi/agent/extensions/ext-toggle.ts.off \
// ~/.pi/agent/extensions/ext-toggle.ts`, then /reload.
"ext-toggle": () =>
"Disabling ext-toggle would remove the /ext command itself — only manual `mv ~/.pi/agent/extensions/ext-toggle.ts.off ~/.pi/agent/extensions/ext-toggle.ts` recovers it. Refusing.",
}; };
// ── Filesystem helpers ──────────────────────────────────────────────────────── // ── Filesystem helpers ────────────────────────────────────────────────────────
+1 -1
View File
@@ -11,7 +11,7 @@
* Status bar shows the number of checkpoints saved in the current session. * Status bar shows the number of checkpoints saved in the current session.
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
// entryId → stash ref (e.g. "refs/stash" or a full sha) // entryId → stash ref (e.g. "refs/stash" or a full sha)
+514 -41
View File
@@ -22,7 +22,8 @@
* }, * },
* "context7": { * "context7": {
* "type": "remote", * "type": "remote",
* "url": "https://mcp.context7.com/mcp" * "url": "https://mcp.context7.com/mcp",
* "headers": { "Authorization": "Bearer …" }
* } * }
* } * }
* } * }
@@ -32,11 +33,13 @@
* command argv array — first element is the executable, rest are args. * command argv array — first element is the executable, rest are args.
* For local servers only. * For local servers only.
* url remote MCP endpoint URL. For remote servers only. * url remote MCP endpoint URL. For remote servers only.
* headers optional object of HTTP headers to send with every request to
* a remote server (e.g. Authorization, X-API-Key). Remote only.
* enabled default true. Set false to disable a server without * enabled default true. Set false to disable a server without
* removing it from settings.json. * removing it from settings.json.
* env optional object of environment variables to inject into * env optional object of environment variables to inject into
* the spawned subprocess. Inherits parent env first, then * the spawned subprocess. Inherits parent env first, then
* overlays these keys. * overlays these keys. Local only.
* *
* Tool naming * Tool naming
* *
@@ -47,22 +50,35 @@
* *
* Lifecycle * Lifecycle
* *
* • Servers are spawned at extension load time (pi startup or /reload). * • Servers are connected at extension load time (pi startup or /reload).
* • Each server's `tools/list` is awaited before pi finishes registering. * • Each server's `tools/list` is awaited before pi finishes registering.
* • Subprocess stderr is silenced unless PI_MCP_LOADER_DEBUG=1. * • Local subprocess stderr is silenced unless PI_MCP_LOADER_DEBUG=1.
* • Subprocesses receive SIGTERM at session_shutdown. * • Local subprocesses receive SIGTERM at session_shutdown; remote
* • A server that fails to start (binary missing, init handshake error) * servers receive an MCP DELETE if a session id was issued.
* logs a single line to stderr and is skipped. Other servers continue. * • A server that fails to start (binary missing, init handshake error,
* Pi keeps working without that server's tools. * remote 4xx/5xx) logs a single line to stderr and is skipped. Other
* servers continue. Pi keeps working without that server's tools.
* *
* Limitations (v1) * Transports
* *
* • Only local/stdio transport is implemented. Remote (streamable-http, * localstdio JSON-RPC subprocess (mcp-searxng, gitea-mcp, …).
* SSE) servers are detected and skipped with a warning. v2 will add * remote — streamable-HTTP per MCP spec 2025-03-26: POST JSON-RPC, server
* remote support — context7 is the prime motivator. * replies either application/json or text/event-stream. Optional
* • No reconnect: if a subprocess dies mid-session, its tools become * Mcp-Session-Id header round-tripped if the server issues one.
* unavailable until pi is reloaded. The mempalace.ts extension has * No GET-stream subscription (we don't consume server-initiated
* the same limitation today. * notifications).
*
* Limitations
*
* • No stdio reconnect: if a stdio subprocess dies mid-session, its tools
* become unavailable until pi is reloaded. The mempalace.ts extension
* has the same limitation today.
* • Remote sessions self-heal on 404: if a streamable-HTTP server
* forgets our session id (e.g. server restart), the next request gets
* HTTP 404; the client transparently re-initializes and retries the
* request once. Persistent 404s after refresh surface as errors.
* • No OAuth flow: remote servers requiring OAuth must be accessed with
* a pre-issued bearer token via `headers`.
* *
* Coexistence with mempalace.ts * Coexistence with mempalace.ts
* *
@@ -72,13 +88,30 @@
* bespoke extension in place. If you also list mempalace under the `mcp` * bespoke extension in place. If you also list mempalace under the `mcp`
* block in settings.json the loader will register a parallel set of * block in settings.json the loader will register a parallel set of
* tools, which is harmless but redundant. Don't. * tools, which is harmless but redundant. Don't.
*
* Slash command
*
* `/mcp` lists configured MCP servers with their runtime status
* (running / failed / disabled / invalid) and lets
* you toggle the `enabled` flag in settings.json. Same UX as `/ext`:
* space stages, enter applies + reloads, esc cancels.
*/ */
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import {
type ExtensionAPI,
getSettingsListTheme,
} from "@earendil-works/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SettingItem,
SettingsList,
} from "@earendil-works/pi-tui";
import { Type } from "typebox"; import { Type } from "typebox";
// ── MCP types ──────────────────────────────────────────────────────────────── // ── MCP types ────────────────────────────────────────────────────────────────
@@ -99,6 +132,7 @@ type LocalServerConfig = {
type RemoteServerConfig = { type RemoteServerConfig = {
type: "remote"; type: "remote";
url: string; url: string;
headers?: Record<string, string>;
enabled?: boolean; enabled?: boolean;
}; };
@@ -107,6 +141,13 @@ type ServerConfig = LocalServerConfig | RemoteServerConfig;
const SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json"); const SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
const DEBUG = process.env.PI_MCP_LOADER_DEBUG === "1"; const DEBUG = process.env.PI_MCP_LOADER_DEBUG === "1";
// MCP protocol version we advertise. Servers we've tested:
// - searxng (mcp-searxng) accepts both 2024-11-05 and 2025-11-25
// - context7 accepts both
// - mempalace bridge: not touched by this loader
// Bump as the spec evolves; both stdio and remote clients use this.
const MCP_PROTOCOL_VERSION = "2025-11-25";
function dlog(msg: string) { function dlog(msg: string) {
if (DEBUG) process.stderr.write(`[mcp-loader] ${msg}\n`); if (DEBUG) process.stderr.write(`[mcp-loader] ${msg}\n`);
} }
@@ -145,21 +186,35 @@ function readMcpServers(): Record<string, ServerConfig> {
// ── Stdio MCP client ───────────────────────────────────────────────────────── // ── Stdio MCP client ─────────────────────────────────────────────────────────
class StdioMcpClient { interface IMcpClient {
readonly serverName: string;
tools: McpTool[];
start(): Promise<void>;
callTool(name: string, args: Record<string, unknown>): Promise<any>;
stop(): void | Promise<void>;
}
class StdioMcpClient implements IMcpClient {
private proc: ChildProcessWithoutNullStreams | null = null; private proc: ChildProcessWithoutNullStreams | null = null;
private nextId = 1; private nextId = 1;
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>(); private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
private stdoutBuf = ""; private stdoutBuf = "";
private serverName: string; public readonly serverName: string;
private command: string;
private args: string[];
private env: Record<string, string> | undefined;
public tools: McpTool[] = []; public tools: McpTool[] = [];
constructor(serverName: string) { constructor(serverName: string, command: string, args: string[], env?: Record<string, string>) {
this.serverName = serverName; this.serverName = serverName;
this.command = command;
this.args = args;
this.env = env;
} }
async start(command: string, args: string[], env?: Record<string, string>): Promise<void> { async start(): Promise<void> {
const childEnv = { ...process.env, ...(env ?? {}) }; const childEnv = { ...process.env, ...(this.env ?? {}) };
this.proc = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv }); this.proc = spawn(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv });
this.proc.on("error", (err) => this.failAll(err)); this.proc.on("error", (err) => this.failAll(err));
this.proc.on("exit", (code, signal) => { this.proc.on("exit", (code, signal) => {
@@ -180,9 +235,9 @@ class StdioMcpClient {
// MCP initialize handshake // MCP initialize handshake
await this.request("initialize", { await this.request("initialize", {
protocolVersion: "2024-11-05", protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {}, capabilities: {},
clientInfo: { name: "pi-mcp-loader", version: "0.1.0" }, clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
}); });
this.notify("notifications/initialized", {}); this.notify("notifications/initialized", {});
@@ -249,6 +304,195 @@ class StdioMcpClient {
} }
} }
// ── Streamable-HTTP MCP client (remote) ──────────────────────────────────────
//
// Per MCP spec 2025-03-26: a single endpoint URL accepts POST with a
// JSON-RPC body. The server responds either with application/json (a
// single response) or text/event-stream (an SSE stream containing the
// response, possibly preceded by notifications, terminated by stream
// close). We do NOT open a separate GET subscription stream — we don't
// consume server-initiated notifications today.
//
// Session id: if the server returns an Mcp-Session-Id header on
// initialize, we round-trip it on every subsequent request and DELETE
// it on stop().
class RemoteMcpClient implements IMcpClient {
public readonly serverName: string;
private url: string;
private extraHeaders: Record<string, string>;
private sessionId: string | null = null;
private nextId = 1;
public tools: McpTool[] = [];
constructor(serverName: string, url: string, headers?: Record<string, string>) {
this.serverName = serverName;
this.url = url;
this.extraHeaders = headers ?? {};
}
async start(): Promise<void> {
await this.request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
});
await this.notify("notifications/initialized", {});
const listed = await this.request("tools/list", {});
this.tools = (listed?.tools as McpTool[]) ?? [];
}
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
return this.request("tools/call", { name, arguments: args });
}
async stop(): Promise<void> {
if (!this.sessionId) return;
try {
await fetch(this.url, { method: "DELETE", headers: this.buildHeaders() });
} catch {}
}
private buildHeaders(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
...this.extraHeaders,
};
if (this.sessionId) h["Mcp-Session-Id"] = this.sessionId;
return h;
}
/**
* Re-establish a session after the server returned 404 to a request that
* carried our `Mcp-Session-Id`. Per spec 2025-11-25, the client MUST start
* a new session by sending a fresh InitializeRequest without a session id.
*
* Caller must clear `this.sessionId` BEFORE invoking this so the new
* initialize POST goes out without the stale id.
*/
private async reinitialize(): Promise<void> {
dlog(`${this.serverName}: session lost (404) — reinitializing`);
await this.request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "pi-mcp-loader", version: "0.2.0" },
}, /*allowReinitOn404=*/ false);
await this.notify("notifications/initialized", {});
}
private async request(method: string, params: unknown, allowReinitOn404 = true): Promise<any> {
const id = this.nextId++;
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
const sessionAtStart = this.sessionId;
let res: Response;
try {
res = await fetch(this.url, { method: "POST", headers: this.buildHeaders(), body });
} catch (err) {
throw new Error(`${this.serverName}: fetch failed on ${method}: ${(err as Error).message}`);
}
const sid = res.headers.get("Mcp-Session-Id") ?? res.headers.get("mcp-session-id");
if (sid) this.sessionId = sid;
// 404 + we sent a session id → server forgot us. Drop our id, re-init,
// retry once. `allowReinitOn404=false` is passed by the recovery path to
// prevent infinite recursion if the server is permanently broken.
if (res.status === 404 && sessionAtStart && allowReinitOn404) {
try { await res.arrayBuffer(); } catch {}
this.sessionId = null;
await this.reinitialize();
return this.request(method, params, /*allowReinitOn404=*/ false);
}
if (!res.ok) {
let detail = "";
try { detail = (await res.text()).slice(0, 200); } catch {}
throw new Error(`${this.serverName}: HTTP ${res.status} on ${method}${detail ? `: ${detail}` : ""}`);
}
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
if (ct.includes("text/event-stream")) {
return await this.readSseForResponse(res, id);
}
if (ct.includes("application/json")) {
const json: any = await res.json();
if (json.error) throw new Error(json.error.message ?? "MCP error");
return json.result;
}
if (res.status === 202) return undefined;
throw new Error(`${this.serverName}: unexpected content-type "${ct}" on ${method}`);
}
private async notify(method: string, params: unknown): Promise<void> {
const body = JSON.stringify({ jsonrpc: "2.0", method, params });
let res: Response;
try {
res = await fetch(this.url, { method: "POST", headers: this.buildHeaders(), body });
} catch (err) {
throw new Error(`${this.serverName}: fetch failed on notify ${method}: ${(err as Error).message}`);
}
try { await res.arrayBuffer(); } catch {}
if (!res.ok && res.status !== 202) {
throw new Error(`${this.serverName}: HTTP ${res.status} on notify ${method}`);
}
}
private async readSseForResponse(res: Response, expectedId: number): Promise<any> {
if (!res.body) throw new Error(`${this.serverName}: SSE response had no body`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let dataLines: string[] = [];
const tryDispatch = (): { matched: boolean; result?: any } => {
if (dataLines.length === 0) return { matched: false };
const data = dataLines.join("\n");
dataLines = [];
let msg: any;
try {
msg = JSON.parse(data);
} catch {
return { matched: false };
}
if (typeof msg.id === "number" && msg.id === expectedId) {
if (msg.error) throw new Error(msg.error.message ?? "MCP error");
return { matched: true, result: msg.result };
}
return { matched: false };
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const rawLine = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (line === "") {
const out = tryDispatch();
if (out.matched) return out.result;
} else if (line.startsWith(":")) {
// SSE comment — ignore (often a keepalive)
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
}
// event:, id:, retry: — ignored; only `data` matters for our use
}
}
const out = tryDispatch();
if (out.matched) return out.result;
} finally {
try { reader.cancel(); } catch {}
}
throw new Error(`${this.serverName}: SSE stream closed without response for id=${expectedId}`);
}
}
// ── MCP result → pi tool result helpers ────────────────────────────────────── // ── MCP result → pi tool result helpers ──────────────────────────────────────
function extractText(mcpResult: any): string { function extractText(mcpResult: any): string {
@@ -267,52 +511,107 @@ function namespacedToolName(serverName: string, toolName: string): string {
// Avoid double-prefix: if the MCP server already names its tools // Avoid double-prefix: if the MCP server already names its tools
// `<serverName>_<...>`, leave them alone. // `<serverName>_<...>`, leave them alone.
const prefix = `${serverName}_`; const prefix = `${serverName}_`;
if (toolName.startsWith(prefix)) return toolName; const raw = toolName.startsWith(prefix) ? toolName : `${prefix}${toolName}`;
return `${prefix}${toolName}`; // Sanitize for the strictest tool-name regex we have to satisfy.
// Anthropic's Messages API allows `^[a-zA-Z0-9_-]{1,64}$` (hyphens OK),
// but AWS Bedrock's Anthropic shim rejects names containing `-` outright
// — it accepts `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`. Hyphenated MCP tool names
// (context7's `resolve-library-id`, `query-docs`) caused entire turns to
// 4xx silently on Bedrock, manifesting as no output at all once the
// server was enabled. Replace any non-[A-Za-z0-9_] with `_`, then prefix
// a leading underscore with `t` if the result starts with a non-letter.
// Truncate to 64 to also satisfy Anthropic's length cap.
let sanitized = raw.replace(/[^A-Za-z0-9_]/g, "_");
if (!/^[A-Za-z]/.test(sanitized)) sanitized = `t_${sanitized}`;
if (sanitized.length > 64) sanitized = sanitized.slice(0, 64);
return sanitized;
} }
// ── Settings.json writer ─────────────────────────────────────────────────────
/**
* Flip the `enabled` flag for a server in settings.json and write back.
* Preserves all other keys verbatim by round-tripping through JSON with
* 2-space indent (matches the file's existing style).
*/
function setServerEnabled(serverName: string, enabled: boolean): void {
const raw = fs.readFileSync(SETTINGS_PATH, "utf8");
const parsed = JSON.parse(raw);
if (!parsed.mcp || !parsed.mcp[serverName]) {
throw new Error(`server '${serverName}' not in settings.json`);
}
if (enabled) {
// Default is true — clear the explicit flag if present, keep the file tidy.
delete parsed.mcp[serverName].enabled;
} else {
parsed.mcp[serverName].enabled = false;
}
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
}
// ── Per-server runtime state (for /mcp display) ──────────────────────────────
type ServerRuntimeState =
| { kind: "running"; toolCount: number }
| { kind: "failed"; message: string }
| { kind: "disabled" }
| { kind: "invalid"; message: string };
// ── Extension entry ────────────────────────────────────────────────────────── // ── Extension entry ──────────────────────────────────────────────────────────
export default async function mcpLoaderExtension(pi: ExtensionAPI) { export default async function mcpLoaderExtension(pi: ExtensionAPI) {
const servers = readMcpServers(); const servers = readMcpServers();
const serverNames = Object.keys(servers); const runtime = new Map<string, ServerRuntimeState>();
if (serverNames.length === 0) { const clients: IMcpClient[] = [];
dlog("no MCP servers configured — nothing to do");
// Always register /mcp — even when no servers are configured, so users
// discover the surface. Handler reads settings.json fresh on each invoke.
registerMcpCommand(pi, runtime);
if (Object.keys(servers).length === 0) {
dlog("no MCP servers configured — nothing to load");
return; return;
} }
const clients: StdioMcpClient[] = [];
for (const [name, cfg] of Object.entries(servers)) { for (const [name, cfg] of Object.entries(servers)) {
if (cfg.enabled === false) { if (cfg.enabled === false) {
dlog(`${name}: disabled in settings.json, skipping`); dlog(`${name}: disabled in settings.json, skipping`);
runtime.set(name, { kind: "disabled" });
continue; continue;
} }
let client: IMcpClient;
if (cfg.type === "remote") { if (cfg.type === "remote") {
warn( const remote = cfg as RemoteServerConfig;
`${name}: remote MCP transport not yet supported by mcp-loader (v1 is stdio-only); skipping`, if (typeof remote.url !== "string" || remote.url.length === 0) {
); warn(`${name}: remote server missing \`url\`, skipping`);
runtime.set(name, { kind: "invalid", message: "remote server missing url" });
continue; continue;
} }
client = new RemoteMcpClient(name, remote.url, remote.headers);
} else {
const local = cfg as LocalServerConfig; const local = cfg as LocalServerConfig;
if (!Array.isArray(local.command) || local.command.length === 0) { if (!Array.isArray(local.command) || local.command.length === 0) {
warn(`${name}: invalid \`command\` (must be non-empty array), skipping`); warn(`${name}: invalid \`command\` (must be non-empty array), skipping`);
runtime.set(name, { kind: "invalid", message: "command must be non-empty array" });
continue; continue;
} }
const [bin, ...args] = local.command; const [bin, ...args] = local.command;
client = new StdioMcpClient(name, bin, args, local.env);
}
const client = new StdioMcpClient(name);
try { try {
await client.start(bin, args, local.env); await client.start();
} catch (err) { } catch (err) {
warn(`${name}: failed to start — ${(err as Error).message}`); const message = (err as Error).message;
client.stop(); warn(`${name}: failed to start — ${message}`);
runtime.set(name, { kind: "failed", message });
try { await client.stop(); } catch {}
continue; continue;
} }
dlog(`${name}: connected, ${client.tools.length} tools`); dlog(`${name}: connected, ${client.tools.length} tools`);
runtime.set(name, { kind: "running", toolCount: client.tools.length });
clients.push(client); clients.push(client);
// Register each MCP tool as a pi tool. // Register each MCP tool as a pi tool.
@@ -355,8 +654,182 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) {
} }
} }
// Tear down subprocesses on session shutdown so reloads don't leak procs. // Tear down clients (stdio subprocesses, remote sessions) on session shutdown.
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
for (const c of clients) c.stop(); await Promise.allSettled(clients.map((c) => Promise.resolve(c.stop())));
});
}
// ── /mcp slash command ──────────────────────────────────────────────────────
const VAL_ON = "● enabled";
const VAL_OFF = "○ disabled";
const VAL_RO = "[read-only]";
function statusLabel(state: ServerRuntimeState | undefined, configEnabled: boolean): string {
if (!state) return configEnabled ? "unknown" : "disabled";
switch (state.kind) {
case "running":
return `running · ${state.toolCount} tool${state.toolCount === 1 ? "" : "s"}`;
case "failed":
return `failed: ${state.message}`;
case "disabled":
return "disabled in settings";
case "invalid":
return `invalid: ${state.message}`;
}
}
function registerMcpCommand(pi: ExtensionAPI, runtime: Map<string, ServerRuntimeState>) {
pi.registerCommand("mcp", {
description: "List and toggle MCP servers configured in ~/.pi/agent/settings.json",
handler: async (_args, ctx) => {
const servers = readMcpServers();
const names = Object.keys(servers);
if (names.length === 0) {
ctx.ui.notify(
`No MCP servers configured. Add an \`mcp\` block to ${SETTINGS_PATH}.`,
"info",
);
return;
}
// Snapshot config-level enabled state (default true if unset).
const configEnabled = new Map<string, boolean>();
for (const [n, cfg] of Object.entries(servers)) {
configEnabled.set(n, cfg.enabled !== false);
}
// Staged state: name → enabled (initialised from configEnabled).
const staged = new Map(configEnabled);
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
let statusText = "";
const items: SettingItem[] = names.map((n) => {
const cfg = servers[n];
const cfgEn = configEnabled.get(n) ?? true;
const state = runtime.get(n);
const isRemote = cfg.type === "remote";
const isInvalid = state?.kind === "invalid";
const transport = isRemote ? "remote" : "local";
const desc = `${transport} · ${statusLabel(state, cfgEn)}`;
if (isInvalid) {
return {
id: n,
label: n,
currentValue: VAL_RO,
description: desc,
};
}
return {
id: n,
label: n,
currentValue: cfgEn ? VAL_ON : VAL_OFF,
values: [VAL_ON, VAL_OFF],
description: desc,
};
});
const container = new Container();
container.addChild({
render(_w: number) {
return [
theme.fg("accent", theme.bold("MCP servers — settings.json `mcp` block")),
theme.fg(
"muted",
" space: toggle (stage) · enter: apply + reload · esc: cancel",
),
"",
];
},
invalidate() {},
});
const settingsList = new SettingsList(
items,
Math.min(items.length + 2, 20),
getSettingsListTheme(),
(id, newValue) => {
const isInvalid = runtime.get(id)?.kind === "invalid";
if (isInvalid) {
settingsList.updateValue(id, VAL_RO);
statusText = `${id}: invalid config — fix in settings.json`;
tui.requestRender();
return;
}
const stagingEnabled = newValue === VAL_ON;
staged.set(id, stagingEnabled);
const drift: string[] = [];
for (const n of names) {
const s = staged.get(n);
const c = configEnabled.get(n);
if (s !== undefined && s !== c) drift.push(`${n}${s ? "on" : "off"}`);
}
statusText = drift.length ? `pending: ${drift.join(", ")}` : "";
tui.requestRender();
},
() => {
done(undefined);
},
);
container.addChild(settingsList);
container.addChild({
render(_w: number) {
if (!statusText) return [""];
const colored = statusText.startsWith("⊘")
? theme.fg("warning", statusText)
: theme.fg("muted", statusText);
return ["", colored];
},
invalidate() {},
});
return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
if (matchesKey(data, Key.enter)) {
const errors: string[] = [];
let applied = 0;
for (const n of names) {
const s = staged.get(n);
const c = configEnabled.get(n);
if (s === undefined || s === c) continue;
try {
setServerEnabled(n, s);
applied++;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`${n}: ${msg}`);
}
}
done(undefined);
if (errors.length) {
ctx.ui.notify(`mcp: ${errors.join(" | ")}`, "error");
} else if (applied > 0) {
ctx.ui.notify(`mcp: applied ${applied} change(s); reloading…`, "info");
ctx.reload().catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(`reload failed: ${msg}`, "error");
});
}
return;
}
settingsList.handleInput?.(data);
tui.requestRender();
},
};
});
},
}); });
} }
+1 -1
View File
@@ -13,7 +13,7 @@
* otherwise → OSC 777 (iTerm2, WezTerm, Ghostty, rxvt-unicode) * otherwise → OSC 777 (iTerm2, WezTerm, Ghostty, rxvt-unicode)
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// ── Notification backends ───────────────────────────────────────────────────── // ── Notification backends ─────────────────────────────────────────────────────
+2 -2
View File
@@ -31,7 +31,7 @@ import { spawn } from "node:child_process";
import { writeFile, unlink } from "node:fs/promises"; import { writeFile, unlink } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { import {
type BashOperations, type BashOperations,
createBashTool, createBashTool,
@@ -41,7 +41,7 @@ import {
type EditOperations, type EditOperations,
type ReadOperations, type ReadOperations,
type WriteOperations, type WriteOperations,
} from "@mariozechner/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
+3 -3
View File
@@ -10,9 +10,9 @@
* correct for that point in history. * correct for that point in history.
*/ */
import { StringEnum } from "@mariozechner/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { Type } from "typebox"; import { Type } from "typebox";
interface Todo { interface Todo {
+1 -1
View File
@@ -41,7 +41,7 @@ link_into_repo() {
require_pi_installed() { require_pi_installed() {
if [[ ! -d "$PI_AGENT_DIR" ]]; then if [[ ! -d "$PI_AGENT_DIR" ]]; then
err "pi not detected at $PI_AGENT_DIR" err "pi not detected at $PI_AGENT_DIR"
printf ' Install pi first: https://github.com/mariozechner/pi-coding-agent\n' printf ' Install pi first: https://github.com/earendil-works/pi\n'
printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n' printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n'
exit 4 exit 4
fi fi