@oh-my-pi/pi-coding-agent 15.13.0 → 15.13.1

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 (83) hide show
  1. package/CHANGELOG.md +1656 -613
  2. package/dist/cli.js +12765 -12731
  3. package/dist/types/autolearn/managed-skills.d.ts +1 -1
  4. package/dist/types/capability/mcp.d.ts +2 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/flag-tables.d.ts +126 -0
  7. package/dist/types/cli/profile-alias.d.ts +29 -0
  8. package/dist/types/cli/profile-bootstrap.d.ts +55 -0
  9. package/dist/types/commands/launch.d.ts +6 -0
  10. package/dist/types/config/model-roles.d.ts +3 -2
  11. package/dist/types/config/settings-schema.d.ts +2 -0
  12. package/dist/types/edit/file-snapshot-store.d.ts +14 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +11 -0
  14. package/dist/types/mcp/manager.d.ts +5 -1
  15. package/dist/types/mcp/oauth-credentials.d.ts +17 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +41 -0
  17. package/dist/types/mcp/types.d.ts +2 -0
  18. package/dist/types/modes/components/background-tan-message.d.ts +9 -0
  19. package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
  20. package/dist/types/modes/interactive-mode.d.ts +4 -0
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/sdk.d.ts +1 -1
  23. package/dist/types/session/messages.d.ts +8 -0
  24. package/dist/types/session/session-manager.d.ts +6 -0
  25. package/dist/types/tools/builtin-names.d.ts +2 -0
  26. package/dist/types/tools/index.d.ts +3 -2
  27. package/dist/types/utils/external-editor.d.ts +11 -1
  28. package/package.json +12 -12
  29. package/src/autolearn/managed-skills.ts +3 -5
  30. package/src/capability/mcp.ts +2 -1
  31. package/src/cli/args.ts +61 -103
  32. package/src/cli/completion-gen.ts +2 -2
  33. package/src/cli/flag-tables.ts +270 -0
  34. package/src/cli/profile-alias.ts +338 -0
  35. package/src/cli/profile-bootstrap.ts +243 -0
  36. package/src/cli.ts +83 -16
  37. package/src/commands/launch.ts +7 -0
  38. package/src/config/mcp-schema.json +4 -0
  39. package/src/config/model-roles.ts +17 -4
  40. package/src/config/settings-schema.ts +2 -0
  41. package/src/discovery/builtin.ts +15 -9
  42. package/src/discovery/helpers.ts +25 -0
  43. package/src/discovery/mcp-json.ts +1 -0
  44. package/src/discovery/omp-extension-roots.ts +2 -2
  45. package/src/edit/file-snapshot-store.ts +43 -0
  46. package/src/eval/__tests__/agent-bridge.test.ts +3 -2
  47. package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
  48. package/src/eval/js/shared/runtime.ts +54 -0
  49. package/src/extensibility/extensions/runner.ts +25 -2
  50. package/src/goals/runtime.ts +4 -1
  51. package/src/internal-urls/docs-index.generated.ts +6 -6
  52. package/src/mcp/manager.ts +108 -71
  53. package/src/mcp/oauth-credentials.ts +104 -0
  54. package/src/mcp/oauth-flow.ts +67 -0
  55. package/src/mcp/types.ts +2 -0
  56. package/src/modes/components/agent-hub.ts +6 -0
  57. package/src/modes/components/background-tan-message.ts +36 -0
  58. package/src/modes/components/mcp-add-wizard.ts +17 -10
  59. package/src/modes/components/model-selector.ts +50 -6
  60. package/src/modes/components/tool-execution.ts +12 -0
  61. package/src/modes/controllers/input-controller.ts +21 -10
  62. package/src/modes/controllers/mcp-command-controller.ts +184 -112
  63. package/src/modes/controllers/tan-command-controller.ts +27 -11
  64. package/src/modes/interactive-mode.ts +6 -0
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/ui-helpers.ts +6 -0
  67. package/src/prompts/bench.md +9 -4
  68. package/src/sdk.ts +6 -5
  69. package/src/session/agent-session.ts +30 -1
  70. package/src/session/messages.ts +9 -0
  71. package/src/session/session-manager.ts +7 -2
  72. package/src/tiny/text.ts +5 -1
  73. package/src/tools/ast-grep.ts +5 -1
  74. package/src/tools/builtin-names.ts +35 -0
  75. package/src/tools/index.ts +3 -2
  76. package/src/tools/read.ts +9 -0
  77. package/src/tools/search.ts +5 -1
  78. package/src/tts/tts-worker.ts +13 -5
  79. package/src/utils/external-editor.ts +15 -2
  80. package/src/utils/title-generator.ts +1 -1
  81. package/src/workspace-tree.ts +46 -6
  82. package/dist/types/utils/tools-manager.test.d.ts +0 -1
  83. package/src/utils/tools-manager.test.ts +0 -25
@@ -27,7 +27,12 @@ import {
27
27
  unsubscribeFromResources,
28
28
  } from "./client";
29
29
  import { loadAllMCPConfigs, validateServerConfig } from "./config";
30
- import { refreshMCPOAuthToken } from "./oauth-flow";
30
+ import {
31
+ lookupMcpOAuthCredential,
32
+ type MCPOAuthCredentialLookup,
33
+ selectMcpOAuthRefreshMaterial,
34
+ } from "./oauth-credentials";
35
+ import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
31
36
  import type { MCPToolDetails } from "./tool-bridge";
32
37
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
33
38
  import type { MCPToolCache } from "./tool-cache";
@@ -400,9 +405,15 @@ export class MCPManager {
400
405
  }
401
406
 
402
407
  // Wire auth refresh for HTTP transports so 401s trigger token refresh.
403
- if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
408
+ // Gate on a resolvable managed credential, not on the auth block:
409
+ // definition-only configs (url-keyed fallback) get Bearer injection
410
+ // too and need the same mid-session refresh hook.
411
+ if (
412
+ connection.transport instanceof HttpTransport &&
413
+ lookupMcpOAuthCredential(this.#authStorage, config)
414
+ ) {
404
415
  connection.transport.onAuthError = async () => {
405
- const refreshed = await this.#resolveAuthConfig(config, true);
416
+ const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
406
417
  if (refreshed.type === "http" || refreshed.type === "sse") {
407
418
  return refreshed.headers ?? null;
408
419
  }
@@ -673,9 +684,11 @@ export class MCPManager {
673
684
 
674
685
  /**
675
686
  * Resolve auth and shell-command substitutions in config before connecting.
687
+ * Pass `oauth: false` to skip OAuth credential injection (used by reauth's
688
+ * unauthenticated probe, which must observe the server's bare 401).
676
689
  */
677
- async prepareConfig(config: MCPServerConfig): Promise<MCPServerConfig> {
678
- return this.#resolveAuthConfig(config);
690
+ async prepareConfig(config: MCPServerConfig, options?: { oauth?: boolean }): Promise<MCPServerConfig> {
691
+ return this.#resolveAuthConfig(config, options);
679
692
  }
680
693
 
681
694
  /**
@@ -925,9 +938,10 @@ export class MCPManager {
925
938
  this.#connections.set(name, connection);
926
939
 
927
940
  // Wire auth refresh for HTTP transports, and reconnect for any transport.
928
- if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
941
+ // Same gate as connectServers: any resolvable managed credential.
942
+ if (connection.transport instanceof HttpTransport && lookupMcpOAuthCredential(this.#authStorage, config)) {
929
943
  connection.transport.onAuthError = async () => {
930
- const refreshed = await this.#resolveAuthConfig(config, true);
944
+ const refreshed = await this.#resolveAuthConfig(config, { forceRefresh: true });
931
945
  if (refreshed.type === "http" || refreshed.type === "sse") {
932
946
  return refreshed.headers ?? null;
933
947
  }
@@ -1169,78 +1183,101 @@ export class MCPManager {
1169
1183
 
1170
1184
  /**
1171
1185
  * Resolve OAuth credentials and shell commands in config.
1186
+ * `oauth: false` skips credential injection (reauth's unauthenticated probe);
1187
+ * `forceRefresh` bypasses the expiry buffer (401/403 auth-error hook).
1172
1188
  */
1173
- async #resolveAuthConfig(config: MCPServerConfig, forceRefresh = false): Promise<MCPServerConfig> {
1189
+ async #resolveAuthConfig(
1190
+ config: MCPServerConfig,
1191
+ opts?: { forceRefresh?: boolean; oauth?: boolean },
1192
+ ): Promise<MCPServerConfig> {
1174
1193
  let resolved: MCPServerConfig = { ...config };
1175
1194
 
1176
1195
  const auth = config.auth;
1177
- if (auth?.type === "oauth" && auth.credentialId && this.#authStorage) {
1178
- const credentialId = auth.credentialId;
1196
+ const lookup: MCPOAuthCredentialLookup | undefined =
1197
+ opts?.oauth !== false ? lookupMcpOAuthCredential(this.#authStorage, config) : undefined;
1198
+ if (lookup && this.#authStorage) {
1199
+ const { credentialId } = lookup;
1179
1200
  try {
1180
- let credential = this.#authStorage.get(credentialId);
1181
- if (credential?.type === "oauth") {
1182
- // Proactive refresh: 5-minute buffer before expiry
1183
- // Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
1184
- const REFRESH_BUFFER_MS = 5 * 60_000;
1185
- const shouldRefresh =
1186
- forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
1187
- if (shouldRefresh && credential.refresh && auth.tokenUrl) {
1188
- const resource =
1189
- auth.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
1190
- try {
1191
- const refreshed = await refreshMCPOAuthToken(
1192
- auth.tokenUrl,
1193
- credential.refresh,
1194
- auth.clientId,
1195
- auth.clientSecret,
1196
- resource,
1197
- );
1198
- const refreshedCredential = { type: "oauth" as const, ...refreshed };
1199
- await this.#authStorage.set(credentialId, refreshedCredential);
1200
- credential = refreshedCredential;
1201
- } catch (refreshError) {
1202
- const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
1203
- if (isDefinitiveOAuthFailure(errorMsg)) {
1204
- // `invalid_grant` / `invalid_token` / 401 from the token endpoint means
1205
- // the server has retired this credential — keeping the stale access
1206
- // token would just re-fail with 401 on every MCP request and leave a
1207
- // poisoned row in agent.db that survives restarts. Drop it now so the
1208
- // next connect attempt surfaces a clean "needs reauth" failure and
1209
- // the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
1210
- // to forget the server entirely).
1211
- logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
1212
- credentialId,
1213
- error: errorMsg,
1214
- });
1215
- await this.#authStorage.remove(credentialId);
1216
- credential = undefined;
1217
- } else {
1218
- logger.warn("MCP OAuth refresh failed, using existing token", {
1219
- credentialId,
1220
- error: refreshError,
1221
- });
1222
- }
1201
+ let credential: MCPStoredOAuthCredential | undefined = lookup.credential;
1202
+ // Refresh material comes from ONE source: the credential's embedded
1203
+ // fields (written atomically with the tokens they minted — tokenUrl
1204
+ // always present) or, for legacy rows that predate embedding, the
1205
+ // config auth block. Never mix the two: a shared file's auth block
1206
+ // can belong to another profile, whose client the grant is NOT
1207
+ // bound to.
1208
+ const material = selectMcpOAuthRefreshMaterial(credential, auth);
1209
+ const tokenUrl = material?.tokenUrl;
1210
+ const clientId = material?.clientId;
1211
+ const clientSecret = material?.clientSecret;
1212
+ const resource =
1213
+ material?.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
1214
+ // Proactive refresh: 5-minute buffer before expiry
1215
+ // Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
1216
+ const REFRESH_BUFFER_MS = 5 * 60_000;
1217
+ const shouldRefresh =
1218
+ opts?.forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
1219
+ if (shouldRefresh && credential.refresh && tokenUrl) {
1220
+ try {
1221
+ const refreshed = await refreshMCPOAuthToken(
1222
+ tokenUrl,
1223
+ credential.refresh,
1224
+ clientId,
1225
+ clientSecret,
1226
+ resource,
1227
+ );
1228
+ // Spread the old credential first so embedded refresh material survives rotation.
1229
+ const refreshedCredential: MCPStoredOAuthCredential = {
1230
+ ...credential,
1231
+ ...refreshed,
1232
+ tokenUrl,
1233
+ clientId,
1234
+ clientSecret,
1235
+ resource,
1236
+ };
1237
+ await this.#authStorage.set(credentialId, refreshedCredential);
1238
+ credential = refreshedCredential;
1239
+ } catch (refreshError) {
1240
+ const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
1241
+ if (isDefinitiveOAuthFailure(errorMsg)) {
1242
+ // `invalid_grant` / `invalid_token` / 401 from the token endpoint means
1243
+ // the server has retired this credential — keeping the stale access
1244
+ // token would just re-fail with 401 on every MCP request and leave a
1245
+ // poisoned row in agent.db that survives restarts. Drop it now so the
1246
+ // next connect attempt surfaces a clean "needs reauth" failure and
1247
+ // the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
1248
+ // to forget the server entirely).
1249
+ logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
1250
+ credentialId,
1251
+ error: errorMsg,
1252
+ });
1253
+ await this.#authStorage.remove(credentialId);
1254
+ credential = undefined;
1255
+ } else {
1256
+ logger.warn("MCP OAuth refresh failed, using existing token", {
1257
+ credentialId,
1258
+ error: refreshError,
1259
+ });
1223
1260
  }
1224
1261
  }
1262
+ }
1225
1263
 
1226
- if (credential?.type === "oauth") {
1227
- if (resolved.type === "http" || resolved.type === "sse") {
1228
- resolved = {
1229
- ...resolved,
1230
- headers: {
1231
- ...resolved.headers,
1232
- Authorization: `Bearer ${credential.access}`,
1233
- },
1234
- };
1235
- } else {
1236
- resolved = {
1237
- ...resolved,
1238
- env: {
1239
- ...resolved.env,
1240
- OAUTH_ACCESS_TOKEN: credential.access,
1241
- },
1242
- };
1243
- }
1264
+ if (credential) {
1265
+ if (resolved.type === "http" || resolved.type === "sse") {
1266
+ resolved = {
1267
+ ...resolved,
1268
+ headers: {
1269
+ ...resolved.headers,
1270
+ Authorization: `Bearer ${credential.access}`,
1271
+ },
1272
+ };
1273
+ } else {
1274
+ resolved = {
1275
+ ...resolved,
1276
+ env: {
1277
+ ...resolved.env,
1278
+ OAUTH_ACCESS_TOKEN: credential.access,
1279
+ },
1280
+ };
1244
1281
  }
1245
1282
  }
1246
1283
  } catch (error) {
@@ -0,0 +1,104 @@
1
+ import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
2
+ import { expandEnvVarsDeep } from "../discovery/helpers";
3
+ import type { AuthStorage } from "../session/auth-storage";
4
+ import {
5
+ isManagedMCPOAuthCredentialId,
6
+ type MCPStoredOAuthCredential,
7
+ mcpOAuthCredentialId,
8
+ mcpOAuthCredentialProfile,
9
+ } from "./oauth-flow";
10
+ import type { MCPAuthConfig, MCPServerConfig } from "./types";
11
+
12
+ export interface MCPOAuthCredentialLookup {
13
+ credentialId: string;
14
+ credential: MCPStoredOAuthCredential;
15
+ }
16
+
17
+ export type MCPOAuthRefreshMaterial = MCPStoredOAuthCredential | MCPAuthConfig | undefined;
18
+
19
+ export function mcpOAuthCredentialIdsForServerUrl(serverUrl: string | undefined): string[] {
20
+ if (!serverUrl) return [];
21
+ const ids: string[] = [];
22
+ for (const url of [expandEnvVarsDeep(serverUrl), serverUrl]) {
23
+ const id = mcpOAuthCredentialId(url);
24
+ if (!ids.includes(id)) ids.push(id);
25
+ }
26
+ return ids;
27
+ }
28
+
29
+ export function hasMcpAuthorizationHeader(config: MCPServerConfig): boolean {
30
+ if (config.type !== "http" && config.type !== "sse") return false;
31
+ return Object.keys(config.headers ?? {}).some(header => header.toLowerCase() === "authorization");
32
+ }
33
+
34
+ export function lookupMcpOAuthCredentialForServer(
35
+ authStorage: AuthStorage | null | undefined,
36
+ auth: MCPAuthConfig | undefined,
37
+ serverUrl: string | undefined,
38
+ options: { allowUrlKeyedFallback?: boolean } = {},
39
+ ): MCPOAuthCredentialLookup | undefined {
40
+ if (!authStorage) return undefined;
41
+ if (auth && auth.type !== "oauth") return undefined;
42
+ const urlKeyedCredentialIds = mcpOAuthCredentialIdsForServerUrl(serverUrl);
43
+ if (
44
+ auth?.credentialId &&
45
+ (!auth.credentialId.startsWith("mcp_oauth:profile:") || urlKeyedCredentialIds.includes(auth.credentialId))
46
+ ) {
47
+ const credential = authStorage.get(auth.credentialId);
48
+ if (credential?.type === "oauth") {
49
+ return { credentialId: auth.credentialId, credential };
50
+ }
51
+ }
52
+ if (options.allowUrlKeyedFallback === false) return undefined;
53
+ for (const credentialId of urlKeyedCredentialIds) {
54
+ const credential = authStorage.get(credentialId);
55
+ if (credential?.type === "oauth") {
56
+ return { credentialId, credential };
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export function lookupMcpOAuthCredential(
63
+ authStorage: AuthStorage | null | undefined,
64
+ config: MCPServerConfig,
65
+ ): MCPOAuthCredentialLookup | undefined {
66
+ const auth = config.auth;
67
+ if (config.type !== "http" && config.type !== "sse") {
68
+ return lookupMcpOAuthCredentialForServer(authStorage, auth, undefined);
69
+ }
70
+ if (hasMcpAuthorizationHeader(config)) {
71
+ return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url, { allowUrlKeyedFallback: false });
72
+ }
73
+ return lookupMcpOAuthCredentialForServer(authStorage, auth, config.url);
74
+ }
75
+
76
+ export function selectMcpOAuthRefreshMaterial(
77
+ credential: MCPStoredOAuthCredential,
78
+ auth: MCPAuthConfig | undefined,
79
+ ): MCPOAuthRefreshMaterial {
80
+ return credential.tokenUrl ? credential : auth;
81
+ }
82
+
83
+ export async function removeManagedMcpOAuthCredential(
84
+ authStorage: AuthStorage,
85
+ credentialId: string | undefined,
86
+ ): Promise<boolean> {
87
+ if (!isManagedMCPOAuthCredentialId(credentialId)) return false;
88
+ const scopedProfile = mcpOAuthCredentialProfile(credentialId);
89
+ if (scopedProfile !== undefined && scopedProfile !== (getActiveProfile() ?? "default")) return false;
90
+ if (authStorage.get(credentialId)?.type !== "oauth") return false;
91
+ await authStorage.remove(credentialId);
92
+ return true;
93
+ }
94
+
95
+ export async function removeManagedMcpOAuthCredentials(
96
+ authStorage: AuthStorage,
97
+ credentialIds: readonly (string | undefined)[],
98
+ ): Promise<boolean> {
99
+ let removed = false;
100
+ for (const credentialId of credentialIds) {
101
+ removed = (await removeManagedMcpOAuthCredential(authStorage, credentialId)) || removed;
102
+ }
103
+ return removed;
104
+ }
@@ -9,6 +9,60 @@ import type { OAuthCallbackFlowOptions } from "@oh-my-pi/pi-ai/oauth/callback-se
9
9
  import { OAuthCallbackFlow } from "@oh-my-pi/pi-ai/oauth/callback-server";
10
10
  import type { OAuthController, OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
11
11
  import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
12
+ import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
13
+ import type { OAuthCredential } from "../session/auth-storage";
14
+
15
+ /** Credential-id prefix for OMP-managed MCP OAuth credentials keyed by profile and server URL. */
16
+ const MCP_OAUTH_URL_CREDENTIAL_PREFIX = "mcp_oauth:";
17
+
18
+ /** Credential-id prefix for profile-scoped MCP OAuth credentials (`mcp_oauth:profile:<profile>:<serverUrl>`). */
19
+ const MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX = `${MCP_OAUTH_URL_CREDENTIAL_PREFIX}profile:`;
20
+
21
+ /**
22
+ * Deterministic credential id for an MCP server URL scoped to an OMP profile.
23
+ *
24
+ * Local profile stores are already separate, but auth-broker storage shares one
25
+ * provider namespace across profiles. Including the profile in the provider key
26
+ * keeps a shared project `mcp.json` definition from making profile B overwrite
27
+ * or read profile A's OAuth row for the same server URL. The URL is used
28
+ * verbatim (query string included) because it can carry tenant selectors such
29
+ * as `?project_ref=`.
30
+ */
31
+ export function mcpOAuthCredentialId(serverUrl: string, profile: string | undefined = getActiveProfile()): string {
32
+ return `${MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX}${profile ?? "default"}:${serverUrl}`;
33
+ }
34
+
35
+ /** Whether a credential id was minted by OMP's MCP OAuth flows (either era). */
36
+ export function isManagedMCPOAuthCredentialId(credentialId: string | undefined): credentialId is string {
37
+ return (
38
+ !!credentialId &&
39
+ (credentialId.startsWith("mcp_oauth_") || credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX))
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Profile segment of a profile-scoped `mcp_oauth:profile:<profile>:<serverUrl>`
45
+ * credential id, or `undefined` for legacy non-profile-scoped managed ids
46
+ * (`mcp_oauth:<url>`, `mcp_oauth_<rand>`). The server URL itself contains `:`
47
+ * and `/`, so only the segment between the prefix and the FIRST subsequent `:`
48
+ * is the profile; everything after it is the URL.
49
+ */
50
+ export function mcpOAuthCredentialProfile(credentialId: string): string | undefined {
51
+ if (!credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) return undefined;
52
+ const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
53
+ return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
54
+ }
55
+
56
+ /**
57
+ * Stored MCP OAuth credential. Refresh material is embedded so token refresh
58
+ * works without any `auth` block persisted in (possibly shared) config files.
59
+ */
60
+ export interface MCPStoredOAuthCredential extends OAuthCredential {
61
+ tokenUrl?: string;
62
+ clientId?: string;
63
+ clientSecret?: string;
64
+ resource?: string;
65
+ }
12
66
 
13
67
  const DEFAULT_PORT = 3000;
14
68
  const CALLBACK_PATH = "/callback";
@@ -126,6 +180,15 @@ export interface MCPOAuthConfig {
126
180
  clientSecret?: string;
127
181
  /** OAuth scopes (space-separated) */
128
182
  scopes?: string;
183
+ /**
184
+ * `prompt` parameter for the authorization request. Defaults to `"consent"`
185
+ * so the provider always shows its authorize screen instead of silently
186
+ * re-approving the browser's current session — without it, reauthorizing to
187
+ * switch accounts/workspaces is impossible once a session cookie exists
188
+ * (RFC 6749 §3.1 requires servers to ignore the param when unsupported).
189
+ * Set to `""` to omit the parameter entirely.
190
+ */
191
+ prompt?: string;
129
192
  /** Exact redirect URI to advertise to the provider */
130
193
  redirectUri?: string;
131
194
  /** Custom callback port (default: 3000) */
@@ -202,6 +265,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
202
265
  if (this.config.scopes && !params.get("scope")) {
203
266
  params.set("scope", this.config.scopes);
204
267
  }
268
+ const prompt = this.config.prompt ?? "consent";
269
+ if (prompt && !params.get("prompt")) {
270
+ params.set("prompt", prompt);
271
+ }
205
272
  const existingResource = params.get("resource")?.trim();
206
273
  if (existingResource) {
207
274
  this.#resource = resolveResourceUri(existingResource);
package/src/mcp/types.ts CHANGED
@@ -74,6 +74,8 @@ interface MCPServerConfigBase {
74
74
  redirectUri?: string;
75
75
  callbackPort?: number;
76
76
  callbackPath?: string;
77
+ /** `prompt` param for the authorization request (default "consent"; "" to omit) */
78
+ prompt?: string;
77
79
  };
78
80
  }
79
81
 
@@ -28,6 +28,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
28
28
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
29
29
  import type { AgentSession } from "../../session/agent-session";
30
30
  import {
31
+ BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
31
32
  type CustomMessage,
32
33
  isSilentAbort,
33
34
  LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
@@ -45,6 +46,7 @@ import type { ObservableSession, SessionObserverRegistry } from "../session-obse
45
46
  import { getEditorTheme, theme } from "../theme/theme";
46
47
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
47
48
  import { AssistantMessageComponent } from "./assistant-message";
49
+ import { createBackgroundTanDispatchBlock } from "./background-tan-message";
48
50
  import { BashExecutionComponent } from "./bash-execution";
49
51
  import { BranchSummaryMessageComponent } from "./branch-summary-message";
50
52
  import { CollabPromptMessageComponent } from "./collab-prompt-message";
@@ -1215,6 +1217,10 @@ export class AgentHubOverlayComponent extends Container {
1215
1217
  this.#chatLog.addChild(card);
1216
1218
  return;
1217
1219
  }
1220
+ if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
1221
+ this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
1222
+ return;
1223
+ }
1218
1224
  const handoffComponent = createHandoffSummaryMessageComponent(
1219
1225
  message as CustomMessage<unknown>,
1220
1226
  this.#chatExpanded,
@@ -0,0 +1,36 @@
1
+ import { Text } from "@oh-my-pi/pi-tui";
2
+ import type { BackgroundTanDispatchDetails, CustomMessage } from "../../session/messages";
3
+ import { replaceTabs } from "../../tools/render-utils";
4
+ import { theme } from "../theme/theme";
5
+ import { TranscriptBlock } from "./transcript-container";
6
+
7
+ const TAN_WORK_PREVIEW_LENGTH = 56;
8
+
9
+ function previewWork(work: string): string {
10
+ const singleLine = replaceTabs(work).trim().replace(/\s+/g, " ");
11
+ if (singleLine.length <= TAN_WORK_PREVIEW_LENGTH) return singleLine;
12
+ return `${singleLine.slice(0, TAN_WORK_PREVIEW_LENGTH - 1)}…`;
13
+ }
14
+
15
+ /**
16
+ * Single-line transcript pill for a `/tan` background-dispatch breadcrumb,
17
+ * styled as a sibling of the "Background job completed" line. The full
18
+ * system-notice content (the persisted `content`) is for the model only — the
19
+ * user sees one compact line, not the raw `<system-notice>` block.
20
+ */
21
+ export function createBackgroundTanDispatchBlock(message: CustomMessage<unknown>): TranscriptBlock {
22
+ const details = (message as CustomMessage<Partial<BackgroundTanDispatchDetails>>).details;
23
+ const jobId = details?.jobId ?? "unknown";
24
+ const work = details?.work ? previewWork(details.work) : undefined;
25
+ const line = [
26
+ theme.fg("muted", `${theme.icon.output} Tangent dispatched`),
27
+ theme.fg("dim", "[task]"),
28
+ theme.fg("accent", jobId),
29
+ work ? theme.fg("dim", `${theme.format.dash} ${work}`) : undefined,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+ const block = new TranscriptBlock();
34
+ block.addChild(new Text(line, 1, 0));
35
+ return block;
36
+ }
@@ -49,14 +49,19 @@ type WizardStep =
49
49
 
50
50
  /**
51
51
  * Result of the wizard's OAuth callback. `credentialId` is mandatory;
52
- * `clientId`/`clientSecret` are populated when the OAuth provider performed
53
- * dynamic client registration (or when the caller pre-supplied them) so the
54
- * wizard can fold them into the final `mcp.json` entry for refresh.
52
+ * `clientId` is populated when the OAuth provider performed dynamic client
53
+ * registration (or when the caller pre-supplied it) so the wizard can fold it
54
+ * into the final `mcp.json` entry. Refresh material (including any DCR client
55
+ * secret) is embedded in the stored credential, never written to config files.
55
56
  */
56
57
  export interface MCPAddWizardOAuthResult {
57
58
  credentialId: string;
58
59
  clientId?: string;
59
- clientSecret?: string;
60
+ resource?: string;
61
+ }
62
+
63
+ interface MCPAddWizardOAuthOptions {
64
+ serverUrl?: string;
60
65
  resource?: string;
61
66
  }
62
67
 
@@ -125,7 +130,7 @@ export class MCPAddWizard extends Container {
125
130
  clientId: string,
126
131
  clientSecret: string,
127
132
  scopes: string,
128
- resource?: string,
133
+ options?: MCPAddWizardOAuthOptions,
129
134
  ) => Promise<MCPAddWizardOAuthResult>)
130
135
  | null = null;
131
136
  #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
@@ -140,7 +145,7 @@ export class MCPAddWizard extends Container {
140
145
  clientId: string,
141
146
  clientSecret: string,
142
147
  scopes: string,
143
- resource?: string,
148
+ options?: MCPAddWizardOAuthOptions,
144
149
  ) => Promise<MCPAddWizardOAuthResult>,
145
150
  onTestConnection?: (config: MCPServerConfig) => Promise<void>,
146
151
  onRender?: () => void,
@@ -1157,14 +1162,16 @@ export class MCPAddWizard extends Container {
1157
1162
  this.#state.oauthClientId,
1158
1163
  this.#state.oauthClientSecret,
1159
1164
  this.#state.oauthScopes,
1160
- oauthResource || undefined,
1165
+ {
1166
+ serverUrl: this.#state.url || undefined,
1167
+ resource: oauthResource || undefined,
1168
+ },
1161
1169
  );
1162
1170
 
1163
- // Store credential ID + any dynamically-registered client credentials,
1164
- // so the final mcp.json entry persists everything needed for refresh.
1171
+ // Store credential ID + any dynamically-registered client id. DCR client
1172
+ // secrets stay embedded in the stored credential, never in mcp.json.
1165
1173
  this.#state.oauthCredentialId = oauthResult.credentialId;
1166
1174
  if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
1167
- if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
1168
1175
  this.#state.oauthResource = oauthResult.resource ?? oauthResource;
1169
1176
 
1170
1177
  // Show success message
@@ -936,7 +936,8 @@ export class ModelSelectorComponent extends Container {
936
936
  // Build role badges. Solid badges are configured; outlined badges are auto-selected defaults.
937
937
  const roleBadgeTokens: string[] = [];
938
938
  for (const role of MODEL_ROLE_IDS) {
939
- const { tag, color } = getRoleInfo(role, this.#settings);
939
+ const { tag, color, hidden } = getRoleInfo(role, this.#settings);
940
+ if (hidden) continue;
940
941
  const assigned = this.#roles[role];
941
942
  if (!tag || !assigned || !modelsAreEqual(assigned.model, item.model)) continue;
942
943
 
@@ -1053,6 +1054,10 @@ export class ModelSelectorComponent extends Container {
1053
1054
  this.#menuStep = "role";
1054
1055
  this.#menuSelectedRole = null;
1055
1056
  this.#menuSelectedIndex = 0;
1057
+ // Collapse the model list while the action/thinking menu is open so the
1058
+ // menu owns the full viewport instead of stacking below a now-irrelevant
1059
+ // (and often off-screen) list.
1060
+ this.#listContainer.clear();
1056
1061
  this.#updateMenu();
1057
1062
  }
1058
1063
 
@@ -1061,6 +1066,8 @@ export class ModelSelectorComponent extends Container {
1061
1066
  this.#menuStep = "role";
1062
1067
  this.#menuSelectedRole = null;
1063
1068
  this.#menuContainer.clear();
1069
+ // Restore the model list that #openMenu collapsed.
1070
+ this.#updateList();
1064
1071
  }
1065
1072
 
1066
1073
  #updateMenu(): void {
@@ -1088,11 +1095,21 @@ export class ModelSelectorComponent extends Container {
1088
1095
  ? ` Thinking for: ${selectedRoleName} (${selectedItem.id})`
1089
1096
  : ` Action for: ${selectedItem.id}`;
1090
1097
  const hintText = showingThinking ? " Enter: confirm Esc: back" : " Enter: continue Esc: cancel";
1091
- const menuWidth = Math.max(
1098
+ // Window the option list so a long action/thinking menu scrolls inside the
1099
+ // viewport instead of running off the bottom of the screen.
1100
+ const maxVisible = this.#getMenuVisibleCount(optionLines.length);
1101
+ const needsScroll = optionLines.length > maxVisible;
1102
+ const startIndex = needsScroll
1103
+ ? Math.max(0, Math.min(this.#menuSelectedIndex - Math.floor(maxVisible / 2), optionLines.length - maxVisible))
1104
+ : 0;
1105
+ const endIndex = needsScroll ? startIndex + maxVisible : optionLines.length;
1106
+ const contentWidth = Math.max(
1092
1107
  visibleWidth(headerText),
1093
1108
  visibleWidth(hintText),
1094
1109
  ...optionLines.map(line => visibleWidth(line)),
1095
1110
  );
1111
+ // Reserve one column for the scrollbar when the list overflows.
1112
+ const menuWidth = contentWidth + (needsScroll ? 1 : 0);
1096
1113
 
1097
1114
  this.#menuContainer.addChild(new Spacer(1));
1098
1115
  this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
@@ -1109,12 +1126,28 @@ export class ModelSelectorComponent extends Container {
1109
1126
  }
1110
1127
  this.#menuContainer.addChild(new Spacer(1));
1111
1128
 
1112
- for (let i = 0; i < optionLines.length; i++) {
1129
+ const visibleRows: string[] = [];
1130
+ for (let i = startIndex; i < endIndex; i++) {
1113
1131
  const lineText = optionLines[i];
1114
- if (!lineText) continue;
1132
+ if (lineText === undefined) continue;
1115
1133
  const isSelected = i === this.#menuSelectedIndex;
1116
- const line = isSelected ? theme.fg("accent", lineText) : theme.fg("muted", lineText);
1117
- this.#menuContainer.addChild(new Text(line, 0, 0));
1134
+ visibleRows.push(isSelected ? theme.fg("accent", lineText) : theme.fg("muted", lineText));
1135
+ }
1136
+ if (needsScroll) {
1137
+ const sv = new ScrollView(visibleRows, {
1138
+ height: visibleRows.length,
1139
+ scrollbar: "auto",
1140
+ totalRows: optionLines.length,
1141
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
1142
+ });
1143
+ sv.setScrollOffset(startIndex);
1144
+ for (const row of sv.render(menuWidth)) {
1145
+ this.#menuContainer.addChild(new Text(row, 0, 0));
1146
+ }
1147
+ } else {
1148
+ for (const row of visibleRows) {
1149
+ this.#menuContainer.addChild(new Text(row, 0, 0));
1150
+ }
1118
1151
  }
1119
1152
 
1120
1153
  this.#menuContainer.addChild(new Spacer(1));
@@ -1122,6 +1155,17 @@ export class ModelSelectorComponent extends Container {
1122
1155
  this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
1123
1156
  }
1124
1157
 
1158
+ #getMenuVisibleCount(optionCount: number): number {
1159
+ // Rows the selector chrome and the menu's own header/hint/borders/spacers
1160
+ // consume, leaving the remainder of the viewport for the scrollable option
1161
+ // window. Without a known terminal height (e.g. tests) show every option.
1162
+ const MENU_CHROME_ROWS = 19;
1163
+ const MIN_VISIBLE_OPTIONS = 4;
1164
+ const terminalRows = this.#tui.terminal?.rows ?? 0;
1165
+ if (!Number.isFinite(terminalRows) || terminalRows <= 0) return optionCount;
1166
+ return Math.max(MIN_VISIBLE_OPTIONS, Math.min(optionCount, terminalRows - MENU_CHROME_ROWS));
1167
+ }
1168
+
1125
1169
  handleInput(keyData: string): void {
1126
1170
  if (this.#isMenuOpen) {
1127
1171
  this.#handleMenuInput(keyData);