@oh-my-pi/pi-coding-agent 16.5.1 → 16.5.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 (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3442 -3408
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  5. package/dist/types/eval/backend.d.ts +3 -3
  6. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  7. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  8. package/dist/types/goals/guided-setup.d.ts +12 -0
  9. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  10. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  13. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  15. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  16. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  18. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/types.d.ts +1 -0
  22. package/dist/types/session/messages.d.ts +15 -0
  23. package/dist/types/tools/grep.d.ts +0 -2
  24. package/dist/types/tools/read.d.ts +0 -4
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +136 -49
  27. package/src/advisor/runtime.ts +16 -34
  28. package/src/autoresearch/dashboard.ts +2 -2
  29. package/src/cli/config-cli.ts +15 -3
  30. package/src/config/settings-schema.ts +10 -0
  31. package/src/cursor.ts +2 -0
  32. package/src/discovery/claude-plugins.ts +9 -3
  33. package/src/discovery/omp-plugins.ts +6 -2
  34. package/src/discovery/substitute-plugin-root.ts +32 -0
  35. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  36. package/src/eval/backend.ts +3 -3
  37. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  38. package/src/eval/py/prelude.py +28 -1
  39. package/src/exec/bash-executor.ts +30 -43
  40. package/src/extensibility/extensions/wrapper.ts +18 -18
  41. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  43. package/src/extensibility/plugins/manager.ts +7 -7
  44. package/src/goals/guided-setup.ts +29 -1
  45. package/src/internal-urls/history-protocol.ts +95 -15
  46. package/src/internal-urls/registry-helpers.ts +50 -1
  47. package/src/launch/broker.ts +38 -25
  48. package/src/mcp/oauth-discovery.ts +20 -1
  49. package/src/mcp/oauth-flow.ts +3 -1
  50. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  51. package/src/modes/components/agent-dashboard.ts +2 -2
  52. package/src/modes/components/agent-hub.ts +15 -2
  53. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  54. package/src/modes/components/chat-transcript-builder.ts +4 -3
  55. package/src/modes/components/dynamic-border.ts +9 -6
  56. package/src/modes/components/extensions/extension-list.ts +2 -2
  57. package/src/modes/components/hook-selector.ts +10 -4
  58. package/src/modes/components/login-dialog.ts +5 -0
  59. package/src/modes/components/mcp-add-wizard.ts +5 -0
  60. package/src/modes/components/plan-review-overlay.ts +11 -11
  61. package/src/modes/components/read-tool-group.ts +1 -8
  62. package/src/modes/controllers/input-controller.ts +4 -2
  63. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  64. package/src/modes/controllers/selector-controller.ts +9 -2
  65. package/src/modes/controllers/todo-command-controller.ts +18 -14
  66. package/src/modes/interactive-mode.ts +7 -3
  67. package/src/modes/prompt-action-autocomplete.ts +6 -1
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/system-prompt.md +1 -0
  70. package/src/prompts/tools/eval.md +2 -2
  71. package/src/prompts/tools/grep.md +1 -2
  72. package/src/prompts/tools/read.md +2 -4
  73. package/src/sdk.ts +28 -31
  74. package/src/session/agent-session.ts +44 -22
  75. package/src/session/messages.test.ts +66 -0
  76. package/src/session/messages.ts +37 -0
  77. package/src/system-prompt.test.ts +36 -0
  78. package/src/system-prompt.ts +1 -1
  79. package/src/tools/browser/registry.ts +17 -3
  80. package/src/tools/eval.ts +14 -9
  81. package/src/tools/gh.ts +3 -1
  82. package/src/tools/grep.ts +5 -45
  83. package/src/tools/path-utils.ts +7 -1
  84. package/src/tools/read.ts +23 -74
  85. package/src/utils/title-generator.ts +10 -6
  86. package/src/web/search/providers/perplexity-auth.ts +20 -11
  87. package/src/web/search/providers/perplexity.ts +14 -2
@@ -277,6 +277,8 @@ export interface MCPOAuthConfig {
277
277
  authorizationUrl: string;
278
278
  /** Token endpoint URL */
279
279
  tokenUrl: string;
280
+ /** Dynamic client registration endpoint advertised by the authorization server. */
281
+ registrationUrl?: string;
280
282
  /** Client ID (optional when already embedded in authorization URL) */
281
283
  clientId?: string;
282
284
  /** Client secret (optional for PKCE flows) */
@@ -561,7 +563,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
561
563
  * accept the later authorize request for the same scope set.
562
564
  */
563
565
  async #tryRegisterClient(redirectUri: string): Promise<void> {
564
- const registrationEndpoint = await this.#resolveRegistrationEndpoint();
566
+ const registrationEndpoint = this.config.registrationUrl ?? (await this.#resolveRegistrationEndpoint());
565
567
  if (!registrationEndpoint) return;
566
568
 
567
569
  try {
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { getThemeByName, setThemeInstance, theme } from "../../theme/theme";
6
+ import { DynamicBorder } from "../dynamic-border";
7
+
8
+ const componentPath = path.resolve(import.meta.dir, "../dynamic-border.ts");
9
+
10
+ describe("DynamicBorder", () => {
11
+ // Regression for #5366: extensions importing legacy pi UI components get a
12
+ // second `src` module graph whose module-level `theme` is never assigned by
13
+ // host startup. `render()` must degrade to plain glyphs instead of throwing
14
+ // "undefined is not an object (evaluating 'theme.boxRound')" and killing the
15
+ // TUI. Other test files in the same process initialize the theme module, so
16
+ // the uninitialized state is only reachable in a fresh module graph — a
17
+ // subprocess reproduces the actual reported scenario deterministically.
18
+ it("renders plain glyphs when the module-level theme is uninitialized", async () => {
19
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-dynamic-border-"));
20
+ try {
21
+ const script = [
22
+ `import { DynamicBorder } from ${JSON.stringify(componentPath)};`,
23
+ 'const custom = new DynamicBorder(str => "<" + str + ">");',
24
+ `if (JSON.stringify(custom.render(4)) !== JSON.stringify(["<────>"])) process.exit(1);`,
25
+ "const plain = new DynamicBorder();",
26
+ `if (JSON.stringify(plain.render(3)) !== JSON.stringify(["───"])) process.exit(2);`,
27
+ "",
28
+ ].join("\n");
29
+ const file = path.join(dir, "uninitialized-theme.ts");
30
+ await Bun.write(file, script);
31
+ const proc = Bun.spawn(["bun", file], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
32
+ const [exitCode, stderr] = await Promise.all([
33
+ proc.exited,
34
+ new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
35
+ ]);
36
+ expect(stderr).toBe("");
37
+ expect(exitCode).toBe(0);
38
+ } finally {
39
+ await fs.rm(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ it("paints with theme.boxRound.horizontal once the theme is initialized", async () => {
44
+ const previous = theme;
45
+ const loaded = await getThemeByName("dark");
46
+ if (!loaded) throw new Error("theme unavailable");
47
+ setThemeInstance(loaded);
48
+ try {
49
+ const border = new DynamicBorder(str => `<${str}>`);
50
+ expect(border.render(3)).toEqual([`<${loaded.boxRound.horizontal.repeat(3)}>`]);
51
+ } finally {
52
+ if (previous) setThemeInstance(previous);
53
+ }
54
+ });
55
+ });
@@ -1142,11 +1142,11 @@ export class AgentDashboard extends Container {
1142
1142
  return;
1143
1143
  }
1144
1144
 
1145
- if (matchesSelectUp(data) || data === "k") {
1145
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
1146
1146
  this.#moveSelection(-1);
1147
1147
  return;
1148
1148
  }
1149
- if (matchesSelectDown(data) || data === "j") {
1149
+ if (matchesSelectDown(data) || matchesKey(data, "j")) {
1150
1150
  this.#moveSelection(1);
1151
1151
  return;
1152
1152
  }
@@ -340,6 +340,19 @@ export class AgentHubOverlayComponent extends Container {
340
340
  this.#handleTableInput(keyData);
341
341
  }
342
342
 
343
+ /**
344
+ * Seed the table's left-left close detector with the current time so a single
345
+ * subsequent `←` (within {@link LEFT_TAP_WINDOW_MS}) dismisses the hub.
346
+ *
347
+ * The editor's own double-tap detector consumes the `←←` that opens the hub,
348
+ * leaving this detector at its fresh `0` — without this handoff the user would
349
+ * have to press `←←` a second time to escape. Called by the opener when the hub
350
+ * was raised by that gesture.
351
+ */
352
+ armCloseTap(): void {
353
+ this.#lastLeftTap = Date.now();
354
+ }
355
+
343
356
  /**
344
357
  * Open the fullscreen transcript viewer for an agent id (public for table Enter
345
358
  * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
@@ -573,14 +586,14 @@ export class AgentHubOverlayComponent extends Container {
573
586
  }
574
587
  return;
575
588
  }
576
- if (keyData === "j" || matchesSelectDown(keyData)) {
589
+ if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) {
577
590
  if (this.#rows.length > 0) {
578
591
  this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
579
592
  }
580
593
  this.#requestRender();
581
594
  return;
582
595
  }
583
- if (keyData === "k" || matchesSelectUp(keyData)) {
596
+ if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) {
584
597
  if (this.#rows.length > 0) {
585
598
  this.#selectedRow = Math.max(this.#selectedRow - 1, 0);
586
599
  }
@@ -496,9 +496,9 @@ export class AgentTranscriptViewer implements Component {
496
496
  this.deps.requestRender();
497
497
  return true;
498
498
  }
499
- if (data === "j" || matchesSelectDown(data)) {
499
+ if (matchesKey(data, "j") || matchesSelectDown(data)) {
500
500
  this.#scrollView.scroll(1);
501
- } else if (data === "k" || matchesSelectUp(data)) {
501
+ } else if (matchesKey(data, "k") || matchesSelectUp(data)) {
502
502
  this.#scrollView.scroll(-1);
503
503
  } else if (data === "g") {
504
504
  this.#scrollView.scrollToTop();
@@ -280,7 +280,7 @@ export class ChatTranscriptBuilder {
280
280
  hideThinkingBlock,
281
281
  () => this.deps.requestRender(),
282
282
  this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers
283
- undefined, // placeholder for imageBudget
283
+ this.deps.ui.imageBudget,
284
284
  proseOnlyThinking,
285
285
  );
286
286
  this.container.addChild(assistantComponent);
@@ -352,8 +352,9 @@ export class ChatTranscriptBuilder {
352
352
  content.name,
353
353
  content.arguments,
354
354
  {
355
- // Images can't be sliced through the scroll viewport; keep them off.
356
- showImages: false,
355
+ // Stable ids and Kitty placeholder cells keep images anchored
356
+ // while the transcript viewport scrolls and reflows.
357
+ showImages: settings.get("terminal.showImages"),
357
358
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
358
359
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
359
360
  liveRegion: this.container,
@@ -1,19 +1,21 @@
1
1
  import type { Component } from "@oh-my-pi/pi-tui";
2
- import { theme } from "../../modes/theme/theme";
2
+ import { fgOrPlain, theme } from "../../modes/theme/theme";
3
3
 
4
4
  /**
5
5
  * Dynamic border component that adjusts to viewport width.
6
6
  *
7
- * Note: When used from hooks loaded via jiti, the global `theme` may be undefined
8
- * because jiti creates a separate module cache. Always pass an explicit color
9
- * function when using DynamicBorder in components exported for hook use.
7
+ * Note: the module-level `theme` may be `undefined` — when loaded through jiti
8
+ * (separate module cache) or from a second `src` module graph in npm-package
9
+ * installs, where the host bundle assigns `theme` but this copy never sees it
10
+ * (issue #5366). Both the default color and `render()` degrade to plain,
11
+ * unstyled output instead of crashing the TUI.
10
12
  */
11
13
  export class DynamicBorder implements Component {
12
14
  #color: (str: string) => string;
13
15
  #cachedWidth = -1;
14
16
  #cachedLines: string[] | undefined;
15
17
 
16
- constructor(color: (str: string) => string = str => theme.fg("border", str)) {
18
+ constructor(color: (str: string) => string = str => fgOrPlain("border", str)) {
17
19
  this.#color = color;
18
20
  }
19
21
 
@@ -26,7 +28,8 @@ export class DynamicBorder implements Component {
26
28
  if (this.#cachedLines && this.#cachedWidth === width) {
27
29
  return this.#cachedLines;
28
30
  }
29
- const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))];
31
+ const horizontal = typeof theme === "undefined" ? "─" : theme.boxRound.horizontal;
32
+ const lines = [this.#color(horizontal.repeat(Math.max(1, width)))];
30
33
  this.#cachedWidth = width;
31
34
  this.#cachedLines = lines;
32
35
  return lines;
@@ -449,12 +449,12 @@ export class ExtensionList implements Component {
449
449
 
450
450
  handleInput(data: string): void {
451
451
  // Navigation
452
- if (matchesSelectUp(data) || data === "k") {
452
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
453
453
  this.#moveSelectionUp();
454
454
  return;
455
455
  }
456
456
 
457
- if (matchesSelectDown(data) || data === "j") {
457
+ if (matchesSelectDown(data) || matchesKey(data, "j")) {
458
458
  this.#moveSelectionDown();
459
459
  return;
460
460
  }
@@ -652,17 +652,23 @@ export class HookSelectorComponent extends Container {
652
652
  return;
653
653
  }
654
654
 
655
- if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && keyData === "k")) {
655
+ if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && matchesKey(keyData, "k"))) {
656
656
  this.#moveSelection(-1);
657
- } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && keyData === "j")) {
657
+ } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && matchesKey(keyData, "j"))) {
658
658
  this.#moveSelection(1);
659
659
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
660
660
  const selected = this.#filteredOptions[this.#selectedIndex];
661
661
  if (selected && !this.#isDisabled(selected.index)) this.#onSelectCallback(selected.option.label);
662
- } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) {
662
+ } else if (
663
+ matchesKey(keyData, "left") ||
664
+ (this.#slider && !this.#isSearchEnabled() && matchesKey(keyData, "h"))
665
+ ) {
663
666
  if (this.#slider) this.#moveSlider(-1);
664
667
  else this.#onLeftCallback?.();
665
- } else if (matchesKey(keyData, "right") || (this.#slider && !this.#isSearchEnabled() && keyData === "l")) {
668
+ } else if (
669
+ matchesKey(keyData, "right") ||
670
+ (this.#slider && !this.#isSearchEnabled() && matchesKey(keyData, "l"))
671
+ ) {
666
672
  if (this.#slider) this.#moveSlider(1);
667
673
  else this.#onRightCallback?.();
668
674
  } else if (this.#onExternalEditorCallback && matchesAppExternalEditor(keyData)) {
@@ -164,6 +164,11 @@ export class LoginDialogComponent extends Container {
164
164
  this.#tui.requestRender();
165
165
  }
166
166
 
167
+ /** Route non-bracketed paste transports into the active login input. */
168
+ pasteText(text: string): void {
169
+ this.#input.pasteText(text);
170
+ }
171
+
167
172
  handleInput(data: string): void {
168
173
  const kb = getKeybindings();
169
174
 
@@ -63,6 +63,7 @@ export interface MCPAddWizardOAuthResult {
63
63
  interface MCPAddWizardOAuthOptions {
64
64
  serverUrl?: string;
65
65
  resource?: string;
66
+ registrationUrl?: string;
66
67
  /**
67
68
  * External cancellation source. Aborting it tears down the in-flight OAuth
68
69
  * flow and surfaces a neutral cancellation error. The wizard wires its own
@@ -82,6 +83,7 @@ interface WizardState {
82
83
  authMethod: AuthMethod;
83
84
  oauthAuthUrl: string;
84
85
  oauthTokenUrl: string;
86
+ oauthRegistrationUrl: string;
85
87
  oauthClientId: string;
86
88
  oauthClientSecret: string;
87
89
  oauthScopes: string;
@@ -113,6 +115,7 @@ export class MCPAddWizard extends Container {
113
115
  authMethod: "none",
114
116
  oauthAuthUrl: "",
115
117
  oauthTokenUrl: "",
118
+ oauthRegistrationUrl: "",
116
119
  oauthClientId: "",
117
120
  oauthClientSecret: "",
118
121
  oauthScopes: "",
@@ -1026,6 +1029,7 @@ export class MCPAddWizard extends Container {
1026
1029
  if (oauth) {
1027
1030
  this.#state.oauthAuthUrl = oauth.authorizationUrl;
1028
1031
  this.#state.oauthTokenUrl = oauth.tokenUrl;
1032
+ this.#state.oauthRegistrationUrl = oauth.registrationUrl || "";
1029
1033
  this.#state.oauthClientId = oauth.clientId || "";
1030
1034
  this.#state.oauthScopes = oauth.scopes || "";
1031
1035
  this.#state.oauthResource = oauth.resource || (this.#state.transport === "stdio" ? "" : this.#state.url);
@@ -1196,6 +1200,7 @@ export class MCPAddWizard extends Container {
1196
1200
  this.#state.oauthScopes,
1197
1201
  {
1198
1202
  serverUrl: this.#state.url || undefined,
1203
+ registrationUrl: this.#state.oauthRegistrationUrl || undefined,
1199
1204
  resource: oauthResource || undefined,
1200
1205
  abortSignal: this.#oauthAbort.signal,
1201
1206
  },
@@ -400,8 +400,8 @@ export class PlanReviewOverlay implements Component {
400
400
  // Left/right always drive the slider. The sidebar sits beside the body
401
401
  // (above this row), not the slider, so stealing left for it would strand
402
402
  // the operator unable to step the model tier back — reach the ToC via Tab.
403
- const isLeft = matchesKey(data, "left") || (this.#slider !== undefined && data === "h");
404
- const isRight = matchesKey(data, "right") || (this.#slider !== undefined && data === "l");
403
+ const isLeft = matchesKey(data, "left") || (this.#slider !== undefined && matchesKey(data, "h"));
404
+ const isRight = matchesKey(data, "right") || (this.#slider !== undefined && matchesKey(data, "l"));
405
405
  if (isLeft) {
406
406
  this.#moveSlider(-1);
407
407
  return;
@@ -410,12 +410,12 @@ export class PlanReviewOverlay implements Component {
410
410
  this.#moveSlider(1);
411
411
  return;
412
412
  }
413
- if (matchesSelectUp(data) || data === "k") {
413
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
414
414
  if (this.#selectedIndex === this.#firstEnabledIndex()) this.#setFocus("body");
415
415
  else this.#moveSelection(-1);
416
416
  return;
417
417
  }
418
- if (matchesSelectDown(data) || data === "j") {
418
+ if (matchesSelectDown(data) || matchesKey(data, "j")) {
419
419
  this.#moveSelection(1);
420
420
  return;
421
421
  }
@@ -427,13 +427,13 @@ export class PlanReviewOverlay implements Component {
427
427
  }
428
428
 
429
429
  #handleBody(data: string): void {
430
- if (matchesKey(data, "left") || data === "h") {
430
+ if (matchesKey(data, "left") || matchesKey(data, "h")) {
431
431
  if (this.#sidebarShown) this.#setFocus("toc");
432
432
  return;
433
433
  }
434
434
  if (
435
435
  matchesKey(data, "right") ||
436
- data === "l" ||
436
+ matchesKey(data, "l") ||
437
437
  matchesKey(data, "enter") ||
438
438
  matchesKey(data, "return") ||
439
439
  data === "\n"
@@ -444,12 +444,12 @@ export class PlanReviewOverlay implements Component {
444
444
  // Vertical nav flows between regions at the edges: scrolling off the bottom
445
445
  // drops into the actions ("next step"); scrolling off the top steps back up
446
446
  // to the ToC.
447
- if (matchesSelectUp(data) || data === "k") {
447
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
448
448
  if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) this.#setFocus("toc");
449
449
  else this.#scrollView.scroll(-1);
450
450
  return;
451
451
  }
452
- if (matchesSelectDown(data) || data === "j") {
452
+ if (matchesSelectDown(data) || matchesKey(data, "j")) {
453
453
  if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) this.#setFocus("actions");
454
454
  else this.#scrollView.scroll(1);
455
455
  return;
@@ -470,11 +470,11 @@ export class PlanReviewOverlay implements Component {
470
470
  }
471
471
 
472
472
  #handleToc(data: string): void {
473
- if (matchesSelectUp(data) || data === "k") {
473
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
474
474
  this.#moveTocCursor(-1);
475
475
  return;
476
476
  }
477
- if (matchesSelectDown(data) || data === "j") {
477
+ if (matchesSelectDown(data) || matchesKey(data, "j")) {
478
478
  // Past the last section, fall through to the actions ("next step").
479
479
  if (this.#tocCursor >= this.#toc.length - 1) this.#setFocus("actions");
480
480
  else this.#moveTocCursor(1);
@@ -482,7 +482,7 @@ export class PlanReviewOverlay implements Component {
482
482
  }
483
483
  if (
484
484
  matchesKey(data, "right") ||
485
- data === "l" ||
485
+ matchesKey(data, "l") ||
486
486
  matchesKey(data, "enter") ||
487
487
  matchesKey(data, "return") ||
488
488
  data === "\n"
@@ -37,9 +37,6 @@ export function readArgsTargetInternalUrl(args: unknown): boolean {
37
37
  type ReadRenderArgs = {
38
38
  path?: string;
39
39
  file_path?: string;
40
- selector?: string;
41
- // Legacy field from the old schema; tolerated for rebuilt transcripts.
42
- sel?: string;
43
40
  };
44
41
 
45
42
  type ReadToolSuffixResolution = {
@@ -344,11 +341,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
344
341
 
345
342
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
346
343
  if (!toolCallId) return;
347
- const basePath = args.file_path || args.path || "";
348
- const rawSelector =
349
- typeof args.selector === "string" ? args.selector : typeof args.sel === "string" ? args.sel : undefined;
350
- const selector = rawSelector?.trim().replace(/^:+/, "");
351
- const rawPath = selector && selector.length > 0 ? `${basePath}:${selector}` : basePath;
344
+ const rawPath = args.file_path || args.path || "";
352
345
  const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
353
346
  toolCallId,
354
347
  path: rawPath,
@@ -488,14 +488,16 @@ export class InputController {
488
488
  // main session, or returns the focused subagent view to the main session.
489
489
  // Focused ←← intentionally matches Esc. From the main session the gesture
490
490
  // stays inert when there are no subagents (requireContent); the explicit
491
- // hub key still opens the empty roster.
491
+ // hub key still opens the empty roster. `armCloseTap` hands this gesture's
492
+ // tap state to the hub so the same ←← that opened it also arms its close —
493
+ // otherwise the hub's fresh detector demands a second ←← (issue #4780).
492
494
  this.ctx.editor.onLeftAtStart = () => {
493
495
  if (this.ctx.focusedAgentId) {
494
496
  this.#handleFocusedLeftTap();
495
497
  return;
496
498
  }
497
499
  if (this.#detectLeftDoubleTap()) {
498
- this.ctx.showAgentHub({ requireContent: true });
500
+ this.ctx.showAgentHub({ requireContent: true, armCloseTap: true });
499
501
  }
500
502
  };
501
503
 
@@ -14,6 +14,7 @@ import {
14
14
  fetchResourceMetadataScopes,
15
15
  loadAllMCPConfigs,
16
16
  MCPManager,
17
+ type OAuthEndpoints,
17
18
  } from "../../mcp";
18
19
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
19
20
  import {
@@ -603,6 +604,7 @@ export class MCPCommandController {
603
604
  callbackPath: finalConfig.oauth?.callbackPath,
604
605
  redirectUri: finalConfig.oauth?.redirectUri,
605
606
  prompt: finalConfig.oauth?.prompt,
607
+ registrationUrl: oauth.registrationUrl,
606
608
  serverUrl: finalConfig.url,
607
609
  resource: oauthResource,
608
610
  stripSameOriginResource: oauthResourceIsFallback,
@@ -684,6 +686,7 @@ export class MCPCommandController {
684
686
  redirectUri?: string;
685
687
  prompt?: string;
686
688
  serverUrl?: string;
689
+ registrationUrl?: string;
687
690
  resource?: string;
688
691
  stripSameOriginResource?: boolean;
689
692
  /**
@@ -747,6 +750,7 @@ export class MCPCommandController {
747
750
  {
748
751
  authorizationUrl: authUrl,
749
752
  tokenUrl: tokenUrl,
753
+ registrationUrl: opts?.registrationUrl,
750
754
  clientId: resolvedClientId,
751
755
  clientSecret: resolvedClientSecret,
752
756
  scopes: scopes || undefined,
@@ -1038,13 +1042,7 @@ export class MCPCommandController {
1038
1042
  return next;
1039
1043
  }
1040
1044
 
1041
- async #resolveOAuthEndpointsFromServer(config: MCPServerConfig): Promise<{
1042
- authorizationUrl: string;
1043
- tokenUrl: string;
1044
- clientId?: string;
1045
- scopes?: string;
1046
- resource?: string;
1047
- }> {
1045
+ async #resolveOAuthEndpointsFromServer(config: MCPServerConfig): Promise<OAuthEndpoints> {
1048
1046
  // Stdio servers manage credentials inside the child process; OMP's OAuth
1049
1047
  // flow only applies to http/sse transports. Without this guard the
1050
1048
  // unauthenticated preflight below spawns the child, which happily reuses
@@ -1739,6 +1737,7 @@ export class MCPCommandController {
1739
1737
  callbackPath: found.config.oauth?.callbackPath,
1740
1738
  redirectUri: found.config.oauth?.redirectUri,
1741
1739
  prompt: found.config.oauth?.prompt,
1740
+ registrationUrl: oauth.registrationUrl,
1742
1741
  serverUrl,
1743
1742
  resource: oauthResource,
1744
1743
  stripSameOriginResource: oauthResourceIsFallback,
@@ -719,7 +719,7 @@ export class SelectorController {
719
719
  if (role === "default") {
720
720
  const { switched } = await this.ctx.session.setModel(model, role, {
721
721
  selector,
722
- thinkingLevel: concreteThinking,
722
+ thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking,
723
723
  persist: true,
724
724
  currentContextTokens,
725
725
  });
@@ -1544,7 +1544,10 @@ export class SelectorController {
1544
1544
  });
1545
1545
  }
1546
1546
 
1547
- showAgentHub(observers: SessionObserverRegistry, options?: { requireContent?: boolean }): void {
1547
+ showAgentHub(
1548
+ observers: SessionObserverRegistry,
1549
+ options?: { requireContent?: boolean; armCloseTap?: boolean },
1550
+ ): void {
1548
1551
  const hubKeys = [
1549
1552
  ...this.ctx.keybindings.getKeys("app.agents.hub"),
1550
1553
  ...this.ctx.keybindings.getKeys("app.session.observe"),
@@ -1599,6 +1602,10 @@ export class SelectorController {
1599
1602
  this.ctx.editorContainer.clear();
1600
1603
  this.ctx.editorContainer.addChild(hub);
1601
1604
  this.ctx.ui.setFocus(hub);
1605
+ // When the hub was raised by the editor's double-← gesture, prime its own
1606
+ // close detector so the *next* single ← dismisses it — the two taps that
1607
+ // opened it were consumed by the editor's detector (issue #4780).
1608
+ if (options?.armCloseTap) hub.armCloseTap();
1602
1609
  this.ctx.ui.requestRender();
1603
1610
  };
1604
1611
 
@@ -116,16 +116,18 @@ function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; ph
116
116
  // Build system reminder
117
117
  // =============================================================================
118
118
 
119
- function buildSystemReminder(action: string, phases: TodoPhase[]): string {
119
+ function buildSystemReminder(action: string, phases: TodoPhase[], removed = false): string {
120
120
  const md = phases.length === 0 ? "(empty)" : phasesToMarkdown(phases).trimEnd();
121
- return [
122
- "<system-reminder>",
123
- `The user manually modified the todo list (${action}).`,
124
- "Current todo list:",
125
- "",
126
- md,
127
- "</system-reminder>",
128
- ].join("\n");
121
+ const lines = ["<system-reminder>", `The user manually modified the todo list (${action}).`];
122
+ if (removed) {
123
+ lines.push(
124
+ phases.length === 0
125
+ ? "The user intentionally cleared the todo list. Do NOT recreate or re-populate it unless the user explicitly asks; continue the current request without a todo list."
126
+ : "The user intentionally removed the entries no longer shown below. Do NOT re-add them unless the user explicitly asks.",
127
+ );
128
+ }
129
+ lines.push("Current todo list:", "", md, "</system-reminder>");
130
+ return lines.join("\n");
129
131
  }
130
132
 
131
133
  export class TodoCommandController {
@@ -366,7 +368,7 @@ export class TodoCommandController {
366
368
  const current = this.#currentPhases();
367
369
  const trimmed = rest.trim();
368
370
  if (!trimmed) {
369
- this.#commit([], "/todo rm (all)");
371
+ this.#commit([], "/todo rm (all)", { removed: true });
370
372
  this.ctx.showStatus("Cleared all todos.");
371
373
  return;
372
374
  }
@@ -377,7 +379,7 @@ export class TodoCommandController {
377
379
  this.ctx.showError(errors.join("; "));
378
380
  return;
379
381
  }
380
- this.#commit(phases, `/todo rm ${taskHit.task.content}`);
382
+ this.#commit(phases, `/todo rm ${taskHit.task.content}`, { removed: true });
381
383
  this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
382
384
  return;
383
385
  }
@@ -388,7 +390,7 @@ export class TodoCommandController {
388
390
  this.ctx.showError(errors.join("; "));
389
391
  return;
390
392
  }
391
- this.#commit(phases, `/todo rm ${phaseHit.name}`);
393
+ this.#commit(phases, `/todo rm ${phaseHit.name}`, { removed: true });
392
394
  this.ctx.showStatus(`Removed phase: ${phaseHit.name}`);
393
395
  return;
394
396
  }
@@ -454,7 +456,7 @@ export class TodoCommandController {
454
456
  }
455
457
  }
456
458
 
457
- #commit(nextPhases: TodoPhase[], action: string): void {
459
+ #commit(nextPhases: TodoPhase[], action: string, opts?: { removed?: boolean }): void {
458
460
  // 1. In-memory + UI state
459
461
  this.ctx.session.setTodoPhases(nextPhases);
460
462
  this.ctx.setTodos(nextPhases);
@@ -463,7 +465,9 @@ export class TodoCommandController {
463
465
  this.ctx.sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { phases: nextPhases });
464
466
 
465
467
  // 3. Inject system reminder so the agent learns about the change next turn.
466
- const reminderText = buildSystemReminder(action, nextPhases);
468
+ // Removals carry explicit intent so the agent does not rebuild the
469
+ // cleared/removed items on its next turn (issue #5258).
470
+ const reminderText = buildSystemReminder(action, nextPhases, opts?.removed ?? false);
467
471
  const message = {
468
472
  role: "developer" as const,
469
473
  content: [{ type: "text" as const, text: reminderText }],
@@ -71,7 +71,7 @@ import type {
71
71
  import type { CompactOptions } from "../extensibility/extensions/types";
72
72
  import type { Skill } from "../extensibility/skills";
73
73
  import { loadSlashCommands } from "../extensibility/slash-commands";
74
- import { type GuidedGoalMessage, runGuidedGoalTurn } from "../goals/guided-setup";
74
+ import { type GuidedGoalMessage, newGuidedGoalSessionId, runGuidedGoalTurn } from "../goals/guided-setup";
75
75
  import type { Goal, GoalModeState } from "../goals/state";
76
76
  import { resolveLocalUrlToPath } from "../internal-urls";
77
77
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
@@ -3115,8 +3115,12 @@ export class InteractiveMode implements InteractiveModeContext {
3115
3115
 
3116
3116
  const messages: GuidedGoalMessage[] = [{ role: "user", content: initial }];
3117
3117
  let latestDraftObjective: string | undefined;
3118
+ // One Codex side session for the whole interview: every follow-up turn
3119
+ // reuses it so a multi-question interview shares a single websocket-only
3120
+ // Codex socket instead of leaking one per turn (#5471 review).
3121
+ const guidedGoalSessionId = newGuidedGoalSessionId(this.session);
3118
3122
  for (let turn = 0; turn < 6; turn++) {
3119
- const result = await runGuidedGoalTurn(this.session, { messages });
3123
+ const result = await runGuidedGoalTurn(this.session, { messages, sideSessionId: guidedGoalSessionId });
3120
3124
  if (result.objective?.trim()) latestDraftObjective = result.objective.trim();
3121
3125
  if (result.kind === "question") {
3122
3126
  messages.push({ role: "assistant", content: result.question });
@@ -4156,7 +4160,7 @@ export class InteractiveMode implements InteractiveModeContext {
4156
4160
  await this.#selectorController.showDebugSelector();
4157
4161
  }
4158
4162
 
4159
- showAgentHub(options?: { requireContent?: boolean }): void {
4163
+ showAgentHub(options?: { requireContent?: boolean; armCloseTap?: boolean }): void {
4160
4164
  this.#selectorController.showAgentHub(this.#observerRegistry, options);
4161
4165
  }
4162
4166
 
@@ -156,7 +156,12 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
156
156
  const commandName = commandText.slice(1, spaceIndex);
157
157
  const command = this.#commands.find(cmd => cmd.name === commandName || cmd.aliases?.includes(commandName));
158
158
  if (command && (!("allowArgs" in command) || command.allowArgs !== false)) {
159
- return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
159
+ const argumentSuggestions = await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
160
+ if (argumentSuggestions) return argumentSuggestions;
161
+ // No slash-argument completion for this input: fall through to
162
+ // internal-url completion only. `#` prompt-action tokens stay
163
+ // literal text inside slash command arguments.
164
+ return getInternalUrlSuggestions(textBeforeCursor, this.#basePath);
160
165
  }
161
166
  }
162
167
 
@@ -368,7 +368,7 @@ export interface InteractiveModeContext {
368
368
  showProviderSetup(): Promise<void>;
369
369
  showHookConfirm(title: string, message: string): Promise<boolean>;
370
370
  showDebugSelector(): Promise<void>;
371
- showAgentHub(options?: { requireContent?: boolean }): void;
371
+ showAgentHub(options?: { requireContent?: boolean; armCloseTap?: boolean }): void;
372
372
  resetObserverRegistry(): void;
373
373
 
374
374
  // Input handling
@@ -57,6 +57,7 @@ Special URLs for internal resources; with most FS/bash tools they auto-resolve t
57
57
  - `memory://root`: project memory summary
58
58
  {{/if}}
59
59
  - `agent://<id>`: agent output artifact; `/<path>` extracts a JSON field
60
+ - `history://<id>`: read-only markdown transcript of an agent (live, parked, or released); bare `history://` lists all agents. Serves any agent whose session file persists on disk, not just registered peers.
60
61
  - `artifact://<id>`: artifact content
61
62
  - `local://<name>.md`: plan artifacts or shared content for subagents
62
63
  {{#if hasObsidian}}