@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.2

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 (114) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-xfpkakrn.md} +47 -0
  3. package/dist/cli.js +3891 -3885
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  18. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  19. package/dist/types/modes/interactive-mode.d.ts +1 -0
  20. package/dist/types/modes/theme/theme.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +2 -0
  22. package/dist/types/secrets/index.d.ts +22 -0
  23. package/dist/types/secrets/obfuscator.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  27. package/dist/types/session/session-storage.d.ts +5 -2
  28. package/dist/types/session/session-tools.d.ts +2 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -1
  30. package/dist/types/system-prompt.d.ts +8 -0
  31. package/dist/types/task/executor.d.ts +27 -0
  32. package/dist/types/task/persisted-revive.d.ts +7 -0
  33. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  34. package/dist/types/tools/browser/attach.d.ts +9 -1
  35. package/dist/types/tools/browser/launch.d.ts +20 -1
  36. package/dist/types/tools/browser/registry.d.ts +2 -0
  37. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  38. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  40. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  41. package/dist/types/utils/image-loading.d.ts +2 -0
  42. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  43. package/dist/types/web/search/types.d.ts +2 -2
  44. package/package.json +12 -12
  45. package/src/commit/agentic/index.ts +2 -1
  46. package/src/commit/pipeline.ts +3 -2
  47. package/src/config/keybindings.ts +6 -1
  48. package/src/config/settings-schema.ts +16 -3
  49. package/src/edit/hashline/block-resolver.ts +5 -5
  50. package/src/edit/hashline/diff.ts +22 -15
  51. package/src/edit/hashline/execute.ts +14 -13
  52. package/src/edit/renderer.ts +3 -3
  53. package/src/eval/executor-base.ts +39 -0
  54. package/src/eval/jl/executor.ts +13 -2
  55. package/src/eval/js/context-manager.ts +97 -14
  56. package/src/eval/js/executor.ts +3 -0
  57. package/src/eval/js/index.ts +1 -0
  58. package/src/eval/py/executor.ts +55 -15
  59. package/src/eval/rb/executor.ts +8 -1
  60. package/src/extensibility/extensions/runner.ts +121 -4
  61. package/src/extensibility/extensions/types.ts +15 -0
  62. package/src/extensibility/extensions/wrapper.ts +19 -2
  63. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  64. package/src/live/visualizer.ts +8 -1
  65. package/src/lsp/index.ts +51 -11
  66. package/src/main.ts +3 -0
  67. package/src/modes/acp/acp-agent.ts +16 -5
  68. package/src/modes/components/assistant-message.ts +5 -10
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/keybinding-hints.ts +4 -13
  71. package/src/modes/components/status-line/segments.ts +4 -5
  72. package/src/modes/components/tool-execution.ts +4 -5
  73. package/src/modes/controllers/input-controller.ts +18 -40
  74. package/src/modes/controllers/live-command-controller.ts +1 -0
  75. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  76. package/src/modes/interactive-mode.ts +59 -2
  77. package/src/modes/theme/theme.ts +13 -6
  78. package/src/modes/types.ts +2 -0
  79. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  80. package/src/prompts/system/plan-mode-active.md +5 -5
  81. package/src/sdk.ts +47 -16
  82. package/src/secrets/index.ts +83 -3
  83. package/src/secrets/obfuscator.ts +70 -15
  84. package/src/session/agent-session-types.ts +7 -1
  85. package/src/session/agent-session.ts +393 -112
  86. package/src/session/codex-auto-reset.ts +594 -123
  87. package/src/session/eval-runner.ts +2 -0
  88. package/src/session/messages.ts +1 -1
  89. package/src/session/session-maintenance.ts +48 -1
  90. package/src/session/session-manager.ts +4 -3
  91. package/src/session/session-stats.ts +14 -3
  92. package/src/session/session-storage.ts +47 -15
  93. package/src/session/session-tools.ts +30 -2
  94. package/src/session/turn-recovery.ts +15 -2
  95. package/src/slash-commands/builtin-registry.ts +21 -15
  96. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  97. package/src/system-prompt.ts +13 -1
  98. package/src/task/executor.ts +168 -15
  99. package/src/task/persisted-revive.ts +26 -1
  100. package/src/telemetry-export.ts +8 -4
  101. package/src/tools/bash-interceptor.ts +85 -8
  102. package/src/tools/bash.ts +1 -1
  103. package/src/tools/browser/attach.ts +35 -10
  104. package/src/tools/browser/launch.ts +60 -11
  105. package/src/tools/browser/registry.ts +18 -2
  106. package/src/tools/browser/tab-protocol.ts +8 -0
  107. package/src/tools/browser/tab-supervisor.ts +31 -4
  108. package/src/tools/browser/tab-worker.ts +32 -2
  109. package/src/tools/output-meta.ts +16 -1
  110. package/src/tools/shell-tokenize.ts +101 -0
  111. package/src/utils/image-loading.ts +8 -3
  112. package/src/utils/shell-snapshot.ts +31 -21
  113. package/src/web/search/providers/duckduckgo.ts +183 -15
  114. package/src/web/search/types.ts +6 -1
@@ -1,31 +1,22 @@
1
1
  /**
2
2
  * Utilities for formatting keybinding hints in the UI.
3
3
  */
4
- import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
5
- import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
4
+ import { getKeybindings, type Keybinding } from "@oh-my-pi/pi-tui";
5
+ import { type AppKeybinding, formatKeyHints, type KeybindingsManager } from "../../config/keybindings";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
 
8
- /**
9
- * Format keys array as display string (e.g., ["ctrl+c", "escape"] -> "ctrl+c/escape").
10
- */
11
- function formatKeys(keys: KeyId[]): string {
12
- if (keys.length === 0) return "";
13
- if (keys.length === 1) return keys[0]!;
14
- return keys.join("/");
15
- }
16
-
17
8
  /**
18
9
  * Get display string for an editor action.
19
10
  */
20
11
  export function editorKey(action: Keybinding): string {
21
- return formatKeys(getKeybindings().getKeys(action));
12
+ return formatKeyHints(getKeybindings().getKeys(action));
22
13
  }
23
14
 
24
15
  /**
25
16
  * Get display string for an app action.
26
17
  */
27
18
  export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
28
- return formatKeys(keybindings.getKeys(action));
19
+ return formatKeyHints(keybindings.getKeys(action));
29
20
  }
30
21
 
31
22
  /**
@@ -5,6 +5,7 @@ import { TERMINAL } from "@oh-my-pi/pi-tui";
5
5
  import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
6
  import { type ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../../tools/render-utils";
8
+ import { fileHyperlink } from "../../../tui/hyperlink";
8
9
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
10
  import { sanitizeStatusText } from "../../shared";
10
11
  import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
@@ -280,7 +281,8 @@ const pathSegment: StatusLineSegment = {
280
281
  if (stripPrefix && ctx.worktree) {
281
282
  const { projectName, worktreeName } = ctx.worktree;
282
283
  const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
283
- const content = withIcon(theme.icon.worktree, clampPathLength(label, opts.maxLength ?? 40));
284
+ const text = fileHyperlink(getProjectDir(), clampPathLength(label, opts.maxLength ?? 40));
285
+ const content = withIcon(theme.icon.worktree, text);
284
286
  return { content: theme.fg("statusLinePath", content), visible: true };
285
287
  }
286
288
 
@@ -301,13 +303,10 @@ const pathSegment: StatusLineSegment = {
301
303
  }
302
304
 
303
305
  pwd = clampPathLength(pwd, opts.maxLength ?? 40);
304
- if (repoSuffix) {
305
- pwd = `${pwd}${repoSuffix}`;
306
- }
307
306
 
308
307
  const showScratchIcon = scratch && stripPrefix;
309
308
  const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
310
- const content = withIcon(icon, pwd);
309
+ const content = withIcon(icon, `${fileHyperlink(projectDir, pwd)}${repoSuffix}`);
311
310
  return { content: theme.fg("statusLinePath", content), visible: true };
312
311
  },
313
312
  };
@@ -27,6 +27,7 @@ import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/rend
27
27
  import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
28
28
  import type { XdevState } from "../../tools/xdev";
29
29
  import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
30
+ import { convertImageToPng } from "../../utils/image-loading";
30
31
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
31
32
  import { renderDiff } from "./diff";
32
33
 
@@ -651,11 +652,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
651
652
 
652
653
  // Convert async - catch errors from processing
653
654
  const index = i;
654
- new Bun.Image(Buffer.from(img.data, "base64"))
655
- .png()
656
- .toBase64()
657
- .then(data => {
658
- this.#convertedImages.set(index, { data, mimeType: "image/png" });
655
+ convertImageToPng({ type: "image", data: img.data, mimeType: img.mimeType })
656
+ .then(converted => {
657
+ this.#convertedImages.set(index, converted);
659
658
  this.#displayInputVersion++;
660
659
  this.#updateDisplay();
661
660
  this.#ui.requestRender();
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
4
  import type { ImageContent } from "@oh-my-pi/pi-ai";
5
5
  import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
6
- import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
6
+ import { isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
7
7
  import { isSettingsInitialized, settings } from "../../config/settings";
8
8
  import { resolveLocalRoot } from "../../internal-urls";
9
9
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -20,7 +20,6 @@ import manualContinuePrompt from "../../prompts/system/manual-continue.md" with
20
20
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
21
21
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
22
22
  import { isTinyTitleLocalModelKey } from "../../tiny/models";
23
- import { isLowSignalTitleInput } from "../../tiny/text";
24
23
  import { tinyTitleClient } from "../../tiny/title-client";
25
24
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
26
25
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
@@ -404,14 +403,13 @@ export class InputController {
404
403
  this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
405
404
  this.ctx.editor.setActionKeys("app.display.reset", this.ctx.keybindings.getKeys("app.display.reset"));
406
405
  this.ctx.editor.onDisplayReset = () => {
407
- // Explicit user gesture (Ctrl+L): re-query the terminal background once
408
- // so a mid-session light/dark switch is picked up even on terminals
409
- // without an end-to-end Mode 2031 notification path (#5352). The
410
- // appearance callback re-evaluates the auto theme; the repaint below
411
- // then renders the resolved palette. Bounded to one OSC 11 probe per
412
- // gesture — no timers, no periodic polling.
413
- this.ctx.ui.terminal.refreshAppearance?.();
414
- this.ctx.ui.resetDisplay();
406
+ // Explicit user gesture (display reset, Alt+L by default): re-query the
407
+ // terminal background once so a mid-session light/dark switch is picked
408
+ // up even on terminals without an end-to-end Mode 2031 notification
409
+ // path (#5352). The appearance callback re-evaluates the auto theme; the
410
+ // repaint below then renders the resolved palette. Bounded to one OSC 11
411
+ // probe per gesture — no timers, no periodic polling.
412
+ this.ctx.resetDisplayAfterAppearanceRefresh();
415
413
  };
416
414
  this.ctx.editor.onExit = () => this.handleCtrlD();
417
415
  this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
@@ -487,6 +485,9 @@ export class InputController {
487
485
  for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
488
486
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
489
487
  }
488
+ for (const key of this.ctx.keybindings.getKeys("app.live.toggle")) {
489
+ this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleLiveCommand());
490
+ }
490
491
  // Hold the space bar to push-to-talk: the editor recognizes the auto-repeat burst, tracks
491
492
  // the spam back out, and toggles STT on hold start / release. Gated on `stt.enabled` so a
492
493
  // disabled STT leaves the space bar typing normally.
@@ -901,12 +902,9 @@ export class InputController {
901
902
  }
902
903
 
903
904
  /**
904
- * Kick off session-title generation while the session is still unnamed.
905
- * Invoked AFTER the optimistic user row is painted so the local tiny-title
906
- * worker's subprocess spawn never lands ahead of the first frame (issue #6462).
907
- * Skips slash extension commands (consumed locally by AgentSession.prompt()),
908
- * already-named sessions, PI_NO_TITLE, and low-signal greetings — no model or
909
- * download UI for those.
905
+ * Kick off session-title generation after the optimistic user row paints.
906
+ * Local extension commands are consumed before reaching the shared session
907
+ * title gate and must not name the conversation.
910
908
  */
911
909
  #maybeStartTitleGeneration(text: string): void {
912
910
  const runner = this.ctx.session.extensionRunner;
@@ -915,32 +913,12 @@ export class InputController {
915
913
  text.startsWith("/") &&
916
914
  runner?.getCommand(extensionCommandSpace === -1 ? text.slice(1) : text.slice(1, extensionCommandSpace)) !==
917
915
  undefined;
918
- if (
919
- isLocalExtensionCommand ||
920
- this.ctx.sessionManager.getSessionName() ||
921
- $env.PI_NO_TITLE ||
922
- isLowSignalTitleInput(text)
923
- ) {
916
+ if (isLocalExtensionCommand) {
924
917
  return;
925
918
  }
926
- this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
927
- this.ctx.session
928
- .generateTitle(text)
929
- .then(async title => {
930
- // Re-check: a concurrent attempt for an earlier message may have
931
- // already named the session. Don't clobber it. Terminal title and
932
- // accent updates fire from the onSessionNameChanged listener.
933
- if (title && !this.ctx.sessionManager.getSessionName()) {
934
- await this.ctx.sessionManager.setSessionName(title, "auto");
935
- }
936
- })
937
- .catch(err => {
938
- logger.warn("title-generator: uncaught auto-title error", {
939
- sessionId: this.ctx.session.sessionId,
940
- reason: "uncaught-auto-title-error",
941
- error: err instanceof Error ? err.message : String(err),
942
- });
943
- });
919
+ this.ctx.session.maybeStartTitleGeneration(text, () => {
920
+ this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
921
+ });
944
922
  }
945
923
 
946
924
  /** Submit editor text to the focused subagent session (chat-only focus policy). */
@@ -99,6 +99,7 @@ export class LiveCommandController {
99
99
  void this.stop().catch(cause => this.#ctx.showError(errorFrom(cause).message));
100
100
  },
101
101
  onToggleMute: () => this.#session?.toggleMute(),
102
+ stopKeys: this.#ctx.keybindings.getKeys("app.live.toggle"),
102
103
  });
103
104
  this.#mountVisualizer(visualizer);
104
105
 
@@ -1228,7 +1228,7 @@ export class MCPCommandController {
1228
1228
  await addMCPServer(filePath, name, config);
1229
1229
 
1230
1230
  // Reload MCP manager
1231
- await this.#reloadMCP();
1231
+ await this.reloadServers();
1232
1232
  const state =
1233
1233
  config.enabled === false
1234
1234
  ? "disconnected"
@@ -1486,7 +1486,7 @@ export class MCPCommandController {
1486
1486
  await removeMCPServer(filePath, name);
1487
1487
 
1488
1488
  // Reload MCP manager
1489
- await this.#reloadMCP();
1489
+ await this.reloadServers();
1490
1490
 
1491
1491
  this.#showMessage(["", theme.fg("success", `- Removed server "${name}" from ${scope} config`), ""].join("\n"));
1492
1492
  } catch (error) {
@@ -1736,7 +1736,7 @@ export class MCPCommandController {
1736
1736
  );
1737
1737
  return;
1738
1738
  }
1739
- await this.#reloadMCP();
1739
+ await this.reloadServers();
1740
1740
  this.#showMessage(
1741
1741
  ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
1742
1742
  );
@@ -1745,7 +1745,7 @@ export class MCPCommandController {
1745
1745
 
1746
1746
  const updated = this.#stripOAuthAuth(found.config);
1747
1747
  await updateMCPServer(found.filePath, name, updated);
1748
- await this.#reloadMCP();
1748
+ await this.reloadServers();
1749
1749
 
1750
1750
  this.#showMessage(
1751
1751
  ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
@@ -1856,7 +1856,7 @@ export class MCPCommandController {
1856
1856
  await updateMCPServer(found.filePath, name, updatedConfig);
1857
1857
  }
1858
1858
  if (options.reload !== false) {
1859
- await this.#reloadMCP();
1859
+ await this.reloadServers();
1860
1860
  const state = await this.#waitForServerConnectionWithAnimation(name);
1861
1861
 
1862
1862
  const lines = [
@@ -1891,7 +1891,7 @@ export class MCPCommandController {
1891
1891
  async #handleReload(): Promise<void> {
1892
1892
  try {
1893
1893
  this.#showMessage(["", theme.fg("muted", "Reloading MCP servers and runtime tools..."), ""].join("\n"));
1894
- await this.#reloadMCP();
1894
+ await this.reloadServers();
1895
1895
  const connectedCount = this.ctx.mcpManager?.getConnectedServers().length ?? 0;
1896
1896
  this.#showMessage(
1897
1897
  [
@@ -1979,18 +1979,35 @@ export class MCPCommandController {
1979
1979
  }
1980
1980
 
1981
1981
  /**
1982
- * Reload MCP manager with new configs
1982
+ * Reconnect every configured MCP server and rebind the session's MCP tools.
1983
+ *
1984
+ * Disconnects all live connections, rediscovers `.mcp.json` configs, and
1985
+ * calls `session.refreshMCPTools(...)` so config edits take effect without a
1986
+ * restart. Public because `/reload-plugins` reuses it alongside `/mcp reload`
1987
+ * and the config-mutation flows in this controller.
1988
+ *
1989
+ * Discovery options are derived from settings so the reload honors the same
1990
+ * opt-outs as startup — notably `mcp.enableProjectConfig: false`, which must
1991
+ * keep project `.mcp.json` servers from being started on reload.
1983
1992
  */
1984
- async #reloadMCP(): Promise<void> {
1993
+ async reloadServers(): Promise<void> {
1985
1994
  if (!this.ctx.mcpManager) {
1986
1995
  return;
1987
1996
  }
1988
1997
 
1989
1998
  // Disconnect all existing servers
1990
1999
  await this.ctx.mcpManager.disconnectAll();
1991
-
1992
- // Rediscover and connect
1993
- const result = await this.ctx.mcpManager.discoverAndConnect();
2000
+ // Prompt enrichment is asynchronous. Clear commands before rediscovery so
2001
+ // removed/disabled servers cannot leave stale `/server:prompt` entries;
2002
+ // newly loaded prompts repopulate them through the manager callback.
2003
+ this.ctx.session.setMCPPromptCommands([]);
2004
+
2005
+ // Rediscover and connect, mirroring startup's discovery filters.
2006
+ const result = await this.ctx.mcpManager.discoverAndConnect({
2007
+ enableProjectConfig: this.ctx.settings.get("mcp.enableProjectConfig") ?? true,
2008
+ filterExa: true,
2009
+ filterBrowser: this.ctx.settings.get("browser.enabled") ?? false,
2010
+ });
1994
2011
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1995
2012
 
1996
2013
  this.#showMCPConnectionErrors(result.errors);
@@ -37,6 +37,7 @@ import {
37
37
  TUI,
38
38
  visibleWidth,
39
39
  } from "@oh-my-pi/pi-tui";
40
+ import type { TerminalAppearanceRequestToken } from "@oh-my-pi/pi-tui/terminal";
40
41
  import { isInsideTerminalMultiplexer } from "@oh-my-pi/pi-tui/terminal-capabilities";
41
42
  import {
42
43
  $env,
@@ -429,6 +430,8 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
429
430
  return ["", theme.bold(theme.fg("accent", "Subagents")), ...rows.map(line => ` ${line}`)];
430
431
  }
431
432
 
433
+ const CTRL_L_APPEARANCE_RESPONSE_DEADLINE_MS = 2000;
434
+
432
435
  export class InteractiveMode implements InteractiveModeContext {
433
436
  session: AgentSession;
434
437
  sessionManager: SessionManager;
@@ -471,6 +474,8 @@ export class InteractiveMode implements InteractiveModeContext {
471
474
  #loopAutoSubmitTimer: NodeJS.Timeout | undefined;
472
475
  #todoAutoClearTimer: NodeJS.Timeout | undefined;
473
476
  #modelCycleClearTimer: NodeJS.Timeout | undefined;
477
+ #nextAppearanceRequestToken = 1;
478
+ #appearanceRefreshRequest: { token: TerminalAppearanceRequestToken; deadline: number } | undefined;
474
479
  todoPhases: TodoPhase[] = [];
475
480
  hideThinkingBlock = false;
476
481
  #sessionsWithDisplayableThinkingContent = new WeakSet<AgentSession>();
@@ -1141,8 +1146,38 @@ export class InteractiveMode implements InteractiveModeContext {
1141
1146
  // Subscribe to terminal dark/light appearance changes.
1142
1147
  // The terminal queries background color via OSC 11 at startup and on
1143
1148
  // Mode 2031 notifications, computing luminance to detect dark/light.
1144
- this.ui.terminal.onAppearanceChange(mode => {
1145
- onTerminalAppearanceChange(mode);
1149
+ const unsubscribeAppearanceReport = this.ui.terminal.onAppearanceReport?.((_mode, requestToken) => {
1150
+ const request = this.#appearanceRefreshRequest;
1151
+ if (request === undefined || requestToken !== request.token) return;
1152
+ // ProcessTerminal dispatches report callbacks first, then synchronously
1153
+ // dispatches onAppearanceChange when the reported appearance changed.
1154
+ // That change callback consumes the request below before this microtask
1155
+ // runs; an unchanged matching report has no change callback, so it
1156
+ // consumes the one-shot here. Comparing the captured request prevents a
1157
+ // newer Ctrl+L request from being cleared by this report's microtask.
1158
+ queueMicrotask(() => {
1159
+ if (this.#appearanceRefreshRequest === request) {
1160
+ this.#appearanceRefreshRequest = undefined;
1161
+ }
1162
+ });
1163
+ });
1164
+ if (unsubscribeAppearanceReport) {
1165
+ this.#eventBusUnsubscribers.push(unsubscribeAppearanceReport);
1166
+ }
1167
+ this.ui.terminal.onAppearanceChange((mode, requestToken) => {
1168
+ const request = this.#appearanceRefreshRequest;
1169
+ const appearanceRefreshWasRequested =
1170
+ request !== undefined &&
1171
+ Date.now() <= request.deadline &&
1172
+ (requestToken === request.token || requestToken === undefined);
1173
+ if (request !== undefined && requestToken === request.token) {
1174
+ this.#appearanceRefreshRequest = undefined;
1175
+ }
1176
+ // Ctrl+L already replays immediately below. If either its asynchronous
1177
+ // OSC 11 response or an automatic query ahead of it reveals a theme
1178
+ // change, commit that change so theme loading performs a second full
1179
+ // replay with the newly detected palette.
1180
+ onTerminalAppearanceChange(mode, appearanceRefreshWasRequested ? {} : undefined);
1146
1181
  });
1147
1182
 
1148
1183
  // A branch change (checkout, worktree switch, `git switch`) invalidates
@@ -3932,6 +3967,7 @@ export class InteractiveMode implements InteractiveModeContext {
3932
3967
  }
3933
3968
 
3934
3969
  stop(): void {
3970
+ this.#appearanceRefreshRequest = undefined;
3935
3971
  if (this.loadingAnimation) {
3936
3972
  this.#stopLoadingAnimation(false);
3937
3973
  }
@@ -4752,6 +4788,27 @@ export class InteractiveMode implements InteractiveModeContext {
4752
4788
  this.#inputController.handleCtrlZ();
4753
4789
  }
4754
4790
 
4791
+ resetDisplayAfterAppearanceRefresh(): void {
4792
+ const refreshAppearance = this.ui.terminal.refreshAppearance;
4793
+ if (refreshAppearance) {
4794
+ const token = this.#nextAppearanceRequestToken++;
4795
+ const request = {
4796
+ token,
4797
+ deadline: Date.now() + CTRL_L_APPEARANCE_RESPONSE_DEADLINE_MS,
4798
+ };
4799
+ this.#appearanceRefreshRequest = request;
4800
+ const acceptedToken = refreshAppearance.call(this.ui.terminal, token);
4801
+ if (acceptedToken !== token && this.#appearanceRefreshRequest === request) {
4802
+ this.#appearanceRefreshRequest = undefined;
4803
+ }
4804
+ } else {
4805
+ this.#appearanceRefreshRequest = undefined;
4806
+ }
4807
+ // Preserve Ctrl+L's immediate full replay when the probe is unsupported,
4808
+ // receives no response, or reports an unchanged appearance.
4809
+ this.ui.resetDisplay();
4810
+ }
4811
+
4755
4812
  handleDequeue(): void {
4756
4813
  this.#inputController.handleDequeue();
4757
4814
  }
@@ -2323,10 +2323,13 @@ export function setAutoThemeMapping(mode: "dark" | "light", themeName: string):
2323
2323
  * The terminal layer queries OSC 11 (background color) and computes luminance;
2324
2324
  * Mode 2031 notifications trigger re-queries rather than providing the value directly.
2325
2325
  */
2326
- export function onTerminalAppearanceChange(mode: "dark" | "light"): void {
2326
+ export function onTerminalAppearanceChange(
2327
+ mode: "dark" | "light",
2328
+ event: ThemeChangeEvent = { ephemeral: true },
2329
+ ): void {
2327
2330
  if (terminalReportedAppearance === mode) return;
2328
2331
  terminalReportedAppearance = mode;
2329
- reevaluateAutoTheme("terminal appearance");
2332
+ reevaluateAutoTheme("terminal appearance", event);
2330
2333
  }
2331
2334
 
2332
2335
  export function setThemeInstance(themeInstance: Theme): void {
@@ -2530,6 +2533,10 @@ function reevaluateAutoTheme(debugLabel: string, event: ThemeChangeEvent = {}, a
2530
2533
  applyResolvedAutoTheme(resolved, debugLabel, event);
2531
2534
  }
2532
2535
 
2536
+ function reevaluateAutoThemeForAppearance(debugLabel: string, appearance?: "dark" | "light"): void {
2537
+ reevaluateAutoTheme(debugLabel, { ephemeral: true }, appearance);
2538
+ }
2539
+
2533
2540
  // ============================================================================
2534
2541
  // macOS Appearance Fallback Observer
2535
2542
  // ============================================================================
@@ -2606,7 +2613,7 @@ export function startMacOSAppearanceReprobeFallback(terminal: MacOSAppearanceRep
2606
2613
  const appearance = probeResponseConfirmed ? terminal.appearance : undefined;
2607
2614
  cancelProbeSequence();
2608
2615
  if (!autoDetectedTheme || !appearance) return;
2609
- reevaluateAutoTheme("macOS appearance reconciliation", {}, appearance);
2616
+ reevaluateAutoThemeForAppearance("macOS appearance reconciliation", appearance);
2610
2617
  }, MACOS_APPEARANCE_RECONCILE_DELAY_MS);
2611
2618
  reconciliationTimer.unref?.();
2612
2619
  };
@@ -2635,7 +2642,7 @@ export function startMacOSAppearanceReprobeFallback(terminal: MacOSAppearanceRep
2635
2642
  return;
2636
2643
  }
2637
2644
  if (appearance === "dark" || appearance === "light") {
2638
- reevaluateAutoTheme("macOS provisional appearance", {}, appearance);
2645
+ reevaluateAutoThemeForAppearance("macOS provisional appearance", appearance);
2639
2646
  }
2640
2647
  scheduleProbeSequence();
2641
2648
  });
@@ -2670,7 +2677,7 @@ function startMacAppearanceObserver(): void {
2670
2677
  macObserver = MacAppearanceObserver.start((err, appearance) => {
2671
2678
  if (!err && (appearance === "dark" || appearance === "light")) {
2672
2679
  macOSReportedAppearance = appearance;
2673
- reevaluateAutoTheme("macOS fallback");
2680
+ reevaluateAutoThemeForAppearance("macOS fallback");
2674
2681
  }
2675
2682
  });
2676
2683
  } catch (err) {
@@ -2694,7 +2701,7 @@ function stopMacAppearanceObserver(): void {
2694
2701
  function startSigwinchListener(): void {
2695
2702
  stopSigwinchListener();
2696
2703
  sigwinchHandler = () => {
2697
- reevaluateAutoTheme("SIGWINCH");
2704
+ reevaluateAutoThemeForAppearance("SIGWINCH");
2698
2705
  };
2699
2706
  process.on("SIGWINCH", sigwinchHandler);
2700
2707
  startMacAppearanceObserver();
@@ -401,6 +401,8 @@ export interface InteractiveModeContext {
401
401
  handleCtrlC(): void;
402
402
  handleCtrlD(): void;
403
403
  handleCtrlZ(): void;
404
+ /** Re-query terminal appearance for an explicit display reset, then immediately replay the display. */
405
+ resetDisplayAfterAppearanceRefresh(): void;
404
406
  handleDequeue(): void;
405
407
  handleImagePaste(): Promise<boolean>;
406
408
  /** Queue a message for delivery only after the active agent turn would stop. */
@@ -51,6 +51,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
51
51
  `| \`${appKey(bindings, "app.retry")}\` | Retry last failed assistant turn |`,
52
52
  `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
53
53
  "| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |",
54
+ `| \`${appKey(bindings, "app.live.toggle")}\` | Start/stop live voice mode (/live) |`,
54
55
  `| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
55
56
  "| `#<number>` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |",
56
57
  "| `#` / `#<text>` | Prompt actions (copy / undo / move cursor) |",
@@ -28,12 +28,12 @@ Choose a short kebab-case `<slug>` naming this task and write the plan to `local
28
28
  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.
29
29
 
30
30
  {{#if isHashlineEditMode}}
31
- 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:
32
- - `SWAP.BLK N:` on a heading line — rewrite that entire section in place.
33
- - `CUT.BLK N` on a heading line — drop the whole section.
34
- - `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).
31
+ Structure the plan as `##`/`###` markdown sections so you can revise it section-by-section: with `{{editToolName}}`, the `N*` locator targets a heading's WHOLE section (through every nested deeper heading, up to the next same-or-higher heading). Use composable locators to grow the plan without rewriting the file:
32
+ - `PUT N*:` on a heading line — rewrite that entire section in place.
33
+ - `CUT N*` on a heading line — drop the whole section.
34
+ - `PUT >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).
35
35
 
36
- 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`/`CUT`/`SWAP`.
36
+ Write each section together with its body — `N*` needs a multi-line section; a bare heading with no body falls back to plain `PUT >N:`/`CUT N`/`PUT N:`.
37
37
  {{/if}}
38
38
 
39
39
  ## Ground every claim
package/src/sdk.ts CHANGED
@@ -67,6 +67,7 @@ import { createBridgeEditTool, createBridgeGrepFactory } from "./cursor-bridge-t
67
67
  import "./discovery";
68
68
  import { initializeWithSettings } from "./discovery";
69
69
  import { disposeAllJuliaKernelSessions, disposeJuliaKernelSessionsByOwner } from "./eval/jl/executor";
70
+ import { disposeVmContextsByOwner } from "./eval/js/context-manager";
70
71
  import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
71
72
  import { disposeAllRubyKernelSessions, disposeRubyKernelSessionsByOwner } from "./eval/rb/executor";
72
73
  import { defaultEvalSessionId } from "./eval/session-id";
@@ -119,11 +120,13 @@ import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with
119
120
  import { AgentLifecycleManager } from "./registry/agent-lifecycle";
120
121
  import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
121
122
  import {
123
+ builtinCredentialSecretEntries,
122
124
  collectEnvSecrets,
123
125
  deobfuscateSessionContext,
124
126
  deobfuscateToolArguments,
125
127
  getExistingSecretPlaceholderKey,
126
128
  getSecretPlaceholderKey,
129
+ getSecretPlaceholderKeySync,
127
130
  loadSecrets,
128
131
  obfuscateMessages,
129
132
  obfuscateProviderContext,
@@ -1359,21 +1362,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1359
1362
  if (settings.get("secrets.enabled")) {
1360
1363
  const fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir);
1361
1364
  const envEntries = collectEnvSecrets();
1362
- const allEntries = [...envEntries, ...fileEntries];
1363
- const needsPlaceholderKey = secretEntriesNeedPlaceholderKey(allEntries);
1365
+ // Built-in credential-pattern entries come last so user-configured entries
1366
+ // (plain literals, custom regexes) take precedence in the scan order.
1367
+ const allEntries = [...envEntries, ...fileEntries, ...builtinCredentialSecretEntries()];
1368
+ // Only CONFIGURED entries force startup key creation: a configured
1369
+ // obfuscate-mode secret — or a default (no custom `replacement`)
1370
+ // replace-mode regex whose key-derived idempotent fallback marker needs a
1371
+ // stable key across restarts (see `secretEntryNeedsPlaceholderKey`) —
1372
+ // mints placeholders as soon as the obfuscator is built. The built-in
1373
+ // credential-pattern entry matches dynamically, so it resolves the
1374
+ // persisted key lazily on first match instead of creating the key file
1375
+ // for every secrets.enabled session; a session whose content never
1376
+ // contains a credential-shaped token must not require the key, otherwise a
1377
+ // headless run on an unwritable default config root pays for a feature it
1378
+ // does not use.
1379
+ const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]);
1364
1380
  const placeholderKey = needsPlaceholderKey
1365
1381
  ? await getSecretPlaceholderKey(agentDir)
1366
1382
  : await getExistingSecretPlaceholderKey(agentDir);
1367
1383
  if (allEntries.length > 0) {
1368
- // The persisted placeholder key and creating its key file under the
1369
- // configured agentDir — is only needed for reversible obfuscate-mode
1370
- // placeholders, or for a default (no custom `replacement`) replace-mode
1371
- // regex whose key-derived idempotent fallback marker needs a stable key
1372
- // across restarts (see `secretEntryNeedsPlaceholderKey`). A replace-only
1373
- // secrets set with no such regex must not require the key; otherwise a
1374
- // headless run with an unwritable default config root fails startup for a
1375
- // feature it does not use.
1376
- obfuscator = new SecretObfuscator(allEntries, placeholderKey);
1384
+ obfuscator = new SecretObfuscator(allEntries, placeholderKey ?? (() => getSecretPlaceholderKeySync(agentDir)));
1377
1385
  }
1378
1386
  if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) {
1379
1387
  // No configured entry produced an active secret (e.g. only ignored short
@@ -2564,6 +2572,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2564
2572
  autoApprove: options.autoApprove ?? false,
2565
2573
  });
2566
2574
  const toolContextStore = new ToolContextStore(getSessionContext);
2575
+ // Native built-in implementations backing same-tool `ctx.invokeTool`, so a tool that
2576
+ // re-registers a built-in (e.g. wrapping `write`) can delegate to the original — reaching the
2577
+ // unwrapped native execute, which inherits the caller's already-granted approval rather than
2578
+ // re-running the gate. Seeded from the xdev registry when present (it retains discoverable
2579
+ // built-ins like `browser` that xdev partitioning removes from the active tool array), else
2580
+ // from the built-in registry; captured before the ExtensionToolWrapper pass so the natives
2581
+ // stay unwrapped. The extension runner exposes it to re-registered tools via createContext.
2582
+ const nativeToolsByName = new Map<string, Tool>(toolSession.xdev?.tools ?? undefined);
2567
2583
 
2568
2584
  const registeredTools = restrictToolNames ? [] : extensionRunner.getAllRegisteredTools();
2569
2585
  const sdkCustomTools =
@@ -2587,17 +2603,32 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2587
2603
 
2588
2604
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
2589
2605
  const builtInRegistryToolNames = toolSession.xdev?.builtInNames ?? new Set(toolRegistry.keys());
2606
+ // Capture the native built-in implementations before extension re-registration replaces registry
2607
+ // entries and before the ExtensionToolWrapper pass below, so `ctx.invokeTool` reaches the
2608
+ // unwrapped native execute (inheriting the caller's already-granted approval, not re-gating).
2609
+ for (const [name, tool] of toolRegistry) {
2610
+ nativeToolsByName.set(name, tool);
2611
+ }
2590
2612
  if (!restrictToolNames && !toolRegistry.has("goal") && settings.get("goal.enabled")) {
2591
2613
  const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
2592
2614
  if (goalTool) {
2593
- toolRegistry.set(goalTool.name, wrapToolWithMetaNotice(goalTool));
2615
+ const wrapped = wrapToolWithMetaNotice(goalTool);
2616
+ toolRegistry.set(goalTool.name, wrapped);
2594
2617
  builtInRegistryToolNames.add(goalTool.name);
2618
+ nativeToolsByName.set(goalTool.name, wrapped);
2595
2619
  }
2596
2620
  }
2597
2621
  for (const tool of wrappedExtensionTools) {
2598
2622
  toolRegistry.set(tool.name, tool);
2599
2623
  builtInRegistryToolNames.delete(tool.name);
2600
2624
  }
2625
+ // Expose the native built-ins to same-tool `ctx.invokeTool` on re-registered tools. Set after
2626
+ // the override loop so the map holds the natives, not the extension replacements. The context
2627
+ // factory is the loop's own tool context, so a delegated native call sees ordinary session state.
2628
+ extensionRunner.setNativeToolResolver(name => {
2629
+ const tool = nativeToolsByName.get(name);
2630
+ return tool ? { tool, makeContext: () => toolContextStore.getContext() } : undefined;
2631
+ });
2601
2632
  if (deferMCPDiscoveryForUI && mcpManager) {
2602
2633
  for (const name of collectPendingMCPToolNames(options.toolNames)) {
2603
2634
  if (!toolRegistry.has(name)) {
@@ -2660,11 +2691,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2660
2691
  writeRegistration ??= (async () => {
2661
2692
  const writeTool = await logger.time("createTools:write:session", BUILTIN_TOOLS.write, toolSession);
2662
2693
  if (!writeTool || toolRegistry.has("write")) return builtInRegistryToolNames.has("write");
2663
- toolRegistry.set(
2664
- writeTool.name,
2665
- new ExtensionToolWrapper(wrapToolWithMetaNotice(writeTool), extensionRunner) as Tool,
2666
- );
2694
+ const nativeWrite = wrapToolWithMetaNotice(writeTool);
2695
+ toolRegistry.set(writeTool.name, new ExtensionToolWrapper(nativeWrite, extensionRunner) as Tool);
2667
2696
  builtInRegistryToolNames.add(writeTool.name);
2697
+ nativeToolsByName.set(writeTool.name, nativeWrite);
2668
2698
  return true;
2669
2699
  })().finally(() => {
2670
2700
  writeRegistration = undefined;
@@ -3715,6 +3745,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3715
3745
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
3716
3746
  await disposeRubyKernelSessionsByOwner(evalKernelOwnerId);
3717
3747
  await disposeJuliaKernelSessionsByOwner(evalKernelOwnerId);
3748
+ await disposeVmContextsByOwner(evalKernelOwnerId);
3718
3749
  if (ownsAuthStorage) authStorage.close();
3719
3750
  }
3720
3751
  } catch (cleanupError) {