@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.
- package/CHANGELOG.md +41 -1
- package/dist/cli.js +3089 -3104
- package/dist/types/cli/bench-cli.d.ts +6 -1
- package/dist/types/commands/bench.d.ts +8 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/service-tier.d.ts +39 -24
- package/dist/types/config/settings-schema.d.ts +37 -37
- package/dist/types/edit/index.d.ts +1 -0
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/edit/snapshot-details.d.ts +33 -0
- package/dist/types/mcp/config-writer.d.ts +48 -0
- package/dist/types/mcp/oauth-flow.d.ts +4 -6
- package/dist/types/mcp/types.d.ts +3 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
- package/dist/types/session/agent-session.d.ts +37 -13
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +2 -2
- package/dist/types/session/session-entries.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +5 -6
- package/dist/types/system-prompt.test.d.ts +1 -0
- package/dist/types/task/executor.d.ts +6 -6
- package/dist/types/task/types.d.ts +6 -0
- package/dist/types/task/worktree.d.ts +32 -6
- package/dist/types/tiny/title-client.d.ts +5 -1
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/utils/git.d.ts +17 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/cli/args.ts +32 -1
- package/src/cli/bench-cli.ts +97 -22
- package/src/cli/tiny-models-cli.ts +18 -4
- package/src/cli/web-search-cli.ts +6 -1
- package/src/commands/bench.ts +10 -1
- package/src/config/mcp-schema.json +11 -2
- package/src/config/model-discovery.ts +66 -8
- package/src/config/model-registry.ts +13 -6
- package/src/config/service-tier.ts +85 -56
- package/src/config/settings-schema.ts +42 -36
- package/src/config/settings.ts +47 -0
- package/src/edit/hashline/execute.ts +15 -9
- package/src/edit/index.ts +19 -6
- package/src/edit/modes/patch.ts +3 -2
- package/src/edit/modes/replace.ts +3 -2
- package/src/edit/renderer.ts +4 -0
- package/src/edit/snapshot-details.ts +77 -0
- package/src/eval/agent-bridge.ts +4 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +1 -1
- package/src/mcp/config-writer.ts +121 -0
- package/src/mcp/config.ts +10 -6
- package/src/mcp/oauth-flow.ts +10 -8
- package/src/mcp/transports/stdio.ts +9 -17
- package/src/mcp/types.ts +3 -0
- package/src/modes/components/extensions/extension-dashboard.ts +46 -0
- package/src/modes/components/extensions/state-manager.ts +24 -3
- package/src/modes/controllers/event-controller.ts +24 -8
- package/src/modes/controllers/tool-args-reveal.ts +100 -22
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/prompts/bench.md +4 -10
- package/src/prompts/tools/irc.md +19 -29
- package/src/prompts/tools/job.md +8 -14
- package/src/prompts/tools/lsp.md +19 -30
- package/src/prompts/tools/task.md +42 -62
- package/src/sdk.ts +13 -11
- package/src/session/agent-session.ts +196 -76
- package/src/session/messages.ts +1 -1
- package/src/session/session-context.ts +4 -4
- package/src/session/session-entries.ts +2 -2
- package/src/session/session-listing.ts +9 -8
- package/src/session/session-loader.ts +98 -3
- package/src/session/session-manager.ts +43 -6
- package/src/slash-commands/builtin-registry.ts +2 -10
- package/src/subprocess/worker-client.ts +12 -4
- package/src/system-prompt.test.ts +158 -0
- package/src/system-prompt.ts +69 -26
- package/src/task/executor.ts +23 -16
- package/src/task/index.ts +7 -5
- package/src/task/isolation-runner.ts +15 -1
- package/src/task/types.ts +6 -0
- package/src/task/worktree.ts +219 -38
- package/src/tiny/title-client.ts +19 -13
- package/src/tools/index.ts +3 -3
- package/src/tools/irc.ts +9 -3
- package/src/tools/path-utils.ts +4 -2
- package/src/tools/read.ts +28 -28
- package/src/utils/file-mentions.ts +10 -1
- package/src/utils/git.ts +38 -0
- package/src/web/search/index.ts +14 -8
- package/src/web/search/providers/duckduckgo.ts +150 -78
- package/src/web/search/providers/gemini.ts +268 -185
- 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
|
-
"
|
|
143
|
+
"tier.advisor",
|
|
144
144
|
];
|
|
145
145
|
|
|
146
146
|
const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [
|
package/src/mcp/config-writer.ts
CHANGED
|
@@ -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
|
|
109
|
-
|
|
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 (
|
|
116
|
-
|
|
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
|
}
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -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.
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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: "
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
content.
|
|
639
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)"
|