@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/support.d.ts +0 -14
  4. package/dist/types/commands/mcp-serve.d.ts +4 -0
  5. package/dist/types/discovery/mcp-json.d.ts +1 -0
  6. package/dist/types/eval/py/kernel.d.ts +18 -0
  7. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  8. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  9. package/dist/types/extensibility/skill-management.d.ts +1 -0
  10. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  11. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  12. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  14. package/dist/types/lsp/config.d.ts +8 -5
  15. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  16. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  17. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -1
  18. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  19. package/dist/types/sdk/broker/broker.d.ts +6 -1
  20. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  22. package/dist/types/session/session-storage.d.ts +7 -0
  23. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  24. package/dist/types/task/isolation-dir.d.ts +26 -0
  25. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  26. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  27. package/dist/types/tools/github-side-effect.d.ts +22 -0
  28. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  29. package/package.json +7 -7
  30. package/src/async/endpoint-id.ts +26 -0
  31. package/src/async/job-manager.ts +19 -10
  32. package/src/async/support.ts +0 -25
  33. package/src/capability/index.ts +14 -16
  34. package/src/cli/doctor/repairs.ts +4 -1
  35. package/src/cli/doctor-supervisor.ts +3 -0
  36. package/src/cli/public-command-registry.ts +28 -6
  37. package/src/commands/mcp-serve.ts +7 -0
  38. package/src/commands/sdk.ts +453 -415
  39. package/src/config/file-lock.ts +9 -11
  40. package/src/config/model-profile-activation.ts +5 -5
  41. package/src/coordinator-mcp/server.ts +2 -0
  42. package/src/customization/inventory.ts +1 -13
  43. package/src/dap/config.ts +2 -2
  44. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  45. package/src/discovery/builtin.ts +3 -1
  46. package/src/discovery/helpers.ts +12 -9
  47. package/src/discovery/mcp-json.ts +20 -12
  48. package/src/eval/py/executor.ts +459 -77
  49. package/src/eval/py/kernel.ts +79 -13
  50. package/src/eval/py/tool-bridge.ts +4 -0
  51. package/src/export/html/html-export-destination.ts +16 -0
  52. package/src/export/html/index.ts +2 -6
  53. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  54. package/src/extensibility/extensions/runner.ts +11 -1
  55. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  56. package/src/extensibility/gjc-plugins/runtime-adapters.ts +27 -15
  57. package/src/extensibility/skill-management.ts +3 -0
  58. package/src/gjc-runtime/launch-worktree.ts +56 -5
  59. package/src/gjc-runtime/psmux-detect.ts +28 -4
  60. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  61. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  62. package/src/gjc-runtime/state-writer.ts +22 -8
  63. package/src/gjc-runtime/tmux-common.ts +8 -5
  64. package/src/internal-urls/docs-index.generated.ts +7 -7
  65. package/src/lsp/config.ts +12 -63
  66. package/src/memories/index.ts +88 -7
  67. package/src/modes/components/hook-editor.ts +1 -1
  68. package/src/modes/components/hook-selector.ts +1 -1
  69. package/src/modes/controllers/input-controller.ts +2 -2
  70. package/src/modes/controllers/runtime-mcp-command-controller.ts +21 -13
  71. package/src/modes/controllers/selector-controller.ts +3 -7
  72. package/src/modes/controllers/todo-command-controller.ts +1 -1
  73. package/src/modes/interactive-mode.ts +1 -1
  74. package/src/runtime-mcp/client.ts +4 -2
  75. package/src/runtime-mcp/json-rpc.ts +2 -1
  76. package/src/runtime-mcp/manager.ts +29 -11
  77. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  78. package/src/runtime-mcp/oauth-flow.ts +11 -4
  79. package/src/runtime-mcp/oauth-public-url.ts +8 -2
  80. package/src/runtime-mcp/pool.ts +6 -0
  81. package/src/runtime-mcp/project-host-values.ts +46 -0
  82. package/src/runtime-mcp/transports/http.ts +2 -1
  83. package/src/sdk/acp/adapter.ts +2 -0
  84. package/src/sdk/broker/broker.ts +6 -0
  85. package/src/sdk/broker/doctor-restart.ts +4 -0
  86. package/src/sdk/broker/ensure.ts +29 -2
  87. package/src/sdk/host/session-runtime.test.ts +58 -9
  88. package/src/sdk/host/session-runtime.ts +65 -35
  89. package/src/sdk/router/session-router.ts +7 -6
  90. package/src/sdk/session.ts +2 -7
  91. package/src/session/agent-session.ts +59 -17
  92. package/src/session/artifacts.ts +3 -2
  93. package/src/session/internal/managed-session-scope.ts +403 -81
  94. package/src/session/internal/managed-session-storage.ts +125 -17
  95. package/src/session/session-manager.ts +12 -0
  96. package/src/session/session-storage.ts +30 -1
  97. package/src/skill-state/active-state.ts +13 -0
  98. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  99. package/src/slash-commands/builtin-registry.ts +1 -1
  100. package/src/slash-commands/helpers/mcp.ts +8 -4
  101. package/src/task/isolation-dir.ts +143 -0
  102. package/src/task/worktree.ts +16 -7
  103. package/src/tools/ast-edit-write-path.ts +68 -0
  104. package/src/tools/ast-edit.ts +7 -2
  105. package/src/tools/browser/cdp-url.ts +18 -0
  106. package/src/tools/browser.ts +8 -1
  107. package/src/tools/gh.ts +10 -0
  108. package/src/tools/github-side-effect.ts +112 -0
  109. package/src/tools/monitor-mutation.ts +27 -0
  110. package/src/tools/monitor.ts +7 -0
  111. package/src/tools/python.ts +7 -6
  112. package/src/utils/external-editor.ts +3 -2
  113. 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
- // Local Binary Resolution
279
+ // Trusted executable resolution
280
280
  // =============================================================================
281
281
 
282
282
  /**
283
- * Local bin directories to check before $PATH, ordered by priority.
284
- * Each entry maps a root marker to the bin directory to check.
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 resolveTrustedLspCommand(command: string, cwd: string): string | null {
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 the language server binary is available (local or $PATH)
560
- const resolved = resolveTrustedLspCommand(config.command, cwd);
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 = resolveTrustedLspCommand(config.command, cwd);
524
+ const resolved = resolveTrustedCommand(config.command, cwd);
576
525
  if (!resolved) continue;
577
526
  available[name] = { ...config, resolvedCommand: resolved };
578
527
  }
@@ -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 { completeSimple, Effort, type Model } from "@gajae-code/ai/core";
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 input = prompt.render(consolidationTemplate, {
742
- raw_memories: truncateByApproxTokens(rawMemories, 20_000),
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: 8192,
755
- reasoning: clampThinkingLevelForModel(model, Effort.Medium),
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.stop();
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.stop();
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.stop();
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.stop();
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(config: MCPServerConfig): Promise<{
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
- await this.#handleTestConnection(config);
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 oauth = await this.#resolveOAuthEndpointsFromServer(baseConfig);
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.stop();
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.stop(),
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
- // roots are answered from local state without user interaction.
500
- inputResponses[key] = await defaultRequestHandler("roots/list", undefined);
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
- fetch(url, {
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(config);
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(config);
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(config: MCPServerConfig, forceRefresh = false): Promise<MCPServerConfig> {
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();