@oh-my-pi/pi-coding-agent 17.0.8 → 17.0.9
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 +33 -1
- package/dist/cli.js +4850 -4774
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +27 -0
- package/dist/types/config/settings-schema.d.ts +34 -3
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/settings-defs.d.ts +1 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +8 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/index.d.ts +3 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +13 -12
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +1 -1
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +40 -0
- package/src/config/model-resolver.ts +0 -1
- package/src/config/settings-schema.ts +41 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/lsp/client.ts +23 -0
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +25 -7
- package/src/main.ts +1 -0
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/render.ts +94 -35
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/modes/components/agent-hub.ts +26 -9
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/settings-defs.ts +5 -2
- package/src/modes/components/settings-selector.ts +9 -5
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/controllers/command-controller.ts +14 -7
- package/src/modes/controllers/mcp-command-controller.ts +70 -40
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/tools/task.md +12 -2
- package/src/sdk.ts +45 -3
- package/src/session/agent-session.ts +63 -32
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +41 -2
- package/src/session/session-loader.ts +10 -5
- package/src/slash-commands/helpers/usage-report.ts +3 -1
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +52 -8
- package/src/task/structured-subagent.ts +3 -1
- package/src/task/types.ts +16 -0
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/index.ts +3 -0
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/web/search/providers/firecrawl.ts +46 -13
- package/src/web/search/types.ts +5 -1
package/src/mcp/types.ts
CHANGED
|
@@ -206,10 +206,17 @@ export interface MCPResourceContent {
|
|
|
206
206
|
|
|
207
207
|
export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
|
|
208
208
|
|
|
209
|
+
/** Structured authentication challenge returned in a tool result. */
|
|
210
|
+
export interface MCPAuthChallenge {
|
|
211
|
+
/** Values from `_meta["mcp/www_authenticate"]`. */
|
|
212
|
+
readonly wwwAuthenticate: readonly string[];
|
|
213
|
+
}
|
|
214
|
+
|
|
209
215
|
/** tools/call response */
|
|
210
216
|
export interface MCPToolCallResult {
|
|
211
217
|
content: MCPContent[];
|
|
212
218
|
isError?: boolean;
|
|
219
|
+
_meta?: Record<string, unknown>;
|
|
213
220
|
}
|
|
214
221
|
|
|
215
222
|
// =============================================================================
|
|
@@ -76,26 +76,43 @@ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): st
|
|
|
76
76
|
return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Format a resolved selector, preserving provider identity when requested. */
|
|
80
|
+
function formatResolvedModelBadge(resolved: string, preserveProvider = false): string {
|
|
81
|
+
// Model ids may themselves contain colons (`qwen3:14b`), so only treat the
|
|
82
|
+
// suffix as a thinking level when it parses as one.
|
|
83
|
+
const colon = resolved.lastIndexOf(":");
|
|
84
|
+
const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
|
|
85
|
+
const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
|
|
86
|
+
const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1);
|
|
87
|
+
return formatModelBadge(label, level);
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
/**
|
|
80
91
|
* Active model + reasoning level for a hub row: live session state when the
|
|
81
92
|
* agent is attached, else the executor-reported `resolvedModel` selector
|
|
82
|
-
* (`provider/id`, optionally `:<level>`).
|
|
93
|
+
* (`provider/id`, optionally `:<level>`). Active retry fallbacks retain their
|
|
94
|
+
* provider and carry an explicit marker. Undefined when no model is known
|
|
83
95
|
* (e.g. a parked historical agent restored from disk).
|
|
84
96
|
*/
|
|
85
97
|
function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
|
|
98
|
+
const progress = observed?.progress;
|
|
99
|
+
// Prefer the live session's own resolved fallback selector; else honor the
|
|
100
|
+
// executor-reported fallback flag. The latter covers observer-only rows (no
|
|
101
|
+
// live session) AND live rows whose fallback armed no session retry state —
|
|
102
|
+
// e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied`
|
|
103
|
+
// without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined.
|
|
104
|
+
const fallbackSelector =
|
|
105
|
+
ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined);
|
|
106
|
+
if (fallbackSelector) {
|
|
107
|
+
return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`;
|
|
108
|
+
}
|
|
86
109
|
const model = ref.session?.model;
|
|
87
110
|
if (model) {
|
|
88
111
|
const level = model.thinking ? ref.session?.thinkingLevel : undefined;
|
|
89
112
|
return formatModelBadge(model.id, level);
|
|
90
113
|
}
|
|
91
|
-
const resolved =
|
|
92
|
-
|
|
93
|
-
// Model ids may themselves contain colons (`qwen3:14b`), so only treat the
|
|
94
|
-
// suffix as a thinking level when it parses as one.
|
|
95
|
-
const colon = resolved.lastIndexOf(":");
|
|
96
|
-
const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
|
|
97
|
-
const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
|
|
98
|
-
return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
|
|
114
|
+
const resolved = progress?.resolvedModel;
|
|
115
|
+
return resolved ? formatResolvedModelBadge(resolved) : undefined;
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
/** Result of one host-backed transcript read for the Agent Hub viewer. */
|
|
@@ -60,6 +60,8 @@ export class OAuthSelectorComponent extends Container {
|
|
|
60
60
|
/** First provider index of the visible ScrollView window (last #updateList). */
|
|
61
61
|
#scrollStart = 0;
|
|
62
62
|
#visibleCount = 0;
|
|
63
|
+
/** Visible list window, shrunk by {@link setMaxHeight} on short screens. */
|
|
64
|
+
#maxVisible = OAUTH_SELECTOR_MAX_VISIBLE;
|
|
63
65
|
#mode: "login" | "logout";
|
|
64
66
|
#authStorage: AuthStorage;
|
|
65
67
|
#onSelectCallback: (providerId: string) => void;
|
|
@@ -111,6 +113,24 @@ export class OAuthSelectorComponent extends Container {
|
|
|
111
113
|
this.#validationGeneration += 1;
|
|
112
114
|
this.#stopSpinner();
|
|
113
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fit the selector into `lines` rendered rows by shrinking the visible list
|
|
119
|
+
* window (the window is centered on the selection, so the selected row is
|
|
120
|
+
* always visible at any height). Prefers keeping the full chrome — borders,
|
|
121
|
+
* spacers, title, search status — but sacrifices the trailing spacer/border
|
|
122
|
+
* (clipped by the host) before dropping below three visible rows.
|
|
123
|
+
*/
|
|
124
|
+
setMaxHeight(lines: number): void {
|
|
125
|
+
// Above the rows: LIST_ROW_OFFSET; below: search status + spacer + border.
|
|
126
|
+
const strict = lines - LIST_ROW_OFFSET - 3;
|
|
127
|
+
// Keeps only the rows + search status inside `lines`.
|
|
128
|
+
const relaxed = lines - LIST_ROW_OFFSET - 1;
|
|
129
|
+
const rows = Math.min(OAUTH_SELECTOR_MAX_VISIBLE, Math.max(1, strict, Math.min(relaxed, 3)));
|
|
130
|
+
if (rows === this.#maxVisible) return;
|
|
131
|
+
this.#maxVisible = rows;
|
|
132
|
+
this.#updateList();
|
|
133
|
+
}
|
|
114
134
|
#hasSelectableAuth(providerId: string): boolean {
|
|
115
135
|
return this.#mode === "logout" ? this.#authStorage.has(providerId) : this.#authStorage.hasAuth(providerId);
|
|
116
136
|
}
|
|
@@ -227,7 +247,7 @@ export class OAuthSelectorComponent extends Container {
|
|
|
227
247
|
}
|
|
228
248
|
|
|
229
249
|
#isSearchEnabled(): boolean {
|
|
230
|
-
return this.#allProviders.length >
|
|
250
|
+
return this.#allProviders.length > this.#maxVisible;
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
#shouldRenderSearchStatus(): boolean {
|
|
@@ -286,7 +306,7 @@ export class OAuthSelectorComponent extends Container {
|
|
|
286
306
|
this.#listContainer.clear();
|
|
287
307
|
|
|
288
308
|
const total = this.#filteredProviders.length;
|
|
289
|
-
const maxVisible =
|
|
309
|
+
const maxVisible = this.#maxVisible;
|
|
290
310
|
const startIndex =
|
|
291
311
|
total <= maxVisible
|
|
292
312
|
? 0
|
|
@@ -381,7 +401,7 @@ export class OAuthSelectorComponent extends Container {
|
|
|
381
401
|
// Page up - jump up by one visible page
|
|
382
402
|
else if (matchesKey(keyData, "pageUp")) {
|
|
383
403
|
if (this.#filteredProviders.length > 0) {
|
|
384
|
-
this.#selectedIndex = Math.max(0, this.#selectedIndex -
|
|
404
|
+
this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
|
|
385
405
|
}
|
|
386
406
|
this.#statusMessage = undefined;
|
|
387
407
|
this.#updateList();
|
|
@@ -389,10 +409,7 @@ export class OAuthSelectorComponent extends Container {
|
|
|
389
409
|
// Page down - jump down by one visible page
|
|
390
410
|
else if (matchesKey(keyData, "pageDown")) {
|
|
391
411
|
if (this.#filteredProviders.length > 0) {
|
|
392
|
-
this.#selectedIndex = Math.min(
|
|
393
|
-
this.#filteredProviders.length - 1,
|
|
394
|
-
this.#selectedIndex + OAUTH_SELECTOR_MAX_VISIBLE,
|
|
395
|
-
);
|
|
412
|
+
this.#selectedIndex = Math.min(this.#filteredProviders.length - 1, this.#selectedIndex + this.#maxVisible);
|
|
396
413
|
}
|
|
397
414
|
this.#statusMessage = undefined;
|
|
398
415
|
this.#updateList();
|
|
@@ -66,6 +66,7 @@ export interface SubmenuSettingDef extends BaseSettingDef {
|
|
|
66
66
|
|
|
67
67
|
export interface TextInputSettingDef extends BaseSettingDef {
|
|
68
68
|
type: "text";
|
|
69
|
+
secret: boolean;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export interface ProviderLimitsSettingDef extends BaseSettingDef {
|
|
@@ -176,11 +177,13 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
|
|
|
176
177
|
if (options) {
|
|
177
178
|
return { ...base, type: "submenu", options };
|
|
178
179
|
}
|
|
179
|
-
return { ...base, type: "text" };
|
|
180
|
+
return { ...base, type: "text", secret: ui.secret === true };
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
if (schemaType === "record") {
|
|
183
|
-
return path === "providers.maxInFlightRequests"
|
|
184
|
+
return path === "providers.maxInFlightRequests"
|
|
185
|
+
? { ...base, type: "providerLimits" }
|
|
186
|
+
: { ...base, type: "text", secret: false };
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
return null;
|
|
@@ -65,6 +65,7 @@ class TextInputSubmenu extends Container {
|
|
|
65
65
|
label: string,
|
|
66
66
|
description: string,
|
|
67
67
|
currentValue: string,
|
|
68
|
+
secret: boolean,
|
|
68
69
|
private readonly onSubmit: (value: string) => void,
|
|
69
70
|
private readonly onCancel: () => void,
|
|
70
71
|
) {
|
|
@@ -78,6 +79,7 @@ class TextInputSubmenu extends Container {
|
|
|
78
79
|
this.addChild(new Spacer(1));
|
|
79
80
|
|
|
80
81
|
this.#input = new Input();
|
|
82
|
+
this.#input.mask = secret;
|
|
81
83
|
if (currentValue) {
|
|
82
84
|
this.#input.setValue(currentValue);
|
|
83
85
|
}
|
|
@@ -293,6 +295,7 @@ class ProviderLimitsSubmenu extends Container {
|
|
|
293
295
|
`Max In-Flight Requests: ${provider}`,
|
|
294
296
|
"Enter a positive number. Decimals round down. Clear the field to make this provider unlimited.",
|
|
295
297
|
limits[provider]?.toString() ?? "",
|
|
298
|
+
false,
|
|
296
299
|
value => {
|
|
297
300
|
const next = { ...limits };
|
|
298
301
|
const trimmed = value.trim();
|
|
@@ -837,7 +840,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
837
840
|
id: def.path,
|
|
838
841
|
label: def.label,
|
|
839
842
|
description: def.description,
|
|
840
|
-
currentValue: this.#formatTextInputValue(def
|
|
843
|
+
currentValue: this.#formatTextInputValue(def, currentValue),
|
|
841
844
|
submenu: (cv, done) => this.#createTextInput(def, cv, done),
|
|
842
845
|
changed,
|
|
843
846
|
};
|
|
@@ -992,12 +995,13 @@ export class SettingsSelectorComponent implements Component {
|
|
|
992
995
|
def.label,
|
|
993
996
|
def.description,
|
|
994
997
|
this.#formatTextInputEditValue(def.path, settings.get(def.path)),
|
|
998
|
+
def.secret,
|
|
995
999
|
value => {
|
|
996
1000
|
// Empty string clears the setting; undefined-typed string settings
|
|
997
1001
|
// store "" which the browser.ts expandPath ignores (no-op fallback).
|
|
998
1002
|
this.#setSettingValue(def.path, value);
|
|
999
1003
|
this.callbacks.onChange(def.path, settings.get(def.path));
|
|
1000
|
-
wrappedDone(this.#formatTextInputValue(def
|
|
1004
|
+
wrappedDone(this.#formatTextInputValue(def, settings.get(def.path)));
|
|
1001
1005
|
},
|
|
1002
1006
|
() => wrappedDone(),
|
|
1003
1007
|
);
|
|
@@ -1022,9 +1026,9 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1022
1026
|
return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
|
|
1023
1027
|
}
|
|
1024
1028
|
|
|
1025
|
-
#formatTextInputValue(
|
|
1026
|
-
if (
|
|
1027
|
-
return this.#formatTextInputEditValue(path, value);
|
|
1029
|
+
#formatTextInputValue(def: SettingDef & { type: "text" }, value: unknown): string {
|
|
1030
|
+
if (def.secret) return value ? "••••••••" : "";
|
|
1031
|
+
return this.#formatTextInputEditValue(def.path, value);
|
|
1028
1032
|
}
|
|
1029
1033
|
|
|
1030
1034
|
#formatTextInputEditValue(_path: SettingPath, value: unknown): string {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { beforeAll, describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
2
2
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
3
3
|
import { type Component, Text } from "@oh-my-pi/pi-tui";
|
|
4
|
-
import { Settings } from "../../config/settings";
|
|
4
|
+
import { Settings, settings } from "../../config/settings";
|
|
5
|
+
import { renderMCPResult } from "../../mcp/render";
|
|
6
|
+
import type { MCPToolDetails } from "../../mcp/tool-bridge";
|
|
5
7
|
import { getThemeByName, setThemeInstance, theme } from "../theme/theme";
|
|
6
8
|
import { ToolExecutionComponent, type ToolExecutionUi } from "./tool-execution";
|
|
7
9
|
|
|
@@ -26,6 +28,10 @@ describe("ToolExecutionComponent custom renderer failures", () => {
|
|
|
26
28
|
setThemeInstance(loaded);
|
|
27
29
|
});
|
|
28
30
|
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
settings.set("mcp.renderMarkdownResults", true);
|
|
33
|
+
});
|
|
34
|
+
|
|
29
35
|
it("falls back to the custom tool label when a renderCall child component throws during render", () => {
|
|
30
36
|
const tool: AgentTool = {
|
|
31
37
|
name: "graphify_graph",
|
|
@@ -99,3 +105,58 @@ describe("ToolExecutionComponent custom renderer failures", () => {
|
|
|
99
105
|
expect(text).toContain(rawResultText);
|
|
100
106
|
});
|
|
101
107
|
});
|
|
108
|
+
|
|
109
|
+
describe("MCP result Markdown rendering", () => {
|
|
110
|
+
const details: MCPToolDetails = {
|
|
111
|
+
serverName: "context-mode",
|
|
112
|
+
mcpToolName: "ctx_search",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
await Settings.init({ inMemory: true });
|
|
117
|
+
const loaded = await getThemeByName("dark");
|
|
118
|
+
if (!loaded) throw new Error("theme unavailable");
|
|
119
|
+
setThemeInstance(loaded);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
settings.set("mcp.renderMarkdownResults", true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("renders inline Markdown by default", () => {
|
|
127
|
+
const component = renderMCPResult(
|
|
128
|
+
{ content: [{ type: "text", text: "**bold result** and `code`" }], details },
|
|
129
|
+
{ expanded: true, isPartial: false },
|
|
130
|
+
theme,
|
|
131
|
+
);
|
|
132
|
+
const rendered = visibleText(component.render(80));
|
|
133
|
+
|
|
134
|
+
expect(rendered).toContain("bold result and code");
|
|
135
|
+
expect(rendered).not.toContain("**bold result**");
|
|
136
|
+
expect(rendered).not.toContain("`code`");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps Markdown syntax literal when the setting is disabled", () => {
|
|
140
|
+
settings.set("mcp.renderMarkdownResults", false);
|
|
141
|
+
const component = renderMCPResult(
|
|
142
|
+
{ content: [{ type: "text", text: "**bold result**" }], details },
|
|
143
|
+
{ expanded: true, isPartial: false },
|
|
144
|
+
theme,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(visibleText(component.render(80))).toContain("**bold result**");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("preserves structured JSON rendering when Markdown is enabled", () => {
|
|
151
|
+
settings.set("mcp.renderMarkdownResults", true);
|
|
152
|
+
const component = renderMCPResult(
|
|
153
|
+
{ content: [{ type: "text", text: '{"status":"**ok**"}' }], details },
|
|
154
|
+
{ expanded: true, isPartial: false },
|
|
155
|
+
theme,
|
|
156
|
+
);
|
|
157
|
+
const rendered = visibleText(component.render(80));
|
|
158
|
+
|
|
159
|
+
expect(rendered).toContain("status");
|
|
160
|
+
expect(rendered).toContain("**ok**");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1636,17 +1636,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
|
|
|
1636
1636
|
}
|
|
1637
1637
|
|
|
1638
1638
|
function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
|
|
1639
|
-
const
|
|
1640
|
-
.map(limit => limit.window
|
|
1641
|
-
.filter(
|
|
1642
|
-
|
|
1643
|
-
|
|
1639
|
+
const windows = limits
|
|
1640
|
+
.map(limit => limit.window)
|
|
1641
|
+
.filter(
|
|
1642
|
+
(window): window is NonNullable<UsageLimit["window"]> =>
|
|
1643
|
+
window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs,
|
|
1644
|
+
);
|
|
1645
|
+
if (windows.length === 0) return null;
|
|
1646
|
+
// Use the shared verb when every contributing window agrees (e.g. all "tick");
|
|
1647
|
+
// mixed or absent labels fall back to the generic "resets".
|
|
1648
|
+
const labels = new Set(windows.map(window => window.resetLabel ?? "resets"));
|
|
1649
|
+
const verb = labels.size === 1 ? [...labels][0]! : "resets";
|
|
1650
|
+
const offsets = windows.map(window => window.resetsAt! - nowMs);
|
|
1644
1651
|
const minReset = Math.min(...offsets);
|
|
1645
1652
|
const maxReset = Math.max(...offsets);
|
|
1646
1653
|
if (maxReset - minReset > 60_000) {
|
|
1647
|
-
return
|
|
1654
|
+
return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
|
|
1648
1655
|
}
|
|
1649
|
-
return
|
|
1656
|
+
return `${verb} in ${formatDuration(minReset)}`;
|
|
1650
1657
|
}
|
|
1651
1658
|
/**
|
|
1652
1659
|
* Compact one-line quota summary for a single advisor's provider.
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
searchSmitheryRegistry,
|
|
48
48
|
toConfigName,
|
|
49
49
|
} from "../../mcp/smithery-registry";
|
|
50
|
-
import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
|
50
|
+
import type { MCPAuthChallenge, MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
|
51
51
|
import { shortenPath } from "../../tools/render-utils";
|
|
52
52
|
import { urlHyperlinkAlways } from "../../tui";
|
|
53
53
|
import { copyToClipboard } from "../../utils/clipboard";
|
|
@@ -1042,7 +1042,10 @@ export class MCPCommandController {
|
|
|
1042
1042
|
return next;
|
|
1043
1043
|
}
|
|
1044
1044
|
|
|
1045
|
-
async #resolveOAuthEndpointsFromServer(
|
|
1045
|
+
async #resolveOAuthEndpointsFromServer(
|
|
1046
|
+
config: MCPServerConfig,
|
|
1047
|
+
authChallenge?: MCPAuthChallenge,
|
|
1048
|
+
): Promise<OAuthEndpoints> {
|
|
1046
1049
|
// Stdio servers manage credentials inside the child process; OMP's OAuth
|
|
1047
1050
|
// flow only applies to http/sse transports. Without this guard the
|
|
1048
1051
|
// unauthenticated preflight below spawns the child, which happily reuses
|
|
@@ -1068,13 +1071,20 @@ export class MCPCommandController {
|
|
|
1068
1071
|
connectionError = error as Error;
|
|
1069
1072
|
}
|
|
1070
1073
|
|
|
1071
|
-
// Server connected fine without auth — reauth is not needed
|
|
1072
|
-
|
|
1074
|
+
// Server connected fine without auth — reauth is not needed. A tool-level
|
|
1075
|
+
// challenge overrides this: servers may allow the anonymous handshake yet
|
|
1076
|
+
// protect individual tool calls with `_meta["mcp/www_authenticate"]`.
|
|
1077
|
+
if (connectionSucceeded && !authChallenge) {
|
|
1073
1078
|
throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
|
|
1074
1079
|
}
|
|
1075
1080
|
|
|
1076
|
-
//
|
|
1077
|
-
|
|
1081
|
+
// Tool calls can carry richer RFC 6750/RFC 9728 hints than the original
|
|
1082
|
+
// connection error. Feed those hints through the same analyzer so
|
|
1083
|
+
// resource_metadata and scope reach protected-resource discovery.
|
|
1084
|
+
const authError = authChallenge
|
|
1085
|
+
? new Error(`${connectionError?.message ?? "HTTP 401"}\n${authChallenge.wwwAuthenticate.join("\n")}`)
|
|
1086
|
+
: connectionError!;
|
|
1087
|
+
const authResult = analyzeAuthError(authError, "url" in config ? config.url : undefined);
|
|
1078
1088
|
let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
|
|
1079
1089
|
|
|
1080
1090
|
if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
|
|
@@ -1678,21 +1688,29 @@ export class MCPCommandController {
|
|
|
1678
1688
|
}
|
|
1679
1689
|
}
|
|
1680
1690
|
|
|
1681
|
-
|
|
1691
|
+
/** Reauthorize a server after a tool-level OAuth challenge. */
|
|
1692
|
+
async handleMCPAuthChallenge(name: string, challenge: MCPAuthChallenge): Promise<MCPServerConfig | undefined> {
|
|
1693
|
+
return this.#handleReauth(name, { silent: true, reload: false, authChallenge: challenge });
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async #handleReauth(
|
|
1697
|
+
name: string | undefined,
|
|
1698
|
+
options: { silent?: boolean; reload?: boolean; authChallenge?: MCPAuthChallenge } = {},
|
|
1699
|
+
): Promise<MCPServerConfig | undefined> {
|
|
1682
1700
|
if (!name) {
|
|
1683
|
-
this.ctx.showError("Server name required. Usage: /mcp reauth <name>");
|
|
1701
|
+
if (!options.silent) this.ctx.showError("Server name required. Usage: /mcp reauth <name>");
|
|
1684
1702
|
return;
|
|
1685
1703
|
}
|
|
1686
1704
|
|
|
1687
1705
|
try {
|
|
1688
1706
|
const found = await this.#resolveServerForAuth(name);
|
|
1689
1707
|
if (!found) {
|
|
1690
|
-
this.ctx.showError(`Server "${name}" not found.`);
|
|
1708
|
+
if (!options.silent) this.ctx.showError(`Server "${name}" not found.`);
|
|
1691
1709
|
return;
|
|
1692
1710
|
}
|
|
1693
1711
|
|
|
1694
1712
|
if (found.config.enabled === false) {
|
|
1695
|
-
this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
|
|
1713
|
+
if (!options.silent) this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
|
|
1696
1714
|
return;
|
|
1697
1715
|
}
|
|
1698
1716
|
|
|
@@ -1705,7 +1723,7 @@ export class MCPCommandController {
|
|
|
1705
1723
|
// happened yet if the server turns out not to need (or support) OAuth.
|
|
1706
1724
|
// Use the same env-expanded config shape runtime discovery passes to
|
|
1707
1725
|
// MCPManager; the raw file value may contain `${...}` placeholders.
|
|
1708
|
-
const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig);
|
|
1726
|
+
const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig, options.authChallenge);
|
|
1709
1727
|
const serverUrl =
|
|
1710
1728
|
runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined;
|
|
1711
1729
|
// A user-supplied client secret may live in either block (the wizard
|
|
@@ -1719,7 +1737,9 @@ export class MCPCommandController {
|
|
|
1719
1737
|
const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret;
|
|
1720
1738
|
const flowClientSecret = userClientSecret ?? storedClientSecret ?? "";
|
|
1721
1739
|
|
|
1722
|
-
|
|
1740
|
+
if (!options.silent) {
|
|
1741
|
+
this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
|
|
1742
|
+
}
|
|
1723
1743
|
|
|
1724
1744
|
const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
|
|
1725
1745
|
const oauthResource =
|
|
@@ -1755,39 +1775,49 @@ export class MCPCommandController {
|
|
|
1755
1775
|
// Definition-only entries resolve through the url-keyed binding alone;
|
|
1756
1776
|
// skip the write-back so a committed project mcp.json stays clean.
|
|
1757
1777
|
const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined;
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1778
|
+
const shouldPersist = currentAuth || oauthResult.credentialId !== urlKeyedId;
|
|
1779
|
+
const updatedConfig = shouldPersist
|
|
1780
|
+
? this.#persistOAuthResult(baseConfig, oauthResult, {
|
|
1781
|
+
tokenUrl: oauth.tokenUrl,
|
|
1782
|
+
clientId: oauth.clientId,
|
|
1783
|
+
userClientSecret,
|
|
1784
|
+
resource: oauthResource,
|
|
1785
|
+
stripSameOriginResource: oauthResourceIsFallback,
|
|
1786
|
+
})
|
|
1787
|
+
: baseConfig;
|
|
1788
|
+
if (shouldPersist) {
|
|
1789
|
+
await updateMCPServer(found.filePath, name, updatedConfig);
|
|
1790
|
+
}
|
|
1791
|
+
if (options.reload !== false) {
|
|
1792
|
+
await this.#reloadMCP();
|
|
1793
|
+
const state = await this.#waitForServerConnectionWithAnimation(name);
|
|
1770
1794
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1795
|
+
const lines = [
|
|
1796
|
+
"",
|
|
1797
|
+
theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`),
|
|
1798
|
+
"",
|
|
1799
|
+
` Status: ${
|
|
1800
|
+
state === "connected"
|
|
1801
|
+
? theme.fg("success", "connected")
|
|
1802
|
+
: state === "connecting"
|
|
1803
|
+
? theme.fg("muted", "connecting")
|
|
1804
|
+
: theme.fg("warning", "not connected")
|
|
1805
|
+
}`,
|
|
1806
|
+
"",
|
|
1807
|
+
];
|
|
1808
|
+
this.#showMessage(lines.join("\n"));
|
|
1809
|
+
}
|
|
1810
|
+
return updatedConfig;
|
|
1785
1811
|
} catch (error) {
|
|
1786
1812
|
if (error instanceof MCPOAuthCancelledError) {
|
|
1787
|
-
this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
|
|
1813
|
+
if (!options.silent) this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
|
|
1788
1814
|
return;
|
|
1789
1815
|
}
|
|
1790
|
-
|
|
1816
|
+
if (!options.silent) {
|
|
1817
|
+
this.ctx.showError(
|
|
1818
|
+
`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`,
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1791
1821
|
}
|
|
1792
1822
|
}
|
|
1793
1823
|
|
|
@@ -656,6 +656,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
656
656
|
this.#toolUiContextSetter = setToolUIContext;
|
|
657
657
|
this.lspServers = lspServers;
|
|
658
658
|
this.mcpManager = mcpManager;
|
|
659
|
+
this.mcpManager?.setAuthHandler((serverName, challenge) =>
|
|
660
|
+
new MCPCommandController(this).handleMCPAuthChallenge(serverName, challenge),
|
|
661
|
+
);
|
|
659
662
|
this.#eventBus = eventBus;
|
|
660
663
|
if (eventBus) {
|
|
661
664
|
this.#eventBusUnsubscribers.push(
|