@gajae-code/coding-agent 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -3,12 +3,17 @@
3
3
  *
4
4
  * Utilities for reading/writing .gjc/mcp.json files at user or project level.
5
5
  */
6
+
7
+ import * as crypto from "node:crypto";
6
8
  import * as fs from "node:fs";
7
9
  import * as path from "node:path";
10
+ import { exactReplacePath, type NativeExactFileIdentity } from "@gajae-code/natives";
8
11
  import { isEnoent } from "@gajae-code/utils";
9
12
  import { invalidate as invalidateFsCache } from "../capability/fs";
13
+ import { withFileLock } from "../config/file-lock";
10
14
 
11
15
  import { validateServerConfig } from "./config";
16
+ import { readMcpBooleanField, restoreMcpBooleanField, writeMcpBooleanField } from "./policy-json-edit";
12
17
  import { MCP_CONFIG_SCHEMA_URL, type MCPConfigFile, type MCPServerConfig } from "./types";
13
18
 
14
19
  function withSchema(config: MCPConfigFile): MCPConfigFile {
@@ -298,3 +303,226 @@ export async function setServerDisabled(filePath: string, name: string, disabled
298
303
 
299
304
  await writeMCPConfigFile(filePath, updated);
300
305
  }
306
+
307
+ export interface ExactMcpBooleanUpdate {
308
+ readonly beforeRaw?: string;
309
+ readonly beforeIdentity?: NativeExactFileIdentity;
310
+ readonly beforeValue?: unknown;
311
+ readonly afterValue: boolean;
312
+ }
313
+
314
+ interface McpFileSnapshot {
315
+ readonly raw: string;
316
+ readonly identity: NativeExactFileIdentity;
317
+ }
318
+ async function exactSnapshot(filePath: string): Promise<McpFileSnapshot | null> {
319
+ let handle: fs.promises.FileHandle | undefined;
320
+ try {
321
+ // O_NONBLOCK prevents `open()` itself from blocking forever if the
322
+ // lexical path was raced to a FIFO between an earlier lstat and this
323
+ // open; the immediate fstat below still rejects any non-regular file.
324
+ handle = await fs.promises.open(
325
+ filePath,
326
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
327
+ );
328
+ const stat = await handle.stat({ bigint: true });
329
+ const parent = await fs.promises.lstat(path.dirname(filePath), { bigint: true });
330
+ if (!stat.isFile() || stat.nlink !== 1n || parent.isSymbolicLink() || !parent.isDirectory())
331
+ throw new Error("MCP config is not an owned regular file");
332
+ const limit = 1024 * 1024;
333
+ if (stat.size > BigInt(limit)) throw new Error("MCP config size limit");
334
+ const bytes = new Uint8Array(limit + 1);
335
+ let length = 0;
336
+ while (length < bytes.length) {
337
+ const next = await handle.read(bytes, length, bytes.length - length, length);
338
+ if (next.bytesRead === 0) break;
339
+ length += next.bytesRead;
340
+ }
341
+ if (length > limit) throw new Error("MCP config size limit");
342
+ const after = await handle.stat({ bigint: true });
343
+ const lexical = await fs.promises.lstat(filePath, { bigint: true });
344
+ const parentAfter = await fs.promises.lstat(path.dirname(filePath), { bigint: true });
345
+ if (
346
+ after.dev !== stat.dev ||
347
+ after.ino !== stat.ino ||
348
+ after.mtimeNs !== stat.mtimeNs ||
349
+ after.ctimeNs !== stat.ctimeNs ||
350
+ after.size !== stat.size ||
351
+ after.nlink !== 1n ||
352
+ lexical.isSymbolicLink() ||
353
+ lexical.dev !== after.dev ||
354
+ lexical.ino !== after.ino ||
355
+ lexical.mtimeNs !== after.mtimeNs ||
356
+ lexical.ctimeNs !== after.ctimeNs ||
357
+ parentAfter.isSymbolicLink() ||
358
+ parentAfter.dev !== parent.dev ||
359
+ parentAfter.ino !== parent.ino
360
+ )
361
+ throw new Error("MCP config identity changed while reading");
362
+ const contents = bytes.subarray(0, length);
363
+ const identity = {
364
+ dev: stat.dev,
365
+ ino: stat.ino,
366
+ nlink: stat.nlink,
367
+ size: stat.size,
368
+ mtimeNs: stat.mtimeNs,
369
+ parentDev: parent.dev,
370
+ parentIno: parent.ino,
371
+ sha256: crypto.createHash("sha256").update(contents).digest("hex"),
372
+ };
373
+ return { raw: new TextDecoder("utf-8", { fatal: true }).decode(contents), identity };
374
+ } catch (error) {
375
+ const code = (error as NodeJS.ErrnoException).code;
376
+ if (code === "ENOENT") return null;
377
+ throw error;
378
+ } finally {
379
+ await handle?.close();
380
+ }
381
+ }
382
+
383
+ async function exactIdentity(filePath: string): Promise<NativeExactFileIdentity | null> {
384
+ return (await exactSnapshot(filePath))?.identity ?? null;
385
+ }
386
+
387
+ /** Exact-field JSON CAS used by doctor; server names are object keys and may contain dots. */
388
+ export async function updateMCPBooleanExact(
389
+ filePath: string,
390
+ name: string,
391
+ field: "autoload" | "enabled",
392
+ value: boolean,
393
+ expected: { readonly raw: string; readonly identity: NativeExactFileIdentity },
394
+ ): Promise<{ beforeValue: unknown; afterValue: boolean; changed: boolean; afterIdentity?: NativeExactFileIdentity }> {
395
+ return await withFileLock(filePath, async () => {
396
+ const snapshot = await exactSnapshot(filePath);
397
+ const raw = snapshot?.raw;
398
+ const identity = snapshot?.identity;
399
+ if (
400
+ !identity ||
401
+ raw !== expected.raw ||
402
+ identity.dev !== expected.identity.dev ||
403
+ identity.ino !== expected.identity.ino ||
404
+ identity.size !== expected.identity.size ||
405
+ identity.mtimeNs !== expected.identity.mtimeNs ||
406
+ identity.parentDev !== expected.identity.parentDev ||
407
+ identity.parentIno !== expected.identity.parentIno ||
408
+ identity.sha256 !== expected.identity.sha256
409
+ )
410
+ throw new Error("MCP config compare-and-swap conflict");
411
+ const edit = writeMcpBooleanField(raw, name, field, value);
412
+ if (edit.status === "not_found") throw new Error("MCP server not found");
413
+ if (edit.status === "duplicate_key") throw new Error(`MCP config has a duplicate key at ${edit.path}`);
414
+ if (edit.status === "malformed") throw new Error("MCP config is not valid JSON");
415
+ const beforeValue = edit.beforeValue;
416
+ if (beforeValue === value) return { beforeValue, afterValue: value, changed: false };
417
+ const tmpPath = `${filePath}.doctor-${crypto.randomUUID()}.tmp`;
418
+ const handle = await fs.promises.open(
419
+ tmpPath,
420
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
421
+ 0o600,
422
+ );
423
+ try {
424
+ await handle.writeFile(edit.text, "utf8");
425
+ await handle.sync();
426
+ } finally {
427
+ await handle.close();
428
+ }
429
+ const source = await exactIdentity(tmpPath);
430
+ if (!source) throw new Error("MCP config identity unavailable");
431
+ const replaced = exactReplacePath(tmpPath, filePath, source, expected.identity);
432
+ if (!replaced.ok) throw new Error(`MCP config replacement failed: ${replaced.code ?? "unknown"}`);
433
+ const parentHandle = await fs.promises.open(path.dirname(filePath), "r");
434
+ try {
435
+ await parentHandle.sync();
436
+ } finally {
437
+ await parentHandle.close();
438
+ }
439
+ return {
440
+ beforeValue,
441
+ afterValue: value,
442
+ changed: true,
443
+ afterIdentity: (await exactIdentity(filePath)) ?? undefined,
444
+ };
445
+ });
446
+ }
447
+
448
+ export type RestoreMCPBooleanResult =
449
+ | { readonly status: "restored" }
450
+ | { readonly status: "conflict" }
451
+ | { readonly status: "failed"; readonly reason: string };
452
+
453
+ /**
454
+ * Roll back exactly one `mcpServers.<name>.<field>` token to its captured
455
+ * `value` (which may be `undefined` to restore an originally-absent field, or
456
+ * an originally-invalid non-boolean scalar). Binds to the PUBLISHED file's
457
+ * dev/ino/parent identity captured right after the mutating replace — not the
458
+ * pre-mutation identity — and to the field's known current (post-mutation)
459
+ * value, so an unrelated in-place sibling edit made after publication is
460
+ * preserved rather than reverted by a stale whole-file snapshot. A CAS
461
+ * precondition mismatch (identity or field drifted) is a `conflict`; any
462
+ * other unexpected error is reported as `failed` and must never be silently
463
+ * folded into `conflict`, since only `conflict` means "an independent editor
464
+ * already resolved this and no action is required".
465
+ */
466
+ export async function restoreMCPBooleanExact(
467
+ filePath: string,
468
+ name: string,
469
+ field: "autoload" | "enabled",
470
+ originalRaw: string,
471
+ expected: { readonly identity: NativeExactFileIdentity; readonly currentValue: boolean },
472
+ ): Promise<RestoreMCPBooleanResult> {
473
+ return await withFileLock(filePath, async () => {
474
+ let current: McpFileSnapshot | null;
475
+ try {
476
+ current = await exactSnapshot(filePath);
477
+ } catch {
478
+ return { status: "failed", reason: "restore_snapshot_failed" };
479
+ }
480
+ if (
481
+ !current ||
482
+ current.identity.dev !== expected.identity.dev ||
483
+ current.identity.ino !== expected.identity.ino ||
484
+ current.identity.parentDev !== expected.identity.parentDev ||
485
+ current.identity.parentIno !== expected.identity.parentIno
486
+ )
487
+ return { status: "conflict" };
488
+ const read = readMcpBooleanField(current.raw, name, field);
489
+ if (read.status === "duplicate_key") return { status: "failed", reason: `duplicate key at ${read.path}` };
490
+ if (read.status === "malformed") return { status: "failed", reason: "MCP config is not valid JSON" };
491
+ if (read.status === "not_found" || read.value !== expected.currentValue) return { status: "conflict" };
492
+ const edit = restoreMcpBooleanField(current.raw, originalRaw, name, field);
493
+ if (edit.status !== "ok") return { status: "failed", reason: edit.status };
494
+ const tmpPath = `${filePath}.doctor-restore-${crypto.randomUUID()}.tmp`;
495
+ const restoreHandle = await fs.promises.open(
496
+ tmpPath,
497
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
498
+ 0o600,
499
+ );
500
+ try {
501
+ await restoreHandle.writeFile(edit.text, "utf8");
502
+ await restoreHandle.sync();
503
+ } finally {
504
+ await restoreHandle.close();
505
+ }
506
+ const source = await exactIdentity(tmpPath);
507
+ if (!source) return { status: "failed", reason: "restore identity unavailable" };
508
+ const replaced = exactReplacePath(tmpPath, filePath, source, current.identity);
509
+ if (!replaced.ok) return { status: "failed", reason: "restore_publication_unverified" };
510
+ const verified = await exactSnapshot(filePath);
511
+ if (!verified) return { status: "failed", reason: "post-restore verification unavailable" };
512
+ if (
513
+ verified.raw !== edit.text ||
514
+ verified.identity.dev !== source.dev ||
515
+ verified.identity.ino !== source.ino ||
516
+ verified.identity.parentDev !== source.parentDev ||
517
+ verified.identity.parentIno !== source.parentIno
518
+ )
519
+ return { status: "failed", reason: "post_restore_identity_mismatch" };
520
+ const parentHandle = await fs.promises.open(path.dirname(filePath), "r");
521
+ try {
522
+ await parentHandle.sync();
523
+ } finally {
524
+ await parentHandle.close();
525
+ }
526
+ return { status: "restored" };
527
+ });
528
+ }
@@ -73,6 +73,108 @@ export function detectAuthError(error: Error): boolean {
73
73
  return false;
74
74
  }
75
75
 
76
+ const CHALLENGE_KEY_MAX_LENGTH = 64;
77
+
78
+ function isChallengeKeyStart(code: number): boolean {
79
+ return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95;
80
+ }
81
+
82
+ function isChallengeKeyPart(code: number): boolean {
83
+ return isChallengeKeyStart(code) || (code >= 48 && code <= 57) || code === 45;
84
+ }
85
+
86
+ /**
87
+ * Scan challenge parameters without retrying an entire identifier run at every
88
+ * offset. Skipping a failed run is important: a bounded regex could reinterpret
89
+ * the suffix of an oversized key as a different, recognized parameter.
90
+ */
91
+ function extractChallengeEntries(errorMessage: string): Array<[string, string]> {
92
+ const entries: Array<[string, string]> = [];
93
+ let cursor = 0;
94
+
95
+ while (cursor < errorMessage.length) {
96
+ const keyStart = cursor;
97
+ if (!isChallengeKeyStart(errorMessage.charCodeAt(keyStart))) {
98
+ cursor++;
99
+ continue;
100
+ }
101
+
102
+ let keyEnd = keyStart + 1;
103
+ while (isChallengeKeyPart(errorMessage.charCodeAt(keyEnd))) keyEnd++;
104
+
105
+ if (errorMessage.charCodeAt(keyEnd) !== 61 || errorMessage.charCodeAt(keyEnd + 1) !== 34) {
106
+ cursor = keyEnd + 1;
107
+ continue;
108
+ }
109
+
110
+ const valueStart = keyEnd + 2;
111
+ const valueEnd = errorMessage.indexOf('"', valueStart);
112
+ if (valueEnd === -1) break;
113
+ if (valueEnd > valueStart && keyEnd - keyStart <= CHALLENGE_KEY_MAX_LENGTH) {
114
+ entries.push([errorMessage.slice(keyStart, keyEnd).toLowerCase(), errorMessage.slice(valueStart, valueEnd)]);
115
+ }
116
+ cursor = valueEnd + 1;
117
+ }
118
+
119
+ return entries;
120
+ }
121
+
122
+ interface QuotedParameter {
123
+ markerStart: number;
124
+ valueEnd: number;
125
+ value: string;
126
+ }
127
+
128
+ function isRegexLineTerminator(code: number): boolean {
129
+ return code === 10 || code === 13 || code === 0x2028 || code === 0x2029;
130
+ }
131
+
132
+ function collectQuotedParameters(errorMessage: string, prefix: string): QuotedParameter[] {
133
+ const entries: QuotedParameter[] = [];
134
+ for (
135
+ let markerStart = errorMessage.indexOf(prefix);
136
+ markerStart !== -1;
137
+ markerStart = errorMessage.indexOf(prefix, markerStart + 1)
138
+ ) {
139
+ const valueStart = markerStart + prefix.length;
140
+ const valueEnd = errorMessage.indexOf('"', valueStart);
141
+ if (valueEnd === -1) break;
142
+ if (valueEnd > valueStart)
143
+ entries.push({ markerStart, valueEnd, value: errorMessage.slice(valueStart, valueEnd) });
144
+ }
145
+ return entries;
146
+ }
147
+
148
+ /**
149
+ * Preserve the legacy `realm="...".*token_url="..."` fallback without the
150
+ * greedy wildcard rescanning every later `realm` marker. The wildcard cannot
151
+ * cross a JavaScript regex line terminator and selects the last valid token on
152
+ * the matching line.
153
+ */
154
+ function extractLegacyWwwAuth(errorMessage: string): { authorizationUrl: string; tokenUrl: string } | null {
155
+ const lineAt = new Uint32Array(errorMessage.length + 1);
156
+ let line = 0;
157
+ for (let index = 0; index < errorMessage.length; index++) {
158
+ lineAt[index] = line;
159
+ if (isRegexLineTerminator(errorMessage.charCodeAt(index))) line++;
160
+ }
161
+ lineAt[errorMessage.length] = line;
162
+
163
+ const realms = collectQuotedParameters(errorMessage, 'realm="');
164
+ const tokens = collectQuotedParameters(errorMessage, 'token_url="');
165
+ const lastTokenByLine = new Map<number, QuotedParameter>();
166
+ for (const token of tokens) lastTokenByLine.set(lineAt[token.markerStart]!, token);
167
+
168
+ for (const realm of realms) {
169
+ const token = lastTokenByLine.get(lineAt[realm.valueEnd + 1]!);
170
+ if (token && token.markerStart > realm.valueEnd) {
171
+ return { authorizationUrl: realm.value, tokenUrl: token.value };
172
+ }
173
+ }
174
+
175
+ return null;
176
+ }
177
+
76
178
  /**
77
179
  * Extract OAuth endpoints from error response.
78
180
  * Looks for WWW-Authenticate header format or JSON error bodies.
@@ -130,9 +232,10 @@ export function extractOAuthEndpoints(error: Error): OAuthEndpoints | null {
130
232
  try {
131
233
  // Try to parse as JSON error response
132
234
  // Many MCP servers return JSON with OAuth endpoints in error body
133
- const jsonMatch = errorMsg.match(/\{[\s\S]*\}/);
134
- if (jsonMatch) {
135
- const errorBody = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
235
+ const jsonStart = errorMsg.indexOf("{");
236
+ const jsonEnd = errorMsg.lastIndexOf("}");
237
+ if (jsonEnd > jsonStart) {
238
+ const errorBody = JSON.parse(errorMsg.slice(jsonStart, jsonEnd + 1)) as Record<string, unknown>;
136
239
 
137
240
  // Check for OAuth endpoints in error body
138
241
  if (errorBody.oauth || errorBody.authorization || errorBody.auth) {
@@ -160,11 +263,11 @@ export function extractOAuthEndpoints(error: Error): OAuthEndpoints | null {
160
263
  // Not JSON, continue with other detection methods
161
264
  }
162
265
 
163
- const challengeEntries = Array.from(errorMsg.matchAll(/([a-zA-Z_][a-zA-Z0-9_-]*)="([^"]+)"/g));
266
+ const challengeEntries = extractChallengeEntries(errorMsg);
164
267
  if (challengeEntries.length > 0) {
165
268
  const challengeValues = new Map<string, string>();
166
- for (const [, rawKey, value] of challengeEntries) {
167
- challengeValues.set(rawKey.toLowerCase(), value);
269
+ for (const [key, value] of challengeEntries) {
270
+ challengeValues.set(key, value);
168
271
  }
169
272
 
170
273
  const authorizationUrl =
@@ -186,15 +289,12 @@ export function extractOAuthEndpoints(error: Error): OAuthEndpoints | null {
186
289
  }
187
290
  }
188
291
 
189
- // Try to extract from WWW-Authenticate header format
190
- // Example: Bearer realm="https://auth.example.com/oauth/authorize" token_url="https://auth.example.com/oauth/token"
191
- const wwwAuthMatch = errorMsg.match(/realm="([^"]+)".*token_url="([^"]+)"/);
192
- if (wwwAuthMatch) {
292
+ const wwwAuth = extractLegacyWwwAuth(errorMsg);
293
+ if (wwwAuth) {
193
294
  return {
194
- authorizationUrl: wwwAuthMatch[1],
195
- tokenUrl: wwwAuthMatch[2],
196
- clientId: clientIdFromAuthUrl(wwwAuthMatch[1]),
197
- scopes: scopeFromAuthUrl(wwwAuthMatch[1]),
295
+ ...wwwAuth,
296
+ clientId: clientIdFromAuthUrl(wwwAuth.authorizationUrl),
297
+ scopes: scopeFromAuthUrl(wwwAuth.authorizationUrl),
198
298
  };
199
299
  }
200
300
 
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Lossless JSON policy-field editing for `.gjc/mcp.json`.
3
+ *
4
+ * Uses `jsonc-parser`'s AST/edit API instead of `JSON.parse`/`JSON.stringify`
5
+ * so only the selected `mcpServers.<name>.<field>` token is rewritten. Every
6
+ * other token in the document (unrelated fields, large
7
+ * integer literals, `-0`, secrets) survives untouched. `JSON.parse` would
8
+ * silently normalize those values and collapse duplicate keys to "last one
9
+ * wins", hiding a real authoring bug — so duplicate keys along the selected
10
+ * path are rejected instead of being silently resolved.
11
+ */
12
+ import {
13
+ applyEdits,
14
+ findNodeAtLocation,
15
+ getNodeValue,
16
+ modify,
17
+ type Node,
18
+ type ParseError,
19
+ parseTree,
20
+ } from "jsonc-parser";
21
+
22
+ export type McpBooleanField = "autoload" | "enabled";
23
+
24
+ export type McpFieldReadResult =
25
+ | { readonly status: "ok"; readonly exists: boolean; readonly value: unknown }
26
+ | { readonly status: "duplicate_key"; readonly path: string }
27
+ | { readonly status: "malformed" }
28
+ | { readonly status: "not_found" };
29
+
30
+ export type McpFieldWriteResult =
31
+ | { readonly status: "ok"; readonly text: string; readonly beforeValue: unknown }
32
+ | { readonly status: "duplicate_key"; readonly path: string }
33
+ | { readonly status: "malformed" }
34
+ | { readonly status: "not_found" };
35
+
36
+ /**
37
+ * Detect a duplicate property name at each level while descending the given
38
+ * path. Scoped to the selected policy path only — this is not a whole-document
39
+ * scan; it visits only the objects on the selected path.
40
+ */
41
+ export function findDuplicateKeyAlongPath(root: Node, path: readonly string[]): string | undefined {
42
+ let node: Node | undefined = root;
43
+ for (let depth = 0; depth < path.length; depth++) {
44
+ if (node?.type !== "object" || !node.children) return undefined;
45
+ let matched: Node | undefined;
46
+ for (const property of node.children) {
47
+ const keyNode = property.children?.[0];
48
+ if (!keyNode || keyNode.value !== path[depth]) continue;
49
+ if (matched) return path.slice(0, depth + 1).join(".");
50
+ matched = property;
51
+ }
52
+ node = matched?.children?.[1];
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ function parsePolicyTree(raw: string): Node | undefined {
58
+ const errors: ParseError[] = [];
59
+ const root = parseTree(raw, errors, { disallowComments: true, allowTrailingComma: false });
60
+ return root && errors.length === 0 ? root : undefined;
61
+ }
62
+
63
+ /** Read one selected `mcpServers.<name>.<field>` boolean without collapsing duplicate keys. */
64
+ export function readMcpBooleanField(raw: string, serverName: string, field: McpBooleanField): McpFieldReadResult {
65
+ const root = parsePolicyTree(raw);
66
+ if (!root) return { status: "malformed" };
67
+ const path = ["mcpServers", serverName, field];
68
+ const duplicate = findDuplicateKeyAlongPath(root, path);
69
+ if (duplicate) return { status: "duplicate_key", path: duplicate };
70
+ const serverNode = findNodeAtLocation(root, ["mcpServers", serverName]);
71
+ if (serverNode?.type !== "object") return { status: "not_found" };
72
+ const fieldNode = findNodeAtLocation(root, path);
73
+ return { status: "ok", exists: fieldNode !== undefined, value: fieldNode ? getNodeValue(fieldNode) : undefined };
74
+ }
75
+
76
+ /**
77
+ * Compute the lossless text after setting `mcpServers.<name>.<field>` to
78
+ * `value`, or removing it when `value` is `undefined` (used to restore an
79
+ * originally-absent field). Only the selected token's edit range changes.
80
+ */
81
+ export function writeMcpBooleanField(
82
+ raw: string,
83
+ serverName: string,
84
+ field: McpBooleanField,
85
+ /**
86
+ * The new field value. `undefined` removes the key, restoring an
87
+ * originally-absent field on rollback. A rollback may also need to restore
88
+ * an originally-invalid non-boolean scalar, so this intentionally accepts
89
+ * any JSON value rather than only `boolean`.
90
+ */
91
+ value: unknown,
92
+ ): McpFieldWriteResult {
93
+ const before = readMcpBooleanField(raw, serverName, field);
94
+ if (before.status !== "ok") return before;
95
+ const edits = modify(raw, ["mcpServers", serverName, field], value, {});
96
+ return { status: "ok", text: applyEdits(raw, edits), beforeValue: before.value };
97
+ }
98
+
99
+ /** Restore the original token, rather than reserializing its possibly-invalid value. */
100
+ export function restoreMcpBooleanField(
101
+ raw: string,
102
+ original: string,
103
+ serverName: string,
104
+ field: McpBooleanField,
105
+ ): McpFieldWriteResult {
106
+ const current = readMcpBooleanField(raw, serverName, field);
107
+ const before = readMcpBooleanField(original, serverName, field);
108
+ if (current.status !== "ok") return current;
109
+ if (before.status !== "ok") return before;
110
+ if (!before.exists) return writeMcpBooleanField(raw, serverName, field, undefined);
111
+ const currentTree = parsePolicyTree(raw);
112
+ const originalTree = parsePolicyTree(original);
113
+ if (!currentTree || !originalTree) return { status: "malformed" };
114
+ const selected = ["mcpServers", serverName, field];
115
+ const currentNode = findNodeAtLocation(currentTree, selected);
116
+ const originalNode = findNodeAtLocation(originalTree, selected);
117
+ if (!currentNode || !originalNode) return { status: "not_found" };
118
+ return {
119
+ status: "ok",
120
+ beforeValue: current.value,
121
+ text: applyEdits(raw, [
122
+ {
123
+ offset: currentNode.offset,
124
+ length: currentNode.length,
125
+ content: original.slice(originalNode.offset, originalNode.offset + originalNode.length),
126
+ },
127
+ ]),
128
+ };
129
+ }
@@ -0,0 +1,14 @@
1
+ import type { MCPServerConfig } from "./types";
2
+
3
+ export type AutoloadStatus = "autoload" | "autoload-off" | "disabled";
4
+
5
+ /** Pure startup policy shared by runtime-facing CLI inspection and doctor. */
6
+ export function computeAutoloadStatus(
7
+ name: string,
8
+ config: Pick<MCPServerConfig, "enabled" | "autoload">,
9
+ disabledServers: ReadonlySet<string>,
10
+ ): AutoloadStatus {
11
+ if (config.enabled === false || disabledServers.has(name)) return "disabled";
12
+ if (config.autoload === false) return "autoload-off";
13
+ return "autoload";
14
+ }
@@ -67,8 +67,8 @@ export interface MCPServerConfigBase {
67
67
  * Whether an explicit runtime MCP consumer should connect this server
68
68
  * automatically when that consumer starts (default: true). Ordinary
69
69
  * standalone `gjc`, `gjc --tmux`, and print-mode sessions honor autoload for
70
- * conventional registrations; `autoload: false` keeps a server configured
71
- * for on-demand `/mcp` connection. `--no-mcp` opts a session out entirely.
70
+ * conventional registrations; `autoload: false` keeps a server stored but
71
+ * unloaded at startup. `--no-mcp` opts a session out entirely.
72
72
  */
73
73
  autoload?: boolean;
74
74
  /** Connection timeout in milliseconds (default: 30000) */
@@ -198,6 +198,8 @@ export class AcpSdkAdapter {
198
198
  #reverseCancelTtlMs: number;
199
199
  #closed = false;
200
200
  #started = false;
201
+ /** Reverse-provider leases are enabled only after host origin is authenticated. */
202
+ #providerActivationAuthorized = false;
201
203
  constructor(options: AcpSdkAdapterOptions) {
202
204
  if ((options.client === undefined) === (options.router === undefined))
203
205
  throw new AcpSdkAdapterError(
@@ -225,6 +227,11 @@ export class AcpSdkAdapter {
225
227
  return adapter;
226
228
  }
227
229
 
230
+ /** Authorize provider registration after the host startup origin is known. */
231
+ authorizeProviderActivation(): void {
232
+ this.#providerActivationAuthorized = true;
233
+ }
234
+
228
235
  acceptAttachment(attachment: SessionAttachment): void {
229
236
  if (!this.#router || attachment.sessionId !== this.#sessionId)
230
237
  throw new AcpSdkAdapterError("invalid_input", "ACP attachment does not match this session adapter.");
@@ -238,7 +245,8 @@ export class AcpSdkAdapter {
238
245
  this.#connectionId = undefined;
239
246
  this.#routerConnectionReady = false;
240
247
  this.#providersActivated = false;
241
- if (attachment.isCurrent()) void this.#activateProviders().catch(error => this.#reportReconnectFailure(error));
248
+ if (attachment.isCurrent() && this.#providerActivationAuthorized)
249
+ void this.#activateProviders().catch(error => this.#reportReconnectFailure(error));
242
250
  }
243
251
 
244
252
  revokeAttachment(attachment: SessionAttachment): void {
@@ -264,7 +272,7 @@ export class AcpSdkAdapter {
264
272
  this.#providersActivated = false;
265
273
  }
266
274
  if (!attachment.isCurrent()) throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
267
- await this.#activateProviders();
275
+ if (this.#providerActivationAuthorized) await this.#activateProviders();
268
276
  }
269
277
  /**
270
278
  * Re-register reverse providers on a live attachment without aborting in-flight
@@ -273,7 +281,7 @@ export class AcpSdkAdapter {
273
281
  * place for the life of the session (#4909).
274
282
  */
275
283
  async ensureProviders(): Promise<void> {
276
- if (this.#closed || this.#providers.length === 0) return;
284
+ if (!this.#providerActivationAuthorized || this.#closed || this.#providers.length === 0) return;
277
285
  if (this.#ensuring) return await this.#ensuring;
278
286
  const run = (async () => {
279
287
  if (this.#closed) return;
@@ -307,9 +315,15 @@ export class AcpSdkAdapter {
307
315
  return () => this.#frameHandlers.delete(handler);
308
316
  }
309
317
 
310
- async start(): Promise<void> {
318
+ async start(options: { activateProviders?: boolean } = {}): Promise<void> {
319
+ const activateProviders = options.activateProviders !== false;
311
320
  if (this.#closed) throw new AcpSdkAdapterError("connection_closed");
321
+ if (this.#router && this.#attachment) assertMaintenanceCapability(this.#attachment);
312
322
  if (!this.#started) {
323
+ // Direct adapter startup owns providers; ACP attachment defers this
324
+ // authority until runtime origin discovery. Repeated startup cannot
325
+ // promote a secondary attachment.
326
+ this.#providerActivationAuthorized = activateProviders;
313
327
  this.#started = true;
314
328
  if (this.#client) {
315
329
  this.#unsubscribe ??= this.#client.onFrame(frame => void this.#onFrame(frame));
@@ -323,12 +337,13 @@ export class AcpSdkAdapter {
323
337
  this.#connectionId = this.#client.connectionId;
324
338
  }
325
339
  }
326
- if (this.#router) {
327
- if (!this.#attachment?.isCurrent()) return;
328
- if (this.#attachment) assertMaintenanceCapability(this.#attachment);
329
- await this.#activateProviders();
330
- } else {
331
- await this.#activateProviders();
340
+ if (activateProviders) {
341
+ if (this.#router) {
342
+ if (!this.#attachment?.isCurrent()) return;
343
+ if (this.#providerActivationAuthorized) await this.#activateProviders();
344
+ } else {
345
+ if (this.#providerActivationAuthorized) await this.#activateProviders();
346
+ }
332
347
  }
333
348
  this.#heartbeat ??= setInterval(
334
349
  () => void this.#heartbeatLeases().catch(error => this.#reportReconnectFailure(error)),
@@ -575,7 +590,7 @@ export class AcpSdkAdapter {
575
590
  }
576
591
 
577
592
  async #activateProviders(force = false): Promise<void> {
578
- if (this.#providers.length === 0) return;
593
+ if (!this.#providerActivationAuthorized || this.#providers.length === 0) return;
579
594
  if (this.#providerActivation) {
580
595
  await this.#providerActivation;
581
596
  if (this.#closed) return;
@@ -656,7 +671,7 @@ export class AcpSdkAdapter {
656
671
  }
657
672
 
658
673
  async #reclaimProviders(): Promise<void> {
659
- if (this.#closed || !this.#providers.length) return;
674
+ if (!this.#providerActivationAuthorized || this.#closed || !this.#providers.length) return;
660
675
  if (this.#reclaiming) return await this.#reclaiming;
661
676
  this.#reclaiming = (async () => {
662
677
  this.#abortActiveReverseRequests();