@oh-my-pi/pi-coding-agent 16.2.6 → 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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli.js +2656 -2651
  3. package/dist/types/cli/bench-cli.d.ts +3 -3
  4. package/dist/types/commands/bench.d.ts +1 -1
  5. package/dist/types/config/service-tier.d.ts +39 -24
  6. package/dist/types/config/settings-schema.d.ts +36 -36
  7. package/dist/types/mcp/config-writer.d.ts +48 -0
  8. package/dist/types/mcp/types.d.ts +3 -0
  9. package/dist/types/session/agent-session.d.ts +33 -13
  10. package/dist/types/session/messages.d.ts +1 -1
  11. package/dist/types/session/session-context.d.ts +2 -2
  12. package/dist/types/session/session-entries.d.ts +2 -2
  13. package/dist/types/session/session-manager.d.ts +2 -2
  14. package/dist/types/system-prompt.test.d.ts +1 -0
  15. package/dist/types/task/executor.d.ts +6 -6
  16. package/dist/types/task/types.d.ts +6 -0
  17. package/dist/types/task/worktree.d.ts +32 -6
  18. package/dist/types/tiny/title-client.d.ts +5 -1
  19. package/dist/types/tools/index.d.ts +3 -3
  20. package/dist/types/utils/git.d.ts +17 -0
  21. package/package.json +12 -12
  22. package/src/cli/bench-cli.ts +19 -12
  23. package/src/cli/tiny-models-cli.ts +18 -4
  24. package/src/commands/bench.ts +3 -3
  25. package/src/config/mcp-schema.json +10 -1
  26. package/src/config/service-tier.ts +85 -56
  27. package/src/config/settings-schema.ts +42 -36
  28. package/src/config/settings.ts +47 -0
  29. package/src/eval/agent-bridge.ts +4 -2
  30. package/src/internal-urls/docs-index.generated.txt +1 -1
  31. package/src/main.ts +1 -1
  32. package/src/mcp/config-writer.ts +121 -0
  33. package/src/mcp/config.ts +10 -6
  34. package/src/mcp/types.ts +3 -0
  35. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  36. package/src/modes/components/extensions/state-manager.ts +24 -3
  37. package/src/modes/controllers/event-controller.ts +7 -0
  38. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  39. package/src/sdk.ts +12 -11
  40. package/src/session/agent-session.ts +186 -76
  41. package/src/session/messages.ts +1 -1
  42. package/src/session/session-context.ts +4 -4
  43. package/src/session/session-entries.ts +2 -2
  44. package/src/session/session-manager.ts +9 -2
  45. package/src/slash-commands/builtin-registry.ts +2 -10
  46. package/src/system-prompt.test.ts +158 -0
  47. package/src/system-prompt.ts +69 -26
  48. package/src/task/executor.ts +23 -16
  49. package/src/task/index.ts +7 -5
  50. package/src/task/isolation-runner.ts +15 -1
  51. package/src/task/types.ts +6 -0
  52. package/src/task/worktree.ts +219 -38
  53. package/src/tiny/title-client.ts +19 -13
  54. package/src/tools/index.ts +3 -3
  55. package/src/tools/irc.ts +9 -3
  56. package/src/tools/read.ts +28 -28
  57. package/src/utils/file-mentions.ts +10 -1
  58. package/src/utils/git.ts +38 -0
  59. package/src/web/search/providers/duckduckgo.ts +17 -3
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
  }
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
 
@@ -890,6 +890,13 @@ export class EventController {
890
890
  }
891
891
 
892
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();
893
900
  if (event.toolName === "read") {
894
901
  if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
895
902
  const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -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)"
package/src/sdk.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  resolveModelRoleValue,
43
43
  } from "./config/model-resolver";
44
44
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
45
+ import { buildServiceTierByFamily } from "./config/service-tier";
45
46
  import { Settings, type SkillsSettings } from "./config/settings";
46
47
  import { CursorExecHandlers } from "./cursor";
47
48
  import "./discovery";
@@ -1537,7 +1538,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1537
1538
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
1538
1539
  getActiveModelString,
1539
1540
  getActiveModel: () => agent?.state.model ?? model,
1540
- getServiceTier: () => session?.serviceTier,
1541
+ getServiceTierByFamily: () => session?.serviceTierByFamily,
1541
1542
  getImageAttachments: () => session?.getImageAttachments() ?? [],
1542
1543
  getPlanModeState: () => session?.getPlanModeState(),
1543
1544
  getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md",
@@ -2525,13 +2526,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2525
2526
  const openaiWebsocketSetting = settings.get("providers.openaiWebsockets") ?? "off";
2526
2527
  const preferOpenAICodexWebsockets =
2527
2528
  openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined;
2528
- const serviceTierSetting = settings.get("serviceTier");
2529
-
2530
- const initialServiceTier = hasServiceTierEntry
2531
- ? existingSession.serviceTier
2532
- : serviceTierSetting === "none"
2533
- ? undefined
2534
- : serviceTierSetting;
2529
+ const initialServiceTierByFamily = hasServiceTierEntry
2530
+ ? (existingSession.serviceTier ?? {})
2531
+ : buildServiceTierByFamily(
2532
+ settings.get("tier.openai"),
2533
+ settings.get("tier.anthropic"),
2534
+ settings.get("tier.google"),
2535
+ );
2535
2536
 
2536
2537
  // One-shot launch-latency marker: fired the first time the loop dispatches
2537
2538
  // a chat request to the provider transport. See onFirstChatDispatch.
@@ -2579,7 +2580,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2579
2580
  minP: settings.get("minP") >= 0 ? settings.get("minP") : undefined,
2580
2581
  presencePenalty: settings.get("presencePenalty") >= 0 ? settings.get("presencePenalty") : undefined,
2581
2582
  repetitionPenalty: settings.get("repetitionPenalty") >= 0 ? settings.get("repetitionPenalty") : undefined,
2582
- serviceTier: initialServiceTier,
2583
2583
  hideThinkingSummary: settings.get("omitThinking"),
2584
2584
  kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
2585
2585
  preferWebsockets: preferOpenAICodexWebsockets,
@@ -2639,8 +2639,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2639
2639
  // classification persists its concrete effort once a real user turn runs.
2640
2640
  sessionManager.appendThinkingLevelChange(effectiveThinkingLevel);
2641
2641
  }
2642
- if (initialServiceTier) {
2643
- sessionManager.appendServiceTierChange(initialServiceTier);
2642
+ if (Object.keys(initialServiceTierByFamily).length > 0) {
2643
+ sessionManager.appendServiceTierChange(initialServiceTierByFamily);
2644
2644
  }
2645
2645
  }
2646
2646
 
@@ -2691,6 +2691,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2691
2691
  agent,
2692
2692
  pruneToolDescriptions: inlineToolDescriptors,
2693
2693
  thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel,
2694
+ serviceTierByFamily: initialServiceTierByFamily,
2694
2695
  sessionManager,
2695
2696
  settings,
2696
2697
  autoApprove: options.autoApprove,