@oh-my-pi/pi-coding-agent 16.3.4 → 16.3.6

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 (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli.js +3522 -3485
  3. package/dist/types/edit/file-snapshot-store.d.ts +4 -2
  4. package/dist/types/edit/renderer.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  6. package/dist/types/extensibility/shared-events.d.ts +8 -1
  7. package/dist/types/hindsight/content.d.ts +4 -0
  8. package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
  9. package/dist/types/internal-urls/types.d.ts +10 -0
  10. package/dist/types/lsp/config.d.ts +4 -0
  11. package/dist/types/lsp/edits.d.ts +2 -0
  12. package/dist/types/mcp/oauth-discovery.d.ts +30 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +15 -10
  14. package/dist/types/modes/components/bash-execution.d.ts +6 -0
  15. package/dist/types/modes/components/eval-execution.d.ts +6 -0
  16. package/dist/types/modes/components/login-dialog.d.ts +10 -2
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -13
  18. package/dist/types/modes/components/transcript-container.d.ts +26 -29
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
  20. package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
  21. package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
  22. package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/subprocess/worker-runtime.d.ts +11 -0
  25. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  26. package/dist/types/tools/bash.d.ts +0 -2
  27. package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
  28. package/dist/types/tools/eval-render.d.ts +0 -2
  29. package/dist/types/tools/renderers.d.ts +0 -20
  30. package/dist/types/tools/ssh.d.ts +0 -2
  31. package/dist/types/tools/write.d.ts +1 -1
  32. package/dist/types/utils/git.d.ts +16 -0
  33. package/package.json +12 -12
  34. package/src/cli/auth-broker-cli.ts +13 -2
  35. package/src/cli/tiny-models-cli.ts +12 -5
  36. package/src/edit/file-snapshot-store.ts +5 -2
  37. package/src/edit/renderer.ts +0 -5
  38. package/src/extensibility/custom-tools/types.ts +2 -0
  39. package/src/extensibility/shared-events.ts +9 -1
  40. package/src/hindsight/content.ts +31 -0
  41. package/src/internal-urls/artifact-protocol.ts +97 -53
  42. package/src/internal-urls/types.ts +10 -0
  43. package/src/lsp/config.ts +15 -0
  44. package/src/lsp/edits.ts +28 -7
  45. package/src/lsp/index.ts +46 -4
  46. package/src/mcp/oauth-discovery.ts +88 -18
  47. package/src/mnemopi/state.ts +26 -2
  48. package/src/modes/components/assistant-message.ts +134 -82
  49. package/src/modes/components/bash-execution.ts +9 -0
  50. package/src/modes/components/chat-transcript-builder.ts +8 -4
  51. package/src/modes/components/eval-execution.ts +9 -0
  52. package/src/modes/components/login-dialog.ts +16 -2
  53. package/src/modes/components/mcp-add-wizard.ts +9 -1
  54. package/src/modes/components/tool-execution.ts +4 -50
  55. package/src/modes/components/transcript-container.ts +82 -458
  56. package/src/modes/components/tree-selector.ts +9 -3
  57. package/src/modes/controllers/command-controller.ts +0 -3
  58. package/src/modes/controllers/event-controller.ts +74 -14
  59. package/src/modes/controllers/extension-ui-controller.ts +0 -1
  60. package/src/modes/controllers/input-controller.ts +4 -10
  61. package/src/modes/controllers/mcp-command-controller.ts +106 -29
  62. package/src/modes/controllers/selector-controller.ts +9 -1
  63. package/src/modes/interactive-mode.ts +12 -8
  64. package/src/modes/rpc/rpc-client.ts +8 -4
  65. package/src/modes/rpc/rpc-mode.ts +1 -0
  66. package/src/modes/rpc/rpc-types.ts +13 -1
  67. package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
  68. package/src/modes/utils/transcript-render-helpers.ts +40 -13
  69. package/src/modes/utils/ui-helpers.ts +12 -7
  70. package/src/prompts/tools/read.md +1 -1
  71. package/src/sdk.ts +1 -0
  72. package/src/session/agent-session.ts +148 -1
  73. package/src/session/session-context.ts +7 -0
  74. package/src/subprocess/worker-runtime.ts +219 -2
  75. package/src/task/worktree.ts +28 -6
  76. package/src/tiny/worker.ts +14 -4
  77. package/src/tools/bash-skill-urls.ts +3 -3
  78. package/src/tools/bash.ts +0 -4
  79. package/src/tools/browser/tab-supervisor.ts +47 -3
  80. package/src/tools/eval-render.ts +0 -20
  81. package/src/tools/grep.ts +19 -2
  82. package/src/tools/path-utils.ts +4 -0
  83. package/src/tools/read.ts +261 -21
  84. package/src/tools/renderers.ts +0 -20
  85. package/src/tools/ssh.ts +0 -16
  86. package/src/tools/write.ts +13 -6
  87. package/src/utils/git.ts +20 -0
  88. package/src/utils/open.ts +51 -6
@@ -18,6 +18,7 @@ import type { SessionTreeNode } from "../../session/session-entries";
18
18
  import { toPathList } from "../../tools/path-utils";
19
19
  import { shortenPath } from "../../tools/render-utils";
20
20
  import { canonicalizeMessage } from "../../utils/thinking-display";
21
+ import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
21
22
  import { DynamicBorder } from "./dynamic-border";
22
23
  import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers";
23
24
 
@@ -615,15 +616,20 @@ class TreeList implements Component {
615
616
  const content = normalize(this.#extractContent(msgWithContent.content));
616
617
  result = theme.fg("dim", "developer: ") + theme.fg("muted", content);
617
618
  } else if (role === "assistant") {
619
+ const presentation = resolveAssistantErrorPresentation(msg);
620
+ if (presentation.kind === "compact-recovered") {
621
+ result = theme.fg("success", "assistant: ") + theme.fg("dim", presentation.text);
622
+ break;
623
+ }
618
624
  const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
619
625
  const textContent = normalize(this.#extractContent(msgWithContent.content));
620
626
  if (textContent) {
621
627
  result = theme.fg("success", "assistant: ") + textContent;
628
+ } else if (presentation.kind === "full") {
629
+ result =
630
+ theme.fg("success", "assistant: ") + theme.fg("error", normalize(presentation.text).slice(0, 80));
622
631
  } else if (msgWithContent.stopReason === "aborted") {
623
632
  result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
624
- } else if (msgWithContent.errorMessage) {
625
- const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
626
- result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
627
633
  } else {
628
634
  result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
629
635
  }
@@ -1029,9 +1029,6 @@ export class CommandController {
1029
1029
  return;
1030
1030
  }
1031
1031
  const name = this.ctx.sessionManager.getSessionName()!;
1032
- setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd());
1033
- this.ctx.statusLine.invalidate();
1034
- this.ctx.updateEditorBorderColor();
1035
1032
  this.ctx.showStatus(`Session renamed to "${name}".`);
1036
1033
  } catch (err) {
1037
1034
  this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -84,6 +84,8 @@ export class EventController {
84
84
  // restored when the banner clears at the next `agent_start` (see
85
85
  // #handleMessageEnd / #handleAgentStart).
86
86
  #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
87
+ #retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
88
+ #retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
87
89
  #idleCompactionTimer?: NodeJS.Timeout;
88
90
  #idleRecapTimer?: NodeJS.Timeout;
89
91
  // In-flight ephemeral recap turn; aborted by #cancelIdleRecap when any
@@ -325,6 +327,42 @@ export class EventController {
325
327
  this.#terminalProgressActive = false;
326
328
  }
327
329
 
330
+ #trackRetrySupersededAssistantComponent(component: AssistantMessageComponent | undefined): void {
331
+ if (!component) return;
332
+ const persistenceKey = component.messagePersistenceKey();
333
+ if (persistenceKey) this.#retrySupersededAssistantComponents.set(persistenceKey, component);
334
+ if (!this.#retrySupersededAssistantQueue.includes(component)) {
335
+ this.#retrySupersededAssistantQueue.push(component);
336
+ }
337
+ }
338
+
339
+ #takeRetrySupersededAssistantComponent(persistenceKey: string | undefined): AssistantMessageComponent | undefined {
340
+ if (persistenceKey) {
341
+ const component = this.#retrySupersededAssistantComponents.get(persistenceKey);
342
+ if (component) {
343
+ this.#retrySupersededAssistantComponents.delete(persistenceKey);
344
+ this.#retrySupersededAssistantQueue = this.#retrySupersededAssistantQueue.filter(
345
+ item => item !== component,
346
+ );
347
+ return component;
348
+ }
349
+ }
350
+ while (this.#retrySupersededAssistantQueue.length > 0) {
351
+ const component = this.#retrySupersededAssistantQueue.shift();
352
+ if (!component) continue;
353
+ const key = component.messagePersistenceKey();
354
+ if (key && this.#retrySupersededAssistantComponents.get(key) !== component) continue;
355
+ if (key) this.#retrySupersededAssistantComponents.delete(key);
356
+ return component;
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ #clearRetrySupersededAssistantComponents(): void {
362
+ this.#retrySupersededAssistantComponents.clear();
363
+ this.#retrySupersededAssistantQueue = [];
364
+ }
365
+
328
366
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
329
367
  this.#lastIntent = undefined;
330
368
  this.#readToolCallArgs.clear();
@@ -468,7 +506,7 @@ export class EventController {
468
506
  if (!components) return;
469
507
  let removed = false;
470
508
  for (const component of components) {
471
- if (!this.ctx.chatContainer.isWithinLiveRegion(component)) continue;
509
+ if (!this.ctx.chatContainer.isBlockUncommitted(component)) continue;
472
510
  this.ctx.chatContainer.removeChild(component);
473
511
  removed = true;
474
512
  }
@@ -493,16 +531,20 @@ export class EventController {
493
531
  * Resolve the pending displaceable poll block before the next block lands.
494
532
  * A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
495
533
  * is removed so repeated polls read as one persistent poll — while anything
496
- * else seals it in place as final history. Removal is safe only because a
497
- * displaceable block never finalizes: commits stop at the first live block,
498
- * so none of its rows have entered native scrollback (see
499
- * ToolExecutionComponent.isDisplaceableBlock).
534
+ * else seals it in place as final history. Removal is gated on none of the
535
+ * block's rows having entered native scrollback: rows already on the tape
536
+ * are immutable visual history, so a scrolled-off poll seals instead of
537
+ * being retracted.
500
538
  */
501
539
  #resolveDisplaceablePoll(nextToolName?: string): void {
502
540
  const previous = this.#displaceablePollComponent;
503
541
  if (!previous) return;
504
542
  this.#displaceablePollComponent = undefined;
505
- if (nextToolName === "job" && previous.isDisplaceableBlock()) {
543
+ if (
544
+ nextToolName === "job" &&
545
+ previous.isDisplaceableBlock() &&
546
+ this.ctx.chatContainer.isBlockUncommitted(previous)
547
+ ) {
506
548
  this.ctx.chatContainer.removeChild(previous);
507
549
  }
508
550
  // Sealing stops the waiting-poll spinner and freezes the block (for a
@@ -520,7 +562,9 @@ export class EventController {
520
562
  }
521
563
  if (previous.canBeDisplacedBy(nextToolName)) {
522
564
  this.#displaceableTodoComponent = undefined;
523
- this.ctx.chatContainer.removeChild(previous);
565
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
566
+ this.ctx.chatContainer.removeChild(previous);
567
+ }
524
568
  previous.seal();
525
569
  this.ctx.ui.requestRender();
526
570
  return;
@@ -980,7 +1024,9 @@ export class EventController {
980
1024
  const previous = this.#displaceableTodoComponent;
981
1025
  if (previous && previous !== component && previous.isDisplaceableBlock()) {
982
1026
  this.#displaceableTodoComponent = undefined;
983
- this.ctx.chatContainer.removeChild(previous);
1027
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
1028
+ this.ctx.chatContainer.removeChild(previous);
1029
+ }
984
1030
  previous.seal();
985
1031
  }
986
1032
  this.#displaceableTodoComponent = component;
@@ -1219,6 +1265,7 @@ export class EventController {
1219
1265
  }
1220
1266
 
1221
1267
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1268
+ this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
1222
1269
  this.#stopWorkingLoader();
1223
1270
  this.ctx.statusContainer.clear();
1224
1271
  if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
@@ -1246,7 +1293,21 @@ export class EventController {
1246
1293
  this.ctx.retryLoader = undefined;
1247
1294
  this.ctx.statusContainer.clear();
1248
1295
  }
1249
- if (!event.success) {
1296
+ if (event.success) {
1297
+ let appliedRecovered = false;
1298
+ for (const recovered of event.recoveredErrors ?? []) {
1299
+ const component = this.#takeRetrySupersededAssistantComponent(recovered.persistenceKey);
1300
+ if (!component) continue;
1301
+ component.applyRetryRecovery(recovered.retryRecovery);
1302
+ if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
1303
+ appliedRecovered = true;
1304
+ }
1305
+ if (appliedRecovered || (event.recoveredErrors?.length ?? 0) > 0) {
1306
+ this.ctx.clearPinnedError();
1307
+ }
1308
+ this.#clearRetrySupersededAssistantComponents();
1309
+ } else {
1310
+ this.#clearRetrySupersededAssistantComponents();
1250
1311
  this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
1251
1312
  }
1252
1313
  this.#ensureWorkingLoaderWhileStreaming();
@@ -1268,15 +1329,14 @@ export class EventController {
1268
1329
  async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
1269
1330
  // Consecutive notifications (e.g. per-tool matches from one assistant
1270
1331
  // message) merge into the previous block instead of stacking. Mutating an
1271
- // existing block is only safe while it sits inside the live region — a
1272
- // still-mutating block above it means none of its rows have been committed
1273
- // to native scrollback yet (commits are prefix-only and stop at the first
1274
- // live block), so the grown block still repaints.
1332
+ // existing block is only safe while none of its rows have entered native
1333
+ // scrollback committed rows are immutable visual history and a grown
1334
+ // block would shift them.
1275
1335
  const previous = this.#lastTtsrNotification;
1276
1336
  if (
1277
1337
  previous &&
1278
1338
  this.ctx.chatContainer.children.at(-1) === previous &&
1279
- this.ctx.chatContainer.isWithinLiveRegion(previous)
1339
+ this.ctx.chatContainer.isBlockUncommitted(previous)
1280
1340
  ) {
1281
1341
  previous.addRules(event.rules);
1282
1342
  this.ctx.ui.requestRender();
@@ -880,7 +880,6 @@ export class ExtensionUiController {
880
880
 
881
881
  async #updateSessionName(name: string): Promise<void> {
882
882
  await this.ctx.sessionManager.setSessionName(name, "user");
883
- setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
884
883
  }
885
884
 
886
885
  #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
@@ -33,7 +33,7 @@ import { EnhancedPasteController } from "../../utils/enhanced-paste";
33
33
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
34
34
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
35
35
  import { resizeImage } from "../../utils/image-resize";
36
- import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
36
+ import { generateSessionTitle } from "../../utils/title-generator";
37
37
 
38
38
  /**
39
39
  * Slash commands that may carry secrets in their arguments should never be
@@ -843,16 +843,10 @@ export class InputController {
843
843
  )
844
844
  .then(async title => {
845
845
  // Re-check: a concurrent attempt for an earlier message may have
846
- // already named the session. Don't clobber it.
846
+ // already named the session. Don't clobber it. Terminal title and
847
+ // accent updates fire from the onSessionNameChanged listener.
847
848
  if (title && !this.ctx.sessionManager.getSessionName()) {
848
- const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
849
- if (applied) {
850
- setSessionTerminalTitle(
851
- this.ctx.sessionManager.getSessionName()!,
852
- this.ctx.sessionManager.getCwd(),
853
- );
854
- this.ctx.updateEditorBorderColor();
855
- }
849
+ await this.ctx.sessionManager.setSessionName(title, "auto");
856
850
  }
857
851
  })
858
852
  .catch(err => {
@@ -8,7 +8,13 @@ import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
8
8
  import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
9
9
  import type { SourceMeta } from "../../capability/types";
10
10
  import { expandEnvVarsDeep } from "../../discovery/helpers";
11
- import { analyzeAuthError, discoverOAuthEndpoints, loadAllMCPConfigs, MCPManager } from "../../mcp";
11
+ import {
12
+ analyzeAuthError,
13
+ discoverOAuthEndpoints,
14
+ fetchResourceMetadataScopes,
15
+ loadAllMCPConfigs,
16
+ MCPManager,
17
+ } from "../../mcp";
12
18
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
13
19
  import {
14
20
  addMCPServer,
@@ -43,6 +49,7 @@ import {
43
49
  import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
44
50
  import { shortenPath } from "../../tools/render-utils";
45
51
  import { urlHyperlinkAlways } from "../../tui";
52
+ import { copyToClipboard } from "../../utils/clipboard";
46
53
  import { openPath } from "../../utils/open";
47
54
  import { ChatBlock } from "../components/chat-block";
48
55
  import { MCPAddWizard } from "../components/mcp-add-wizard";
@@ -73,23 +80,77 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
73
80
  });
74
81
  }
75
82
 
76
- /** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
83
+ /**
84
+ * Minimum column budget for URL wrapping. Below this the terminal is
85
+ * effectively unusable, but we still emit chunks so no character is silently
86
+ * dropped and the user can widen and reflow.
87
+ */
88
+ const MCP_AUTH_MIN_WRAP_WIDTH = 16;
89
+
90
+ /**
91
+ * Wrap `url` into rows that each fit inside `width`, prefixed by a shared
92
+ * single-column indent so nested composition doesn't touch column 0. When the
93
+ * label + URL fit on one line, returns a single row; otherwise puts the label
94
+ * on its own row and slices the URL into fixed-width chunks. URL chunks are
95
+ * plain code points — browsers strip whitespace when pasted into the address
96
+ * bar, so a multi-row selection copies back to the intact URL.
97
+ */
98
+ function wrapUrlRows(label: string, url: string, width: number): string[] {
99
+ const indent = " ";
100
+ const sanitized = replaceTabs(url);
101
+ const effective = Math.max(MCP_AUTH_MIN_WRAP_WIDTH, Math.trunc(width));
102
+ const inlineWidth = indent.length + label.length + 1 + sanitized.length;
103
+ if (inlineWidth <= effective) {
104
+ return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
105
+ }
106
+ const chunkWidth = Math.max(1, effective - indent.length);
107
+ const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
108
+ for (let i = 0; i < sanitized.length; i += chunkWidth) {
109
+ rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
110
+ }
111
+ return rows;
112
+ }
113
+
114
+ /**
115
+ * Renders the MCP OAuth fallback URL. Always shows the full authorization URL
116
+ * as the primary `Copy URL:` target — that works from any machine, including
117
+ * SSH/WSL/headless sessions where the OMP-hosted `/launch` loopback URL would
118
+ * resolve against the user's local browser and fail.
119
+ *
120
+ * The render is `width`-aware: on any viewport narrower than the composed row
121
+ * ({@link TUI#prepareLine} truncates anything wider with `Ellipsis.Omit`, no
122
+ * marker), the URL is hard-wrapped into width-fitted rows so the primary copy
123
+ * target can never silently lose trailing OAuth parameters — the failure mode
124
+ * that motivated #4418 in the first place. Browsers strip whitespace when a
125
+ * multi-row selection is pasted into the address bar, so the reassembled URL
126
+ * is byte-identical to what we rendered.
127
+ *
128
+ * When the flow's callback server hosts a short `launchUrl`, it is offered
129
+ * as an additional local shortcut for wide-terminal local users. The OSC 8
130
+ * hyperlink continues to carry the full URL for terminals that support it.
131
+ */
77
132
  export class MCPAuthorizationLinkPrompt implements Component {
78
- readonly #url: string;
133
+ readonly #fullUrl: string;
134
+ readonly #launchUrl: string | undefined;
79
135
 
80
- constructor(url: string) {
81
- this.#url = url;
136
+ constructor(url: string, launchUrl?: string) {
137
+ this.#fullUrl = url;
138
+ this.#launchUrl = launchUrl && launchUrl !== url ? launchUrl : undefined;
82
139
  }
83
140
 
84
141
  invalidate(): void {}
85
142
 
86
- render(_width: number): readonly string[] {
87
- const link = urlHyperlinkAlways(this.#url, "Click here to authorize");
88
- return [
143
+ render(width: number): readonly string[] {
144
+ const link = urlHyperlinkAlways(this.#fullUrl, "Click here to authorize");
145
+ const lines: string[] = [
89
146
  ` ${theme.fg("success", "Open authorization URL:")}`,
90
147
  ` ${theme.fg("accent", link)}`,
91
- ` ${theme.fg("muted", `Copy URL: ${replaceTabs(this.#url)}`)}`,
148
+ ...wrapUrlRows("Copy URL:", this.#fullUrl, width),
92
149
  ];
150
+ if (this.#launchUrl) {
151
+ lines.push(...wrapUrlRows("Local shortcut (this machine only):", this.#launchUrl, width));
152
+ }
153
+ return lines;
93
154
  }
94
155
  }
95
156
 
@@ -506,11 +567,18 @@ export class MCPCommandController {
506
567
  finalConfig.url,
507
568
  authResult.authServerUrl,
508
569
  authResult.resourceMetadataUrl,
570
+ { protectedScopes: authResult.scopes },
509
571
  );
510
572
  } catch {
511
573
  // Ignore discovery error and handle below.
512
574
  }
513
575
  }
576
+ if (oauth && !oauth.scopes && authResult.resourceMetadataUrl) {
577
+ // JSON-error-body path skips `discoverOAuthEndpoints`; fetch the
578
+ // advertised protected-resource metadata for the required scopes.
579
+ const scopes = await fetchResourceMetadataScopes(authResult.resourceMetadataUrl);
580
+ if (scopes) oauth = { ...oauth, scopes };
581
+ }
514
582
 
515
583
  if (!oauth) {
516
584
  this.ctx.showError(
@@ -689,7 +757,7 @@ export class MCPCommandController {
689
757
  stripSameOriginResource: opts?.stripSameOriginResource,
690
758
  },
691
759
  {
692
- onAuth: (info: { url: string; instructions?: string }) => {
760
+ onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
693
761
  // Show auth URL prominently in chat as one block
694
762
  const block = new TranscriptBlock();
695
763
  this.ctx.present(block);
@@ -707,24 +775,25 @@ export class MCPCommandController {
707
775
  block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
708
776
  block.addChild(new Spacer(1));
709
777
  block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
710
- // Try to open browser automatically
711
- try {
712
- openPath(info.url);
713
-
714
- // Show confirmation that browser should open
715
- block.addChild(new Spacer(1));
716
- block.addChild(new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0));
717
- block.addChild(new Spacer(1));
718
- block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
719
- block.addChild(new MCPAuthorizationLinkPrompt(info.url));
720
- this.ctx.ui.requestRender();
721
- } catch (_error) {
722
- // Show error if browser doesn't open
723
- block.addChild(new Spacer(1));
724
- block.addChild(new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0));
725
- block.addChild(new MCPAuthorizationLinkPrompt(info.url));
726
- this.ctx.ui.requestRender();
727
- }
778
+ // `openPath` is best-effort it logs spawn failures but never
779
+ // throws, so we always render the copy-URL fallback beneath the
780
+ // "attempting to open browser" line and no earlier try/catch is
781
+ // worth keeping.
782
+ openPath(info.url);
783
+ // Stage the FULL authorization URL on the clipboard via OSC 52.
784
+ // The full URL works from any machine (unlike `launchUrl`, which
785
+ // only resolves against the OMP host), and OSC 52 is a
786
+ // wire-level protocol the terminal writes it to the user's
787
+ // LOCAL clipboard even when OMP is on a remote SSH box.
788
+ // Best-effort: falls back to the visible copy-URL rows below
789
+ // whether or not the terminal honors OSC 52.
790
+ void copyToClipboard(info.url).catch(() => {});
791
+ block.addChild(new Spacer(1));
792
+ block.addChild(new Text(theme.fg("success", "→ Attempting to open browser..."), 1, 0));
793
+ block.addChild(new Spacer(1));
794
+ block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
795
+ block.addChild(new MCPAuthorizationLinkPrompt(info.url, info.launchUrl));
796
+ this.ctx.ui.requestRender();
728
797
  },
729
798
  onProgress: (message: string) => {
730
799
  this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
@@ -1010,7 +1079,15 @@ export class MCPCommandController {
1010
1079
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
1011
1080
 
1012
1081
  if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
1013
- oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl, authResult.resourceMetadataUrl);
1082
+ oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl, authResult.resourceMetadataUrl, {
1083
+ protectedScopes: authResult.scopes,
1084
+ });
1085
+ }
1086
+ if (oauth && !oauth.scopes && authResult.resourceMetadataUrl) {
1087
+ // JSON-error-body path skips `discoverOAuthEndpoints`; fetch the
1088
+ // advertised protected-resource metadata for the required scopes.
1089
+ const scopes = await fetchResourceMetadataScopes(authResult.resourceMetadataUrl);
1090
+ if (scopes) oauth = { ...oauth, scopes };
1014
1091
  }
1015
1092
 
1016
1093
  if (!oauth) {
@@ -1117,11 +1117,19 @@ export class SelectorController {
1117
1117
  const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
1118
1118
  try {
1119
1119
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
1120
- onAuth: (info: { url: string; instructions?: string }) => {
1120
+ onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
1121
1121
  const block = new TranscriptBlock();
1122
+ // Full URL first: works from any machine, including SSH boxes
1123
+ // where the OMP-hosted `launchUrl` would resolve against the
1124
+ // user's local browser and fail.
1122
1125
  block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
1123
1126
  const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
1124
1127
  block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
1128
+ if (info.launchUrl && info.launchUrl !== info.url) {
1129
+ block.addChild(
1130
+ new Text(theme.fg("dim", `Local shortcut (this machine only): ${info.launchUrl}`), 1, 0),
1131
+ );
1132
+ }
1125
1133
  if (info.instructions) {
1126
1134
  block.addChild(new Spacer(1));
1127
1135
  block.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
@@ -930,6 +930,17 @@ export class InteractiveMode implements InteractiveModeContext {
930
930
  pushTerminalTitle();
931
931
  setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
932
932
  this.updateEditorBorderColor();
933
+ // Single side-effect point for title changes: every setSessionName caller
934
+ // (first-input titling, /rename, extension renames, plan seeding, replan
935
+ // refresh) gets the terminal title + accent updates from here. Registered
936
+ // before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode —
937
+ // all of which can reach setSessionName during init.
938
+ this.#eventBusUnsubscribers.push(
939
+ this.sessionManager.onSessionNameChanged(() => {
940
+ setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
941
+ this.#handleSessionAccentInputsChanged();
942
+ }),
943
+ );
933
944
  this.#syncEditorMaxHeight();
934
945
  this.isInitialized = true;
935
946
  this.ui.requestRender(true);
@@ -986,9 +997,6 @@ export class InteractiveMode implements InteractiveModeContext {
986
997
  this.session.subscribe(event => {
987
998
  void this.#handleGoalSessionEvent(event);
988
999
  }),
989
- this.sessionManager.onSessionNameChanged(() => {
990
- this.#handleSessionAccentInputsChanged();
991
- }),
992
1000
  onStatusLineSessionAccentChanged(() => {
993
1001
  this.#syncStatusLineSettings();
994
1002
  this.#handleSessionAccentInputsChanged();
@@ -2658,11 +2666,7 @@ export class InteractiveMode implements InteractiveModeContext {
2658
2666
  // when the user has already chosen a name (preserveContext paths).
2659
2667
  const seededName = humanizePlanTitle(options.title);
2660
2668
  if (seededName && !this.sessionManager.getSessionName()) {
2661
- const applied = await this.sessionManager.setSessionName(seededName, "auto");
2662
- if (applied) {
2663
- setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
2664
- this.updateEditorBorderColor();
2665
- }
2669
+ await this.sessionManager.setSessionName(seededName, "auto");
2666
2670
  }
2667
2671
 
2668
2672
  // markPlanReferenceSent fires only on the dispatch path so the synthetic
@@ -724,17 +724,21 @@ export class RpcClient {
724
724
  * The server will emit an `open_url` extension_ui_request for the auth URL.
725
725
  * Resolves when login completes or rejects on failure.
726
726
  *
727
- * @param onOpenUrl Called when the server emits the auth URL. The host must open
728
- * it in a browser for the callback-server OAuth flow to complete.
727
+ * @param onOpenUrl Called when the server emits the auth URL. The host must
728
+ * open `url` in a browser for the callback-server OAuth flow to complete.
729
+ * When the flow's callback server hosts a `/launch` redirect, `launchUrl`
730
+ * is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
731
+ * the truncation-safe copy target so terminal viewport clipping cannot
732
+ * corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
729
733
  */
730
734
  async login(
731
735
  providerId: string,
732
- options?: { onOpenUrl?: (url: string, instructions?: string) => void },
736
+ options?: { onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void },
733
737
  ): Promise<{ providerId: string }> {
734
738
  const { onOpenUrl } = options ?? {};
735
739
  const listener = onOpenUrl
736
740
  ? (req: RpcExtensionUIRequest) => {
737
- if (req.method === "open_url") onOpenUrl(req.url, req.instructions);
741
+ if (req.method === "open_url") onOpenUrl(req.url, req.instructions, req.launchUrl);
738
742
  }
739
743
  : undefined;
740
744
  if (listener) this.#extensionUiListeners.add(listener);
@@ -1213,6 +1213,7 @@ export async function runRpcMode(
1213
1213
  id: Snowflake.next() as string,
1214
1214
  method: "open_url",
1215
1215
  url: info.url,
1216
+ launchUrl: info.launchUrl,
1216
1217
  instructions: info.instructions,
1217
1218
  } as RpcExtensionUIRequest);
1218
1219
  },
@@ -370,7 +370,19 @@ export type RpcExtensionUIRequest =
370
370
  }
371
371
  | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
372
372
  | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }
373
- | { type: "extension_ui_request"; id: string; method: "open_url"; url: string; instructions?: string };
373
+ | {
374
+ type: "extension_ui_request";
375
+ id: string;
376
+ method: "open_url";
377
+ url: string;
378
+ /**
379
+ * Short loopback URL that 302-redirects to {@link url}. When present,
380
+ * hosts SHOULD surface it as the copy target so terminal viewport
381
+ * truncation cannot corrupt OAuth query parameters on the full URL.
382
+ */
383
+ launchUrl?: string;
384
+ instructions?: string;
385
+ };
374
386
 
375
387
  // ============================================================================
376
388
  // Host Tool Frames (bidirectional)
@@ -80,6 +80,7 @@ export class SignInTab implements SetupTab {
80
80
  #selector: OAuthSelectorComponent;
81
81
  #statusLines: string[] = [];
82
82
  #authUrl: string | undefined;
83
+ #authLaunchUrl: string | undefined;
83
84
  #prompt: PromptState | undefined;
84
85
  #promptResolve: ((value: string) => void) | undefined;
85
86
  #loginAbort: AbortController | undefined;
@@ -146,6 +147,9 @@ export class SignInTab implements SetupTab {
146
147
  theme.fg("accent", `Browser login: ${loginUrlLink(this.#authUrl)} ${loginCopyHint()}`),
147
148
  ...urlLines.slice(0, 2),
148
149
  );
150
+ if (this.#authLaunchUrl) {
151
+ lines.push(theme.fg("dim", `Local shortcut (this machine only): ${this.#authLaunchUrl}`));
152
+ }
149
153
  }
150
154
  if (this.#prompt) {
151
155
  lines.push(theme.fg("warning", this.#prompt.message));
@@ -182,6 +186,7 @@ export class SignInTab implements SetupTab {
182
186
  this.#loggingInProvider = providerId;
183
187
  this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];
184
188
  this.#authUrl = undefined;
189
+ this.#authLaunchUrl = undefined;
185
190
  this.#loginAbort = new AbortController();
186
191
  this.host.restoreFocus();
187
192
  this.host.requestRender();
@@ -189,7 +194,17 @@ export class SignInTab implements SetupTab {
189
194
  await this.#authStorage.login(providerId as OAuthProvider, {
190
195
  signal: this.#loginAbort.signal,
191
196
  onAuth: info => {
197
+ // Store the full authorization URL as the primary copy/display
198
+ // target: it works from any machine, including SSH boxes where
199
+ // the OMP-hosted `launchUrl` would resolve against the user's
200
+ // local browser and fail. The wizard render uses
201
+ // `wrapTextWithAnsi`, so long URLs wrap across lines rather
202
+ // than getting truncated — the RFC 7636 §4.3 PKCE-downgrade
203
+ // bug that motivated `launchUrl` is unreachable through this
204
+ // surface. `launchUrl` is still surfaced as an optional local
205
+ // shortcut for wide-terminal local users.
192
206
  this.#authUrl = info.url;
207
+ this.#authLaunchUrl = info.launchUrl && info.launchUrl !== info.url ? info.launchUrl : undefined;
193
208
  this.#statusLines = [];
194
209
  if (info.instructions) {
195
210
  this.#statusLines.push(theme.fg("warning", info.instructions));
@@ -216,6 +231,7 @@ export class SignInTab implements SetupTab {
216
231
  theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`),
217
232
  ];
218
233
  this.#authUrl = undefined;
234
+ this.#authLaunchUrl = undefined;
219
235
  this.#loggingInProvider = undefined;
220
236
  this.#loginAbort = undefined;
221
237
  this.#selector.stopValidation();
@@ -227,6 +243,7 @@ export class SignInTab implements SetupTab {
227
243
  if (this.#loginAbort?.signal.aborted) {
228
244
  this.#statusLines = [theme.fg("dim", "Login cancelled.")];
229
245
  this.#authUrl = undefined;
246
+ this.#authLaunchUrl = undefined;
230
247
  } else {
231
248
  const message = error instanceof Error ? error.message : String(error);
232
249
  this.#statusLines = [
@@ -234,6 +251,7 @@ export class SignInTab implements SetupTab {
234
251
  theme.fg("dim", "Choose another provider or press Esc to continue."),
235
252
  ];
236
253
  this.#authUrl = undefined;
254
+ this.#authLaunchUrl = undefined;
237
255
  }
238
256
  this.#loggingInProvider = undefined;
239
257
  this.#loginAbort = undefined;