pi-mcp-extension 1.11.2 → 1.11.4

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/README.md CHANGED
@@ -20,14 +20,15 @@
20
20
  - **Clean cancellation** — `AbortSignal` propagation via `notifications/cancelled`
21
21
  - **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries, and automatic recovery when a server transport closes unexpectedly
22
22
  - **Health checks** — Opt-in periodic pings to detect stale connections
23
- - **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with safety-net `SIGKILL` for orphaned subprocesses
23
+ - **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with tree-aware cleanup: descendants of wrapper commands (`npx`, `npm exec`, `uvx`, `sh -c`) are terminated too, and inherited pipes are released so teardown never stalls
24
24
  - **Stable tool names** — Sanitized, collision-avoiding names (max 64 chars) that stay constant across reconnects
25
25
  - **No tool churn** — Activate/deactivate pattern ensures tools don't flicker during reconnection
26
- - **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()`), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
26
+ - **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()`, including Pi 1.3.0 partial overrides), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
27
27
  - **Rich tool results & annotations** — MCP `image` content passthrough (`ImageContent`), plus `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
28
- - **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
29
- - **Structured error handling** — Distinct error codes for config, connection, protocol, and tool-level failures
30
- - **OAuth 2.1** — Browser and headless/SSH authorization with PKCE, protected-resource discovery, Dynamic Client Registration, startup prompts for eager servers, cross-process lock recovery, token refresh, and secure local persistence (see [OAUTH.md](./OAUTH.md))
28
+ - **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp <name>` (with config provenance and failure hints), `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
29
+ - **Structured error handling** — Distinct error codes (`config`, `connection`, `auth`, `timeout`, `protocol`, `tool`) and actionable failure hints
30
+ - **Redacted debug logging** — Opt-in `PI_MCP_DEBUG=1` JSON Lines log (`<agent dir>/logs/pi-mcp.log`, `0600`, ~2 MB rotation) with automatic secret masking and zero TUI `stderr` noise
31
+ - **OAuth 2.1** — Browser and headless/SSH authorization with PKCE, protected-resource discovery, Dynamic Client Registration, startup prompts for eager servers, cross-process lock recovery, token refresh, and origin/issuer-bound local persistence (see [OAUTH.md](./OAUTH.md))
31
32
 
32
33
  ## Installation
33
34
 
@@ -57,7 +58,7 @@ pi -e npm:pi-mcp-extension
57
58
  }
58
59
  ```
59
60
 
60
- 3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
61
+ 3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level). Both files support **JSONC** (`//` line and `/* ... */` block comments):
61
62
  ```jsonc
62
63
  {
63
64
  "mcpServers": {
@@ -70,11 +71,11 @@ pi -e npm:pi-mcp-extension
70
71
  }
71
72
  ```
72
73
 
73
- 4. Start Pi — your MCP tools are ready. Use `/mcp` to check server status.
74
+ 4. Start Pi — your MCP tools are ready. Use `/mcp` to check all servers or `/mcp <name>` to inspect one in detail.
74
75
 
75
76
  ## Configuration
76
77
 
77
- Servers can be configured via JSON files or registered programmatically by other Pi 1.x extensions:
78
+ Servers can be configured via JSON/JSONC files (`//` and `/* */` comments are supported) or registered programmatically by other Pi 1.x extensions:
78
79
 
79
80
  | Source | Precedence & Scope |
80
81
  |---|---|
@@ -103,7 +104,7 @@ Servers can be configured via JSON files or registered programmatically by other
103
104
  "lifecycle": "eager"
104
105
  },
105
106
  "deepsource": {
106
- "url": "https://mcp.deepsource.io/mcp", // "transport" defaults to "streamable-http" when "url" is set
107
+ "url": "https://mcp.deepsource.com/mcp", // "transport" defaults to "streamable-http" when "url" is set
107
108
  "lifecycle": "eager"
108
109
  },
109
110
 
@@ -123,6 +124,7 @@ Servers can be configured via JSON files or registered programmatically by other
123
124
  "context7": {
124
125
  "command": "npx",
125
126
  "args": ["-y", "@context7/mcp"],
127
+ "cwd": "./",
126
128
  "transport": "stdio",
127
129
  "lifecycle": "lazy"
128
130
  },
@@ -184,14 +186,14 @@ An override may set `enabled`, `lifecycle`, and Pi's `exposure` / `toolExposure`
184
186
 
185
187
  ### OAuth Config
186
188
 
187
- Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
189
+ Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes, origin (`serverUrl`) and authorization-server (`issuer`) binding, and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
188
190
 
189
191
  | Field | Type | Default | Description |
190
192
  |---|---|---|---|
191
193
  | `type` | `"oauth"` | `"oauth"` | Authentication type |
192
194
  | `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
193
195
  | `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
194
- | `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
196
+ | `clientSecret` | `string` | — | Secret for a pre-registered confidential client (used in memory; never written to disk) |
195
197
  | `clientName` (or `oauth.clientName`) | `string` | `pi-mcp/<server name>` | `client_name` sent with Dynamic Client Registration, for servers that only accept known clients |
196
198
  | `redirectUrl` (or `oauth.callbackUrl` / `oauth.callbackPort`) | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback (`localhost`, `127.0.0.1`, or `::1` with path `/callback` and an explicit non-zero port; `callbackPort` maps to `http://127.0.0.1:<port>/callback`). Scans forward from `19876` when omitted; binds the exact host and port when configured. |
197
199
 
@@ -199,7 +201,7 @@ Configured under `auth` (or `oauth`). Token and client state are persisted per s
199
201
 
200
202
  Server-name arguments provide autocomplete suggestions from the configured MCP servers. `/mcp:auth` suggests only servers configured for OAuth.
201
203
 
202
- | Command | Description |
204
+ | Slash Command | Description |
203
205
  |---|---|
204
206
  | `/mcp` | Show status summary of all configured servers |
205
207
  | `/mcp <name>` | Show detailed status for one server: config source, transport/lifecycle, tool activation, OAuth credential status, failure hint, and stderr log |
@@ -208,7 +210,12 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
208
210
  | `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
209
211
  | `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
210
212
 
211
- Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, discovered and active tool counts, credential presence, config source, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
213
+ In addition to user slash commands, two built-in **agent tools** let the LLM inspect and connect configured servers directly:
214
+
215
+ | Agent Tool | Parameters | Description |
216
+ |---|---|---|
217
+ | `mcp_status` | `{}` | List configured servers with `lifecycle`, `state`, `tools` (`discovered` / `active`), `auth` credential presence, `source` (`global` / `project` / `extension`), and safe `error` action hints (never returns tokens, headers, or authorization URLs) |
218
+ | `mcp_connect` | `{ name: string }` | Connect one named server, run interactive OAuth if needed, and verify that discovered tools are active before returning success (returns fixed actionable categories on failure instead of raw server response text) |
212
219
 
213
220
  ### Inspecting one server (`/mcp <name>`)
214
221
 
@@ -276,39 +283,53 @@ While a Pi UI is active, the extension never writes diagnostics to Pi's stderr (
276
283
 
277
284
  ## How It Works
278
285
 
279
- ```
280
- ┌─────────────────────────────────────────────────────────┐
281
- │ Pi Agent │
282
- │ │
283
- │ ┌──────────┐ ┌────────────────┐ ┌─────────────┐ │
284
- │ │ index.ts │───▶│ server-manager │───▶│ tool-bridge │ │
285
- │ │ (entry) │ │ (lifecycle) │ │ (MCP → Pi) │ │
286
- │ └──────────┘ └───────┬────────┘ └──────┬──────┘ │
287
- │ │ │ │ │
288
- │ ▼ ▼ ▼ │
289
- │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
290
- │ │ config │ │ MCP SDK │ │ TypeBox │ │
291
- │ │ (Zod) │ │ (transport) │ │ (schema) │ │
292
- │ └──────────┘ └──────────────┘ └──────────────┘ │
293
- └─────────────────────────────────────────────────────────┘
294
- │ │
295
- ▼ ▼
296
- Global/Project ┌──────────────┐
297
- mcp.json files │ MCP Servers │
298
- + Extensions │ (stdio/http) │
299
- └──────────────┘
286
+ ```text
287
+ ┌─────────────────────────────────────────────────────────────────────────┐
288
+ │ Pi Agent (TUI / RPC, ExtensionAPI) │
289
+ │ │
290
+ │ ┌────────────────────┐ ┌────────────────────────┐ │
291
+ │ │ index.ts │───▶│ register-commands │ (/mcp, :start, │
292
+ │ │ (entry & mcp_* │ │ (/mcp* slash commands) │ :stop, :auth) │
293
+ │ │ agent tools) │ └───────────┬────────────┘ │
294
+ │ └────┬──────────┬────┘ │ │
295
+ │ │ └──────────────┐ │ │
296
+ │ │ ▼ ▼ │
297
+ │ │ ┌────────────────────────┐ │
298
+ │ │ │ connection-coordinator │ │
299
+ │ │ │ (in-flight connects, │ │
300
+ │ │ │ authQueue, teardown) │ │
301
+ │ │ └───────────┬────────────┘ │
302
+ │ │ │ │
303
+ │ │ ▼ │
304
+ │ │ ┌────────────────────────┐ ┌───────────┐ │
305
+ │ │ │ server-manager │───▶│tool-bridge│ │
306
+ │ │ │ (stopped → starting │ │(MCP → Pi) │ │
307
+ │ │ │ → ready lifecycle) │ └─────┬─────┘ │
308
+ │ ▼ └───────────┬────────────┘ │ │
309
+ │ ┌────────────────────┐ ▼ ▼ │
310
+ │ │ config │ ┌────────────────────────┐ ┌───────────┐ │
311
+ │ │ (Zod + JSONC) │ │ transport-factory & │ │ TypeBox │ │
312
+ │ └────────┬───────────┘ │ oauth-provider (SDK) │ │ (schema) │ │
313
+ │ │ └───────────┬────────────┘ └───────────┘ │
314
+ └───────────┼────────────────────────────┼────────────────────────────────┘
315
+ │ │
316
+ ▼ ▼
317
+ Global/Project mcp.json ┌──────────────┐
318
+ + Registered Extensions │ MCP Servers │
319
+ │ (stdio/http) │
320
+ └──────────────┘
300
321
  ```
301
322
 
302
323
  1. **Config is loaded** from global `<agent dir>/mcp.json`, trusted project `.pi/mcp.json` (checked via `ctx.isProjectTrusted()` on `session_start`), and extension-registered servers (`pi.getMcpServers()` / `mcp_servers_change`), with `mcp.json` taking precedence over extension-registered servers and project entries overriding global ones
303
- 2. **Eager servers connect** at `session_start` (offering a sequential `Authorize now` / `Not now` prompt when an eager OAuth server needs authorization in an interactive session); lazy servers wait for `mcp_connect`, `/mcp:start`, or `/mcp:auth`
324
+ 2. **Eager servers connect** at `session_start` via `ConnectionCoordinator` (offering a sequential `Authorize now` / `Not now` prompt when an eager OAuth server needs authorization in an interactive session); lazy servers wait for `mcp_connect`, `/mcp:start`, or `/mcp:auth`
304
325
  3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages) before a server enters `ready`
305
- 4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
326
+ 4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas (preserving nullable union ordering, resolving `$ref`s cycle-safely, and inferring/flattening `allOf` object schemas)
306
327
  5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix) and pass through text and `image` content blocks (`ImageContent`)
307
328
  6. **Tools activate/deactivate** automatically as servers connect/disconnect
308
329
  7. **AbortSignal** is wired through to `notifications/cancelled` for clean cancellation
309
330
  8. **Reconnection & recovery** uses a fixed delay schedule with configurable max retries on initial failure, health check failure, or unexpected transport close (`client.onclose`)
310
331
  9. **`list_changed` notifications** trigger re-discovery, deactivating stale tools automatically
311
- 10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
332
+ 10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, flushes the debug log, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
312
333
 
313
334
  ## MCP Spec Compliance
314
335
 
@@ -340,30 +361,38 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, workflow, and the pre-merge
340
361
  ## Development
341
362
 
342
363
  ```bash
343
- # Install dependencies
344
- npm install
364
+ # Install dependencies (clean lockfile install)
365
+ npm ci
345
366
 
346
- # Lint (type-aware ESLint)
367
+ # Lint (type-aware ESLint + structural/complexity checks)
347
368
  npm run lint
348
369
 
349
370
  # Type check (strict mode)
350
371
  npm run typecheck
351
372
 
352
- # Run all tests
373
+ # Run all test suites
353
374
  npm test
354
375
 
376
+ # Run all test suites with coverage floor (95% lines / 85% branches / 83% functions)
377
+ npm run test:coverage
378
+
355
379
  # Run integration tests only (real stdio server)
356
380
  npm run test:integration
381
+
382
+ # Verify npm tarball contents
383
+ npm pack --dry-run
357
384
  ```
358
385
 
359
386
  ### Testing
360
387
 
361
388
  The test suite includes:
362
- - **Config & Pi 1.x compatibility** — Zod schema, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
363
- - **Server lifecycle & coordination** — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
364
- - **OAuth 2.1 & auth locks** — Challenge discovery, PKCE, Dynamic Client Registration, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat and stale lock reclamation
365
- - **Tool bridge & management tools** — JSON Schema → TypeBox conversion, name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, and slash command completions
366
- - **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
389
+ - **Config & Pi 1.x / 1.3.0 compatibility** (`tests/config.test.ts`, `tests/pi-1x-compat.test.ts`) — Zod schema, JSONC comments, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.3.0 `enabled: false` and partial project overrides, `stdio` `cwd`, `oauth.clientName`, Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
390
+ - **Server lifecycle & coordination** (`tests/server-manager.test.ts`, `tests/stdio-process.test.ts`, `tests/transport-factory.test.ts`, `tests/connection-coordinator.test.ts`, `tests/connection-coordination.test.ts`) — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
391
+ - **OAuth 2.1 & auth locks** (`tests/oauth-fixes.test.ts`, `tests/authorize-flow.test.ts`, `tests/auth-lock.test.ts`, `tests/auth-commands.test.ts`, `tests/auth-arguments.test.ts`, `tests/fake-oauth-server.test.ts`) — Challenge discovery, PKCE, Dynamic Client Registration, origin and issuer binding, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat, and stale lock reclamation
392
+ - **Tool bridge, status diagnostics & debug log** (`tests/tool-bridge.test.ts`, `tests/management-tools.test.ts`, `tests/server-status.test.ts`, `tests/status-commands.test.ts`, `tests/completions.test.ts`, `tests/debug-log.test.ts`) — JSON Schema → TypeBox conversion (`allOf` object inference, `$ref` cycle protection, union `null` ordering), name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, `/mcp <name>` config provenance and failure hints, slash command completions, and redacted `PI_MCP_DEBUG=1` JSONL logging
393
+ - **Property-based fuzzing** (`tests/fuzz.test.ts`) — `fast-check` properties covering JSONC comment stripping, callback/redirect URL validation, `WWW-Authenticate` challenge parsing, JSON Schema → TypeBox conversion, tool naming, terminal/log secret sanitization, and custom header merging
394
+ - **Release flow & repository policy** (`tests/release.test.ts`, `tests/ruleset-audit.test.ts`, `tests/repo-policy.test.ts`) — Release PR orchestration and npm/git self-healing (`scripts/release.ts`), live GitHub ruleset drift auditing (`scripts/ruleset-audit.ts`), and CI/ESLint/Qlty policy invariants
395
+ - **End-to-end integration** — Real mock MCP and OAuth servers over `stdio` and HTTP with tool discovery and execution
367
396
 
368
397
  ## Security
369
398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.11.2",
3
+ "version": "1.11.4",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -73,6 +73,24 @@ type InteractiveAuthParams = {
73
73
  reset: boolean;
74
74
  };
75
75
 
76
+ /**
77
+ * Whether a declined server still deserves the `run /mcp:auth <name>` footer hint.
78
+ *
79
+ * WHY: A decline records "this server needed authorization at some point", which stays
80
+ * true until there is positive evidence otherwise. `ready` is that evidence, and so is a
81
+ * `stopped` server whose `lastError` is now a non-auth failure (`ECONNREFUSED`, timeout) —
82
+ * that failure overwrote `AuthRequiredError`, so authorization is no longer the problem
83
+ * and the hint would send the user down the wrong path. An unknown server drops out too.
84
+ * Anything ambiguous (no `lastError` yet, still `starting`) keeps the hint, because
85
+ * clearing it on doubt would hide a pending authorization the user was just told about.
86
+ */
87
+ function retainsAuthHint(server: Pick<ManagedServer, "state" | "lastError"> | undefined): boolean {
88
+ if (!server || server.state === "ready") return false;
89
+ const lastError = server.lastError;
90
+ if (!lastError || lastError instanceof AuthRequiredError) return true;
91
+ return server.state !== "stopped";
92
+ }
93
+
76
94
  export class ConnectionCoordinator {
77
95
  private readonly deps: ConnectionCoordinatorDeps;
78
96
  private readonly connectAttempts = new Map<string, ConnectAttemptEntry>();
@@ -124,7 +142,7 @@ export class ConnectionCoordinator {
124
142
  syncStatus(ctx: ExtensionContext | undefined = this.activeCtx): void {
125
143
  for (const declinedName of Array.from(this.declinedStartupAuth)) {
126
144
  const server = this.deps.manager.getServer(declinedName);
127
- if (!server || server.state === "ready") {
145
+ if (!retainsAuthHint(server)) {
128
146
  this.declinedStartupAuth.delete(declinedName);
129
147
  }
130
148
  }
@@ -147,18 +165,23 @@ export class ConnectionCoordinator {
147
165
 
148
166
  handleRecoveryFailed(serverName: string, error: unknown): void {
149
167
  if (error instanceof AuthRequiredError) {
150
- this.declinedStartupAuth.add(serverName);
151
168
  this.activeCtx?.ui.notify(
152
169
  `pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
153
170
  "warning",
154
171
  );
155
- this.syncStatus(this.activeCtx);
156
- } else {
157
- this.activeCtx?.ui.notify(
158
- `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
159
- "error",
160
- );
172
+ // WHY: Route through markAuthRequired so a mid-session 401 (reported by
173
+ // ServerManager.handleUnauthorizedServerError) lands in the declined set and
174
+ // repaints the footer hint exactly like a declined startup prompt.
175
+ this.markAuthRequired(serverName);
176
+ return;
161
177
  }
178
+ this.activeCtx?.ui.notify(
179
+ `pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
180
+ "error",
181
+ );
182
+ // WHY: The failure just overwrote lastError, so a hint left over from an earlier
183
+ // decline now points at authorization that is not the problem — clear it.
184
+ this.syncStatus(this.activeCtx);
162
185
  }
163
186
 
164
187
  cancelServer(serverName: string): void {
@@ -325,6 +348,9 @@ export class ConnectionCoordinator {
325
348
  try {
326
349
  await this.deps.manager.startServer(name, ctx.cwd);
327
350
  } catch (err) {
351
+ // WHY: A cancelled start means the user stopped the server (/mcp:stop) or the
352
+ // session is tearing down; reporting it as a start failure would be a bogus error.
353
+ if (err instanceof AuthCancelledError) return;
328
354
  if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
329
355
  authRequiredServers.add(name);
330
356
  return;
@@ -335,12 +361,14 @@ export class ConnectionCoordinator {
335
361
  }),
336
362
  );
337
363
 
364
+ // WHY: Reconcile the footer hint once every eager start has settled — a declined
365
+ // server may have connected cleanly or failed with a non-auth error, and either
366
+ // outcome makes "run /mcp:auth <name>" stale.
367
+ this.syncStatus(ctx);
368
+
338
369
  for (const [name] of serversToStart) {
339
370
  if (!authRequiredServers.has(name)) continue;
340
- if (this.declinedStartupAuth.has(name)) {
341
- this.syncStatus(ctx);
342
- continue;
343
- }
371
+ if (this.declinedStartupAuth.has(name)) continue;
344
372
  this.enqueueStartupAuthPrompt(name, ctx);
345
373
  }
346
374
 
@@ -483,8 +511,13 @@ export class ConnectionCoordinator {
483
511
  await this._startAndGuardAbort(serverName, ctx, controller.signal);
484
512
  return true;
485
513
  } catch (err) {
486
- if (!(err instanceof AuthRequiredError) || !serverConfig.auth) throw err;
487
- return false;
514
+ if (err instanceof AuthRequiredError && serverConfig.auth) return false;
515
+ // WHY: A non-auth connect failure (e.g. ECONNREFUSED) overwrites lastError, so a
516
+ // hint left over from an earlier decline no longer describes this server's problem.
517
+ if (!(err instanceof AuthCancelledError)) {
518
+ this.syncStatus(ctx);
519
+ }
520
+ throw err;
488
521
  }
489
522
  }
490
523
 
@@ -493,7 +526,17 @@ export class ConnectionCoordinator {
493
526
  this._ensureActive(controller.signal, serverName);
494
527
  await this.deps.authorize(serverName, ctx, controller.signal, reset);
495
528
  this._ensureActive(controller.signal, serverName);
496
- await this._startAndGuardAbort(serverName, ctx, controller.signal);
529
+ try {
530
+ await this._startAndGuardAbort(serverName, ctx, controller.signal);
531
+ } catch (err) {
532
+ // WHY: Authorization can succeed and the reconnect still fail for a non-auth reason
533
+ // (ECONNREFUSED, timeout). lastError now names that failure, so a hint left over from
534
+ // the earlier decline would tell the user to authorize a server that just authorized.
535
+ if (!(err instanceof AuthCancelledError)) {
536
+ this.syncStatus(ctx);
537
+ }
538
+ throw err;
539
+ }
497
540
  }
498
541
 
499
542
  private async _startAndGuardAbort(
package/src/index.ts CHANGED
@@ -162,8 +162,20 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
162
162
  manager.setRecoveryFailedCallback((serverName, error) => {
163
163
  coordinator.handleRecoveryFailed(serverName, error);
164
164
  });
165
+ manager.setServerReadyCallback(() => {
166
+ // WHY: Eager starts and background _recover() reconnects never pass through
167
+ // coordinator.connect(), so without this the "run /mcp:auth <name>" footer hint
168
+ // survives a server that has since connected cleanly.
169
+ coordinator.syncStatus();
170
+ });
165
171
  bridge.setAuthErrorCallback(async (serverName) => {
166
- await manager.handleUnauthorizedServerError(serverName);
172
+ const authError = await manager.handleUnauthorizedServerError(serverName);
173
+ if (authError) {
174
+ // WHY: A mid-session 401/403 stops the server synchronously, which cancels the
175
+ // deferred _recover() that would have called onRecoveryFailed — so report the
176
+ // failure here or the re-auth warning and the footer hint never appear.
177
+ coordinator.handleRecoveryFailed(serverName, authError);
178
+ }
167
179
  });
168
180
 
169
181
  // ── 3. Session lifecycle ──────────────────────────────────────────────────
@@ -29,8 +29,10 @@ import type { ToolRefreshOptions } from "./tool-bridge.js";
29
29
  import { sanitizeTerminalText, type SanitizeTerminalTextOptions } from "./terminal-text.js";
30
30
  import { describeError, reportDiagnostic } from "./debug-log.js";
31
31
  import {
32
+ destroyStdioStreams,
32
33
  ensurePidTerminated,
33
34
  instrumentStdioTransport,
35
+ waitForChildClose,
34
36
  } from "./stdio-process.js";
35
37
  import { createTransport } from "./transport-factory.js";
36
38
  import {
@@ -104,6 +106,17 @@ export type ToolRefreshCallback = (
104
106
  /** Called when background auto-recovery after a disconnect or health check fails. */
105
107
  export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
106
108
 
109
+ /**
110
+ * Called every time a server reaches `ready`, whether the transition came from an
111
+ * explicit connect, an eager startup, or background `_recover()`.
112
+ *
113
+ * WHY: Readiness is the only state change that makes a previously declined
114
+ * `run /mcp:auth <name>` footer hint obsolete, and eager starts plus background
115
+ * recovery bypass `ConnectionCoordinator.connect()`, which is where the hint is
116
+ * otherwise repainted.
117
+ */
118
+ export type ServerReadyCallback = (serverName: string) => void;
119
+
107
120
  interface HandshakeParams {
108
121
  server: ManagedServer;
109
122
  client: Client;
@@ -204,6 +217,7 @@ export class ServerManager {
204
217
  private onToolRefresh: ToolRefreshCallback | null = null;
205
218
  private onServerStopped: ((name: string) => void) | null = null;
206
219
  private onRecoveryFailed: RecoveryFailedCallback | null = null;
220
+ private onServerReady: ServerReadyCallback | null = null;
207
221
 
208
222
  constructor(config: McpConfig, private readonly storageDir?: string) {
209
223
  this.settings = config.settings;
@@ -237,6 +251,11 @@ export class ServerManager {
237
251
  this.onRecoveryFailed = cb;
238
252
  }
239
253
 
254
+ /** Register callback invoked whenever a server reaches the `ready` state. */
255
+ setServerReadyCallback(cb: ServerReadyCallback): void {
256
+ this.onServerReady = cb;
257
+ }
258
+
240
259
  private _resolveTimeoutMs(server: ManagedServer): number {
241
260
  return server.config.requestTimeoutMs ?? this.settings.requestTimeoutMs;
242
261
  }
@@ -333,11 +352,29 @@ export class ServerManager {
333
352
  /**
334
353
  * Mark an OAuth server as requiring re-authorization when a mid-session
335
354
  * request (tools/list or tools/call) fails with 401/403 insufficient_scope.
355
+ *
356
+ * Returns the {@link AuthRequiredError} when a server that was already `ready`
357
+ * lost its credentials mid-session, and `undefined` otherwise (unknown server,
358
+ * non-OAuth server, or a server that is not `ready` — see below).
359
+ *
360
+ * Only a `ready` server is handled here. For any other state this is a no-op and
361
+ * must NOT touch the server: a 401/403 during a connect attempt (`initialize` ok,
362
+ * `tools/list` 401 while `starting`) arrives while that attempt owns the
363
+ * lifecycle, and shutting the server down here would abort the attempt's own
364
+ * signal. `classifyConnectFailure` then sees `aborted` first and reports
365
+ * `AuthCancelledError` instead of `auth-required`, which suppresses the startup
366
+ * auth prompt, the interactive authorization, and the recovery failure alike.
367
+ * Left alone, the attempt's `_raiseAuthRequired` invalidates the tokens, records
368
+ * the `AuthRequiredError`, and reports it exactly once.
336
369
  */
337
- async handleUnauthorizedServerError(name: string): Promise<void> {
370
+ async handleUnauthorizedServerError(name: string): Promise<AuthRequiredError | undefined> {
338
371
  const server = this.servers.get(name);
339
372
  if (!server || !server.config.auth || server.config.transport === "stdio") {
340
- return;
373
+ return undefined;
374
+ }
375
+ if (server.state !== "ready") {
376
+ // WHY: See the JSDoc above — an in-flight connect attempt reports its own 401.
377
+ return undefined;
341
378
  }
342
379
  const authError = new AuthRequiredError(name);
343
380
  // WHY: Shut down immediately before async disk I/O so server.generation increments
@@ -346,6 +383,7 @@ export class ServerManager {
346
383
  server.lastError = authError;
347
384
  await invalidateStoredTokens(name, this.storageDir, server.config.url);
348
385
  server.lastError = authError;
386
+ return authError;
349
387
  }
350
388
 
351
389
  /** Get recent stderr output for a server. */
@@ -555,30 +593,9 @@ export class ServerManager {
555
593
  );
556
594
 
557
595
  // tools/list_changed: re-discover tools and update Pi registrations
558
- client.setNotificationHandler(
559
- ToolListChangedNotificationSchema,
560
- async () => {
561
- if (!signal.aborted && this.onToolRefresh && server.state === "ready" && server.client === client) {
562
- try {
563
- const refreshTimeoutMs = this._resolveTimeoutMs(server);
564
- await this.onToolRefresh(server.name, server.client, {
565
- requestTimeoutMs: refreshTimeoutMs,
566
- signal,
567
- });
568
- } catch (err) {
569
- const { kind, message } = describeError(err);
570
- reportDiagnostic({
571
- level: "error",
572
- event: "server.tools_refresh_failed",
573
- server: server.name,
574
- message: `Failed to refresh tools for ${server.name}: ${message}`,
575
- fields: { kind },
576
- notify: true,
577
- });
578
- }
579
- }
580
- },
581
- );
596
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
597
+ await this._handleToolListChanged(server, client, signal);
598
+ });
582
599
 
583
600
  // notifications/message: structured logging from MCP servers
584
601
  client.setNotificationHandler(
@@ -606,6 +623,48 @@ export class ServerManager {
606
623
  return client;
607
624
  }
608
625
 
626
+ /**
627
+ * Re-discover tools after a `notifications/tools/list_changed` from a ready server.
628
+ *
629
+ * Extracted from the notification handler so the handler registration stays a single
630
+ * guarded call.
631
+ */
632
+ private async _handleToolListChanged(
633
+ server: ManagedServer,
634
+ client: Client,
635
+ signal: AbortSignal,
636
+ ): Promise<void> {
637
+ const onToolRefresh = this.onToolRefresh;
638
+ if (signal.aborted || !onToolRefresh || server.state !== "ready" || server.client !== client) {
639
+ return;
640
+ }
641
+ try {
642
+ const refreshTimeoutMs = this._resolveTimeoutMs(server);
643
+ await onToolRefresh(server.name, server.client, {
644
+ requestTimeoutMs: refreshTimeoutMs,
645
+ signal,
646
+ });
647
+ } catch (err) {
648
+ const { kind, message } = describeError(err);
649
+ // WHY: Two failures here are already accounted for and must not toast: a cancelled
650
+ // refresh (/mcp:stop, session teardown) is expected, and a 401/403 was just reported
651
+ // by handleUnauthorizedServerError as a re-authorization warning plus the footer hint
652
+ // — a second "Failed to refresh tools" error would read as an unrelated problem.
653
+ const alreadyReported =
654
+ err instanceof AuthCancelledError ||
655
+ signal.aborted ||
656
+ server.lastError instanceof AuthRequiredError;
657
+ reportDiagnostic({
658
+ level: alreadyReported ? "info" : "error",
659
+ event: "server.tools_refresh_failed",
660
+ server: server.name,
661
+ message: `Failed to refresh tools for ${server.name}: ${message}`,
662
+ fields: { kind },
663
+ notify: !alreadyReported,
664
+ });
665
+ }
666
+ }
667
+
609
668
  private async _handshakeAndDiscover(params: HandshakeParams): Promise<void> {
610
669
  const { server, client, transport, timeoutMs, signal, currentCwd } = params;
611
670
  await this._connectWithTimeoutAndSignal(client, transport, timeoutMs, signal, server.name);
@@ -624,6 +683,9 @@ export class ServerManager {
624
683
  this._setState(server, "ready");
625
684
  server.retryCount = 0;
626
685
  server.lastError = null;
686
+ // WHY: Fire only after the server is observably ready with a cleared lastError, so a
687
+ // listener that repaints session state never sees a half-transitioned server.
688
+ this.onServerReady?.(server.name);
627
689
  if (server.cwd && server.cwd !== currentCwd) {
628
690
  void client.sendRootsListChanged().catch(() => {
629
691
  // WHY: Best-effort notification if cwd was updated while handshake was in flight.
@@ -827,6 +889,26 @@ export class ServerManager {
827
889
  };
828
890
  }
829
891
 
892
+ /**
893
+ * Terminate the stdio child process tree, then release the child's pipe handles.
894
+ *
895
+ * WHY: `client.close()` waits for the child's `'close'` event, which never fires while a
896
+ * leaked grandchild still holds the inherited stdout/stderr write ends — the SDK then
897
+ * burns its full 2 × 2,000ms escalation budget. Killing the tree first and destroying the
898
+ * pipes afterwards keeps teardown bounded even if a descendant escaped the tree kill.
899
+ */
900
+ private async _terminateStdioTree(
901
+ pid: number | null,
902
+ transport: Transport | undefined,
903
+ ): Promise<void> {
904
+ try {
905
+ await ensurePidTerminated(pid);
906
+ await waitForChildClose(transport);
907
+ } finally {
908
+ destroyStdioStreams(transport);
909
+ }
910
+ }
911
+
830
912
  private async _cleanupFailedConnect(
831
913
  server: ManagedServer,
832
914
  client: Client,
@@ -839,7 +921,7 @@ export class ServerManager {
839
921
  server.childPid = null;
840
922
  await Promise.allSettled([
841
923
  client.close(),
842
- ensurePidTerminated(pid),
924
+ this._terminateStdioTree(pid, transport),
843
925
  ]);
844
926
  // WHY: Secondary safety check to guarantee PID termination even if transport.close()
845
927
  // or child process exit raced during the initial Promise.allSettled.
@@ -1081,13 +1163,14 @@ export class ServerManager {
1081
1163
  server.childPid ??
1082
1164
  (server.transport as unknown as { _process?: { pid?: number } })?._process?.pid ??
1083
1165
  null;
1166
+ const transport = server.transport;
1084
1167
  server.client = null;
1085
1168
  server.transport = undefined;
1086
1169
  server.childPid = null;
1087
1170
 
1088
1171
  await Promise.allSettled([
1089
1172
  client?.close(),
1090
- ensurePidTerminated(pid),
1173
+ this._terminateStdioTree(pid, transport),
1091
1174
  ]);
1092
1175
  await ensurePidTerminated(pid);
1093
1176
  }
@@ -3,13 +3,20 @@
3
3
  *
4
4
  * Provides pure StringDecoder-based line splitting across chunk boundaries,
5
5
  * StdioClientTransport instrumentation (early PID capture, memoized close,
6
- * stderr wiring), and deterministic SIGTERM → bounded wait → SIGKILL cleanup
7
- * with injectable process/timer dependencies.
6
+ * stderr wiring), descendant process-tree discovery for wrappers such as
7
+ * `npx`, `uvx`, or `sh -c`, and deterministic SIGTERM → bounded wait → SIGKILL
8
+ * cleanup with injectable process/timer dependencies.
8
9
  */
9
10
 
11
+ import { execFile } from "node:child_process";
12
+ import { readFile, readdir } from "node:fs/promises";
10
13
  import { StringDecoder } from "node:string_decoder";
14
+ import { promisify } from "node:util";
15
+ import type { ChildProcess } from "node:child_process";
11
16
  import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
12
17
 
18
+ const execFileAsync = promisify(execFile);
19
+
13
20
  // ─── Line Splitting ───────────────────────────────────────────────────────────
14
21
 
15
22
  export interface LineSplitter {
@@ -27,6 +34,7 @@ export function createLineSplitter(onLine: (line: string) => void): LineSplitter
27
34
 
28
35
  return {
29
36
  write(chunk: Buffer): void {
37
+ flushed = false;
30
38
  remainder += decoder.write(chunk);
31
39
  const lines = remainder.split("\n");
32
40
  remainder = lines.pop() ?? "";
@@ -49,6 +57,25 @@ export function createLineSplitter(onLine: (line: string) => void): LineSplitter
49
57
 
50
58
  // ─── Transport Instrumentation ────────────────────────────────────────────────
51
59
 
60
+ /** Grace window for an exited child's pipes to drain naturally before force-destroying them. */
61
+ const PIPE_DRAIN_GRACE_MS = 100;
62
+
63
+ interface StdioChildRecord {
64
+ process: ChildProcess;
65
+ closed: boolean;
66
+ /** Flush the buffered partial stderr line before the pipe is destroyed. */
67
+ flushStderr(): void;
68
+ }
69
+
70
+ /**
71
+ * Child processes of instrumented transports, keyed by transport.
72
+ *
73
+ * WHY: `StdioClientTransport.close()` clears `_process` synchronously, so the child
74
+ * handle is gone by the time teardown needs its pipes and its stderr flush. A WeakMap
75
+ * keeps both reachable without pinning a closed transport in memory.
76
+ */
77
+ const childRecords = new WeakMap<object, StdioChildRecord>();
78
+
52
79
  export interface InstrumentStdioTransportOptions {
53
80
  onPid(pid: number): void;
54
81
  onStderrLine(line: string): void;
@@ -66,17 +93,35 @@ export function instrumentStdioTransport(
66
93
  // WHY: Capture child PID immediately when StdioClientTransport spawns the process
67
94
  // (before `initialize` is sent), so a timeout or cancellation during `initialize`
68
95
  // can still terminate the child process deterministically before retrying.
96
+ const spawnedProcess = (): ChildProcess | undefined =>
97
+ (transport as unknown as { _process?: ChildProcess })._process;
98
+
99
+ let flushStderr: (() => void) | null = null;
100
+
101
+ const recordProcess = (proc: ChildProcess | undefined): void => {
102
+ if (!proc?.pid) return;
103
+ if (!childRecords.has(transport)) {
104
+ const record: StdioChildRecord = {
105
+ process: proc,
106
+ closed: false,
107
+ flushStderr: () => flushStderr?.(),
108
+ };
109
+ proc.once?.("close", () => {
110
+ record.closed = true;
111
+ });
112
+ childRecords.set(transport, record);
113
+ }
114
+ onPid(proc.pid);
115
+ };
116
+
69
117
  const origStart = transport.start.bind(transport);
70
118
  transport.start = async () => {
71
119
  const startPromise = origStart();
72
- const earlyProc = (transport as unknown as { _process?: { pid?: number } })._process;
73
- if (earlyProc?.pid) {
74
- onPid(earlyProc.pid);
75
- }
120
+ recordProcess(spawnedProcess());
76
121
  await startPromise;
77
- const proc = (transport as unknown as { _process?: { pid?: number } })._process;
122
+ const proc = spawnedProcess();
78
123
  if (proc?.pid) {
79
- onPid(proc.pid);
124
+ recordProcess(proc);
80
125
  } else if (transport.pid) {
81
126
  onPid(transport.pid);
82
127
  }
@@ -99,26 +144,212 @@ export function instrumentStdioTransport(
99
144
  transport.stderr.on("data", (chunk: Buffer) => {
100
145
  splitter.write(chunk);
101
146
  });
102
- const flushStderr = (): void => {
147
+ const flush = (): void => {
103
148
  splitter.end();
104
149
  };
105
- transport.stderr.on("end", flushStderr);
106
- transport.stderr.on("close", flushStderr);
150
+ flushStderr = flush;
151
+ transport.stderr.on("end", flush);
152
+ transport.stderr.on("close", flush);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Wait briefly for an exited child process to drain its stdio pipes and emit `'close'`
158
+ * naturally before `destroyStdioStreams` force-destroys the parent's pipe read ends.
159
+ *
160
+ * WHY: On Windows, `process.stderr` is an overlapped named pipe whose final bytes can
161
+ * still be in flight in libuv when `TerminateProcess` completes. Letting `'close'` fire
162
+ * naturally preserves trailing stderr output when no grandchild holds the pipe open, while
163
+ * bounding the wait to `PIPE_DRAIN_GRACE_MS` when a surviving grandchild keeps it open.
164
+ */
165
+ export async function waitForChildClose(
166
+ transport: object | null | undefined,
167
+ timeoutMs = PIPE_DRAIN_GRACE_MS,
168
+ ): Promise<void> {
169
+ if (!transport) return;
170
+ const record = childRecords.get(transport);
171
+ if (!record || record.closed || typeof record.process.once !== "function") return;
172
+ await new Promise<void>((resolve) => {
173
+ const timer = setTimeout(() => {
174
+ record.process.removeListener?.("close", onClose);
175
+ resolve();
176
+ }, timeoutMs);
177
+ const onClose = (): void => {
178
+ clearTimeout(timer);
179
+ resolve();
180
+ };
181
+ record.process.once("close", onClose);
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Destroy the child's `stdout`/`stderr` pipes once the process tree is gone.
187
+ *
188
+ * WHY: A surviving grandchild (e.g. a server spawned by an `npx` wrapper) inherits the
189
+ * wrapper's pipe write ends, so Node never emits `'close'` for the wrapper and
190
+ * `StdioClientTransport.close()` burns its full 4000ms escalation budget waiting for the
191
+ * `'close'` event. Destroying the parent's read ends lets `close()` resolve immediately
192
+ * even when a descendant escaped the tree kill. `stdin` is left alone: it is our write
193
+ * end and the SDK still ends it as part of its own close sequence.
194
+ */
195
+ export function destroyStdioStreams(transport: object | null | undefined): void {
196
+ if (!transport) return;
197
+ const record = childRecords.get(transport);
198
+ if (!record) return;
199
+ childRecords.delete(transport);
200
+ // WHY: Flush the buffered partial stderr line first. Destroying our read end keeps the
201
+ // SDK's PassThrough from ever emitting 'end', so without this a forced teardown drops the
202
+ // server's last words (the buffered remainder of a line with no trailing newline).
203
+ record.flushStderr();
204
+ record.process.stdout?.destroy();
205
+ record.process.stderr?.destroy();
206
+ }
207
+
208
+ // ─── Descendant Discovery ─────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Upper bound on tracked descendants.
212
+ *
213
+ * WHY: A wrapper that respawns children while we walk the tree could otherwise make
214
+ * discovery unbounded; teardown only needs the tree as it stood when we snapshotted it.
215
+ */
216
+ const MAX_DESCENDANTS = 256;
217
+
218
+ /** Read the direct child PIDs of `pid` from Linux `/proc`. */
219
+ async function readProcChildren(pid: number): Promise<number[]> {
220
+ // WHY: /proc/<pid>/task/<tid>/children lists each thread's direct children, so a
221
+ // multithreaded wrapper is covered without scanning every process in /proc.
222
+ const taskDir = `/proc/${pid}/task`;
223
+ const tids = await readdir(taskDir);
224
+ const children: number[] = [];
225
+ for (const tid of tids) {
226
+ const raw = await readChildrenFile(`${taskDir}/${tid}/children`);
227
+ for (const token of raw.split(/\s+/)) {
228
+ if (token) children.push(Number(token));
229
+ }
230
+ }
231
+ return children;
232
+ }
233
+
234
+ async function readChildrenFile(path: string): Promise<string> {
235
+ try {
236
+ return await readFile(path, "utf8");
237
+ } catch (err) {
238
+ // WHY: A thread can exit between readdir() and readFile(), and a process that dies
239
+ // mid-walk simply has no children left to report.
240
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return "";
241
+ throw err;
107
242
  }
108
243
  }
109
244
 
245
+ /** Timeout for a single `ps` process-table snapshot on non-Linux POSIX systems. */
246
+ const PS_TIMEOUT_MS = 1_000;
247
+
248
+ /**
249
+ * Read the full `ps` process table and index it as `parentPid → childPids`.
250
+ *
251
+ * WHY: macOS and other non-Linux POSIX systems have no `/proc`; `ps -A -o pid=,ppid=`
252
+ * dumps the entire process table in one call so a single snapshot can serve the whole
253
+ * tree walk without spawning `ps` once per node.
254
+ */
255
+ async function readPsProcessTable(): Promise<Map<number, number[]>> {
256
+ const { stdout } = await execFileAsync("ps", ["-A", "-o", "pid=,ppid="], {
257
+ maxBuffer: 16 * 1024 * 1024,
258
+ timeout: PS_TIMEOUT_MS,
259
+ });
260
+ return indexProcessTable(stdout);
261
+ }
262
+
263
+ /** Index `ps -A -o pid=,ppid=` output as a parent PID → child PIDs map. */
264
+ export function indexProcessTable(stdout: string): Map<number, number[]> {
265
+ const childrenOf = new Map<number, number[]>();
266
+ for (const line of stdout.split("\n")) {
267
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
268
+ if (!match) continue;
269
+ const childPid = Number(match[1]);
270
+ const parentPid = Number(match[2]);
271
+ const siblings = childrenOf.get(parentPid);
272
+ if (siblings) {
273
+ siblings.push(childPid);
274
+ } else {
275
+ childrenOf.set(parentPid, [childPid]);
276
+ }
277
+ }
278
+ return childrenOf;
279
+ }
280
+
281
+ function createPlatformChildReader(): (pid: number) => Promise<number[]> {
282
+ if (process.platform === "linux") return readProcChildren;
283
+ if (process.platform === "win32") {
284
+ // WHY: Windows has no `/proc` or `ps`, and the portable tree kill (`taskkill /T`)
285
+ // must be driven by the caller's own signal semantics. Discovery reports nothing so
286
+ // teardown falls back to killing the top-level PID plus destroying the pipes.
287
+ return () => Promise.resolve([]);
288
+ }
289
+ let tablePromise: Promise<Map<number, number[]>> | null = null;
290
+ return async (pid: number): Promise<number[]> => {
291
+ tablePromise ??= readPsProcessTable();
292
+ const childrenOf = await tablePromise;
293
+ return childrenOf.get(pid) ?? [];
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Snapshot the descendant PIDs of `pid`, deepest generation first.
299
+ *
300
+ * Returns `[]` when discovery is unavailable (Windows, containers without `ps` or
301
+ * `/proc`), which leaves the caller's single-PID teardown intact.
302
+ */
303
+ export async function listDescendantPids(
304
+ pid: number,
305
+ readChildren: (pid: number) => Promise<number[]> = createPlatformChildReader(),
306
+ ): Promise<number[]> {
307
+ try {
308
+ const descendants = await walkDescendants(pid, readChildren);
309
+ // Breadth-first order reversed: each generation is signalled before its parent, so a
310
+ // wrapper cannot respawn or reap a child that is still being torn down.
311
+ return descendants.reverse();
312
+ } catch {
313
+ // WHY: Discovery is best-effort by design: the PID can exit between the liveness check
314
+ // and the `/proc` walk, and minimal containers ship neither `ps` nor `/proc`. A tree we
315
+ // cannot read must never fail the caller's shutdown path — teardown still kills the
316
+ // root PID and releases the inherited pipes.
317
+ return [];
318
+ }
319
+ }
320
+
321
+ async function walkDescendants(
322
+ pid: number,
323
+ readChildren: (pid: number) => Promise<number[]>,
324
+ ): Promise<number[]> {
325
+ const descendants: number[] = [];
326
+ const seen = new Set<number>([pid]);
327
+ const pending = await readChildren(pid);
328
+ while (pending.length > 0 && descendants.length < MAX_DESCENDANTS) {
329
+ const next = pending.shift();
330
+ if (next === undefined || seen.has(next)) continue;
331
+ seen.add(next);
332
+ descendants.push(next);
333
+ pending.push(...(await readChildren(next)));
334
+ }
335
+ return descendants;
336
+ }
337
+
110
338
  // ─── Process Supervision ──────────────────────────────────────────────────────
111
339
 
112
340
  export interface StdioProcessDeps {
113
341
  kill(pid: number, signal?: NodeJS.Signals | number): void | boolean;
114
342
  sleep(ms: number): Promise<void>;
115
343
  now(): number;
344
+ /** Snapshot descendant PIDs of `pid`, deepest first. Resolves `[]` when unavailable. */
345
+ listDescendantPids(pid: number): Promise<number[]>;
116
346
  }
117
347
 
118
348
  const defaultDeps: StdioProcessDeps = {
119
349
  kill: (pid, signal) => process.kill(pid, signal),
120
350
  sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
121
351
  now: () => Date.now(),
352
+ listDescendantPids: (pid) => listDescendantPids(pid),
122
353
  };
123
354
 
124
355
  function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps {
@@ -127,6 +358,7 @@ function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps {
127
358
  kill: deps.kill ?? defaultDeps.kill,
128
359
  sleep: deps.sleep ?? defaultDeps.sleep,
129
360
  now: deps.now ?? defaultDeps.now,
361
+ listDescendantPids: deps.listDescendantPids ?? defaultDeps.listDescendantPids,
130
362
  };
131
363
  }
132
364
 
@@ -146,29 +378,68 @@ export function isPidAlive(
146
378
  }
147
379
  }
148
380
 
149
- async function signalAndWaitForExit(
150
- pid: number,
151
- signal: NodeJS.Signals,
152
- timeoutMs: number,
153
- d: StdioProcessDeps,
154
- ): Promise<boolean> {
381
+ /** Send `signal` to one PID; `false` when the PID was already gone (ESRCH). */
382
+ function signalPid(pid: number, signal: NodeJS.Signals, d: StdioProcessDeps): boolean {
155
383
  try {
156
384
  d.kill(pid, signal);
385
+ return true;
157
386
  } catch (err) {
158
387
  if ((err as NodeJS.ErrnoException).code === "ESRCH") {
159
- return true;
388
+ return false;
160
389
  }
390
+ // WHY: EPERM and platform refusals mean this PID cannot be signalled (a recycled PID
391
+ // or a sandboxed process). Teardown still has to signal the rest of the tree within
392
+ // the bounded wait instead of rejecting the caller's shutdown path.
393
+ return true;
394
+ }
395
+ }
396
+
397
+ function allExited(pids: readonly number[], d: StdioProcessDeps): boolean {
398
+ return pids.every((pid) => !isPidAlive(pid, d));
399
+ }
400
+
401
+ async function signalAndWaitForExit(
402
+ pids: readonly number[],
403
+ signal: NodeJS.Signals,
404
+ timeoutMs: number,
405
+ d: StdioProcessDeps,
406
+ ): Promise<boolean> {
407
+ const attempted = pids.map((pid) => signalPid(pid, signal, d));
408
+ // Every target reported ESRCH: the tree is already gone, so there is nothing to wait for.
409
+ if (!attempted.some(Boolean)) {
410
+ return true;
161
411
  }
162
412
 
163
413
  const deadline = d.now() + timeoutMs;
164
414
  while (d.now() < deadline) {
165
- if (!isPidAlive(pid, d)) {
415
+ if (allExited(pids, d)) {
166
416
  return true;
167
417
  }
168
418
  await d.sleep(25);
169
419
  }
170
420
 
171
- return !isPidAlive(pid, d);
421
+ return allExited(pids, d);
422
+ }
423
+
424
+ /**
425
+ * Escalate to SIGKILL across the surviving tree.
426
+ *
427
+ * WHY: The wrapper can spawn new children while it ignores SIGTERM, so the tree is
428
+ * re-snapshotted; the original snapshot still covers descendants of a wrapper that
429
+ * already exited (they were re-parented to init and are no longer discoverable).
430
+ */
431
+ async function escalateToSigkill(
432
+ rootPid: number,
433
+ descendants: readonly number[],
434
+ d: StdioProcessDeps,
435
+ ): Promise<void> {
436
+ const rescanned = await d.listDescendantPids(rootPid);
437
+ const survivors = [...new Set([...rescanned, ...descendants])].filter((candidate) =>
438
+ isPidAlive(candidate, d),
439
+ );
440
+ const targets = [...survivors, rootPid].filter((candidate) => isPidAlive(candidate, d));
441
+ if (targets.length === 0) return;
442
+ await signalAndWaitForExit(targets, "SIGKILL", 200, d);
172
443
  }
173
444
 
174
445
  export async function ensurePidTerminated(
@@ -180,10 +451,12 @@ export async function ensurePidTerminated(
180
451
  return;
181
452
  }
182
453
 
183
- if (await signalAndWaitForExit(pid, "SIGTERM", 500, d)) {
454
+ // WHY: Snapshot before signalling any PID: a wrapper that exits first re-parents its
455
+ // children to init, after which they can no longer be found by parent PID.
456
+ const descendants = await d.listDescendantPids(pid);
457
+ if (await signalAndWaitForExit([...descendants, pid], "SIGTERM", 500, d)) {
184
458
  return;
185
459
  }
186
460
 
187
- await signalAndWaitForExit(pid, "SIGKILL", 200, d);
461
+ await escalateToSigkill(pid, descendants, d);
188
462
  }
189
-