@gtrabanco/pi-nan-provider 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-27T21:25:24.946Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-09-29T22:08:21.449Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -71,7 +71,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
71
71
  "cost": {
72
72
  "input": 0,
73
73
  "output": 0
74
- }
74
+ },
75
+ "canonical_model_id": "deepseek/deepseek-v4.1-flash"
75
76
  }
76
77
  },
77
78
  {
@@ -117,7 +118,14 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
117
118
  "reasoning": true,
118
119
  "reasoning_options": [
119
120
  {
120
- "type": "toggle"
121
+ "type": "effort",
122
+ "values": [
123
+ "none",
124
+ "low",
125
+ "medium",
126
+ "high",
127
+ "max"
128
+ ]
121
129
  }
122
130
  ],
123
131
  "tool_call": true,
@@ -142,7 +150,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
142
150
  "cost": {
143
151
  "input": 0,
144
152
  "output": 0
145
- }
153
+ },
154
+ "canonical_model_id": "google/gemma-4-26b-a4b-it"
146
155
  }
147
156
  },
148
157
  {
@@ -184,7 +193,17 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
184
193
  "family": "glm-flash",
185
194
  "attachment": true,
186
195
  "reasoning": true,
187
- "reasoning_options": [],
196
+ "reasoning_options": [
197
+ {
198
+ "type": "effort",
199
+ "values": [
200
+ "low",
201
+ "medium",
202
+ "high",
203
+ "max"
204
+ ]
205
+ }
206
+ ],
188
207
  "tool_call": true,
189
208
  "structured_output": true,
190
209
  "temperature": true,
@@ -207,12 +226,13 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
207
226
  "cost": {
208
227
  "input": 0,
209
228
  "output": 0
210
- }
229
+ },
230
+ "canonical_model_id": "zhipuai/glm-5.3-flash"
211
231
  }
212
232
  },
213
233
  {
214
- "id": "mimo-v2.5",
215
- "name": "MiMo-V2.5",
234
+ "id": "mimo-v2.6-flash",
235
+ "name": "MiMo-V2.6-Flash",
216
236
  "reasoning": true,
217
237
  "input": [
218
238
  "text",
@@ -235,21 +255,21 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
235
255
  "maxTokensField": "max_tokens"
236
256
  },
237
257
  "notes": [
238
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict."
258
+ "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
259
+ "manual-only: \"mimo-v2.6-flash\" skipped: models.dev provider \"nan\" now lists it, so the manual-only entry is not emitted and the models.dev values are used (omnimodal model (text, image, audio input) served by NaN; limits and modalities from https://nan.builders/docs/models#mimo-v2-6-flash (checked 2026-09-25, quotas re-checked 2026-09-29): 1,048,576 context / 131,072 max output / 1.0B monthly quota per member. Reasoning: accepted, depth not adjustable (docs table, checked 2026-09-29). Tool calling: yes. Streaming: yes. Input modalities (pi-representable): text, image (audio not representable in pi's Model type). Fallback entry: emitted only while models.dev provider nan does not list the model — models.dev started listing it on 2026-09-29, so the generator then keeps the models.dev values and attaches this note to that entry instead of emitting a second one.)"
239
260
  ],
240
261
  "extras": {
241
- "id": "mimo-v2.5",
242
- "name": "MiMo-V2.5",
243
- "description": "Open MiMo model for multimodal coding agents and long-context automation",
262
+ "id": "mimo-v2.6-flash",
263
+ "name": "MiMo-V2.6-Flash",
264
+ "description": "MiMo Flash model for multimodal coding agents and long-context automation",
244
265
  "family": "mimo",
245
266
  "attachment": true,
246
267
  "reasoning": true,
247
268
  "reasoning_options": [],
248
269
  "tool_call": true,
249
270
  "temperature": true,
250
- "knowledge": "2024-12",
251
- "release_date": "2026-04-22",
252
- "last_updated": "2026-04-22",
271
+ "release_date": "2026-09-22",
272
+ "last_updated": "2026-09-22",
253
273
  "modalities": {
254
274
  "input": [
255
275
  "text",
@@ -268,38 +288,10 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
268
288
  "cost": {
269
289
  "input": 0,
270
290
  "output": 0
271
- }
291
+ },
292
+ "canonical_model_id": "xiaomi/mimo-v2.6-flash"
272
293
  }
273
294
  },
274
- {
275
- "id": "mimo-v2.6-flash",
276
- "name": "mimo-v2.6-flash",
277
- "reasoning": true,
278
- "input": [
279
- "text",
280
- "image"
281
- ],
282
- "cost": {
283
- "input": 0,
284
- "output": 0,
285
- "cacheRead": 0,
286
- "cacheWrite": 0
287
- },
288
- "contextWindow": 1048576,
289
- "maxTokens": 131072,
290
- "compat": {
291
- "supportsDeveloperRole": false,
292
- "supportsReasoningEffort": true,
293
- "supportsUsageInStreaming": true,
294
- "supportsFinishReason": true,
295
- "maxTokensField": "max_tokens"
296
- },
297
- "notes": [
298
- "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
299
- "manual-only: \"mimo-v2.6-flash\" not yet on models.dev provider nan (omnimodal model (text, image, audio input) served by NaN, not yet listed on models.dev provider nan (checked 2026-09-25); included so the live /models refresh does not hand it UNKNOWN_MODEL_LIMITS. Same limits as mimo-v2.5: 1,048,576 / 131,072 / 1.0B monthly quota per member. Reasoning: accepted, depth not adjustable. Tool calling: yes. Streaming: yes. Input modalities (pi-representable): text, image (audio not representable in pi's Model type).)."
300
- ],
301
- "extras": {}
302
- },
303
295
  {
304
296
  "id": "qwen3.6",
305
297
  "name": "Qwen3.6 35B-A3B",
@@ -343,7 +335,14 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
343
335
  "reasoning": true,
344
336
  "reasoning_options": [
345
337
  {
346
- "type": "toggle"
338
+ "type": "effort",
339
+ "values": [
340
+ "none",
341
+ "low",
342
+ "medium",
343
+ "high",
344
+ "max"
345
+ ]
347
346
  }
348
347
  ],
349
348
  "tool_call": true,
@@ -368,7 +367,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
368
367
  "cost": {
369
368
  "input": 0,
370
369
  "output": 0
371
- }
370
+ },
371
+ "canonical_model_id": "alibaba/qwen3.6-35b-a3b"
372
372
  }
373
373
  },
374
374
  {
@@ -428,7 +428,8 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
428
428
  "cost": {
429
429
  "input": 0,
430
430
  "output": 0
431
- }
431
+ },
432
+ "canonical_model_id": "alibaba/qwen3.8-flash"
432
433
  }
433
434
  }
434
435
  ];
@@ -436,15 +437,17 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
436
437
  export const GENERATED_CATALOG_META = {
437
438
  source: "https://models.dev/api.json",
438
439
  modelsDevProvider: "nan",
439
- fetchedAt: "2026-09-27T21:25:24.946Z",
440
- modelCount: 7,
441
- models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.5","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
440
+ fetchedAt: "2026-09-29T22:08:21.449Z",
441
+ modelCount: 6,
442
+ models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
442
443
  notes: [
444
+ "non-chat: \"qwen-image-2.1\" excluded from the chat catalog (image-generation model (text→image; output modality image) documented in https://nan.builders/docs/models and https://nan.builders/openapi.json (checked 2026-09-29) — not a chat model, out of the static chat catalog (models.dev reports limit.output 0))",
443
445
  "live-only: \"glm5.3\" kept out of the static catalog (premium-tier model (models.dev now documents it with 1M context / 131,072 max output; NaN docs https://nan.builders/docs/models + https://nan.builders/openapi.json, checked 2026-09-13) kept live-only so a non-premium key never sees a model it cannot call when the live /models fetch is unavailable; premium keys still get it via the /models refresh with conservative placeholder limits)",
444
446
  "provider-removed: \"glm5.2\" excluded from the catalog (removed by NaN (2026-09-05); absent from the official chat model list in https://nan.builders/openapi.json and https://nan.builders/docs/models (checked 2026-09-07) while models.dev provider nan still listed it — excluded so regeneration does not resurrect it)",
447
+ "provider-removed: \"mimo-v2.5\" excluded from the catalog (removed by NaN (checked 2026-09-29): zero mentions in https://nan.builders/docs/models and in the model param description of https://nan.builders/openapi.json, and models.dev provider nan dropped it as well — superseded by mimo-v2.6-flash; excluded so regeneration does not resurrect it)",
445
448
  "compat matches the maintainer's working ~/.pi/agent/models.json LiteLLM config for api.nan.builders (2026-09-04): supportsDeveloperRole false, supportsReasoningEffort true, maxTokensField max_tokens. NaN's docs example sets only supportsDeveloperRole: true and is not battle-tested. supportsFinishReason true (2026-09-13, issue #2): the LiteLLM gateway intermittently closes SSE streams before emitting finish_reason; with true pi-ai raises 'Stream ended without finish_reason', which matches pi-ai's retryable-provider pattern ('ended without') and is retried automatically, whereas false silently synthesized stop/toolUse and stalled the turn mid-answer. supportsUsageInStreaming true (2026-09-16, issue #7): NaN's published schema is silent about stream_options, but the live gateway honors it — two identical streaming calls per model, differing only in stream_options: { include_usage: true }, returned 0 usage chunks without it and exactly 1 with it (prompt/completion/reasoning/cached token counts) on deepseek-v4-flash, glm5.3-flash, qwen3.6, mimo-v2.5 and gemma4, and a real pi session then recorded token counts where it recorded zeros. pi-ai only sends stream_options when this is not false, and the sanitizer forwards it when the model declares true, so chat models opt in by default and usage is reported (issue #4). A model that does not report streaming usage can still opt out per model with a models.json compat override (supportsUsageInStreaming: false); the sanitizer then strips stream_options and the payload stays strict.",
446
449
  "input includes image: NaN serves the Vision-Exp variant ('takes images as input', https://nan.builders/docs/models; the image_url content-parts in https://nan.builders/openapi.json list deepseek-v4-flash among the vision models). models.dev provider nan also lists text+image now (DeepSeek V4.1 Flash entry, checked 2026-09-13; its 2026-09-07 snapshot listed text only), so this override is kept as a pin for the vision capability rather than as a divergence. reasoning_effort_values=[]: the model decides per-request how much to reason (https://nan.builders/docs/models, checked 2026-09-25).",
447
- "manual-only: \"mimo-v2.6-flash\" not yet on models.dev provider nan (omnimodal model (text, image, audio input) served by NaN, not yet listed on models.dev provider nan (checked 2026-09-25); included so the live /models refresh does not hand it UNKNOWN_MODEL_LIMITS. Same limits as mimo-v2.5: 1,048,576 / 131,072 / 1.0B monthly quota per member. Reasoning: accepted, depth not adjustable. Tool calling: yes. Streaming: yes. Input modalities (pi-representable): text, image (audio not representable in pi's Model type).).",
450
+ "manual-only: \"mimo-v2.6-flash\" skipped: models.dev provider \"nan\" now lists it, so the manual-only entry is not emitted and the models.dev values are used (omnimodal model (text, image, audio input) served by NaN; limits and modalities from https://nan.builders/docs/models#mimo-v2-6-flash (checked 2026-09-25, quotas re-checked 2026-09-29): 1,048,576 context / 131,072 max output / 1.0B monthly quota per member. Reasoning: accepted, depth not adjustable (docs table, checked 2026-09-29). Tool calling: yes. Streaming: yes. Input modalities (pi-representable): text, image (audio not representable in pi's Model type). Fallback entry: emitted only while models.dev provider nan does not list the model — models.dev started listing it on 2026-09-29, so the generator then keeps the models.dev values and attaches this note to that entry instead of emitting a second one.)",
448
451
  "contextWindow 262,144: the earlier 1,000,000 override (maintainer-confirmed 2026-09-05) was withdrawn 2026-09-07 — the updated https://nan.builders/docs/models still states '262K token context, the model's native window' and models.dev agrees at 262,144; NaN docs are treated as the most reliable source (maintainer instruction, 2026-09-07)."
449
452
  ],
450
453
  } as const;
package/src/commands.ts CHANGED
@@ -1,45 +1,40 @@
1
1
  /**
2
- * `/nan-mcp` — slash command configuring BOTH MCP bridges this package
3
- * registers as pi tools:
2
+ * `/nan-mcp` — slash command configuring both native MCP servers this
3
+ * package registers:
4
4
  *
5
- * - `web-search` — the official NaN remote MCP server (api.nan.builders/mcp),
6
- * exposing `nan_web_search`. Default: enabled.
7
- * - `nan-mcp-server` — the community stdio media server (image generation /
8
- * editing, TTS, STT). Default: enabled, lazy (spawned per tool call).
5
+ * - `nan-search` — the official NaN remote MCP server (api.nan.builders/mcp),
6
+ * exposing `mcp__nan-search__web_search`. Default: enabled.
7
+ * - `nan-media` — the community stdio media server (flux-2-klein / kokoro /
8
+ * whisper), exposing `mcp__nan-media__generate_image` etc. Default: enabled.
9
9
  *
10
- * pi has no MCP client and therefore no `/mcp` command of its own, so this
11
- * command owns the toggles:
12
- *
13
- * - `/nan-mcp` or `/nan-mcp status` — state of both bridges and their source.
14
- * - `/nan-mcp enable [target]` — enable a bridge (or both, when no target is
15
- * given) and persist it in `<agentDir>/nan-provider.json`; enabled bridges
16
- * register their tools immediately for the current session.
17
- * - `/nan-mcp disable [target]` — disable persistently. pi has no
18
- * unregisterTool, so already-registered tools remain until the next
19
- * session/restart; the persisted toggle governs future sessions.
10
+ * Both are session-scoped native servers (visible in /mcp, source "extension").
11
+ * /nan-mcp enables/disables them via pi.registerMcpServer / pi.unregisterMcpServer,
12
+ * persisting the toggle in nan-provider.json.
20
13
  *
21
14
  * Targets accept aliases: `web-search` (`search`, `web_search`, `official`,
22
15
  * `nan-web-search`) and `nan-mcp-server` (`media`, `media-mcp`, `nan-media`).
23
16
  * Explicit env vars (NAN_MCP_TOOLS, NAN_MEDIA_MCP) override the persisted
24
17
  * toggles for the session.
18
+ *
19
+ * Native behavior changes from the old bridge:
20
+ * - `disable` IMMEDIATELY hides tools (unregisterMcpServer disconnects the
21
+ * server; the MCP extension hides tools when disconnected).
22
+ * - `enable` IMMEDIATELY registers the server for the current session.
23
+ * - `status` reads the server state from pi.getMcpServers().
24
+ * - Missing API key at load: server is NOT registered; guidance notification
25
+ * replaces the old "errors at call time" behavior.
25
26
  */
26
27
 
27
28
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
28
- import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource } from "./mcp/nan-media.ts";
29
- import { webSearchBridgeEnabled, webSearchBridgeSource } from "./mcp/nan-search.ts";
30
- import { NAN_STATE_FILE, readBridgeState, writeBridgeState, type BridgeKey } from "./mcp/state.ts";
31
-
32
- export interface NanMcpCommandOptions {
33
- /** Register web-search bridge tools now; must skip already-registered tools. */
34
- registerWebSearchTools: () => void;
35
- /** Register media bridge tools now; must skip already-registered tools. */
36
- registerMediaTools: () => void;
37
- }
29
+ import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
30
+ import { webSearchBridgeEnabled, webSearchBridgeSource, mcpToolsEnvExplicit, mcpToolsDisabled } from "./mcp/api-key.ts";
31
+ import { NAN_API_KEY_ENV, tryResolveNanApiKeyViaRegistry } from "./mcp/api-key.ts";
32
+ import { NAN_STATE_FILE, readBridgeState, writeBridgeState, type BridgeKey, readState, writeState } from "./mcp/state.ts";
38
33
 
39
34
  const USAGE =
40
35
  "Usage: /nan-mcp [status] · /nan-mcp enable [web-search|nan-mcp-server] · /nan-mcp disable [web-search|nan-mcp-server]";
41
36
 
42
- /** Canonical bridge targets with aliases (e.g. `/mcp enable nan-mcp-server` muscle memory). */
37
+ /** Canonical bridge targets with aliases. */
43
38
  const TARGETS: Record<string, BridgeKey> = {
44
39
  "web-search": "webSearch",
45
40
  search: "webSearch",
@@ -67,19 +62,64 @@ function sourceLabel(bridge: BridgeKey, source: "env" | "persisted" | "default")
67
62
  if (source === "persisted") {
68
63
  return `persisted in <agentDir>/${NAN_STATE_FILE} (${targetName(bridge)}: ${readBridgeState(bridge)})`;
69
64
  }
70
- return "default (both bridges are enabled and lazy by default)";
65
+ return "default (both bridges are enabled by default)";
66
+ }
67
+
68
+ /** Resolve a bridge key to its native server name. */
69
+ function serverName(bridge: BridgeKey): string {
70
+ return bridge === "webSearch" ? "nan-search" : "nan-media";
71
+ }
72
+
73
+ /** Describe the bridge in the status message. */
74
+ function bridgeDescription(bridge: BridgeKey): string {
75
+ return bridge === "webSearch"
76
+ ? "web-search bridge (official NaN MCP → mcp__nan-search__web_search)"
77
+ : "nan-mcp-server bridge (community media MCP → mcp__nan-media__generate_image/edit_image/text_to_speech/list_voices/speech_to_text)";
78
+ }
79
+
80
+ /** Check if a native server of this name is currently registered. */
81
+ function nativeServerRegistered(pi: ExtensionAPI, name: string): boolean {
82
+ try {
83
+ return pi.getMcpServers().some((s) => s.name === name);
84
+ } catch {
85
+ return false;
86
+ }
71
87
  }
72
88
 
73
- function statusMessage(): string {
74
- return [
75
- `web-search bridge (official NaN MCP → nan_web_search): ${webSearchBridgeEnabled() ? "enabled" : "disabled"} — ${sourceLabel("webSearch", webSearchBridgeSource())}.`,
76
- `nan-mcp-server bridge (community media MCP → nan_generate_image/nan_edit_image/nan_text_to_speech/nan_list_voices/nan_speech_to_text): ${mediaMcpEnabled() ? "enabled" : "disabled"} — ${sourceLabel("mediaMcp", mediaMcpSource())}.`,
77
- `Media spawn command: ${mediaMcpCommand().join(" ")} (per tool call, terminated after).`,
78
- "Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.",
79
- ].join("\n");
89
+ /** Status message listing both bridges' gate state and native registration. */
90
+ function statusMessage(pi: ExtensionAPI): string {
91
+ const lines: string[] = [];
92
+
93
+ for (const bridge of ["webSearch" as BridgeKey, "mediaMcp" as BridgeKey]) {
94
+ const enabled = bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
95
+ const source = bridge === "webSearch" ? webSearchBridgeSource() : mediaMcpSource();
96
+ const regStatus = nativeServerRegistered(pi, serverName(bridge)) ? "registered (native)" : "not registered";
97
+ lines.push(`${bridgeDescription(bridge)}: ${enabled ? "enabled" : "disabled"} — ${sourceLabel(bridge, source)}, ${regStatus}.`);
98
+
99
+ if (bridge === "webSearch") {
100
+ // Key status for web-search
101
+ const keyStatus = resolveKeyStatus(pi);
102
+ lines.push(` API key: ${keyStatus}.`);
103
+ }
104
+ if (bridge === "mediaMcp") {
105
+ lines.push(` Media spawn command: ${mediaMcpCommand().join(" ")}.`);
106
+ }
107
+ }
108
+
109
+ lines.push("Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.");
110
+ return lines.join("\n");
111
+ }
112
+
113
+ /** Check API key resolution status for web-search. */
114
+ async function resolveKeyStatus(pi: ExtensionAPI): Promise<string> {
115
+ // @ts-expect-error — modelRegistry is a runtime surface.
116
+ const registryKey = await tryResolveNanApiKeyViaRegistry(pi.modelRegistry);
117
+ const envKey = process.env[NAN_API_KEY_ENV];
118
+ if (registryKey || envKey) return "resolved";
119
+ return "NOT SET — run /login nan or export NAN_API_KEY";
80
120
  }
81
121
 
82
- /** Parse an optional target argument; no target = both bridges. Returns undefined target name on unknown. */
122
+ /** Parse an optional target argument; no target = both bridges. */
83
123
  function parseTarget(token: string | undefined): { bridges: BridgeKey[]; target?: string } | undefined {
84
124
  if (token === undefined) return { bridges: ["webSearch", "mediaMcp"] };
85
125
  const key = TARGETS[token.toLowerCase()];
@@ -92,18 +132,25 @@ function describeBridges(bridges: BridgeKey[]): string {
92
132
  return names.length === 2 ? "both bridges" : names.join(" and ");
93
133
  }
94
134
 
95
- export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOptions): void {
135
+ /**
136
+ * Check if a bridge is effectively enabled (gate resolved).
137
+ */
138
+ function bridgeEffectivelyEnabled(bridge: BridgeKey): boolean {
139
+ return bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
140
+ }
141
+
142
+ export function registerNanMcpCommand(pi: ExtensionAPI): void {
96
143
  pi.registerCommand("nan-mcp", {
97
144
  description:
98
- "NaN MCP configuration: enable/disable/status for both bridges (official web-search + community nan-mcp-server)",
145
+ "NaN MCP configuration: enable/disable/status for both native servers (official web-search + community media)",
99
146
  getArgumentCompletions: (argumentPrefix: string) => {
100
147
  const prefix = argumentPrefix.trim().toLowerCase();
101
148
  const items = [
102
- { value: "enable", label: "enable", description: "Enable a bridge (or both) and persist it" },
103
- { value: "disable", label: "disable", description: "Disable a bridge (or both) persistently" },
104
- { value: "status", label: "status", description: "Show current bridge status" },
105
- { value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search bridge" },
106
- { value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media bridge" },
149
+ { value: "enable", label: "enable", description: "Enable a native server (or both) and persist it" },
150
+ { value: "disable", label: "disable", description: "Disable a native server (or both) persistently" },
151
+ { value: "status", label: "status", description: "Show current server status and registration" },
152
+ { value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search server" },
153
+ { value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media server" },
107
154
  ];
108
155
  const filtered = items.filter((item) => item.value.startsWith(prefix));
109
156
  return filtered.length > 0 ? filtered : null;
@@ -114,12 +161,11 @@ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOp
114
161
  const subcommand = rawSubcommand.toLowerCase();
115
162
 
116
163
  if (subcommand === "status") {
117
- ctx.ui.notify(statusMessage(), "info");
164
+ ctx.ui.notify(statusMessage(pi), "info");
118
165
  return;
119
166
  }
120
167
 
121
168
  if (subcommand === "enable" || subcommand === "disable") {
122
- // Trailing tokens beyond the target (e.g. duplicate target names) are ignored.
123
169
  const parsed = parseTarget(rawTarget);
124
170
  if (parsed === undefined || rest.length > 0) {
125
171
  ctx.ui.notify(
@@ -136,23 +182,57 @@ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOp
136
182
  for (const bridge of bridges) {
137
183
  writeBridgeState(bridge, enabled);
138
184
  }
139
- if (enabled) {
140
- if (bridges.includes("webSearch")) options.registerWebSearchTools();
141
- if (bridges.includes("mediaMcp")) options.registerMediaTools();
185
+
186
+ // Immediate native registration/unregistration.
187
+ for (const bridge of bridges) {
188
+ const name = serverName(bridge);
189
+ if (enabled) {
190
+ // Only register if gate allows it.
191
+ if (bridgeEffectivelyEnabled(bridge)) {
192
+ if (bridge === "webSearch") {
193
+ // @ts-expect-error — modelRegistry is a runtime surface.
194
+ const registryKey = await tryResolveNanApiKeyViaRegistry(pi.modelRegistry);
195
+ const apiKey = registryKey ?? process.env[NAN_API_KEY_ENV];
196
+ if (apiKey) {
197
+ pi.registerMcpServer(name, {
198
+ type: "http",
199
+ url: "https://api.nan.builders/mcp",
200
+ headers: { Authorization: `Bearer ${apiKey}` },
201
+ exposure: "direct",
202
+ });
203
+ } else {
204
+ ctx.ui.notify(
205
+ `${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
206
+ "warning",
207
+ );
208
+ }
209
+ } else {
210
+ const mediaArgs = mediaMcpCommand();
211
+ pi.registerMcpServer(name, {
212
+ type: "stdio",
213
+ command: mediaArgs[0]!,
214
+ args: mediaArgs.slice(1),
215
+ env: { [NAN_API_KEY_ENV]: process.env[NAN_API_KEY_ENV] ?? "" },
216
+ exposure: "direct",
217
+ timeout: mediaMcpTimeoutSec(),
218
+ });
219
+ }
220
+ }
221
+ } else {
222
+ pi.unregisterMcpServer(name);
223
+ }
142
224
  }
143
225
 
144
226
  const where = target ? `for "${target}"` : "for both bridges";
145
227
  const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
146
228
  if (enabled) {
147
229
  ctx.ui.notify(
148
- `Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Tools are available now; both bridges are lazy (web-search calls the endpoint per request, nan-mcp-server spawns per call), so nothing runs until invoked.` +
149
- " An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var would override this toggle.",
230
+ `Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers registered for this session.`,
150
231
  "info",
151
232
  );
152
233
  } else {
153
234
  ctx.ui.notify(
154
- `Disabled ${describeBridges(bridges)} ${where}, ${persistence}. pi has no unregisterTool, so tools already registered in this session remain until restart (or /reload); future sessions will not register them.` +
155
- " An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var overrides this toggle.",
235
+ `Disabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers unregistered — tools hidden immediately.`,
156
236
  "warning",
157
237
  );
158
238
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * NaN-aware context-overflow classification.
3
3
  *
4
- * Root cause (pi-ai, still present on 0.85.1 / main): when history is replayed
4
+ * Root cause (pi-ai, verified on 0.87.1): when history is replayed
5
5
  * into a DIFFERENT model, `transformMessages` downgrades every non-redacted
6
6
  * `thinking` block to plain `text` verbatim, with no size bound — and nothing
7
7
  * bounds the SUM across messages. Switching from a 1M-context model
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-model thinking guard.
3
3
  *
4
- * Root cause this guards against (pi-ai, still present on 0.85.1 / main): when
4
+ * Root cause this guards against (pi-ai, verified on 0.87.1): when
5
5
  * history is replayed into a DIFFERENT model, `transformMessages` downgrades
6
6
  * every non-redacted `thinking` block to a plain `text` block verbatim
7
7
  * (`packages/ai/src/api/transform-messages.ts`), and `openai-completions`