@oh-my-pi/pi-coding-agent 16.1.22 → 16.1.23

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 (45) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/cli.js +3441 -3300
  3. package/dist/types/cli/gc-cli.d.ts +58 -0
  4. package/dist/types/commands/gc.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +54 -0
  6. package/dist/types/config/settings.d.ts +11 -0
  7. package/dist/types/mcp/transports/stdio.d.ts +25 -1
  8. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
  9. package/dist/types/modes/theme/theme.d.ts +1 -0
  10. package/dist/types/session/session-listing.d.ts +10 -1
  11. package/dist/types/system-prompt.d.ts +2 -0
  12. package/dist/types/tools/plan-mode-guard.d.ts +7 -0
  13. package/package.json +12 -12
  14. package/scripts/bench-guard.ts +1 -1
  15. package/src/cli/gc-cli.ts +939 -0
  16. package/src/cli-commands.ts +1 -0
  17. package/src/commands/gc.ts +46 -0
  18. package/src/config/settings-schema.ts +45 -0
  19. package/src/config/settings.ts +44 -6
  20. package/src/edit/hashline/filesystem.ts +11 -6
  21. package/src/eval/__tests__/julia-prelude.test.ts +18 -0
  22. package/src/eval/jl/runner.jl +7 -1
  23. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
  24. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +6 -0
  25. package/src/internal-urls/docs-index.generated.txt +1 -1
  26. package/src/lsp/index.ts +8 -1
  27. package/src/mcp/oauth-discovery.ts +20 -20
  28. package/src/mcp/transports/stdio.test.ts +20 -3
  29. package/src/mcp/transports/stdio.ts +45 -14
  30. package/src/modes/controllers/input-controller.ts +2 -2
  31. package/src/modes/controllers/selector-controller.ts +10 -0
  32. package/src/modes/interactive-mode.ts +26 -9
  33. package/src/modes/theme/mermaid-rendering.test.ts +53 -0
  34. package/src/modes/theme/theme.ts +33 -14
  35. package/src/prompts/system/plan-mode-active.md +9 -0
  36. package/src/prompts/system/system-prompt.md +2 -0
  37. package/src/sdk.ts +43 -4
  38. package/src/session/agent-session.ts +323 -62
  39. package/src/session/session-listing.ts +35 -2
  40. package/src/slash-commands/builtin-registry.ts +20 -2
  41. package/src/system-prompt.ts +4 -0
  42. package/src/tools/acp-bridge.ts +6 -1
  43. package/src/tools/plan-mode-guard.ts +26 -13
  44. package/src/utils/edit-mode.ts +19 -2
  45. package/src/utils/shell-snapshot.ts +1 -1
package/src/lsp/index.ts CHANGED
@@ -2054,7 +2054,14 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
2054
2054
  }
2055
2055
 
2056
2056
  if (action === "reload" && (isWorkspace || !resolvedFile)) {
2057
- const servers = getLspServers(config);
2057
+ // `reload *` is the user's explicit request to re-read config from
2058
+ // disk. Drop the per-cwd cache entry so `.omp/lsp.json`, root markers,
2059
+ // and plugin configs added after the first LSP call become visible —
2060
+ // otherwise `getConfig` returns the first observation for the rest of
2061
+ // the process lifetime (#3546).
2062
+ configCache.delete(this.session.cwd);
2063
+ const refreshedConfig = getConfig(this.session.cwd);
2064
+ const servers = getLspServers(refreshedConfig);
2058
2065
  if (servers.length === 0) {
2059
2066
  return {
2060
2067
  content: [{ type: "text", text: "No language server found for this action" }],
@@ -308,10 +308,15 @@ export async function discoverOAuthEndpoints(
308
308
  "/.mcp/auth",
309
309
  "/authorize", // Some MCP servers expose OAuth config here
310
310
  ];
311
- const urlsToQuery: string[] = [];
311
+ const urlsToQuery: Array<{ url: string; issuerCandidate: boolean }> = [];
312
312
  const visitedAuthServers = new Set<string>();
313
313
 
314
314
  let protectedResource = opts?.protectedResource;
315
+ const addDiscoveryBase = (url: string | undefined, issuerCandidate: boolean): void => {
316
+ if (!url || visitedAuthServers.has(url)) return;
317
+ urlsToQuery.push({ url, issuerCandidate });
318
+ visitedAuthServers.add(url);
319
+ };
315
320
 
316
321
  // Step 1: If a resource_metadata URL was provided, fetch it to discover auth servers.
317
322
  // This follows the RFC 9728 chain: resource_metadata → authorization_servers.
@@ -332,10 +337,7 @@ export async function discoverOAuthEndpoints(
332
337
  ? meta.authorization_servers.filter((entry): entry is string => typeof entry === "string")
333
338
  : [];
334
339
  for (const s of authServers) {
335
- if (!visitedAuthServers.has(s)) {
336
- urlsToQuery.push(s);
337
- visitedAuthServers.add(s);
338
- }
340
+ addDiscoveryBase(s, true);
339
341
  }
340
342
  }
341
343
  } catch {
@@ -343,13 +345,9 @@ export async function discoverOAuthEndpoints(
343
345
  }
344
346
  }
345
347
 
346
- // Step 2: Add explicit authServerUrl and serverUrl (deduped against visited)
347
- for (const url of [authServerUrl, serverUrl].filter((v): v is string => Boolean(v))) {
348
- if (!visitedAuthServers.has(url)) {
349
- urlsToQuery.push(url);
350
- visitedAuthServers.add(url);
351
- }
352
- }
348
+ // Step 2: Add explicit authServerUrl as an issuer candidate, then the resource server fallback.
349
+ addDiscoveryBase(authServerUrl, true);
350
+ addDiscoveryBase(serverUrl, false);
353
351
 
354
352
  const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
355
353
  if (metadata.authorization_endpoint && metadata.token_endpoint) {
@@ -414,10 +412,10 @@ export async function discoverOAuthEndpoints(
414
412
  return null;
415
413
  };
416
414
 
417
- for (const baseUrl of urlsToQuery) {
415
+ for (const base of urlsToQuery) {
418
416
  for (const path of wellKnownPaths) {
419
417
  // Try each well-known path at both the absolute origin and relative
420
- const urlsToTry = buildWellKnownUrls(path, baseUrl);
418
+ const urlsToTry = buildWellKnownUrls(path, base.url);
421
419
  for (const url of urlsToTry) {
422
420
  try {
423
421
  const response = await fetchImpl(url.toString(), {
@@ -429,13 +427,15 @@ export async function discoverOAuthEndpoints(
429
427
  if (response.ok) {
430
428
  const metadata = (await response.json()) as Record<string, unknown>;
431
429
  // Authorization-server / OpenID Connect metadata documents carry an
432
- // `issuer` field that MUST equal the queried base URL (RFC 8414 §3.3,
433
- // OIDC Discovery §4.3). Protected-resource and nonstandard paths use a
434
- // different schema, so the issuer check only gates the two official
435
- // auth-server documents.
430
+ // `issuer` field that MUST equal the queried base URL only when that
431
+ // URL came from an auth-server source (RFC 8414 §3.3, OIDC Discovery
432
+ // §4.3). Resource-server fallback probes can legitimately return
433
+ // cross-host issuer metadata.
436
434
  const requireIssuerMatch =
437
- path === "/.well-known/oauth-authorization-server" || path === "/.well-known/openid-configuration";
438
- const issuerOk = requireIssuerMatch ? issuerMatchesBase(metadata.issuer, baseUrl) : true;
435
+ base.issuerCandidate &&
436
+ (path === "/.well-known/oauth-authorization-server" ||
437
+ path === "/.well-known/openid-configuration");
438
+ const issuerOk = requireIssuerMatch ? issuerMatchesBase(metadata.issuer, base.url) : true;
439
439
  const endpoints = issuerOk ? findEndpoints(metadata) : null;
440
440
  if (endpoints) return endpoints;
441
441
 
@@ -3,19 +3,35 @@ import { describe, expect, it } from "bun:test";
3
3
  import { resolveStdioSpawnCommand } from "./stdio";
4
4
 
5
5
  describe("resolveStdioSpawnCommand", () => {
6
- it("hides direct Windows executable MCP servers", async () => {
6
+ it("hides Windows executable MCP servers when the host has no console", async () => {
7
+ // Hidden so a console-app child does not allocate a visible window when
8
+ // OMP is launched without a terminal console (#3536).
7
9
  await expect(
8
10
  resolveStdioSpawnCommand(
9
11
  { command: "server.exe", args: ["--stdio"] },
10
- { cwd: process.cwd(), env: {}, platform: "win32" },
12
+ { cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: false },
11
13
  ),
12
14
  ).resolves.toEqual({
13
15
  cmd: ["server.exe", "--stdio"],
14
16
  windowsHide: true,
17
+ detached: false,
15
18
  });
16
19
  });
17
20
 
18
- it("keeps off-Windows spawn options unchanged", async () => {
21
+ it("inherits an attached Windows console instead of forcing CREATE_NO_WINDOW", async () => {
22
+ await expect(
23
+ resolveStdioSpawnCommand(
24
+ { command: "server.exe", args: ["--stdio"] },
25
+ { cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: true },
26
+ ),
27
+ ).resolves.toEqual({
28
+ cmd: ["server.exe", "--stdio"],
29
+ windowsHide: false,
30
+ detached: false,
31
+ });
32
+ });
33
+
34
+ it("detaches off-Windows MCP servers so terminal job-control signals cannot stop them", async () => {
19
35
  await expect(
20
36
  resolveStdioSpawnCommand(
21
37
  { command: "server.exe", args: ["--stdio"] },
@@ -23,6 +39,7 @@ describe("resolveStdioSpawnCommand", () => {
23
39
  ),
24
40
  ).resolves.toEqual({
25
41
  cmd: ["server.exe", "--stdio"],
42
+ detached: true,
26
43
  });
27
44
  });
28
45
  });
@@ -7,9 +7,9 @@
7
7
 
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
-
11
10
  import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
12
11
  import { type Subprocess, spawn } from "bun";
12
+ import { hostHasInheritableConsole } from "../../eval/py/spawn-options";
13
13
  import type {
14
14
  JsonRpcError,
15
15
  JsonRpcMessage,
@@ -22,16 +22,40 @@ import type {
22
22
  import { toJsonRpcError } from "../../mcp/types";
23
23
  import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
24
24
 
25
- /** Subprocess argv for launching an MCP stdio server. */
25
+ /** Subprocess argv and platform-derived spawn flags for an MCP stdio server. */
26
26
  export interface StdioSpawnCommand {
27
27
  cmd: string[];
28
+ /**
29
+ * Hide the Windows console window for the direct child.
30
+ *
31
+ * Windows uses this only when the OMP host has no console to share. When
32
+ * the host is running inside a terminal, `windowsHide: true` maps to
33
+ * `CREATE_NO_WINDOW`, which strips that inheritable console from hidden
34
+ * `cmd.exe` / PowerShell wrapper chains. Their console grandchildren then
35
+ * allocate fresh visible conhost windows during startup or reconnects
36
+ * (#3567).
37
+ */
28
38
  windowsHide?: boolean;
39
+ /**
40
+ * Run the subprocess in its own session.
41
+ *
42
+ * POSIX: `true`. Detach → `setsid`, so the MCP process tree has no
43
+ * controlling terminal and terminal job-control signals (Ctrl+Z SIGTSTP,
44
+ * background-read SIGTTIN) cannot stop stdio servers such as
45
+ * `chrome-devtools-mcp` and leave our read loop blocked on silent pipes.
46
+ *
47
+ * Windows: `false`. There is no SIGTSTP/SIGTTIN to escape, and Windows
48
+ * wrapper chains must stay in the OMP console session so nested console
49
+ * grandchildren keep stdout routed through our pipe (#3544).
50
+ */
51
+ detached: boolean;
29
52
  }
30
53
 
31
54
  /** Inputs used to resolve platform-specific stdio spawn behavior. */
32
55
  export interface ResolveStdioSpawnOptions {
33
56
  cwd: string;
34
57
  env: Record<string, string | undefined>;
58
+ hostHasInheritableConsole?: boolean;
35
59
  platform?: NodeJS.Platform;
36
60
  }
37
61
 
@@ -142,6 +166,7 @@ async function resolveWindowsNpmShimCommand(
142
166
  command: string,
143
167
  args: readonly string[],
144
168
  cwd: string,
169
+ windowsHide: boolean,
145
170
  ): Promise<StdioSpawnCommand | null> {
146
171
  if (!isWindowsBatchCommand(command)) return null;
147
172
  if (!hasPathSegment(command)) return null;
@@ -176,7 +201,8 @@ async function resolveWindowsNpmShimCommand(
176
201
  const nodeCommand = (await fileExists(siblingNode)) ? siblingNode : "node";
177
202
  return {
178
203
  cmd: [nodeCommand, target, ...args],
179
- windowsHide: true,
204
+ windowsHide,
205
+ detached: false,
180
206
  };
181
207
  }
182
208
 
@@ -229,25 +255,28 @@ export async function resolveStdioSpawnCommand(
229
255
  options: ResolveStdioSpawnOptions,
230
256
  ): Promise<StdioSpawnCommand> {
231
257
  const args = config.args ?? [];
232
- if (options.platform !== "win32") return { cmd: [config.command, ...args] };
258
+ if (options.platform !== "win32") return { cmd: [config.command, ...args], detached: true };
233
259
 
260
+ const windowsHide = options.hostHasInheritableConsole === undefined ? true : !options.hostHasInheritableConsole;
234
261
  const resolved = await resolveWindowsCommandPath(config.command, options.cwd, options.env);
235
262
  const resolvedCommand = resolved ?? config.command;
236
- const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd);
263
+ const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd, windowsHide);
237
264
  if (npmShimCommand) return npmShimCommand;
238
265
 
239
266
  // Direct-spawn only when we resolved to a concrete file AND its extension
240
267
  // is not a batch script. Everything else (resolved .cmd/.bat, or an
241
268
  // unresolved extensionless command) goes through cmd.exe so PATHEXT runs.
242
- // Every Windows stdio server launch hides its console window; otherwise
243
- // direct .exe servers pop a visible cmd window while the MCP server lives.
244
- const windowsHide = true;
269
+ // Windows stdio servers stay attached so wrapper grandchildren inherit the
270
+ // same console session. Only hide the child when OMP itself has no console
271
+ // to share; CREATE_NO_WINDOW breaks console inheritance for nested wrappers.
272
+ const detached = false;
245
273
  const needsCmdExe = resolved === null || isWindowsBatchCommand(resolvedCommand);
246
- if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide };
274
+ if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
247
275
 
248
276
  return {
249
277
  cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
250
278
  windowsHide,
279
+ detached,
251
280
  };
252
281
  }
253
282
 
@@ -341,12 +370,14 @@ export class StdioTransport implements MCPTransport {
341
370
  cwd,
342
371
  env,
343
372
  platform: process.platform,
373
+ hostHasInheritableConsole: hostHasInheritableConsole(),
344
374
  });
345
375
 
346
- // Spawn in a new session (detached setsid) so the MCP process tree has
347
- // no controlling terminal. Otherwise terminal job-control signals (Ctrl+Z
348
- // SIGTSTP, background-read SIGTTIN) can stop stdio servers such as
349
- // chrome-devtools-mcp and leave our read loop blocked on silent pipes.
376
+ // Platform-derived session and console-window handling come from
377
+ // `resolveStdioSpawnCommand`: POSIX detaches into its own session to
378
+ // escape terminal job-control signals (SIGTSTP, SIGTTIN); Windows stays
379
+ // attached, and only hides the child when the host has no console to
380
+ // share. See `StdioSpawnCommand`.
350
381
  this.#process = spawn({
351
382
  cmd: spawnCommand.cmd,
352
383
  cwd,
@@ -355,7 +386,7 @@ export class StdioTransport implements MCPTransport {
355
386
  stdout: "pipe",
356
387
  stderr: "pipe",
357
388
  windowsHide: spawnCommand.windowsHide,
358
- detached: true,
389
+ detached: spawnCommand.detached,
359
390
  });
360
391
 
361
392
  this.#connected = true;
@@ -969,7 +969,7 @@ export class InputController {
969
969
  //
970
970
  // SIGTSTP: brush-core (the embedded shell behind every bash tool call)
971
971
  // installs a tokio SIGTSTP listener on `Process::wait` to detect when
972
- // its children have been stopped (`crates/brush-core-vendored/src/sys/
972
+ // its children have been stopped (`crates/vendor/brush-core/src/sys/
973
973
  // unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::
974
974
  // signal(SIGTSTP)`). Per tokio's documented contract, the first call
975
975
  // for a given SignalKind permanently replaces the kernel-default
@@ -993,7 +993,7 @@ export class InputController {
993
993
  // children that must survive the suspend (MCP stdio servers via
994
994
  // the `detached: true` spawn in `mcp/transports/stdio.ts`, every
995
995
  // brush external command via brush's per-child `setsid` in
996
- // `crates/brush-core-vendored/src/commands.rs`) are already in
996
+ // `crates/vendor/brush-core/src/commands.rs`) are already in
997
997
  // their own sessions, so pgid=0 does not reach them.
998
998
  process.kill(0, "SIGSTOP");
999
999
  } catch (err) {
@@ -22,6 +22,7 @@ import {
22
22
  getSymbolTheme,
23
23
  previewTheme,
24
24
  setColorBlindMode,
25
+ setMarkdownMermaidRendering,
25
26
  setSymbolPreset,
26
27
  setTheme,
27
28
  theme,
@@ -374,6 +375,15 @@ export class SelectorController {
374
375
  this.ctx.ui.requestRender();
375
376
  break;
376
377
 
378
+ case "tui.renderMermaid":
379
+ setMarkdownMermaidRendering(value as boolean);
380
+ this.ctx.session.refreshBaseSystemPrompt().catch(err => {
381
+ this.ctx.showError(`Failed to apply Mermaid rendering setting: ${err}`);
382
+ });
383
+ this.ctx.rebuildChatFromMessages();
384
+ this.ctx.ui.resetDisplay();
385
+ break;
386
+
377
387
  case "theme": {
378
388
  setTheme(value as string, true).then(result => {
379
389
  this.ctx.statusLine.invalidate();
@@ -167,6 +167,7 @@ import {
167
167
  getSymbolTheme,
168
168
  onTerminalAppearanceChange,
169
169
  onThemeChange,
170
+ setMarkdownMermaidRendering,
170
171
  theme,
171
172
  } from "./theme/theme";
172
173
  import type {
@@ -588,6 +589,7 @@ export class InteractiveMode implements InteractiveModeContext {
588
589
  }
589
590
 
590
591
  setTuiTight(settings.get("tui.tight"));
592
+ setMarkdownMermaidRendering(settings.get("tui.renderMermaid"));
591
593
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
592
594
  this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
593
595
  // OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
@@ -3021,16 +3023,31 @@ export class InteractiveMode implements InteractiveModeContext {
3021
3023
  // Capture the operator's tier choice and hand it to #approvePlan, which
3022
3024
  // applies it AFTER #exitPlanMode. #exitPlanMode normally restores
3023
3025
  // #planModePreviousModelState (the model from before plan mode), so
3024
- // applying the slider choice any earlier would be silently reverted
3025
- // the bug that made "continue with slow" keep executing on the default
3026
- // model. For compact-context approval, the plan model is kept through
3027
- // compaction, then a successful compaction transitions to the slider model
3028
- // (or restores the pre-plan model when no slider choice was made).
3029
- // `cycle.currentIndex` is exactly that restored model, so any chosen tier
3030
- // differing from it needs an explicit executionModel this also covers
3031
- // leaving the slider on its `default` anchor while planning ran elsewhere.
3026
+ // applying the slider choice any earlier would be silently reverted.
3027
+ // Pass executionModel only when the slider was actually shown a
3028
+ // singleton cycle (e.g. only modelRoles.plan is configured, so
3029
+ // getRoleModelCycle synthesizes a lone `default` entry from the
3030
+ // currently active plan model) hides the slider, the operator made
3031
+ // no selection, and the pre-plan model is not in the cycle. Pinning
3032
+ // that singleton would silently switch the session back to the plan
3033
+ // model after #exitPlanMode restored the pre-plan model.
3034
+ // Treat the choice as implicit only when applying the selected role
3035
+ // would land on the same end state as the restore — same model AND
3036
+ // the same effective thinking level. A role with an explicit thinking
3037
+ // suffix that differs from the restored thinking level must still go
3038
+ // through applyRoleModel, otherwise approving on the same model with a
3039
+ // different configured thinking level silently keeps the pre-plan level.
3040
+ const restoredState = this.#planModePreviousModelState;
3041
+ const restoredIndex =
3042
+ cycle && restoredState
3043
+ ? cycle.models.findIndex(entry => {
3044
+ if (!modelsAreEqual(entry.model, restoredState.model)) return false;
3045
+ if (!entry.explicitThinkingLevel) return true;
3046
+ return entry.thinkingLevel === restoredState.thinkingLevel;
3047
+ })
3048
+ : -1;
3032
3049
  const executionModel =
3033
- cycle && selectedTierIndex !== cycle.currentIndex ? cycle.models[selectedTierIndex] : undefined;
3050
+ slider && cycle && selectedTierIndex !== restoredIndex ? cycle.models[selectedTierIndex] : undefined;
3034
3051
  await this.#approvePlan(latestPlanContent, {
3035
3052
  planFilePath,
3036
3053
  title: details.title,
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Markdown } from "@oh-my-pi/pi-tui";
3
+ import { Settings } from "../../config/settings";
4
+ import { buildSystemPrompt } from "../../system-prompt";
5
+ import { getMarkdownTheme, getThemeByName, setMarkdownMermaidRendering, setThemeInstance } from "./theme";
6
+
7
+ const workspaceTree = {
8
+ rootPath: "/tmp/project",
9
+ rendered: "",
10
+ truncated: false,
11
+ totalLines: 0,
12
+ agentsMdFiles: [],
13
+ };
14
+
15
+ function stripAnsi(text: string): string {
16
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
17
+ }
18
+
19
+ beforeAll(async () => {
20
+ await Settings.init({ inMemory: true });
21
+ const theme = await getThemeByName("dark");
22
+ if (!theme) throw new Error("theme unavailable");
23
+ setThemeInstance(theme);
24
+ });
25
+
26
+ afterEach(() => {
27
+ setMarkdownMermaidRendering(true);
28
+ });
29
+
30
+ describe("Mermaid rendering setting", () => {
31
+ it("removes the Mermaid prompt note when rendering is disabled", async () => {
32
+ const { systemPrompt } = await buildSystemPrompt({
33
+ renderMermaid: false,
34
+ contextFiles: [],
35
+ skills: [],
36
+ toolNames: [],
37
+ workspaceTree,
38
+ });
39
+
40
+ expect(systemPrompt.join("\n")).not.toContain("```mermaid");
41
+ });
42
+
43
+ it("falls back to a highlighted code fence when rendering is disabled", () => {
44
+ setMarkdownMermaidRendering(false);
45
+
46
+ const markdown = new Markdown("```mermaid\ngraph TD\n A --> B\n```", 0, 0, getMarkdownTheme());
47
+ const lines = stripAnsi(markdown.render(80).join("\n"));
48
+
49
+ expect(lines).toContain("```mermaid");
50
+ expect(lines).toContain("graph TD");
51
+ expect(lines).toContain("-->");
52
+ });
53
+ });
@@ -2824,23 +2824,36 @@ export function getSymbolTheme(): SymbolTheme {
2824
2824
 
2825
2825
  let cachedMarkdownTheme: MarkdownTheme | undefined;
2826
2826
  let cachedMarkdownThemeRef: Theme | undefined;
2827
+ let markdownMermaidRendering = true;
2828
+
2829
+ export function setMarkdownMermaidRendering(enabled: boolean): void {
2830
+ if (markdownMermaidRendering === enabled) return;
2831
+ markdownMermaidRendering = enabled;
2832
+ cachedMarkdownTheme = undefined;
2833
+ }
2827
2834
 
2828
2835
  export function getMarkdownTheme(): MarkdownTheme {
2829
2836
  if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
2830
2837
  return cachedMarkdownTheme;
2831
2838
  }
2832
- // Mermaid ASCII diagrams render with the active palette so they read as
2833
- // content rather than raw monochrome. Roles mirror the SVG renderer's
2834
- // mapping; `text`/`muted`/`border`/`borderMuted`/`accent` exist in every theme.
2835
- const mermaidColorMode = theme.getColorMode() === "truecolor" ? "truecolor" : "ansi256";
2836
- const mermaidTheme = {
2837
- fg: theme.getColorHex("text"),
2838
- border: theme.getColorHex("border"),
2839
- line: theme.getColorHex("muted"),
2840
- arrow: theme.getColorHex("accent"),
2841
- corner: theme.getColorHex("muted"),
2842
- junction: theme.getColorHex("borderMuted"),
2843
- };
2839
+ const mermaid = markdownMermaidRendering
2840
+ ? (() => {
2841
+ // Mermaid ASCII diagrams render with the active palette so they read as
2842
+ // content rather than raw monochrome. Roles mirror the SVG renderer's
2843
+ // mapping; `text`/`muted`/`border`/`borderMuted`/`accent` exist in every theme.
2844
+ const mermaidColorMode =
2845
+ theme.getColorMode() === "truecolor" ? ("truecolor" as const) : ("ansi256" as const);
2846
+ const mermaidTheme = {
2847
+ fg: theme.getColorHex("text"),
2848
+ border: theme.getColorHex("border"),
2849
+ line: theme.getColorHex("muted"),
2850
+ arrow: theme.getColorHex("accent"),
2851
+ corner: theme.getColorHex("muted"),
2852
+ junction: theme.getColorHex("borderMuted"),
2853
+ };
2854
+ return { mermaidColorMode, mermaidTheme };
2855
+ })()
2856
+ : undefined;
2844
2857
  const markdownTheme: MarkdownTheme = {
2845
2858
  heading: (text: string) => theme.fg("mdHeading", text),
2846
2859
  link: (text: string) => theme.fg("mdLink", text),
@@ -2857,8 +2870,14 @@ export function getMarkdownTheme(): MarkdownTheme {
2857
2870
  underline: (text: string) => theme.underline(text),
2858
2871
  strikethrough: (text: string) => chalk.strikethrough(text),
2859
2872
  symbols: getSymbolTheme(),
2860
- resolveMermaidAscii: (source, maxWidth) =>
2861
- resolveMermaidAscii(source, { maxWidth, theme: mermaidTheme, colorMode: mermaidColorMode }),
2873
+ resolveMermaidAscii: mermaid
2874
+ ? (source, maxWidth) =>
2875
+ resolveMermaidAscii(source, {
2876
+ maxWidth,
2877
+ theme: mermaid.mermaidTheme,
2878
+ colorMode: mermaid.mermaidColorMode,
2879
+ })
2880
+ : undefined,
2862
2881
  highlightCode: (code: string, lang?: string): string[] => {
2863
2882
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2864
2883
  const highlighted = highlightCached(code, validLang, theme);
@@ -24,6 +24,15 @@ Choose a short kebab-case `<slug>` naming this task and write the plan to `local
24
24
 
25
25
  Use `{{editToolName}}` for incremental edits and `{{writeToolName}}` only to create or fully replace the file. You MUST write findings into the plan as you learn them — you NEVER batch all writing to the end.
26
26
 
27
+ {{#if isHashlineEditMode}}
28
+ Structure the plan as `##`/`###` markdown sections so you can revise it section-by-section: with `{{editToolName}}`, a heading anchors its WHOLE section (through every nested deeper heading, up to the next same-or-higher heading). Rely on the block ops to grow the plan without rewriting the file:
29
+ - `SWAP.BLK N:` on a heading line — rewrite that entire section in place.
30
+ - `DEL.BLK N` on a heading line — drop the whole section.
31
+ - `INS.BLK.POST N:` on a heading line — add a new section AFTER that one (end the inserted body with a blank line so the next heading stays separated).
32
+
33
+ Write each section together with its body — block ops need a multi-line section; a bare heading with no body falls back to plain `INS.POST`/`DEL`/`SWAP`.
34
+ {{/if}}
35
+
27
36
  ## Ground every claim
28
37
 
29
38
  You eliminate unknowns by discovering facts, not by asking.
@@ -16,7 +16,9 @@ You are a helpful assistant the team trusts with load-bearing changes, operating
16
16
  - Consider what code compiles to. NEVER allocate avoidably; no needless copies or computation.
17
17
  - You are not alone in this repo. Treat unexpected changes as the user's work and adapt.
18
18
  - In terminal prose and final chat, you MAY use LaTeX math (`$`, `$$`, `\text`, `\times`) and color (`\textcolor`, `\colorbox`, `\fcolorbox`).
19
+ {{#if renderMermaid}}
19
20
  - To show a diagram, you MAY emit a ` ```mermaid ` block — the terminal renders it as ASCII. Use it for genuine structure or flow, not trivia.
21
+ {{/if}}
20
22
 
21
23
  RUNTIME
22
24
  ==============
package/src/sdk.ts CHANGED
@@ -1223,7 +1223,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1223
1223
  const allowedModels = await logger.time("resolveAllowedModels", () =>
1224
1224
  resolveAllowedModels(modelRegistry, settings, modelMatchPreferences),
1225
1225
  );
1226
- const defaultRoleSpec = logger.time("resolveDefaultModelRole", () =>
1226
+ let defaultRoleSpec = logger.time("resolveDefaultModelRole", () =>
1227
1227
  resolveModelRoleValue(settings.getModelRole("default"), allowedModels, {
1228
1228
  settings,
1229
1229
  matchPreferences: modelMatchPreferences,
@@ -1946,9 +1946,47 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1946
1946
  // Re-resolve the allowed set: extension factories above may have
1947
1947
  // registered providers/models that weren't visible at startup.
1948
1948
  const fallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences);
1949
- const defaultModel = pickDefaultAvailableModel(fallbackCandidates.filter(hasModelAuth));
1950
- if (defaultModel) {
1951
- model = defaultModel;
1949
+
1950
+ // Retry the default-role lookup against the post-extension allowed
1951
+ // set. Extension factories register providers AFTER the early
1952
+ // `defaultRoleSpec` resolution, so a role pointing at an extension
1953
+ // model (e.g. an openai-compat plugin's `posthog/claude-opus-4-8`)
1954
+ // returned `undefined` there. Without this retry the next step's
1955
+ // `pickDefaultAvailableModel` happily replaces the user's configured
1956
+ // default with a bundled provider's default whenever a stray
1957
+ // `OPENAI_API_KEY`/`ANTHROPIC_API_KEY` is in the environment.
1958
+ // (issue #3569)
1959
+ if (!hasExplicitModel && !defaultRoleSpec.model) {
1960
+ const reResolvedRoleSpec = resolveModelRoleValue(settings.getModelRole("default"), fallbackCandidates, {
1961
+ settings,
1962
+ matchPreferences: modelMatchPreferences,
1963
+ modelRegistry,
1964
+ });
1965
+ if (reResolvedRoleSpec.model) {
1966
+ defaultRoleSpec = reResolvedRoleSpec;
1967
+ const resolvedDefaultModel = reResolvedRoleSpec.model;
1968
+ model = resolvedDefaultModel;
1969
+ modelFallbackMessage = undefined;
1970
+ // Recompute the thinking level against the now-real model.
1971
+ // `pickInitialThinkingLevel` closes over `defaultRoleSpec`,
1972
+ // so the role's explicit selector (e.g. `:max`) now applies.
1973
+ thinkingLevel = pickInitialThinkingLevel(resolvedDefaultModel);
1974
+ autoThinking = thinkingLevel === AUTO_THINKING;
1975
+ effectiveThinkingLevel = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel;
1976
+ effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () =>
1977
+ autoThinking
1978
+ ? resolveProvisionalAutoLevel(resolvedDefaultModel)
1979
+ : resolveThinkingLevelForModel(resolvedDefaultModel, effectiveThinkingLevel),
1980
+ );
1981
+ preconnectModelHost(resolvedDefaultModel.baseUrl);
1982
+ }
1983
+ }
1984
+
1985
+ if (!model) {
1986
+ const defaultModel = pickDefaultAvailableModel(fallbackCandidates.filter(hasModelAuth));
1987
+ if (defaultModel) {
1988
+ model = defaultModel;
1989
+ }
1952
1990
  }
1953
1991
  if (model) {
1954
1992
  if (modelFallbackMessage) {
@@ -2221,6 +2259,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2221
2259
  memoryRootEnabled: memoryBackend.id === "local",
2222
2260
  model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
2223
2261
  personality: agentKind === "sub" ? "none" : settings.get("personality"),
2262
+ renderMermaid: settings.get("tui.renderMermaid"),
2224
2263
  });
2225
2264
 
2226
2265
  if (options.systemPrompt === undefined) {