@oh-my-pi/pi-coding-agent 13.10.0 → 13.10.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.10.1] - 2026-03-10
6
+ ### Added
7
+
8
+ - Exported `submitInteractiveInput()` function for programmatic submission of user input in interactive mode
9
+ - Added proactive OAuth token refresh for MCP server connections with 5-minute expiry buffer
10
+ - Added reactive 401/403 retry with automatic token refresh on HTTP MCP transports
11
+ - Added `refreshMCPOAuthToken()` for standard OAuth 2.0 refresh_token grants
12
+ - Persisted `tokenUrl`, `clientId`, and `clientSecret` in MCP auth config for cross-session token refresh
13
+
5
14
  ## [13.10.0] - 2026-03-10
6
15
  ### Fixed
7
16
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.10.0",
4
+ "version": "13.10.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.10.0",
45
- "@oh-my-pi/pi-agent-core": "13.10.0",
46
- "@oh-my-pi/pi-ai": "13.10.0",
47
- "@oh-my-pi/pi-natives": "13.10.0",
48
- "@oh-my-pi/pi-tui": "13.10.0",
49
- "@oh-my-pi/pi-utils": "13.10.0",
44
+ "@oh-my-pi/omp-stats": "13.10.1",
45
+ "@oh-my-pi/pi-agent-core": "13.10.1",
46
+ "@oh-my-pi/pi-ai": "13.10.1",
47
+ "@oh-my-pi/pi-natives": "13.10.1",
48
+ "@oh-my-pi/pi-tui": "13.10.1",
49
+ "@oh-my-pi/pi-utils": "13.10.1",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
package/src/main.ts CHANGED
@@ -26,6 +26,7 @@ import { exportFromFile } from "./export/html";
26
26
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
27
27
  import { InteractiveMode, runPrintMode, runRpcMode } from "./modes";
28
28
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
29
+ import type { SubmittedUserInput } from "./modes/types";
29
30
  import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "./sdk";
30
31
  import type { AgentSession } from "./session/agent-session";
31
32
  import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
@@ -66,6 +67,29 @@ export interface InteractiveModeNotify {
66
67
  message: string;
67
68
  }
68
69
 
70
+ export async function submitInteractiveInput(
71
+ mode: Pick<InteractiveMode, "markPendingSubmissionStarted" | "finishPendingSubmission" | "showError">,
72
+ session: Pick<AgentSession, "prompt">,
73
+ input: SubmittedUserInput,
74
+ ): Promise<void> {
75
+ if (input.cancelled) {
76
+ return;
77
+ }
78
+
79
+ try {
80
+ // Continue shortcuts submit an already-started empty prompt with no optimistic user message.
81
+ if (!input.started && !mode.markPendingSubmissionStarted(input)) {
82
+ return;
83
+ }
84
+ await session.prompt(input.text, { images: input.images });
85
+ } catch (error: unknown) {
86
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
87
+ mode.showError(errorMessage);
88
+ } finally {
89
+ mode.finishPendingSubmission(input);
90
+ }
91
+ }
92
+
69
93
  async function runInteractiveMode(
70
94
  session: AgentSession,
71
95
  version: string,
@@ -126,20 +150,7 @@ async function runInteractiveMode(
126
150
 
127
151
  while (true) {
128
152
  const input = await mode.getUserInput();
129
- if (input.cancelled) {
130
- continue;
131
- }
132
- try {
133
- if (!mode.markPendingSubmissionStarted(input)) {
134
- continue;
135
- }
136
- await session.prompt(input.text, { images: input.images });
137
- } catch (error: unknown) {
138
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
139
- mode.showError(errorMessage);
140
- } finally {
141
- mode.finishPendingSubmission(input);
142
- }
153
+ await submitInteractiveInput(mode, session, input);
143
154
  }
144
155
  }
145
156
 
@@ -25,9 +25,11 @@ import {
25
25
  unsubscribeFromResources,
26
26
  } from "./client";
27
27
  import { loadAllMCPConfigs, validateServerConfig } from "./config";
28
+ import { refreshMCPOAuthToken } from "./oauth-flow";
28
29
  import type { MCPToolDetails } from "./tool-bridge";
29
30
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
30
31
  import type { MCPToolCache } from "./tool-cache";
32
+ import { HttpTransport } from "./transports/http";
31
33
  import type {
32
34
  MCPGetPromptResult,
33
35
  MCPPrompt,
@@ -315,6 +317,18 @@ export class MCPManager {
315
317
  this.#pendingConnections.delete(name);
316
318
  this.#connections.set(name, connection);
317
319
  }
320
+
321
+ // Wire auth refresh for HTTP transports so 401s trigger token refresh
322
+ if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
323
+ connection.transport.onAuthError = async () => {
324
+ const refreshed = await this.#resolveAuthConfig(config, true);
325
+ if (refreshed.type === "http" || refreshed.type === "sse") {
326
+ return refreshed.headers ?? null;
327
+ }
328
+ return null;
329
+ };
330
+ }
331
+
318
332
  return connection;
319
333
  },
320
334
  error => {
@@ -814,15 +828,39 @@ export class MCPManager {
814
828
  /**
815
829
  * Resolve OAuth credentials and shell commands in config.
816
830
  */
817
- async #resolveAuthConfig(config: MCPServerConfig): Promise<MCPServerConfig> {
831
+ async #resolveAuthConfig(config: MCPServerConfig, forceRefresh = false): Promise<MCPServerConfig> {
818
832
  let resolved: MCPServerConfig = { ...config };
819
833
 
820
834
  const auth = config.auth;
821
835
  if (auth?.type === "oauth" && auth.credentialId && this.#authStorage) {
822
836
  const credentialId = auth.credentialId;
823
837
  try {
824
- const credential = this.#authStorage.get(credentialId);
838
+ let credential = this.#authStorage.get(credentialId);
825
839
  if (credential?.type === "oauth") {
840
+ // Proactive refresh: 5-minute buffer before expiry
841
+ // Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
842
+ const REFRESH_BUFFER_MS = 5 * 60_000;
843
+ const shouldRefresh =
844
+ forceRefresh || (credential.expires && Date.now() >= credential.expires - REFRESH_BUFFER_MS);
845
+ if (shouldRefresh && credential.refresh && auth.tokenUrl) {
846
+ try {
847
+ const refreshed = await refreshMCPOAuthToken(
848
+ auth.tokenUrl,
849
+ credential.refresh,
850
+ auth.clientId,
851
+ auth.clientSecret,
852
+ );
853
+ const refreshedCredential = { type: "oauth" as const, ...refreshed };
854
+ await this.#authStorage.set(credentialId, refreshedCredential);
855
+ credential = refreshedCredential;
856
+ } catch (refreshError) {
857
+ logger.warn("MCP OAuth refresh failed, using existing token", {
858
+ credentialId,
859
+ error: refreshError,
860
+ });
861
+ }
862
+ }
863
+
826
864
  if (resolved.type === "http" || resolved.type === "sse") {
827
865
  resolved = {
828
866
  ...resolved,
@@ -254,3 +254,44 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
254
254
  }
255
255
  }
256
256
  }
257
+
258
+ /**
259
+ * Refresh an MCP OAuth token using the standard refresh_token grant.
260
+ * Returns updated credentials; preserves the old refresh token if the server doesn't rotate it.
261
+ */
262
+ export async function refreshMCPOAuthToken(
263
+ tokenUrl: string,
264
+ refreshToken: string,
265
+ clientId?: string,
266
+ clientSecret?: string,
267
+ ): Promise<OAuthCredentials> {
268
+ const params = new URLSearchParams({
269
+ grant_type: "refresh_token",
270
+ refresh_token: refreshToken,
271
+ });
272
+ if (clientId) params.set("client_id", clientId);
273
+ if (clientSecret) params.set("client_secret", clientSecret);
274
+
275
+ const response = await fetch(tokenUrl, {
276
+ method: "POST",
277
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
278
+ body: params.toString(),
279
+ });
280
+
281
+ if (!response.ok) {
282
+ const text = await response.text();
283
+ throw new Error(`MCP OAuth refresh failed: ${response.status} ${text}`);
284
+ }
285
+
286
+ const data = (await response.json()) as {
287
+ access_token: string;
288
+ refresh_token?: string;
289
+ expires_in?: number;
290
+ };
291
+ const expiresIn = data.expires_in ?? 3600;
292
+ return {
293
+ access: data.access_token,
294
+ refresh: data.refresh_token ?? refreshToken,
295
+ expires: Date.now() + expiresIn * 1000,
296
+ };
297
+ }
@@ -26,6 +26,8 @@ export class HttpTransport implements MCPTransport {
26
26
  onClose?: () => void;
27
27
  onError?: (error: Error) => void;
28
28
  onNotification?: (method: string, params: unknown) => void;
29
+ /** Called on 401/403 to attempt token refresh. Returns updated headers or null. */
30
+ onAuthError?: () => Promise<Record<string, string> | null>;
29
31
 
30
32
  constructor(private config: MCPHttpServerConfig | MCPSseServerConfig) {}
31
33
 
@@ -102,6 +104,27 @@ export class HttpTransport implements MCPTransport {
102
104
  method: string,
103
105
  params?: Record<string, unknown>,
104
106
  options?: MCPRequestOptions,
107
+ ): Promise<T> {
108
+ try {
109
+ return await this.#executeRequest<T>(method, params, options);
110
+ } catch (error) {
111
+ // Retry once on auth failure if onAuthError is wired
112
+ if (this.onAuthError && error instanceof Error && /^HTTP (401|403):/.test(error.message)) {
113
+ const newHeaders = await this.onAuthError();
114
+ if (newHeaders) {
115
+ // Persist refreshed headers so subsequent requests use them directly
116
+ this.config = { ...this.config, headers: newHeaders };
117
+ return this.#executeRequest<T>(method, params, options);
118
+ }
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ async #executeRequest<T>(
125
+ method: string,
126
+ params: Record<string, unknown> | undefined,
127
+ options: MCPRequestOptions | undefined,
105
128
  ): Promise<T> {
106
129
  if (!this.#connected) {
107
130
  throw new Error("Transport not connected");
package/src/mcp/types.ts CHANGED
@@ -49,6 +49,12 @@ export interface MCPAuthConfig {
49
49
  type: "oauth" | "apikey";
50
50
  /** Credential ID for OAuth (references agent.db) */
51
51
  credentialId?: string;
52
+ /** Token endpoint URL — persisted for proactive token refresh */
53
+ tokenUrl?: string;
54
+ /** Client ID — persisted for token refresh */
55
+ clientId?: string;
56
+ /** Client secret — persisted for token refresh */
57
+ clientSecret?: string;
52
58
  }
53
59
 
54
60
  /** Base server config with shared options */
@@ -1030,6 +1030,9 @@ export class MCPAddWizard extends Container {
1030
1030
  config.auth = {
1031
1031
  type: "oauth",
1032
1032
  credentialId: this.#state.oauthCredentialId,
1033
+ tokenUrl: this.#state.oauthTokenUrl || undefined,
1034
+ clientId: this.#state.oauthClientId || undefined,
1035
+ clientSecret: this.#state.oauthClientSecret || undefined,
1033
1036
  };
1034
1037
  }
1035
1038
 
@@ -1054,6 +1057,9 @@ export class MCPAddWizard extends Container {
1054
1057
  config.auth = {
1055
1058
  type: "oauth",
1056
1059
  credentialId: this.#state.oauthCredentialId,
1060
+ tokenUrl: this.#state.oauthTokenUrl || undefined,
1061
+ clientId: this.#state.oauthClientId || undefined,
1062
+ clientSecret: this.#state.oauthClientSecret || undefined,
1057
1063
  };
1058
1064
  }
1059
1065
 
@@ -1250,6 +1256,9 @@ export class MCPAddWizard extends Container {
1250
1256
  config.auth = {
1251
1257
  type: "oauth",
1252
1258
  credentialId: this.#state.oauthCredentialId,
1259
+ tokenUrl: this.#state.oauthTokenUrl || undefined,
1260
+ clientId: this.#state.oauthClientId || undefined,
1261
+ clientSecret: this.#state.oauthClientSecret || undefined,
1253
1262
  };
1254
1263
  }
1255
1264
 
@@ -1275,6 +1284,9 @@ export class MCPAddWizard extends Container {
1275
1284
  config.auth = {
1276
1285
  type: "oauth",
1277
1286
  credentialId: this.#state.oauthCredentialId,
1287
+ tokenUrl: this.#state.oauthTokenUrl || undefined,
1288
+ clientId: this.#state.oauthClientId || undefined,
1289
+ clientSecret: this.#state.oauthClientSecret || undefined,
1278
1290
  };
1279
1291
  }
1280
1292
 
@@ -226,7 +226,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
226
226
  label: "Auto",
227
227
  description: "Preferred web-search provider",
228
228
  },
229
- { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
229
+ { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
230
230
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
231
231
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
232
232
  { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
@@ -413,6 +413,9 @@ export class MCPCommandController {
413
413
  auth: {
414
414
  type: "oauth",
415
415
  credentialId,
416
+ tokenUrl: oauth.tokenUrl,
417
+ clientId: oauth.clientId ?? finalConfig.oauth?.clientId,
418
+ clientSecret: undefined,
416
419
  },
417
420
  };
418
421
  } catch (oauthError) {
@@ -1296,7 +1299,9 @@ export class MCPCommandController {
1296
1299
  }
1297
1300
 
1298
1301
  const currentAuth = (
1299
- found.config as MCPServerConfig & { auth?: { type: "oauth" | "apikey"; credentialId?: string } }
1302
+ found.config as MCPServerConfig & {
1303
+ auth?: { type: "oauth" | "apikey"; credentialId?: string; clientSecret?: string };
1304
+ }
1300
1305
  ).auth;
1301
1306
  if (currentAuth?.type === "oauth") {
1302
1307
  await this.#removeManagedOAuthCredential(currentAuth.credentialId);
@@ -1321,6 +1326,9 @@ export class MCPCommandController {
1321
1326
  auth: {
1322
1327
  type: "oauth",
1323
1328
  credentialId,
1329
+ tokenUrl: oauth.tokenUrl,
1330
+ clientId: oauth.clientId ?? found.config.oauth?.clientId,
1331
+ clientSecret: currentAuth?.clientSecret,
1324
1332
  },
1325
1333
  };
1326
1334
  await updateMCPServer(found.filePath, name, updated);
@@ -629,15 +629,11 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
629
629
  const terminalId = getTerminalId();
630
630
  if (!terminalId) return;
631
631
 
632
- try {
633
- const breadcrumbDir = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR);
634
- const breadcrumbFile = path.join(breadcrumbDir, terminalId);
635
- const content = `${cwd}\n${sessionFile}\n`;
636
- // Bun.write auto-creates parent dirs
637
- void Bun.write(breadcrumbFile, content);
638
- } catch {
639
- // Best-effort — don't break session creation if breadcrumb fails
640
- }
632
+ const breadcrumbDir = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR);
633
+ const breadcrumbFile = path.join(breadcrumbDir, terminalId);
634
+ const content = `${cwd}\n${sessionFile}\n`;
635
+ // Best-effort don't break session creation if breadcrumb fails
636
+ Bun.write(breadcrumbFile, content).catch(() => {});
641
637
  }
642
638
 
643
639
  /**
@@ -14,7 +14,7 @@ export const JSON_TREE_SCALAR_LEN_COLLAPSED = 60;
14
14
  export const JSON_TREE_SCALAR_LEN_EXPANDED = 2000;
15
15
 
16
16
  /** Keys injected by the harness that should not be displayed to users */
17
- const HIDDEN_ARG_KEYS = new Set([INTENT_FIELD]);
17
+ const HIDDEN_ARG_KEYS = new Set([INTENT_FIELD, "__partialJson"]);
18
18
 
19
19
  /** Strip harness-internal keys from tool args for display */
20
20
  export function stripInternalArgs(args: Record<string, unknown>): Record<string, unknown> {