@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/cli.js +3089 -3104
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/service-tier.d.ts +39 -24
  7. package/dist/types/config/settings-schema.d.ts +37 -37
  8. package/dist/types/edit/index.d.ts +1 -0
  9. package/dist/types/edit/renderer.d.ts +4 -0
  10. package/dist/types/edit/snapshot-details.d.ts +33 -0
  11. package/dist/types/mcp/config-writer.d.ts +48 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  13. package/dist/types/mcp/types.d.ts +3 -0
  14. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  15. package/dist/types/session/agent-session.d.ts +37 -13
  16. package/dist/types/session/messages.d.ts +1 -1
  17. package/dist/types/session/session-context.d.ts +2 -2
  18. package/dist/types/session/session-entries.d.ts +2 -2
  19. package/dist/types/session/session-manager.d.ts +5 -6
  20. package/dist/types/system-prompt.test.d.ts +1 -0
  21. package/dist/types/task/executor.d.ts +6 -6
  22. package/dist/types/task/types.d.ts +6 -0
  23. package/dist/types/task/worktree.d.ts +32 -6
  24. package/dist/types/tiny/title-client.d.ts +5 -1
  25. package/dist/types/tools/index.d.ts +3 -3
  26. package/dist/types/utils/git.d.ts +17 -0
  27. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  28. package/dist/types/web/search/types.d.ts +1 -1
  29. package/package.json +12 -12
  30. package/src/cli/args.ts +32 -1
  31. package/src/cli/bench-cli.ts +97 -22
  32. package/src/cli/tiny-models-cli.ts +18 -4
  33. package/src/cli/web-search-cli.ts +6 -1
  34. package/src/commands/bench.ts +10 -1
  35. package/src/config/mcp-schema.json +11 -2
  36. package/src/config/model-discovery.ts +66 -8
  37. package/src/config/model-registry.ts +13 -6
  38. package/src/config/service-tier.ts +85 -56
  39. package/src/config/settings-schema.ts +42 -36
  40. package/src/config/settings.ts +47 -0
  41. package/src/edit/hashline/execute.ts +15 -9
  42. package/src/edit/index.ts +19 -6
  43. package/src/edit/modes/patch.ts +3 -2
  44. package/src/edit/modes/replace.ts +3 -2
  45. package/src/edit/renderer.ts +4 -0
  46. package/src/edit/snapshot-details.ts +77 -0
  47. package/src/eval/agent-bridge.ts +4 -2
  48. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  49. package/src/internal-urls/docs-index.generated.txt +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/mcp/config-writer.ts +121 -0
  52. package/src/mcp/config.ts +10 -6
  53. package/src/mcp/oauth-flow.ts +10 -8
  54. package/src/mcp/transports/stdio.ts +9 -17
  55. package/src/mcp/types.ts +3 -0
  56. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  57. package/src/modes/components/extensions/state-manager.ts +24 -3
  58. package/src/modes/controllers/event-controller.ts +24 -8
  59. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  60. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  61. package/src/prompts/bench.md +4 -10
  62. package/src/prompts/tools/irc.md +19 -29
  63. package/src/prompts/tools/job.md +8 -14
  64. package/src/prompts/tools/lsp.md +19 -30
  65. package/src/prompts/tools/task.md +42 -62
  66. package/src/sdk.ts +13 -11
  67. package/src/session/agent-session.ts +196 -76
  68. package/src/session/messages.ts +1 -1
  69. package/src/session/session-context.ts +4 -4
  70. package/src/session/session-entries.ts +2 -2
  71. package/src/session/session-listing.ts +9 -8
  72. package/src/session/session-loader.ts +98 -3
  73. package/src/session/session-manager.ts +43 -6
  74. package/src/slash-commands/builtin-registry.ts +2 -10
  75. package/src/subprocess/worker-client.ts +12 -4
  76. package/src/system-prompt.test.ts +158 -0
  77. package/src/system-prompt.ts +69 -26
  78. package/src/task/executor.ts +23 -16
  79. package/src/task/index.ts +7 -5
  80. package/src/task/isolation-runner.ts +15 -1
  81. package/src/task/types.ts +6 -0
  82. package/src/task/worktree.ts +219 -38
  83. package/src/tiny/title-client.ts +19 -13
  84. package/src/tools/index.ts +3 -3
  85. package/src/tools/irc.ts +9 -3
  86. package/src/tools/path-utils.ts +4 -2
  87. package/src/tools/read.ts +28 -28
  88. package/src/utils/file-mentions.ts +10 -1
  89. package/src/utils/git.ts +38 -0
  90. package/src/web/search/index.ts +14 -8
  91. package/src/web/search/providers/duckduckgo.ts +150 -78
  92. package/src/web/search/providers/gemini.ts +268 -185
  93. package/src/web/search/types.ts +1 -1
package/src/main.ts CHANGED
@@ -140,7 +140,7 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
140
140
  "advisor.subagents",
141
141
  "advisor.syncBacklog",
142
142
  "advisor.immuneTurns",
143
- "serviceTierAdvisor",
143
+ "tier.advisor",
144
144
  ];
145
145
 
146
146
  const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [
@@ -227,3 +227,124 @@ export async function setServerDisabled(filePath: string, name: string, disabled
227
227
 
228
228
  await writeMCPConfigFile(filePath, updated);
229
229
  }
230
+
231
+ /**
232
+ * Read the user-level force-enable list (allowlist that overrides a
233
+ * non-writable source config's `enabled: false`).
234
+ */
235
+ export async function readEnabledServers(filePath: string): Promise<string[]> {
236
+ const config = await readMCPConfigFile(filePath);
237
+ return Array.isArray(config.enabledServers) ? config.enabledServers : [];
238
+ }
239
+
240
+ /**
241
+ * Add or remove a server name from the user-level force-enable list.
242
+ * The list overrides a discovered server's `enabled: false` flag but does
243
+ * NOT override the `disabledServers` denylist.
244
+ */
245
+ export async function setServerForceEnabled(filePath: string, name: string, force: boolean): Promise<void> {
246
+ const config = await readMCPConfigFile(filePath);
247
+ const current = new Set(config.enabledServers ?? []);
248
+
249
+ if (force) {
250
+ current.add(name);
251
+ } else {
252
+ current.delete(name);
253
+ }
254
+
255
+ const updated: MCPConfigFile = {
256
+ ...config,
257
+ enabledServers: current.size > 0 ? Array.from(current).sort() : undefined,
258
+ };
259
+
260
+ if (!updated.enabledServers) {
261
+ delete updated.enabledServers;
262
+ }
263
+
264
+ await writeMCPConfigFile(filePath, updated);
265
+ }
266
+
267
+ /** Paths and target state for toggling one MCP server across known config files. */
268
+ export interface SetMcpServerEnabledOptions {
269
+ userPath: string;
270
+ projectPath: string;
271
+ /**
272
+ * Absolute path to the loaded row's source mcp.json. Provide ONLY for
273
+ * formats this codebase owns (native `.omp/mcp.json` and `mcp-json`
274
+ * `mcp.json`/`.mcp.json`). Tool-owned configs (opencode.json, claude.json,
275
+ * settings.json …) MUST be omitted; we never mutate another tool's file.
276
+ */
277
+ sourcePath?: string;
278
+ name: string;
279
+ enabled: boolean;
280
+ }
281
+
282
+ /**
283
+ * Flip a server's enabled/disabled state regardless of where it lives.
284
+ *
285
+ * Resolution order, mirroring `/mcp enable` / `/mcp disable` plus the dashboard
286
+ * fix for non-writable source configs:
287
+ *
288
+ * - Server found in `sourcePath` (writable) → write `enabled` on that entry.
289
+ * - Else server in project mcp.json → write `enabled` there.
290
+ * - Else server in user mcp.json → write `enabled` there.
291
+ * - Else (server defined in a tool-owned source like opencode.json, OR a
292
+ * purely discovered server):
293
+ * - Disable → add to the user-level `disabledServers` denylist.
294
+ * - Enable → add to the user-level `enabledServers` allowlist so the
295
+ * dashboard / runtime override the non-writable source's
296
+ * `enabled: false` flag.
297
+ *
298
+ * Cleanup invariants — on every call:
299
+ * - Re-enable clears any stale denylist entry so a server disabled via
300
+ * `/mcp disable` and re-enabled here doesn't stay suppressed.
301
+ * - Disable clears any stale allowlist entry so re-disabling a
302
+ * force-enabled server actually takes effect.
303
+ */
304
+ export async function setMcpServerEnabled(options: SetMcpServerEnabledOptions): Promise<void> {
305
+ const { userPath, projectPath, sourcePath, name, enabled } = options;
306
+ const candidatePaths = [...new Set([sourcePath, projectPath, userPath].filter(path => path !== undefined))];
307
+ let updatedInConfig = false;
308
+
309
+ for (const filePath of candidatePaths) {
310
+ const config = await readMCPConfigFile(filePath);
311
+ const server = config.mcpServers?.[name];
312
+ if (server === undefined) continue;
313
+
314
+ await updateMCPServer(filePath, name, { ...server, enabled });
315
+ updatedInConfig = true;
316
+ break;
317
+ }
318
+
319
+ if (enabled) {
320
+ // Either we just wrote `enabled: true` on a writable source, or the
321
+ // server lives in a non-writable source whose `enabled: false` flag we
322
+ // need to override via the user allowlist. Either way the denylist
323
+ // entry (if any) must clear so the row becomes active.
324
+ const denied = await readDisabledServers(userPath);
325
+ if (denied.includes(name)) {
326
+ await setServerDisabled(userPath, name, false);
327
+ }
328
+
329
+ const forced = await readEnabledServers(userPath);
330
+ const isForced = forced.includes(name);
331
+ if (!updatedInConfig && !isForced) {
332
+ await setServerForceEnabled(userPath, name, true);
333
+ } else if (updatedInConfig && isForced) {
334
+ // Writable source now carries `enabled: true`; the override is
335
+ // redundant. Drop it so the user's allowlist stays tidy.
336
+ await setServerForceEnabled(userPath, name, false);
337
+ }
338
+ return;
339
+ }
340
+
341
+ // Disable path. Clear any force-enable override regardless of source so the
342
+ // disable actually sticks.
343
+ const forced = await readEnabledServers(userPath);
344
+ if (forced.includes(name)) {
345
+ await setServerForceEnabled(userPath, name, false);
346
+ }
347
+ if (!updatedInConfig) {
348
+ await setServerDisabled(userPath, name, true);
349
+ }
350
+ }
package/src/mcp/config.ts CHANGED
@@ -9,7 +9,7 @@ import { mcpCapability } from "../capability/mcp";
9
9
  import type { SourceMeta } from "../capability/types";
10
10
  import type { MCPServer } from "../discovery";
11
11
  import { loadCapability } from "../discovery";
12
- import { readDisabledServers } from "./config-writer";
12
+ import { readDisabledServers, readEnabledServers } from "./config-writer";
13
13
  import type { MCPServerConfig } from "./types";
14
14
 
15
15
  /** Options for loading MCP configs */
@@ -105,16 +105,20 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
105
105
  ? result.items
106
106
  : result.items.filter(server => server._source.level !== "project");
107
107
 
108
- // Load user-level disabled servers list
109
- const disabledServers = new Set(await readDisabledServers(getMCPConfigPath("user", cwd)));
108
+ // Load user-level disable/force-enable lists. The denylist always wins; the
109
+ // allowlist overrides a non-writable source config's `enabled: false`.
110
+ const userPath = getMCPConfigPath("user", cwd);
111
+ const [disabledServers, forcedEnabled] = await Promise.all([
112
+ readDisabledServers(userPath).then(list => new Set(list)),
113
+ readEnabledServers(userPath).then(list => new Set(list)),
114
+ ]);
110
115
  // Convert to legacy format and preserve source metadata
111
116
  let configs: Record<string, MCPServerConfig> = {};
112
117
  let sources: Record<string, SourceMeta> = {};
113
118
  for (const server of servers) {
114
119
  const config = convertToLegacyConfig(server);
115
- if (config.enabled === false || disabledServers.has(server.name)) {
116
- continue;
117
- }
120
+ if (disabledServers.has(server.name)) continue;
121
+ if (config.enabled === false && !forcedEnabled.has(server.name)) continue;
118
122
  configs[server.name] = config;
119
123
  sources[server.name] = server._source;
120
124
  }
@@ -75,6 +75,10 @@ export interface MCPStoredOAuthCredential extends OAuthCredential {
75
75
  const DEFAULT_PORT = 3000;
76
76
  const CALLBACK_PATH = "/callback";
77
77
 
78
+ function hasOAuthScope(scopes: string | null | undefined, scope: string): boolean {
79
+ return !!scopes && scopes.split(/\s+/).includes(scope);
80
+ }
81
+
78
82
  function isLoopbackHostname(hostname: string): boolean {
79
83
  return hostname === "localhost" || hostname === "127.0.0.1";
80
84
  }
@@ -225,12 +229,10 @@ export interface MCPOAuthConfig {
225
229
  /** OAuth scopes (space-separated) */
226
230
  scopes?: string;
227
231
  /**
228
- * `prompt` parameter for the authorization request. Defaults to `"consent"`
229
- * so the provider always shows its authorize screen instead of silently
230
- * re-approving the browser's current session without it, reauthorizing to
231
- * switch accounts/workspaces is impossible once a session cookie exists
232
- * (RFC 6749 §3.1 requires servers to ignore the param when unsupported).
233
- * Set to `""` to omit the parameter entirely.
232
+ * `prompt` parameter for the authorization request. By default the parameter
233
+ * is omitted, matching the reference MCP SDK, except for `offline_access`
234
+ * requests where OIDC Core requires `prompt=consent` to issue refresh-token
235
+ * access. Set to `""` to omit the parameter entirely.
234
236
  */
235
237
  prompt?: string;
236
238
  /** Exact redirect URI to advertise to the provider */
@@ -325,7 +327,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
325
327
  if (this.config.scopes && !params.get("scope")) {
326
328
  params.set("scope", this.config.scopes);
327
329
  }
328
- const prompt = this.config.prompt ?? "consent";
330
+ const prompt = this.config.prompt ?? (hasOAuthScope(params.get("scope"), "offline_access") ? "consent" : "");
329
331
  if (prompt && !params.get("prompt")) {
330
332
  params.set("prompt", prompt);
331
333
  }
@@ -494,7 +496,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
494
496
  Accept: "application/json",
495
497
  },
496
498
  body: JSON.stringify({
497
- client_name: "Codex",
499
+ client_name: "oh-my-pi",
498
500
  redirect_uris: [redirectUri],
499
501
  grant_types: ["authorization_code", "refresh_token"],
500
502
  response_types: ["code"],
@@ -147,21 +147,6 @@ function resolveWindowsShimPath(value: string, shimDir: string): string | null {
147
147
  return path.join(shimDir, ...suffix.split(/[\\/]+/).filter(Boolean));
148
148
  }
149
149
 
150
- function extractWindowsNpmShimTarget(content: string): string | null {
151
- const match = /"%_prog%"\s+"([^"]+)"\s+%\*/i.exec(content);
152
- return match?.[1] ?? null;
153
- }
154
-
155
- /**
156
- * Extract the shim's PATH-fallback interpreter (`SET "_prog=node"`). The
157
- * `IF EXIST` branch assigns a `%dp0%`-prefixed value, so requiring a
158
- * non-`%`-leading value picks the bare program name.
159
- */
160
- function extractWindowsNpmShimProg(content: string): string | null {
161
- const match = /SET\s+"_prog=([^%"][^"]*)"/i.exec(content);
162
- return match?.[1] ?? null;
163
- }
164
-
165
150
  async function resolveWindowsNpmShimCommand(
166
151
  command: string,
167
152
  args: readonly string[],
@@ -171,6 +156,11 @@ async function resolveWindowsNpmShimCommand(
171
156
  if (!isWindowsBatchCommand(command)) return null;
172
157
  if (!hasPathSegment(command)) return null;
173
158
  const commandPath = path.resolve(cwd, command);
159
+ const commandName = path
160
+ .basename(commandPath)
161
+ .replace(/\.cmd$/i, "")
162
+ .toLowerCase();
163
+ if (commandName === "npx") return null;
174
164
 
175
165
  let content: string;
176
166
  try {
@@ -181,7 +171,9 @@ async function resolveWindowsNpmShimCommand(
181
171
 
182
172
  // cmd-shim emits the same invocation line for every interpreter; only
183
173
  // bypass cmd.exe when the shim's fallback interpreter is actually node.
184
- const prog = extractWindowsNpmShimProg(content);
174
+ // The IF EXIST branch assigns a %dp0%-prefixed value, so requiring a
175
+ // non-%-leading SET value picks the bare PATH-fallback program name.
176
+ const prog = /SET\s+"_prog=([^%"][^"]*)"/i.exec(content)?.[1];
185
177
  if (
186
178
  !prog ||
187
179
  path
@@ -191,7 +183,7 @@ async function resolveWindowsNpmShimCommand(
191
183
  )
192
184
  return null;
193
185
 
194
- const rawTarget = extractWindowsNpmShimTarget(content);
186
+ const rawTarget = /"%_prog%"\s+"([^"]+)"\s+%\*/i.exec(content)?.[1];
195
187
  if (!rawTarget) return null;
196
188
 
197
189
  const target = resolveWindowsShimPath(rawTarget, path.dirname(commandPath));
package/src/mcp/types.ts CHANGED
@@ -111,7 +111,10 @@ export const MCP_CONFIG_SCHEMA_URL =
111
111
  export interface MCPConfigFile {
112
112
  $schema?: string;
113
113
  mcpServers?: Record<string, MCPServerConfig>;
114
+ /** Names to hide regardless of any source `enabled` flag. Highest precedence. */
114
115
  disabledServers?: string[];
116
+ /** Names to force-enable when a non-writable source reports `enabled: false`. */
117
+ enabledServers?: string[];
115
118
  }
116
119
 
117
120
  // =============================================================================
@@ -24,7 +24,9 @@ import {
24
24
  truncateToWidth,
25
25
  visibleWidth,
26
26
  } from "@oh-my-pi/pi-tui";
27
+ import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
27
28
  import { Settings } from "../../../config/settings";
29
+ import { setMcpServerEnabled } from "../../../mcp/config-writer";
28
30
  import { getTabBarTheme } from "../../../modes/shared";
29
31
  import { theme } from "../../../modes/theme/theme";
30
32
  import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
@@ -263,6 +265,14 @@ export class ExtensionDashboard implements Component {
263
265
  const sm = this.settings ?? Settings.instance;
264
266
  if (!sm) return;
265
267
 
268
+ // MCP toggles route through the canonical denylist in
269
+ // `~/.omp/agent/mcp.json` so `/mcp list`, the MCP runtime, and this
270
+ // dashboard agree on every server's enabled state (issue #3827).
271
+ if (extensionId.startsWith("mcp:")) {
272
+ void this.#toggleMcpExtension(extensionId, enabled, sm);
273
+ return;
274
+ }
275
+
266
276
  const disabled = ((sm.get("disabledExtensions") as string[]) ?? []).slice();
267
277
  if (enabled) {
268
278
  const index = disabled.indexOf(extensionId);
@@ -281,6 +291,42 @@ export class ExtensionDashboard implements Component {
281
291
  void this.#refreshFromState();
282
292
  }
283
293
 
294
+ async #toggleMcpExtension(extensionId: string, enabled: boolean, sm: Settings): Promise<void> {
295
+ const name = extensionId.slice("mcp:".length);
296
+ try {
297
+ await setMcpServerEnabled({
298
+ userPath: getMCPConfigPath("user", this.cwd),
299
+ projectPath: getMCPConfigPath("project", this.cwd),
300
+ sourcePath: this.#writableMcpSourcePath(extensionId),
301
+ name,
302
+ enabled,
303
+ });
304
+ } catch (error) {
305
+ logger.warn("Failed to persist MCP toggle", { name, enabled, error: String(error) });
306
+ }
307
+
308
+ // Reconcile `settings.disabledExtensions` with the canonical mcp.json
309
+ // state so a legacy `mcp:<name>` flag from before this routing change
310
+ // doesn't keep the server marked disabled after the user re-enables it
311
+ // via the UI.
312
+ const stored = ((sm.get("disabledExtensions") as string[]) ?? []).slice();
313
+ const had = stored.indexOf(extensionId);
314
+ if (enabled && had !== -1) {
315
+ stored.splice(had, 1);
316
+ sm.set("disabledExtensions", stored);
317
+ this.#applyDisabledExtensions(stored);
318
+ }
319
+
320
+ await this.#refreshFromState();
321
+ }
322
+
323
+ #writableMcpSourcePath(extensionId: string): string | undefined {
324
+ const extension = this.#state.extensions.find(ext => ext.id === extensionId);
325
+ if (!extension) return undefined;
326
+ if (extension.source.provider !== "native" && extension.source.provider !== "mcp-json") return undefined;
327
+ return extension.path;
328
+ }
329
+
284
330
  async #refreshFromState(): Promise<void> {
285
331
  const refreshToken = ++this.#refreshToken;
286
332
  // Remember the current tab so it survives the re-sort.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import * as path from "node:path";
6
6
  import { fuzzyMatch } from "@oh-my-pi/pi-tui";
7
- import { logger } from "@oh-my-pi/pi-utils";
7
+ import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
8
8
  import type { ContextFile } from "../../../capability/context-file";
9
9
  import type { ExtensionModule } from "../../../capability/extension-module";
10
10
  import type { Hook } from "../../../capability/hook";
@@ -22,6 +22,7 @@ import {
22
22
  isProviderEnabled,
23
23
  loadCapability,
24
24
  } from "../../../discovery";
25
+ import { readDisabledServers, readEnabledServers } from "../../../mcp/config-writer";
25
26
  import type {
26
27
  DashboardState,
27
28
  Extension,
@@ -141,12 +142,32 @@ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): P
141
142
  logger.warn("Failed to load extension-modules capability", { error: String(error) });
142
143
  }
143
144
 
144
- // Load MCP servers
145
+ // Load MCP servers. The dashboard mirrors `/mcp list` (issue #3827) by
146
+ // honoring the same disable signals: the dashboard-private settings list,
147
+ // the per-server `enabled: false` flag, and the user-level `disabledServers`
148
+ // denylist that `/mcp disable` writes through `setServerDisabled`. The
149
+ // user-level `enabledServers` allowlist overrides a non-writable source's
150
+ // `enabled: false` (e.g. opencode.json) but never the denylist.
145
151
  try {
152
+ const userMcpPath = cwd ? getMCPConfigPath("user", cwd) : undefined;
153
+ const [mcpDisabledNames, mcpForcedEnabled] = await Promise.all([
154
+ userMcpPath
155
+ ? readDisabledServers(userMcpPath)
156
+ .then(list => new Set(list))
157
+ .catch(() => new Set<string>())
158
+ : Promise.resolve(new Set<string>()),
159
+ userMcpPath
160
+ ? readEnabledServers(userMcpPath)
161
+ .then(list => new Set(list))
162
+ .catch(() => new Set<string>())
163
+ : Promise.resolve(new Set<string>()),
164
+ ]);
146
165
  const mcps = await loadCapability<MCPServer>("mcps", loadOpts);
147
166
  for (const server of mcps.all) {
148
167
  const id = makeExtensionId("mcp", server.name);
149
- const isDisabled = disabledExtensions.has(id);
168
+ const forced = mcpForcedEnabled.has(server.name);
169
+ const sourceSaysDisabled = server.enabled === false && !forced;
170
+ const isDisabled = mcpDisabledNames.has(server.name) || disabledExtensions.has(id) || sourceSaysDisabled;
150
171
  const isShadowed = (server as { _shadowed?: boolean })._shadowed;
151
172
  const providerEnabled = isProviderEnabled(server._source.provider);
152
173
 
@@ -50,6 +50,15 @@ const MAX_LIVE_IRC_CARDS = 4;
50
50
  const IDLE_RECAP_MIN_SECONDS = 1;
51
51
  const IDLE_RECAP_MAX_SECONDS = 3600;
52
52
 
53
+ const RAW_PARTIAL_JSON_RENDERERS: Record<string, true> = { bash: true, edit: true, apply_patch: true };
54
+
55
+ function exposesRawPartialJson(toolName: string, rawInput: boolean, tool: unknown): boolean {
56
+ if (rawInput) return true;
57
+ if (RAW_PARTIAL_JSON_RENDERERS[toolName]) return true;
58
+ if (tool === null || typeof tool !== "object" || !("renderCall" in tool)) return false;
59
+ return typeof tool.renderCall === "function";
60
+ }
61
+
53
62
  type AgentSessionEventHandlers = {
54
63
  [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
55
64
  };
@@ -623,7 +632,7 @@ export class EventController {
623
632
  // Internal URL read falls through to ToolExecutionComponent below.
624
633
  }
625
634
 
626
- // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
635
+ // Preserve the raw partial JSON only for renderers that need to surface fields before the JSON object closes.
627
636
  // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
628
637
  // While the JSON is still open, ToolArgsRevealController paces the
629
638
  // reveal (write/edit/bash previews grow smoothly when a slow provider
@@ -631,13 +640,14 @@ export class EventController {
631
640
  // as-is — mirroring how assistant text snaps at message_end.
632
641
  let renderArgs: Record<string, unknown>;
633
642
  const partialJson = getStreamingPartialJson(content);
643
+ const rawInput = content.customWireName !== undefined;
644
+ const tool = this.ctx.viewSession.getToolByName(content.name);
634
645
  if (partialJson) {
635
- renderArgs = this.#toolArgsReveal.setTarget(
636
- content.id,
637
- partialJson,
638
- content.customWireName !== undefined,
639
- content.arguments,
640
- );
646
+ renderArgs = this.#toolArgsReveal.setTarget(content.id, partialJson, {
647
+ rawInput,
648
+ exposeRawPartialJson: exposesRawPartialJson(content.name, rawInput, tool),
649
+ fullArgs: content.arguments,
650
+ });
641
651
  } else {
642
652
  this.#toolArgsReveal.finish(content.id);
643
653
  renderArgs = content.arguments;
@@ -645,7 +655,6 @@ export class EventController {
645
655
  if (!this.ctx.pendingTools.has(content.id)) {
646
656
  this.#resolveDisplaceablePoll(content.name);
647
657
  this.#resetReadGroup();
648
- const tool = this.ctx.viewSession.getToolByName(content.name);
649
658
  const component = new ToolExecutionComponent(
650
659
  content.name,
651
660
  renderArgs,
@@ -881,6 +890,13 @@ export class EventController {
881
890
  }
882
891
 
883
892
  async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
893
+ // A transient overlay (auto-compaction / auto-retry / handoff) that ran
894
+ // between this tool's start and end could have detached the working
895
+ // loader. `tool_execution_update` already reconciles this so the spinner
896
+ // reappears mid-tool; mirror it here so subagent (`task`) completions —
897
+ // which only fire `tool_execution_end`, never `_update` — do not leave
898
+ // the UI looking idle while the session keeps streaming (#3857).
899
+ this.#ensureWorkingLoaderWhileStreaming();
884
900
  if (event.toolName === "read") {
885
901
  if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
886
902
  const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -1,4 +1,4 @@
1
- import { parseStreamingJson } from "@oh-my-pi/pi-utils";
1
+ import { parseStreamingJson, parseStreamingJsonThrottled, STREAMING_JSON_PARSE_MIN_GROWTH } from "@oh-my-pi/pi-utils";
2
2
  import { nextStep, STREAMING_REVEAL_FRAME_MS } from "./streaming-reveal";
3
3
 
4
4
  /** Minimal component surface the reveal pushes frames into. */
@@ -19,6 +19,16 @@ type RevealEntry = {
19
19
  revealed: number;
20
20
  /** Custom-tool raw input: display args are `{ input: prefix }`, never parsed as JSON. */
21
21
  rawInput: boolean;
22
+ /** Whether the renderer observes fresh raw JSON prefixes directly. */
23
+ exposeRawPartialJson: boolean;
24
+ /** Last parsed JSON args from the revealed prefix. */
25
+ parsedArgs: Record<string, unknown>;
26
+ /** Prefix length covered by `parsedArgs`. */
27
+ parsedLen: number;
28
+ /** Last object handed to a component; reused when visible args have not changed. */
29
+ displayArgs: Record<string, unknown>;
30
+ /** Raw prefix carried by `displayArgs.__partialJson`. */
31
+ displayPrefix: string;
22
32
  };
23
33
 
24
34
  /** Clamp a slice end into `text`, never splitting a surrogate pair: a prefix
@@ -32,15 +42,64 @@ function clampSliceEnd(text: string, end: number): number {
32
42
  return code >= 0xd800 && code <= 0xdbff ? end + 1 : end;
33
43
  }
34
44
 
35
- /** Display args for a revealed raw-stream prefix. Function-tool prefixes are
36
- * re-parsed with the same streaming-tolerant parser providers use, so every
37
- * frame is a state the provider itself could have produced; custom tools
38
- * mirror the provider's `{ input }` shape. `__partialJson` carries the
39
- * matching raw prefix for renderers that read it directly (bash env preview,
40
- * edit strategies). */
41
- function buildDisplayArgs(prefix: string, rawInput: boolean): Record<string, unknown> {
42
- const base: Record<string, unknown> = rawInput ? { input: prefix } : parseStreamingJson(prefix);
43
- return { ...base, __partialJson: prefix };
45
+ type ToolArgsRevealTarget = {
46
+ rawInput: boolean;
47
+ exposeRawPartialJson: boolean;
48
+ fullArgs: Record<string, unknown>;
49
+ };
50
+
51
+ type DisplayArgsStep = {
52
+ args: Record<string, unknown>;
53
+ changed: boolean;
54
+ };
55
+
56
+ function initialDisplayArgs(): Record<string, unknown> {
57
+ return { __partialJson: "" };
58
+ }
59
+
60
+ function resetDisplayState(entry: RevealEntry): void {
61
+ entry.parsedArgs = {};
62
+ entry.parsedLen = 0;
63
+ entry.displayArgs = initialDisplayArgs();
64
+ entry.displayPrefix = "";
65
+ }
66
+
67
+ /** Display args for a revealed prefix. Function-tool JSON is parsed at the same
68
+ * growth-throttled cadence providers use, so a long `write` payload cannot make
69
+ * the reveal loop re-parse the whole growing buffer every frame. Renderers that
70
+ * read raw JSON directly still receive fresh `__partialJson` prefixes; other
71
+ * renderers get a stable object reference while parsed fields are unchanged. */
72
+ function displayArgsForPrefix(entry: RevealEntry, prefix: string, forceParse = false): DisplayArgsStep {
73
+ if (entry.rawInput) {
74
+ if (prefix === entry.displayPrefix) return { args: entry.displayArgs, changed: false };
75
+ const args = { input: prefix, __partialJson: prefix };
76
+ entry.displayArgs = args;
77
+ entry.displayPrefix = prefix;
78
+ return { args, changed: true };
79
+ }
80
+
81
+ let parsedChanged = false;
82
+ if (forceParse || (prefix.length > 0 && prefix.length < STREAMING_JSON_PARSE_MIN_GROWTH)) {
83
+ entry.parsedArgs = parseStreamingJson<Record<string, unknown>>(prefix);
84
+ entry.parsedLen = prefix.length;
85
+ parsedChanged = true;
86
+ } else {
87
+ const throttled = parseStreamingJsonThrottled<Record<string, unknown>>(prefix, entry.parsedLen);
88
+ if (throttled) {
89
+ entry.parsedArgs = throttled.value;
90
+ entry.parsedLen = throttled.parsedLen;
91
+ parsedChanged = true;
92
+ }
93
+ }
94
+
95
+ const rawPrefixChanged = entry.exposeRawPartialJson && prefix !== entry.displayPrefix;
96
+ if (!parsedChanged && !rawPrefixChanged) return { args: entry.displayArgs, changed: false };
97
+
98
+ const displayPrefix = entry.exposeRawPartialJson || parsedChanged ? prefix : entry.displayPrefix;
99
+ const args = { ...entry.parsedArgs, __partialJson: displayPrefix };
100
+ entry.displayArgs = args;
101
+ entry.displayPrefix = displayPrefix;
102
+ return { args, changed: true };
44
103
  }
45
104
 
46
105
  /**
@@ -49,7 +108,9 @@ function buildDisplayArgs(prefix: string, rawInput: boolean): Record<string, unk
49
108
  * (or throttle their partial parses) would otherwise make write/edit/bash
50
109
  * streaming previews jump in chunks. Each pending tool call reveals its raw
51
110
  * argument stream at the shared 30fps cadence with the same adaptive
52
- * catch-up step, re-parsing the revealed prefix per frame.
111
+ * catch-up step. JSON prefixes are parsed only when enough new bytes arrive to
112
+ * change renderer-visible fields, while raw-prefix consumers still receive
113
+ * fresh `__partialJson` on every reveal frame.
53
114
  *
54
115
  * Reveal units are UTF-16 code units of the raw stream, not graphemes —
55
116
  * the prefix goes through a JSON parser rather than straight to the screen,
@@ -71,12 +132,8 @@ export class ToolArgsRevealController {
71
132
  * args to render right now. With smoothing disabled the full target passes
72
133
  * through in the caller's legacy shape (`{ ...args, __partialJson }`).
73
134
  */
74
- setTarget(
75
- id: string,
76
- partialJson: string,
77
- rawInput: boolean,
78
- fullArgs: Record<string, unknown>,
79
- ): Record<string, unknown> {
135
+ setTarget(id: string, partialJson: string, target: ToolArgsRevealTarget): Record<string, unknown> {
136
+ const { rawInput, exposeRawPartialJson, fullArgs } = target;
80
137
  if (!this.#getSmoothStreaming()) {
81
138
  // Toggle may flip mid-call: drop any live entry so ticks stop.
82
139
  this.#entries.delete(id);
@@ -84,18 +141,34 @@ export class ToolArgsRevealController {
84
141
  }
85
142
  let entry = this.#entries.get(id);
86
143
  if (!entry) {
87
- entry = { component: undefined, target: partialJson, revealed: 0, rawInput };
144
+ entry = {
145
+ component: undefined,
146
+ target: partialJson,
147
+ revealed: 0,
148
+ rawInput,
149
+ exposeRawPartialJson,
150
+ parsedArgs: {},
151
+ parsedLen: 0,
152
+ displayArgs: initialDisplayArgs(),
153
+ displayPrefix: "",
154
+ };
88
155
  this.#entries.set(id, entry);
89
156
  } else {
157
+ if (entry.rawInput !== rawInput || entry.exposeRawPartialJson !== exposeRawPartialJson) {
158
+ entry.rawInput = rawInput;
159
+ entry.exposeRawPartialJson = exposeRawPartialJson;
160
+ resetDisplayState(entry);
161
+ }
90
162
  // Streams only append; a non-prefix target means a rewind — snap into range.
91
163
  if (!partialJson.startsWith(entry.target)) {
92
164
  entry.revealed = Math.min(entry.revealed, partialJson.length);
165
+ resetDisplayState(entry);
93
166
  }
94
167
  entry.target = partialJson;
95
168
  }
96
169
  entry.revealed = clampSliceEnd(entry.target, entry.revealed);
97
170
  this.#syncTimer();
98
- return buildDisplayArgs(entry.target.slice(0, entry.revealed), entry.rawInput);
171
+ return displayArgsForPrefix(entry, entry.target.slice(0, entry.revealed)).args;
99
172
  }
100
173
 
101
174
  /** Attach the component future ticks push frames into. */
@@ -118,7 +191,7 @@ export class ToolArgsRevealController {
118
191
  flushAll(): void {
119
192
  for (const [id, entry] of this.#entries) {
120
193
  if (entry.component && entry.revealed < entry.target.length) {
121
- entry.component.updateArgs(buildDisplayArgs(entry.target, entry.rawInput), id);
194
+ entry.component.updateArgs(displayArgsForPrefix(entry, entry.target, true).args, id);
122
195
  }
123
196
  }
124
197
  this.#entries.clear();
@@ -157,15 +230,20 @@ export class ToolArgsRevealController {
157
230
 
158
231
  #tick(): void {
159
232
  let advanced = false;
233
+ let rendered = false;
160
234
  for (const [id, entry] of this.#entries) {
161
235
  const backlog = entry.target.length - entry.revealed;
162
236
  if (backlog <= 0 || !entry.component) continue;
163
237
  entry.revealed = clampSliceEnd(entry.target, entry.revealed + nextStep(backlog));
164
- entry.component.updateArgs(buildDisplayArgs(entry.target.slice(0, entry.revealed), entry.rawInput), id);
238
+ const display = displayArgsForPrefix(entry, entry.target.slice(0, entry.revealed));
239
+ if (display.changed) {
240
+ entry.component.updateArgs(display.args, id);
241
+ rendered = true;
242
+ }
165
243
  advanced = true;
166
244
  }
167
245
  if (advanced) {
168
- this.#requestRender();
246
+ if (rendered) this.#requestRender();
169
247
  } else {
170
248
  // Every entry caught up (or unbound); setTarget restarts on growth.
171
249
  this.#stopTimer();
@@ -99,9 +99,9 @@ export function buildFileMentionBlock(files: FileMentionMessage["files"], indent
99
99
  const block = new TranscriptBlock();
100
100
  for (const file of files) {
101
101
  let suffix: string;
102
- if (file.skippedReason === "tooLarge") {
102
+ if (file.skippedReason === "tooLarge" || file.skippedReason === "binary") {
103
103
  const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
104
- suffix = `(skipped: ${size})`;
104
+ suffix = file.skippedReason === "binary" ? `(skipped: binary, ${size})` : `(skipped: ${size})`;
105
105
  } else {
106
106
  suffix = file.image
107
107
  ? "(image)"