@gajae-code/coding-agent 0.18.8 → 0.18.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 +98 -0
- package/dist/types/async/endpoint-id.d.ts +16 -0
- package/dist/types/async/support.d.ts +0 -14
- package/dist/types/commands/mcp-serve.d.ts +4 -0
- package/dist/types/discovery/mcp-json.d.ts +1 -0
- package/dist/types/eval/py/kernel.d.ts +18 -0
- package/dist/types/export/html/html-export-destination.d.ts +2 -0
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
- package/dist/types/extensibility/skill-management.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
- package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
- package/dist/types/lsp/config.d.ts +8 -5
- package/dist/types/runtime-mcp/manager.d.ts +2 -1
- package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
- package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -1
- package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
- package/dist/types/sdk/broker/broker.d.ts +6 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -1
- package/dist/types/session/session-storage.d.ts +7 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
- package/dist/types/task/isolation-dir.d.ts +26 -0
- package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
- package/dist/types/tools/browser/cdp-url.d.ts +2 -0
- package/dist/types/tools/github-side-effect.d.ts +22 -0
- package/dist/types/tools/monitor-mutation.d.ts +9 -0
- package/package.json +7 -7
- package/src/async/endpoint-id.ts +26 -0
- package/src/async/job-manager.ts +19 -10
- package/src/async/support.ts +0 -25
- package/src/capability/index.ts +14 -16
- package/src/cli/doctor/repairs.ts +4 -1
- package/src/cli/doctor-supervisor.ts +3 -0
- package/src/cli/public-command-registry.ts +28 -6
- package/src/commands/mcp-serve.ts +7 -0
- package/src/commands/sdk.ts +453 -415
- package/src/config/file-lock.ts +9 -11
- package/src/config/model-profile-activation.ts +5 -5
- package/src/coordinator-mcp/server.ts +2 -0
- package/src/customization/inventory.ts +1 -13
- package/src/dap/config.ts +2 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
- package/src/discovery/builtin.ts +3 -1
- package/src/discovery/helpers.ts +12 -9
- package/src/discovery/mcp-json.ts +20 -12
- package/src/eval/py/executor.ts +459 -77
- package/src/eval/py/kernel.ts +79 -13
- package/src/eval/py/tool-bridge.ts +4 -0
- package/src/export/html/html-export-destination.ts +16 -0
- package/src/export/html/index.ts +2 -6
- package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
- package/src/extensibility/extensions/runner.ts +11 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +27 -15
- package/src/extensibility/skill-management.ts +3 -0
- package/src/gjc-runtime/launch-worktree.ts +56 -5
- package/src/gjc-runtime/psmux-detect.ts +28 -4
- package/src/gjc-runtime/session-state-sidecar.ts +3 -2
- package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
- package/src/gjc-runtime/state-writer.ts +22 -8
- package/src/gjc-runtime/tmux-common.ts +8 -5
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/config.ts +12 -63
- package/src/memories/index.ts +88 -7
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +1 -1
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +21 -13
- package/src/modes/controllers/selector-controller.ts +3 -7
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -1
- package/src/runtime-mcp/client.ts +4 -2
- package/src/runtime-mcp/json-rpc.ts +2 -1
- package/src/runtime-mcp/manager.ts +29 -11
- package/src/runtime-mcp/mcp-redirect.ts +73 -0
- package/src/runtime-mcp/oauth-flow.ts +11 -4
- package/src/runtime-mcp/oauth-public-url.ts +8 -2
- package/src/runtime-mcp/pool.ts +6 -0
- package/src/runtime-mcp/project-host-values.ts +46 -0
- package/src/runtime-mcp/transports/http.ts +2 -1
- package/src/sdk/acp/adapter.ts +2 -0
- package/src/sdk/broker/broker.ts +6 -0
- package/src/sdk/broker/doctor-restart.ts +4 -0
- package/src/sdk/broker/ensure.ts +29 -2
- package/src/sdk/host/session-runtime.test.ts +58 -9
- package/src/sdk/host/session-runtime.ts +65 -35
- package/src/sdk/router/session-router.ts +7 -6
- package/src/sdk/session.ts +2 -7
- package/src/session/agent-session.ts +59 -17
- package/src/session/artifacts.ts +3 -2
- package/src/session/internal/managed-session-scope.ts +403 -81
- package/src/session/internal/managed-session-storage.ts +125 -17
- package/src/session/session-manager.ts +12 -0
- package/src/session/session-storage.ts +30 -1
- package/src/skill-state/active-state.ts +13 -0
- package/src/skill-state/workflow-mutation-guard.ts +102 -10
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/slash-commands/helpers/mcp.ts +8 -4
- package/src/task/isolation-dir.ts +143 -0
- package/src/task/worktree.ts +16 -7
- package/src/tools/ast-edit-write-path.ts +68 -0
- package/src/tools/ast-edit.ts +7 -2
- package/src/tools/browser/cdp-url.ts +18 -0
- package/src/tools/browser.ts +8 -1
- package/src/tools/gh.ts +10 -0
- package/src/tools/github-side-effect.ts +112 -0
- package/src/tools/monitor-mutation.ts +27 -0
- package/src/tools/monitor.ts +7 -0
- package/src/tools/python.ts +7 -6
- package/src/utils/external-editor.ts +3 -2
- package/src/web/insane/url-guard.ts +2 -1
package/src/lsp/config.ts
CHANGED
|
@@ -276,75 +276,19 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
// =============================================================================
|
|
279
|
-
//
|
|
279
|
+
// Trusted executable resolution
|
|
280
280
|
// =============================================================================
|
|
281
281
|
|
|
282
282
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*/
|
|
286
|
-
const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
|
|
287
|
-
// Node.js - check node_modules/.bin/
|
|
288
|
-
{ markers: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"], binDir: "node_modules/.bin" },
|
|
289
|
-
// Python - check virtual environment bin directories
|
|
290
|
-
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".venv/bin" },
|
|
291
|
-
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: "venv/bin" },
|
|
292
|
-
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".env/bin" },
|
|
293
|
-
// Ruby - check vendor bundle and binstubs
|
|
294
|
-
{ markers: ["Gemfile", "Gemfile.lock"], binDir: "vendor/bundle/bin" },
|
|
295
|
-
{ markers: ["Gemfile", "Gemfile.lock"], binDir: "bin" },
|
|
296
|
-
// Go - check project-local bin
|
|
297
|
-
{ markers: ["go.mod", "go.sum"], binDir: "bin" },
|
|
298
|
-
];
|
|
299
|
-
|
|
300
|
-
const WINDOWS_LOCAL_EXECUTABLE_EXTENSIONS = [".exe", ".cmd", ".bat"] as const;
|
|
301
|
-
|
|
302
|
-
function resolveLocalCommand(basePath: string): string | null {
|
|
303
|
-
if (fs.existsSync(basePath)) return basePath;
|
|
304
|
-
if (process.platform !== "win32") return null;
|
|
305
|
-
|
|
306
|
-
// Package managers write Windows launchers with executable suffixes in node_modules/.bin.
|
|
307
|
-
for (const extension of WINDOWS_LOCAL_EXECUTABLE_EXTENSIONS) {
|
|
308
|
-
const candidate = `${basePath}${extension}`;
|
|
309
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Resolve a command to an executable path.
|
|
317
|
-
* Checks project-local bin directories first, then falls back to $PATH.
|
|
318
|
-
*
|
|
319
|
-
* @param command - The command name (e.g., "typescript-language-server")
|
|
320
|
-
* @param cwd - Working directory to search from
|
|
321
|
-
* @returns Absolute path to the executable, or null if not found
|
|
322
|
-
*/
|
|
323
|
-
export function resolveCommand(command: string, cwd: string): string | null {
|
|
324
|
-
// Check local bin directories based on project markers
|
|
325
|
-
for (const { markers, binDir } of LOCAL_BIN_PATHS) {
|
|
326
|
-
if (hasRootMarkers(cwd, markers)) {
|
|
327
|
-
const localPath = path.join(cwd, binDir, command);
|
|
328
|
-
const resolvedLocalPath = resolveLocalCommand(localPath);
|
|
329
|
-
if (resolvedLocalPath) {
|
|
330
|
-
return resolvedLocalPath;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Fall back to $PATH
|
|
336
|
-
return piUtils.$which(command);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Resolve an LSP executable without consulting project-controlled bin directories.
|
|
283
|
+
* Resolve a host-spawn executable from $PATH without consulting project-controlled bin directories.
|
|
284
|
+
* LSP auto-detection and DAP adapter launch both use this rule.
|
|
341
285
|
*
|
|
342
286
|
* Trust is evaluated on both the discovered and canonical paths, but the returned
|
|
343
287
|
* path is the discovered one: multiplexed launchers such as rustup pick the proxied
|
|
344
288
|
* tool from argv[0], so `~/.cargo/bin/rust-analyzer -> rustup` must be spawned as
|
|
345
289
|
* `rust-analyzer`, not as the resolved `rustup` binary.
|
|
346
290
|
*/
|
|
347
|
-
function
|
|
291
|
+
export function resolveTrustedCommand(command: string, cwd: string): string | null {
|
|
348
292
|
if (!path.isAbsolute(command) && (command.includes("/") || command.includes("\\"))) return null;
|
|
349
293
|
const discovered = path.isAbsolute(command) ? command : piUtils.$which(command);
|
|
350
294
|
if (!discovered) return null;
|
|
@@ -353,6 +297,11 @@ function resolveTrustedLspCommand(command: string, cwd: string): string | null {
|
|
|
353
297
|
return discovered;
|
|
354
298
|
}
|
|
355
299
|
|
|
300
|
+
/** Same rule as `resolveTrustedCommand`: project-local bin directories are never searched. */
|
|
301
|
+
export function resolveCommand(command: string, cwd: string): string | null {
|
|
302
|
+
return resolveTrustedCommand(command, cwd);
|
|
303
|
+
}
|
|
304
|
+
|
|
356
305
|
interface ConfigSource {
|
|
357
306
|
allowProcessOverrides: boolean;
|
|
358
307
|
read(): LoadedConfigSource | null;
|
|
@@ -556,8 +505,8 @@ export function loadConfig(cwd: string): LspConfig {
|
|
|
556
505
|
// Check if project has root markers for this language
|
|
557
506
|
if (!hasRootMarkers(cwd, config.rootMarkers)) continue;
|
|
558
507
|
|
|
559
|
-
// Check if
|
|
560
|
-
const resolved =
|
|
508
|
+
// Check if a trusted language server binary is available on $PATH.
|
|
509
|
+
const resolved = resolveTrustedCommand(config.command, cwd);
|
|
561
510
|
if (!resolved) continue;
|
|
562
511
|
|
|
563
512
|
detected[name] = { ...config, resolvedCommand: resolved };
|
|
@@ -572,7 +521,7 @@ export function loadConfig(cwd: string): LspConfig {
|
|
|
572
521
|
for (const [name, config] of Object.entries(mergedWithRuntime)) {
|
|
573
522
|
if (config.disabled) continue;
|
|
574
523
|
if (!hasRootMarkers(cwd, config.rootMarkers)) continue;
|
|
575
|
-
const resolved =
|
|
524
|
+
const resolved = resolveTrustedCommand(config.command, cwd);
|
|
576
525
|
if (!resolved) continue;
|
|
577
526
|
available[name] = { ...config, resolvedCommand: resolved };
|
|
578
527
|
}
|
package/src/memories/index.ts
CHANGED
|
@@ -3,7 +3,14 @@ import type * as fsNode from "node:fs";
|
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import type { AgentMessage } from "@gajae-code/agent-core";
|
|
6
|
-
import {
|
|
6
|
+
import { estimateTextTokensHeuristic } from "@gajae-code/agent-core/compaction";
|
|
7
|
+
import {
|
|
8
|
+
ANTHROPIC_THINKING,
|
|
9
|
+
completeSimple,
|
|
10
|
+
Effort,
|
|
11
|
+
type Model,
|
|
12
|
+
resolveDefaultRequestMaxTokens,
|
|
13
|
+
} from "@gajae-code/ai/core";
|
|
7
14
|
import { clampThinkingLevelForModel } from "@gajae-code/ai/model-thinking";
|
|
8
15
|
import { getAgentDbPath, getMemoriesDir, logger, parseJsonlLenient, prompt } from "@gajae-code/utils";
|
|
9
16
|
import type { ModelRegistry } from "../config/model-registry";
|
|
@@ -74,6 +81,20 @@ const DEFAULTS: MemoryRuntimeConfig = {
|
|
|
74
81
|
summaryInjectionTokenLimit: 5_000,
|
|
75
82
|
};
|
|
76
83
|
|
|
84
|
+
const PHASE2_RAW_MEMORIES_TOKEN_LIMIT = 20_000;
|
|
85
|
+
const PHASE2_ROLLOUT_SUMMARIES_TOKEN_LIMIT = 12_000;
|
|
86
|
+
// Ceiling for the phase 2 response. Phase 2 rewrites MEMORY.md, the summary and every
|
|
87
|
+
// skill in one JSON response, and on adaptive-thinking / reasoning-effort models the
|
|
88
|
+
// reasoning shares this budget. Twice the (approximate) input caps is a generous
|
|
89
|
+
// practical ceiling, not a guarantee that every response fits; a response that still
|
|
90
|
+
// overruns it is reported as truncation.
|
|
91
|
+
const PHASE2_MAX_OUTPUT_TOKENS = 2 * (PHASE2_RAW_MEMORIES_TOKEN_LIMIT + PHASE2_ROLLOUT_SUMMARIES_TOKEN_LIMIT);
|
|
92
|
+
// On a small context window phase 2 shrinks its inputs rather than let the response
|
|
93
|
+
// budget drop below the previous fixed budget.
|
|
94
|
+
const PHASE2_MIN_OUTPUT_TOKENS = 8_192;
|
|
95
|
+
// Request framing plus estimator error when fitting input + output into the context window.
|
|
96
|
+
const PHASE2_CONTEXT_SAFETY_TOKENS = 2_048;
|
|
97
|
+
|
|
77
98
|
interface Stage1Stats {
|
|
78
99
|
claimed: number;
|
|
79
100
|
succeeded: number;
|
|
@@ -738,10 +759,8 @@ async function runConsolidationModel(options: {
|
|
|
738
759
|
const { memoryRoot, model, apiKey } = options;
|
|
739
760
|
const rawMemories = await Bun.file(path.join(memoryRoot, "raw_memories.md")).text();
|
|
740
761
|
const rolloutSummaries = await readRolloutSummaries(memoryRoot);
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
rollout_summaries: truncateByApproxTokens(rolloutSummaries, 12_000),
|
|
744
|
-
});
|
|
762
|
+
const reasoning = clampThinkingLevelForModel(model, Effort.Low);
|
|
763
|
+
const { input, maxTokens } = buildPhase2Request(model, rawMemories, rolloutSummaries, reasoning);
|
|
745
764
|
|
|
746
765
|
const response = await completeSimple(
|
|
747
766
|
model,
|
|
@@ -751,18 +770,27 @@ async function runConsolidationModel(options: {
|
|
|
751
770
|
{
|
|
752
771
|
apiKey,
|
|
753
772
|
metadata: options.metadata,
|
|
754
|
-
maxTokens
|
|
755
|
-
reasoning
|
|
773
|
+
maxTokens,
|
|
774
|
+
reasoning,
|
|
756
775
|
},
|
|
757
776
|
);
|
|
758
777
|
if (response.stopReason === "error") {
|
|
759
778
|
throw new Error(response.errorMessage || "phase2 model error");
|
|
760
779
|
}
|
|
780
|
+
if (response.stopReason === "length") {
|
|
781
|
+
throw new Error(`phase2 output truncated at the max output token limit (${response.usage.output} output tokens)`);
|
|
782
|
+
}
|
|
761
783
|
const text = response.content
|
|
762
784
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
763
785
|
.map(c => c.text)
|
|
764
786
|
.join("\n")
|
|
765
787
|
.trim();
|
|
788
|
+
if (!text) {
|
|
789
|
+
const thinkingBlocks = response.content.filter(c => c.type === "thinking").length;
|
|
790
|
+
throw new Error(
|
|
791
|
+
`phase2 returned no text (stopReason=${response.stopReason}, ${response.usage.output} output tokens, ${thinkingBlocks} thinking blocks)`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
766
794
|
const parsed = parseJsonObject(text);
|
|
767
795
|
if (!parsed) throw new Error("phase2 JSON parse failure");
|
|
768
796
|
const schemaOutput = parseConsolidationOutputSchema(parsed);
|
|
@@ -1101,6 +1129,59 @@ function truncateByApproxTokens(text: string, tokenLimit: number): string {
|
|
|
1101
1129
|
return `${text.slice(0, head)}\n\n...[truncated]...\n\n${text.slice(-tail)}`;
|
|
1102
1130
|
}
|
|
1103
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* Render the phase 2 prompt and choose its response budget.
|
|
1134
|
+
*
|
|
1135
|
+
* The budget is the model's output limit capped at PHASE2_MAX_OUTPUT_TOKENS (or the
|
|
1136
|
+
* provider layer's request default when the limit is unknown). When the context window
|
|
1137
|
+
* is known, input + response + any thinking budget the provider adds on top must fit
|
|
1138
|
+
* in it: the response budget is clamped to the remaining room, and the inputs are
|
|
1139
|
+
* shrunk when that room would fall below PHASE2_MIN_OUTPUT_TOKENS.
|
|
1140
|
+
*/
|
|
1141
|
+
function buildPhase2Request(
|
|
1142
|
+
model: Model,
|
|
1143
|
+
rawMemories: string,
|
|
1144
|
+
rolloutSummaries: string,
|
|
1145
|
+
reasoning: Effort | undefined,
|
|
1146
|
+
): { input: string; maxTokens: number | undefined } {
|
|
1147
|
+
const outputLimit =
|
|
1148
|
+
Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0
|
|
1149
|
+
? Math.min(model.maxTokens, PHASE2_MAX_OUTPUT_TOKENS)
|
|
1150
|
+
: undefined;
|
|
1151
|
+
let rawTokens = Math.min(PHASE2_RAW_MEMORIES_TOKEN_LIMIT, Math.ceil(rawMemories.length / 4));
|
|
1152
|
+
let rolloutTokens = Math.min(PHASE2_ROLLOUT_SUMMARIES_TOKEN_LIMIT, Math.ceil(rolloutSummaries.length / 4));
|
|
1153
|
+
const render = () =>
|
|
1154
|
+
prompt.render(consolidationTemplate, {
|
|
1155
|
+
raw_memories: truncateByApproxTokens(rawMemories, rawTokens),
|
|
1156
|
+
rollout_summaries: truncateByApproxTokens(rolloutSummaries, rolloutTokens),
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const contextWindow =
|
|
1160
|
+
Number.isSafeInteger(model.contextWindow) && model.contextWindow > 0 ? model.contextWindow : undefined;
|
|
1161
|
+
if (contextWindow === undefined) return { input: render(), maxTokens: outputLimit };
|
|
1162
|
+
|
|
1163
|
+
const responseCeiling = resolveDefaultRequestMaxTokens(model, outputLimit);
|
|
1164
|
+
const minResponse = Math.min(PHASE2_MIN_OUTPUT_TOKENS, responseCeiling);
|
|
1165
|
+
// Budget-mode providers may add the thinking budget on top of maxTokens.
|
|
1166
|
+
const addsThinkingBudget = model.thinking?.mode === "budget" || model.thinking?.mode === "anthropic-budget-effort";
|
|
1167
|
+
const thinkingReserve = reasoning && addsThinkingBudget ? ANTHROPIC_THINKING[reasoning] : 0;
|
|
1168
|
+
const inputRoom = contextWindow - PHASE2_CONTEXT_SAFETY_TOKENS - thinkingReserve - minResponse;
|
|
1169
|
+
|
|
1170
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1171
|
+
const input = render();
|
|
1172
|
+
const inputTokens = estimateTextTokensHeuristic(input);
|
|
1173
|
+
if (inputTokens <= inputRoom) {
|
|
1174
|
+
const room = contextWindow - PHASE2_CONTEXT_SAFETY_TOKENS - thinkingReserve - inputTokens;
|
|
1175
|
+
return { input, maxTokens: Math.min(responseCeiling, room) };
|
|
1176
|
+
}
|
|
1177
|
+
if (inputRoom <= 0 || rawTokens + rolloutTokens === 0) break;
|
|
1178
|
+
const scale = (inputRoom / inputTokens) * 0.9;
|
|
1179
|
+
rawTokens = Math.floor(rawTokens * scale);
|
|
1180
|
+
rolloutTokens = Math.floor(rolloutTokens * scale);
|
|
1181
|
+
}
|
|
1182
|
+
throw new Error(`phase2 input does not fit the model context window (${contextWindow} tokens)`);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1104
1185
|
function computeModelTokenBudget(model: Model, config: MemoryRuntimeConfig): number {
|
|
1105
1186
|
const maxTokens =
|
|
1106
1187
|
Number.isFinite(model.contextWindow) && model.contextWindow > 0 ? model.contextWindow : config.fallbackTokenLimit;
|
|
@@ -146,7 +146,7 @@ export class HookEditorComponent extends Container {
|
|
|
146
146
|
|
|
147
147
|
const currentText = this.#editor.getExpandedText();
|
|
148
148
|
try {
|
|
149
|
-
this.#tui.
|
|
149
|
+
this.#tui.suspend();
|
|
150
150
|
const result = await openInEditor(editorCmd, currentText);
|
|
151
151
|
if (result !== null) {
|
|
152
152
|
this.#editor.setText(result);
|
|
@@ -734,7 +734,7 @@ export class HookSelectorComponent extends Container {
|
|
|
734
734
|
|
|
735
735
|
const currentText = editor.getExpandedText();
|
|
736
736
|
try {
|
|
737
|
-
this.#tui.
|
|
737
|
+
this.#tui.suspend();
|
|
738
738
|
const result = await openInEditor(editorCmd, currentText);
|
|
739
739
|
if (result !== null) {
|
|
740
740
|
editor.setText(result);
|
|
@@ -1300,7 +1300,7 @@ export class InputController {
|
|
|
1300
1300
|
});
|
|
1301
1301
|
|
|
1302
1302
|
// Stop the TUI (restore terminal to normal mode)
|
|
1303
|
-
this.ctx.ui.
|
|
1303
|
+
this.ctx.ui.suspend();
|
|
1304
1304
|
|
|
1305
1305
|
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
|
1306
1306
|
process.kill(0, "SIGTSTP");
|
|
@@ -2562,7 +2562,7 @@ export class InputController {
|
|
|
2562
2562
|
let ttyHandle: fs.FileHandle | null = null;
|
|
2563
2563
|
try {
|
|
2564
2564
|
ttyHandle = await this.#openEditorTerminalHandle();
|
|
2565
|
-
this.ctx.ui.
|
|
2565
|
+
this.ctx.ui.suspend();
|
|
2566
2566
|
|
|
2567
2567
|
const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
|
|
2568
2568
|
? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
updateMCPServer,
|
|
20
20
|
} from "../../runtime-mcp/config-writer";
|
|
21
21
|
import { canonicalMCPResourceUri, type MCPOAuthConfig, MCPOAuthFlow } from "../../runtime-mcp/oauth-flow";
|
|
22
|
+
import { nativeMcpSource } from "../../runtime-mcp/project-host-values";
|
|
22
23
|
import {
|
|
23
24
|
clearSmitheryApiKey,
|
|
24
25
|
createSmitheryCliAuthSession,
|
|
@@ -815,7 +816,7 @@ export class MCPCommandController {
|
|
|
815
816
|
* Test connection to an MCP server.
|
|
816
817
|
* Throws an error if connection fails (used for auto-detection).
|
|
817
818
|
*/
|
|
818
|
-
async #handleTestConnection(config: MCPServerConfig): Promise<void> {
|
|
819
|
+
async #handleTestConnection(config: MCPServerConfig, source?: SourceMeta): Promise<void> {
|
|
819
820
|
const testName = `test_${Date.now()}`;
|
|
820
821
|
const manager =
|
|
821
822
|
this.ctx.mcpManager ??
|
|
@@ -823,7 +824,7 @@ export class MCPCommandController {
|
|
|
823
824
|
sharedPoolIdleMs: this.ctx.settings.get("mcp.sharedPoolIdleMs"),
|
|
824
825
|
});
|
|
825
826
|
if (!this.ctx.mcpManager) manager.setAuthStorage(this.ctx.session.modelRegistry.authStorage);
|
|
826
|
-
await manager.withPreparedLease(testName, config, async () => {});
|
|
827
|
+
await manager.withPreparedLease(testName, config, async () => {}, { source });
|
|
827
828
|
}
|
|
828
829
|
|
|
829
830
|
async #findConfiguredServer(
|
|
@@ -880,7 +881,10 @@ export class MCPCommandController {
|
|
|
880
881
|
return next;
|
|
881
882
|
}
|
|
882
883
|
|
|
883
|
-
async #resolveOAuthEndpointsFromServer(
|
|
884
|
+
async #resolveOAuthEndpointsFromServer(
|
|
885
|
+
config: MCPServerConfig,
|
|
886
|
+
source?: SourceMeta,
|
|
887
|
+
): Promise<{
|
|
884
888
|
authorizationUrl: string;
|
|
885
889
|
tokenUrl: string;
|
|
886
890
|
clientId?: string;
|
|
@@ -888,11 +892,12 @@ export class MCPCommandController {
|
|
|
888
892
|
issuer?: string;
|
|
889
893
|
authorizationResponseIssSupported?: boolean;
|
|
890
894
|
}> {
|
|
891
|
-
// First test if server actually needs auth by connecting without OAuth
|
|
895
|
+
// First test if server actually needs auth by connecting without OAuth.
|
|
896
|
+
// A stored file keeps its source so project `!` and secret names stay gated.
|
|
892
897
|
let connectionSucceeded = false;
|
|
893
898
|
let connectionError: Error | undefined;
|
|
894
899
|
try {
|
|
895
|
-
await this.#handleTestConnection(this.#stripOAuthAuth(config));
|
|
900
|
+
await this.#handleTestConnection(this.#stripOAuthAuth(config), source);
|
|
896
901
|
connectionSucceeded = true;
|
|
897
902
|
} catch (error) {
|
|
898
903
|
connectionError = error as Error;
|
|
@@ -967,10 +972,10 @@ export class MCPCommandController {
|
|
|
967
972
|
}
|
|
968
973
|
}
|
|
969
974
|
|
|
970
|
-
async #syncManagerConnection(name: string, config: MCPServerConfig): Promise<void> {
|
|
975
|
+
async #syncManagerConnection(name: string, config: MCPServerConfig, source?: SourceMeta): Promise<void> {
|
|
971
976
|
if (!this.ctx.mcpManager) return;
|
|
972
977
|
if (this.ctx.mcpManager.getConnectionStatus(name) !== "disconnected") return;
|
|
973
|
-
await this.ctx.mcpManager.connectServers({ [name]: config }, {});
|
|
978
|
+
await this.ctx.mcpManager.connectServers({ [name]: config }, source ? { [name]: source } : {});
|
|
974
979
|
if (this.ctx.mcpManager.getConnectionStatus(name) === "connected") {
|
|
975
980
|
await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
|
|
976
981
|
}
|
|
@@ -998,9 +1003,10 @@ export class MCPCommandController {
|
|
|
998
1003
|
// report as connected to avoid false-negative messaging.
|
|
999
1004
|
if (!isConnected && !isConnecting && config.enabled !== false) {
|
|
1000
1005
|
try {
|
|
1001
|
-
|
|
1006
|
+
const source = nativeMcpSource(scope, filePath);
|
|
1007
|
+
await this.#handleTestConnection(config, source);
|
|
1002
1008
|
isConnected = true;
|
|
1003
|
-
await this.#syncManagerConnection(name, config);
|
|
1009
|
+
await this.#syncManagerConnection(name, config, source);
|
|
1004
1010
|
} catch {
|
|
1005
1011
|
// Keep disconnected status
|
|
1006
1012
|
}
|
|
@@ -1276,7 +1282,8 @@ export class MCPCommandController {
|
|
|
1276
1282
|
return;
|
|
1277
1283
|
}
|
|
1278
1284
|
|
|
1279
|
-
const { config } = found;
|
|
1285
|
+
const { config, scope, filePath } = found;
|
|
1286
|
+
const source = nativeMcpSource(scope, filePath);
|
|
1280
1287
|
if (config.enabled === false) {
|
|
1281
1288
|
this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
|
|
1282
1289
|
return;
|
|
@@ -1313,10 +1320,10 @@ export class MCPCommandController {
|
|
|
1313
1320
|
}
|
|
1314
1321
|
|
|
1315
1322
|
lines.push("");
|
|
1316
|
-
await this.#syncManagerConnection(name, config);
|
|
1323
|
+
await this.#syncManagerConnection(name, config, source);
|
|
1317
1324
|
this.#showMessage(lines.join("\n"));
|
|
1318
1325
|
},
|
|
1319
|
-
{ signal: abortController.signal },
|
|
1326
|
+
{ signal: abortController.signal, source },
|
|
1320
1327
|
);
|
|
1321
1328
|
} catch (error) {
|
|
1322
1329
|
if (abortController.signal.aborted || (error instanceof Error && error.name === "AbortError")) {
|
|
@@ -1492,7 +1499,8 @@ export class MCPCommandController {
|
|
|
1492
1499
|
}
|
|
1493
1500
|
|
|
1494
1501
|
const baseConfig = this.#stripOAuthAuth(found.config);
|
|
1495
|
-
const
|
|
1502
|
+
const source = nativeMcpSource(found.scope, found.filePath);
|
|
1503
|
+
const oauth = await this.#resolveOAuthEndpointsFromServer(baseConfig, source);
|
|
1496
1504
|
const oauthClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret ?? "";
|
|
1497
1505
|
|
|
1498
1506
|
this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
|
|
@@ -2777,18 +2777,12 @@ export class SelectorController {
|
|
|
2777
2777
|
return;
|
|
2778
2778
|
}
|
|
2779
2779
|
const { model, role, thinkingLevel, selector: selectedSelector } = selection;
|
|
2780
|
-
if (
|
|
2781
|
-
(role !== null && role !== "default") ||
|
|
2782
|
-
selection.roles?.some(targetRole => targetRole !== "default")
|
|
2783
|
-
) {
|
|
2784
|
-
this.ctx.session.markUserModelSelection();
|
|
2785
|
-
}
|
|
2786
2780
|
if (role === null) {
|
|
2787
2781
|
// Temporary: update agent state but don't persist to settings
|
|
2788
|
-
this.ctx.session.markUserModelSelection();
|
|
2789
2782
|
await this.ctx.session.setModelTemporary(model, thinkingLevel, {
|
|
2790
2783
|
cause: "temporary-operation",
|
|
2791
2784
|
reason: "other",
|
|
2785
|
+
onMutationStarted: () => this.ctx.session.markUserModelSelection(),
|
|
2792
2786
|
});
|
|
2793
2787
|
this.ctx.session.setDefaultFallbackRuntimeModel(
|
|
2794
2788
|
selectedSelector ?? formatModelSelectorValue(`${model.provider}/${model.id}`, thinkingLevel),
|
|
@@ -2826,6 +2820,7 @@ export class SelectorController {
|
|
|
2826
2820
|
selectedSelector && thinkingLevel && selectedSelector.endsWith(`:${thinkingLevel}`)
|
|
2827
2821
|
? selectedSelector.slice(0, -thinkingLevel.length - 1)
|
|
2828
2822
|
: selectedSelector;
|
|
2823
|
+
if (includesRoleAgent && !includesDefault) this.ctx.session.markUserModelSelection();
|
|
2829
2824
|
|
|
2830
2825
|
const rollbackSnapshot = this.#captureDefaultAssignmentRollback();
|
|
2831
2826
|
let defaultMutationStarted = false;
|
|
@@ -2948,6 +2943,7 @@ export class SelectorController {
|
|
|
2948
2943
|
}
|
|
2949
2944
|
const value =
|
|
2950
2945
|
selectedSelector ?? formatModelSelectorValue(`${model.provider}/${model.id}`, thinkingLevel);
|
|
2946
|
+
this.ctx.session.markUserModelSelection();
|
|
2951
2947
|
const assignments = new Map<GjcModelAssignmentTargetId, string>([[role, value]]);
|
|
2952
2948
|
const materializedProfile = materializeActiveModelProfileAssignments({
|
|
2953
2949
|
session: this.ctx.session,
|
|
@@ -409,7 +409,7 @@ export class TodoCommandController {
|
|
|
409
409
|
current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n";
|
|
410
410
|
|
|
411
411
|
const fileHandle = await this.#openTtyHandle();
|
|
412
|
-
this.ctx.ui.
|
|
412
|
+
this.ctx.ui.suspend();
|
|
413
413
|
try {
|
|
414
414
|
const stdio: [number | "inherit", number | "inherit", number | "inherit"] = fileHandle
|
|
415
415
|
? [fileHandle.fd, fileHandle.fd, fileHandle.fd]
|
|
@@ -679,7 +679,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
679
679
|
startPendingSubmission: input => thisMode.startPendingSubmission(input),
|
|
680
680
|
addChatChild: child => addChatChild(thisMode, child),
|
|
681
681
|
requestRender: full => thisMode.ui.requestRender(full),
|
|
682
|
-
stopUi: () => thisMode.ui.
|
|
682
|
+
stopUi: () => thisMode.ui.suspend(),
|
|
683
683
|
startUi: () => thisMode.ui.start(),
|
|
684
684
|
showStatus: message => thisMode.showStatus(message),
|
|
685
685
|
showWarning: message => thisMode.showWarning(message),
|
|
@@ -496,8 +496,10 @@ async function requestWithInputHandling<T>(
|
|
|
496
496
|
throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Aborted");
|
|
497
497
|
}
|
|
498
498
|
if (request.method === "roots/list") {
|
|
499
|
-
//
|
|
500
|
-
|
|
499
|
+
// Use the connection's request handler. Tools-only callers reject
|
|
500
|
+
// roots/list there; the default handler answers from local state.
|
|
501
|
+
const onRequest = connection.transport.onRequest ?? defaultRequestHandler;
|
|
502
|
+
inputResponses[key] = await onRequest("roots/list", undefined);
|
|
501
503
|
continue;
|
|
502
504
|
}
|
|
503
505
|
// Everything else (elicitation, sampling, ...) needs a real interactive
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { logger } from "@gajae-code/utils";
|
|
8
8
|
import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
|
|
9
|
+
import { fetchMcpRespectingOrigin } from "./mcp-redirect";
|
|
9
10
|
import { redactMCPDiagnosticValue, redactMCPEndpoint } from "./redaction";
|
|
10
11
|
|
|
11
12
|
/** Parse SSE response format (lines starting with "data: ") */
|
|
@@ -70,7 +71,7 @@ export async function callMCP<T = unknown>(
|
|
|
70
71
|
|
|
71
72
|
const signal = AbortSignal.timeout(MCP_HTTP_TIMEOUT_MS);
|
|
72
73
|
const response = await translateMCPTimeout(
|
|
73
|
-
|
|
74
|
+
fetchMcpRespectingOrigin(url, {
|
|
74
75
|
method: "POST",
|
|
75
76
|
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
76
77
|
body: JSON.stringify(body),
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
MCPPoolLeaseObsoleteError,
|
|
35
35
|
MCPPoolLeaseReleaseError,
|
|
36
36
|
} from "./pool";
|
|
37
|
+
import { decideUntrustedProjectConfigValue, isUntrustedProjectMcpSource } from "./project-host-values";
|
|
37
38
|
import type { MCPProtocolObservation } from "./protocol";
|
|
38
39
|
import { DEFAULT_MCP_STARTUP_WAIT_MS, MAX_MCP_STARTUP_WAIT_MS, MCP_STARTUP_WAIT_GRACE_MS } from "./startup-policy";
|
|
39
40
|
import type { MCPToolDetails } from "./tool-bridge";
|
|
@@ -1313,8 +1314,9 @@ export class MCPManager {
|
|
|
1313
1314
|
const connectionAbort = new AbortController();
|
|
1314
1315
|
this.#pendingConnectionControllers.set(name, connectionAbort);
|
|
1315
1316
|
// Resolve auth config before connecting, but do so per-server in parallel.
|
|
1317
|
+
const configSource = sources[name] ?? this.#sources.get(name);
|
|
1316
1318
|
const acquireInitialLease = async (): Promise<MCPPoolLease> => {
|
|
1317
|
-
const resolvedConfig = await this.#resolveAuthConfig(config);
|
|
1319
|
+
const resolvedConfig = await this.#resolveAuthConfig(config, false, configSource);
|
|
1318
1320
|
let lease: MCPPoolLease | undefined;
|
|
1319
1321
|
try {
|
|
1320
1322
|
lease = await this.#acquireLease(name, resolvedConfig, config, connectionAbort);
|
|
@@ -1371,7 +1373,7 @@ export class MCPManager {
|
|
|
1371
1373
|
// Wire auth refresh for HTTP transports, and reconnect for any transport.
|
|
1372
1374
|
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
|
1373
1375
|
connection.transport.onAuthError = async () => {
|
|
1374
|
-
const refreshed = await this.#resolveAuthConfig(config, true);
|
|
1376
|
+
const refreshed = await this.#resolveAuthConfig(config, true, configSource);
|
|
1375
1377
|
if (refreshed.type === "http" || refreshed.type === "sse") {
|
|
1376
1378
|
return refreshed.headers ?? null;
|
|
1377
1379
|
}
|
|
@@ -2152,7 +2154,11 @@ export class MCPManager {
|
|
|
2152
2154
|
const config = this.#serverConfigs.get(name);
|
|
2153
2155
|
if (!config) throw new Error(`MCP server unavailable: ${name}`);
|
|
2154
2156
|
await this.#closePendingConnectionCleanup(name);
|
|
2155
|
-
const resolved = await this.#resolveAuthConfig(
|
|
2157
|
+
const resolved = await this.#resolveAuthConfig(
|
|
2158
|
+
config,
|
|
2159
|
+
false,
|
|
2160
|
+
this.#sources.get(name) ?? this.#connections.get(name)?._source,
|
|
2161
|
+
);
|
|
2156
2162
|
const abort = new AbortController();
|
|
2157
2163
|
this.#pendingConnectionControllers.set(name, abort);
|
|
2158
2164
|
let prepared:
|
|
@@ -2308,9 +2314,9 @@ export class MCPManager {
|
|
|
2308
2314
|
/**
|
|
2309
2315
|
* Resolve auth and shell-command substitutions in config before connecting.
|
|
2310
2316
|
*/
|
|
2311
|
-
async prepareConfig(config: MCPServerConfig): Promise<MCPServerConfig> {
|
|
2317
|
+
async prepareConfig(config: MCPServerConfig, source?: SourceMeta): Promise<MCPServerConfig> {
|
|
2312
2318
|
this.#assertRawMCPAccessAllowed();
|
|
2313
|
-
return this.#resolveAuthConfig(config);
|
|
2319
|
+
return this.#resolveAuthConfig(config, false, source);
|
|
2314
2320
|
}
|
|
2315
2321
|
|
|
2316
2322
|
/** Acquire a prepared, pool-owned lease for a scoped transient operation. */
|
|
@@ -2318,7 +2324,7 @@ export class MCPManager {
|
|
|
2318
2324
|
name: string,
|
|
2319
2325
|
config: MCPServerConfig,
|
|
2320
2326
|
fn: (lease: MCPPoolLease) => Promise<T> | T,
|
|
2321
|
-
options: { signal?: AbortSignal } = {},
|
|
2327
|
+
options: { signal?: AbortSignal; source?: SourceMeta } = {},
|
|
2322
2328
|
): Promise<T> {
|
|
2323
2329
|
this.#assertRawMCPAccessAllowed();
|
|
2324
2330
|
const lifecycleEpoch = this.#assertScopedAdmission();
|
|
@@ -2347,7 +2353,7 @@ export class MCPManager {
|
|
|
2347
2353
|
operation: ScopedOperation,
|
|
2348
2354
|
config: MCPServerConfig,
|
|
2349
2355
|
fn: (lease: MCPPoolLease) => Promise<T> | T,
|
|
2350
|
-
options: { signal?: AbortSignal },
|
|
2356
|
+
options: { signal?: AbortSignal; source?: SourceMeta },
|
|
2351
2357
|
): Promise<T> {
|
|
2352
2358
|
const callerSignal = options.signal;
|
|
2353
2359
|
const onAbort = () =>
|
|
@@ -2376,7 +2382,11 @@ export class MCPManager {
|
|
|
2376
2382
|
let failed = false;
|
|
2377
2383
|
let primaryError: unknown;
|
|
2378
2384
|
try {
|
|
2379
|
-
const resolvedConfigPromise = this.#resolveAuthConfig(
|
|
2385
|
+
const resolvedConfigPromise = this.#resolveAuthConfig(
|
|
2386
|
+
config,
|
|
2387
|
+
false,
|
|
2388
|
+
options.source ?? this.#sources.get(operation.name),
|
|
2389
|
+
);
|
|
2380
2390
|
void resolvedConfigPromise.catch(() => {});
|
|
2381
2391
|
const resolvedConfig = await Promise.race([resolvedConfigPromise, abortPromise]);
|
|
2382
2392
|
if (operation.lifecycleEpoch !== this.#scopedLifecycleEpoch || this.#scopedLifecycle !== "open") {
|
|
@@ -2857,7 +2867,7 @@ export class MCPManager {
|
|
|
2857
2867
|
throw new MCPManagerLifecycleError(this.#scopedLifecycle === "disconnecting" ? "disconnect" : "reconnect");
|
|
2858
2868
|
};
|
|
2859
2869
|
assertLifecycle();
|
|
2860
|
-
const resolvedConfig = await this.#resolveAuthConfig(config);
|
|
2870
|
+
const resolvedConfig = await this.#resolveAuthConfig(config, false, source);
|
|
2861
2871
|
assertLifecycle();
|
|
2862
2872
|
const connectionAbort = new AbortController();
|
|
2863
2873
|
this.#pendingConnectionControllers.set(name, connectionAbort);
|
|
@@ -2909,7 +2919,7 @@ export class MCPManager {
|
|
|
2909
2919
|
// Wire auth refresh for HTTP transports, and reconnect for any transport.
|
|
2910
2920
|
if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
|
|
2911
2921
|
connection.transport.onAuthError = async () => {
|
|
2912
|
-
const refreshed = await this.#resolveAuthConfig(config, true);
|
|
2922
|
+
const refreshed = await this.#resolveAuthConfig(config, true, source);
|
|
2913
2923
|
if (refreshed.type === "http" || refreshed.type === "sse") {
|
|
2914
2924
|
return refreshed.headers ?? null;
|
|
2915
2925
|
}
|
|
@@ -3174,7 +3184,11 @@ export class MCPManager {
|
|
|
3174
3184
|
/**
|
|
3175
3185
|
* Resolve OAuth credentials and shell commands in config.
|
|
3176
3186
|
*/
|
|
3177
|
-
async #resolveAuthConfig(
|
|
3187
|
+
async #resolveAuthConfig(
|
|
3188
|
+
config: MCPServerConfig,
|
|
3189
|
+
forceRefresh = false,
|
|
3190
|
+
source?: SourceMeta,
|
|
3191
|
+
): Promise<MCPServerConfig> {
|
|
3178
3192
|
let resolved: MCPServerConfig = { ...config };
|
|
3179
3193
|
|
|
3180
3194
|
const auth = config.auth;
|
|
@@ -3240,7 +3254,11 @@ export class MCPManager {
|
|
|
3240
3254
|
resolved = { ...resolved, noInheritEnv: true };
|
|
3241
3255
|
}
|
|
3242
3256
|
|
|
3257
|
+
const untrustedProject = isUntrustedProjectMcpSource(source, this.#toolsOnly);
|
|
3243
3258
|
const resolveValue = async (value: string): Promise<string | undefined> => {
|
|
3259
|
+
const decision = decideUntrustedProjectConfigValue(value, untrustedProject);
|
|
3260
|
+
if (decision.action === "drop") return undefined;
|
|
3261
|
+
if (decision.action === "literal") return decision.value;
|
|
3244
3262
|
try {
|
|
3245
3263
|
const resolvedValue = await configValue.resolveConfigValue(value);
|
|
3246
3264
|
if (this.#toolsOnly && !resolvedValue) throw new MCPExpectedFailure();
|