@gajae-code/coding-agent 0.18.12 → 0.19.0

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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -97,7 +97,7 @@ export async function refreshMissingQualifiedModelProviders(
97
97
  .getRefreshableProviders()
98
98
  .find(candidate => candidate.toLowerCase() === parsedSelector.provider.toLowerCase());
99
99
  if (!provider || refreshedProviders.has(provider)) continue;
100
- const availableModels = modelRegistry.getAvailable();
100
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
101
101
  const rawModelId = selector.slice(selector.indexOf("/") + 1);
102
102
  if (
103
103
  resolveProviderModelReference(provider, rawModelId, availableModels) ||
@@ -1047,9 +1047,10 @@ export function resolveModelOverride(
1047
1047
  settings?: Settings,
1048
1048
  sessionId?: string,
1049
1049
  aliasIntent?: "preset-equivalent" | "reject",
1050
+ credentialSessionId?: string,
1050
1051
  ): { model?: Model<Api>; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } {
1051
1052
  if (modelPatterns.length === 0) return { explicitThinkingLevel: false };
1052
- const availableModels = modelRegistry.getAvailable();
1053
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
1053
1054
  const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
1054
1055
  for (const pattern of modelPatterns) {
1055
1056
  const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
@@ -1141,13 +1142,13 @@ async function resolveModelChainEntries(
1141
1142
  startIndex: number,
1142
1143
  circuitPolicy: { skipOpenCircuits: boolean },
1143
1144
  ): Promise<ModelChainResolution & { firstOpenCircuit?: { index: number; skipCount: number } }> {
1144
- const availableModels = modelRegistry.getAvailable();
1145
- const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
1146
- const skips: Array<{ selector: string; reason: string }> = [];
1147
- const authFailedModels = new Set<string>();
1148
1145
  const canonicalSessionId =
1149
1146
  options?.canonicalSessionId === null ? undefined : (options?.canonicalSessionId ?? sessionId);
1150
1147
  const credentialSessionId = options?.credentialSessionId ?? sessionId;
1148
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
1149
+ const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
1150
+ const skips: Array<{ selector: string; reason: string }> = [];
1151
+ const authFailedModels = new Set<string>();
1151
1152
  let firstOpenCircuit: { index: number; skipCount: number } | undefined;
1152
1153
  for (let activeIndex = startIndex; activeIndex < modelPatterns.length; activeIndex += 1) {
1153
1154
  const selector = modelPatterns[activeIndex];
@@ -1279,7 +1280,8 @@ export async function resolveModelOverrideWithAuthFallback(
1279
1280
  parentFallbackSelector?: string;
1280
1281
  skips: Array<{ selector: string; reason: string }>;
1281
1282
  }> {
1282
- const availableModels = modelRegistry.getAvailable();
1283
+ const credentialSessionId = options?.credentialSessionId ?? authSessionId;
1284
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
1283
1285
  const matchPreferences = { usageOrder: settings?.getStorage()?.getModelUsageOrder() };
1284
1286
  let requestedModel: Model<Api> | undefined;
1285
1287
  let requestedResolution: ResolvedModelRoleValue | undefined;
@@ -1294,6 +1296,7 @@ export async function resolveModelOverrideWithAuthFallback(
1294
1296
  settings,
1295
1297
  undefined,
1296
1298
  options?.aliasIntent,
1299
+ credentialSessionId,
1297
1300
  ).model;
1298
1301
  if (parentActiveModel) {
1299
1302
  modelRegistry.seedCanonicalVariant?.(canonicalScope, parentActiveModel);
@@ -1315,7 +1318,7 @@ export async function resolveModelOverrideWithAuthFallback(
1315
1318
  matchPreferences,
1316
1319
  modelRegistry,
1317
1320
  sessionId: canonicalScope,
1318
- credentialSessionId: authSessionId,
1321
+ credentialSessionId,
1319
1322
  aliasIntent: options?.aliasIntent,
1320
1323
  });
1321
1324
  if (!requestedModel && candidate.model) {
@@ -1335,7 +1338,7 @@ export async function resolveModelOverrideWithAuthFallback(
1335
1338
  continue;
1336
1339
  }
1337
1340
  }
1338
- const key = await modelRegistry.getApiKey(candidate.model, authSessionId);
1341
+ const key = await modelRegistry.getApiKey(candidate.model, credentialSessionId);
1339
1342
  if (isAuthenticatedOrKeyless(key)) {
1340
1343
  return { ...candidate, requestedModel: candidate.model, authFallbackUsed: false, activeIndex, skips };
1341
1344
  }
@@ -1345,8 +1348,9 @@ export async function resolveModelOverrideWithAuthFallback(
1345
1348
  ) {
1346
1349
  const equivalentRetry = await resolveModelChainWithAuth([pattern], modelRegistry, settings, authSessionId, {
1347
1350
  managedFallback: options.managedFallback,
1348
- aliasIntent: options.aliasIntent,
1351
+ aliasIntent: options?.aliasIntent,
1349
1352
  canonicalSessionId: canonicalScope,
1353
+ credentialSessionId,
1350
1354
  });
1351
1355
  if (equivalentRetry.model) {
1352
1356
  return {
@@ -1367,10 +1371,17 @@ export async function resolveModelOverrideWithAuthFallback(
1367
1371
  return { explicitThinkingLevel: false, requestedModel, authFallbackUsed: false, activeIndex, skips };
1368
1372
  }
1369
1373
  const fallback = parentActiveModelPattern
1370
- ? resolveModelOverride([parentActiveModelPattern], modelRegistry, settings, authSessionId, options?.aliasIntent)
1374
+ ? resolveModelOverride(
1375
+ [parentActiveModelPattern],
1376
+ modelRegistry,
1377
+ settings,
1378
+ credentialSessionId,
1379
+ options?.aliasIntent,
1380
+ credentialSessionId,
1381
+ )
1371
1382
  : { explicitThinkingLevel: false };
1372
1383
  if (fallback.model) {
1373
- const fallbackKey = await modelRegistry.getApiKey(fallback.model, authSessionId);
1384
+ const fallbackKey = await modelRegistry.getApiKey(fallback.model, credentialSessionId);
1374
1385
  if (isAuthenticatedOrKeyless(fallbackKey)) {
1375
1386
  const isParentSubstitution = requestedModel === undefined || !modelsAreEqual(fallback.model, requestedModel);
1376
1387
  return {
@@ -1452,8 +1463,9 @@ export async function resolveModelScope(
1452
1463
  patterns: string[],
1453
1464
  modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
1454
1465
  preferences?: ModelMatchPreferences,
1466
+ credentialSessionId?: string,
1455
1467
  ): Promise<ScopedModel[]> {
1456
- const availableModels = modelRegistry.getAvailable();
1468
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
1457
1469
 
1458
1470
  const scopedModels: ScopedModel[] = [];
1459
1471
  const addScopedModel = (selection: ScopedModel): void => {
@@ -1558,13 +1570,14 @@ export async function resolveAllowedModels(
1558
1570
  modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
1559
1571
  settings: Settings | undefined,
1560
1572
  preferences?: ModelMatchPreferences,
1573
+ credentialSessionId?: string,
1561
1574
  ): Promise<Model<Api>[]> {
1562
- const available = modelRegistry.getAvailable();
1575
+ const available = modelRegistry.getAvailable(credentialSessionId);
1563
1576
  const patterns = settings?.get("enabledModels");
1564
1577
  if (!patterns || patterns.length === 0) {
1565
1578
  return available;
1566
1579
  }
1567
- const scoped = await resolveModelScope(patterns, modelRegistry, preferences);
1580
+ const scoped = await resolveModelScope(patterns, modelRegistry, preferences, credentialSessionId);
1568
1581
  if (scoped.length === 0) {
1569
1582
  return [];
1570
1583
  }
@@ -1688,6 +1701,7 @@ export async function findInitialModel(options: {
1688
1701
  defaultModelId?: string;
1689
1702
  defaultThinkingSelector?: ThinkingLevel;
1690
1703
  modelRegistry: InitialModelRegistry;
1704
+ credentialSessionId?: string;
1691
1705
  }): Promise<InitialModelResult> {
1692
1706
  const {
1693
1707
  cliProvider,
@@ -1698,6 +1712,7 @@ export async function findInitialModel(options: {
1698
1712
  defaultModelId,
1699
1713
  defaultThinkingSelector,
1700
1714
  modelRegistry,
1715
+ credentialSessionId,
1701
1716
  } = options;
1702
1717
 
1703
1718
  let model: Model<Api> | undefined;
@@ -1705,7 +1720,11 @@ export async function findInitialModel(options: {
1705
1720
 
1706
1721
  // 1. CLI args take priority
1707
1722
  if (cliProvider && cliModel) {
1708
- const found = resolveProviderModelReference(cliProvider, cliModel, modelRegistry.getAvailable());
1723
+ const found = resolveProviderModelReference(
1724
+ cliProvider,
1725
+ cliModel,
1726
+ modelRegistry.getAvailable(credentialSessionId),
1727
+ );
1709
1728
 
1710
1729
  if (!found) {
1711
1730
  console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
@@ -1730,7 +1749,11 @@ export async function findInitialModel(options: {
1730
1749
 
1731
1750
  // 3. Try saved default from settings
1732
1751
  if (defaultProvider && defaultModelId) {
1733
- const found = resolveProviderModelReference(defaultProvider, defaultModelId, modelRegistry.getAvailable());
1752
+ const found = resolveProviderModelReference(
1753
+ defaultProvider,
1754
+ defaultModelId,
1755
+ modelRegistry.getAvailable(credentialSessionId),
1756
+ );
1734
1757
 
1735
1758
  if (found) {
1736
1759
  model = found;
@@ -1745,7 +1768,7 @@ export async function findInitialModel(options: {
1745
1768
  }
1746
1769
 
1747
1770
  // 4. Try first available model with valid API key
1748
- const availableModels = modelRegistry.getAvailable();
1771
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
1749
1772
 
1750
1773
  if (availableModels.length > 0) {
1751
1774
  // Try to find a default model from known providers
@@ -1774,11 +1797,16 @@ export async function restoreModelFromSession(
1774
1797
  currentModel: Model<Api> | undefined,
1775
1798
  shouldPrintMessages: boolean,
1776
1799
  modelRegistry: RestorableModelRegistry,
1800
+ credentialSessionId?: string,
1777
1801
  ): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
1778
- const restoredModel = resolveProviderModelReference(savedProvider, savedModelId, modelRegistry.getAvailable());
1802
+ const restoredModel = resolveProviderModelReference(
1803
+ savedProvider,
1804
+ savedModelId,
1805
+ modelRegistry.getAvailable(credentialSessionId),
1806
+ );
1779
1807
 
1780
1808
  // Check if restored model exists and has a valid API key
1781
- const hasApiKey = restoredModel ? !!(await modelRegistry.getApiKey(restoredModel)) : false;
1809
+ const hasApiKey = restoredModel ? !!(await modelRegistry.getApiKey(restoredModel, credentialSessionId)) : false;
1782
1810
 
1783
1811
  if (restoredModel && hasApiKey) {
1784
1812
  if (shouldPrintMessages) {
@@ -1806,7 +1834,7 @@ export async function restoreModelFromSession(
1806
1834
  }
1807
1835
 
1808
1836
  // Try to find any available model
1809
- const availableModels = modelRegistry.getAvailable();
1837
+ const availableModels = modelRegistry.getAvailable(credentialSessionId);
1810
1838
 
1811
1839
  if (availableModels.length > 0) {
1812
1840
  // Try to find a default model from known providers
@@ -505,6 +505,13 @@ export const SETTINGS_SCHEMA = {
505
505
  description: "Grace period before a master-spawned child orphaned by confirmed master loss is closed.",
506
506
  validate: (value: number) => Number.isSafeInteger(value) && value >= 60_000 && value <= 3_600_000,
507
507
  },
508
+ "sdk.brokerAbandonedGraceMs": {
509
+ type: "number",
510
+ default: 120_000,
511
+ description:
512
+ "Grace before a detached broker with no client connection, no live session host, and no open spawn authority exits on its own. A supervised `gjc sdk broker run` service never reaps itself.",
513
+ validate: (value: number) => Number.isSafeInteger(value) && value >= 60_000 && value <= 3_600_000,
514
+ },
508
515
 
509
516
  // Notifications (shared daemon with Telegram/Discord/Slack presentation adapters)
510
517
  "notifications.enabled": { type: "boolean", default: false },
@@ -1803,7 +1810,7 @@ export const SETTINGS_SCHEMA = {
1803
1810
  tab: "interaction",
1804
1811
  label: "Prompt Suggestions",
1805
1812
  description:
1806
- "Predict your likely next prompt after each turn (smol-model call) and show it as ghost text; Tab accepts",
1813
+ "Predict your likely next prompt after each turn (one extra default-role model call) and show it as ghost text; Tab accepts",
1807
1814
  },
1808
1815
  },
1809
1816
 
@@ -3596,6 +3603,11 @@ export const SETTINGS_SCHEMA = {
3596
3603
  default: [] as string[],
3597
3604
  },
3598
3605
 
3606
+ "mcp.discoveryDeferAutoload": {
3607
+ type: "boolean",
3608
+ default: false,
3609
+ },
3610
+
3599
3611
  "mcp.notifications": {
3600
3612
  type: "boolean",
3601
3613
  default: false,
@@ -392,6 +392,8 @@ const GLOBAL_ONLY_SETTINGS = new Set<SettingPath>([
392
392
  "telemetry.enabled",
393
393
  // Destination of the operator Hindsight server. A project file must not redirect it.
394
394
  "hindsight.apiUrl",
395
+ // Destination of generic remote compaction. A project file must not redirect the transcript POST.
396
+ "compaction.remoteEndpoint",
395
397
  // Operator consent for repository skills. A project file must not flip it.
396
398
  "skills.trustProjectSkills",
397
399
  "skills.enablePiProject",
@@ -2157,19 +2157,54 @@ const eventWebhookTails = new Map<string, Promise<void>>();
2157
2157
  const eventWebhookStartupTails = new Map<string, Promise<void>>();
2158
2158
  const EVENT_WEBHOOK_ERROR_CAP = 240;
2159
2159
 
2160
+ const EVENT_WEBHOOK_DIAGNOSTIC_CAP = 512;
2161
+
2162
+ /** Printable ASCII only, so a journal id cannot inject another diagnostic line. */
2163
+ function webhookEventDiagnosticToken(eventId: string, maxLength: number): string {
2164
+ if (maxLength <= 0) return "";
2165
+ let token = "";
2166
+ for (let index = 0; index < eventId.length && token.length < maxLength; index += 1) {
2167
+ const code = eventId.charCodeAt(index);
2168
+ if (code >= 0x20 && code <= 0x7e) token += eventId[index];
2169
+ }
2170
+ return token;
2171
+ }
2172
+
2173
+ /** Test-only access for generated normalization and truncation properties. */
2174
+ export function webhookEventDiagnosticTokenForTest(eventId: string, maxLength: number): string {
2175
+ return webhookEventDiagnosticToken(eventId, maxLength);
2176
+ }
2177
+
2160
2178
  async function appendEventWebhookDiagnostic(namespaceDir: string, eventId: string, error: unknown): Promise<void> {
2161
2179
  const code =
2162
2180
  error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
2163
2181
  ? error.message.slice(0, EVENT_WEBHOOK_ERROR_CAP)
2164
2182
  : "event_webhook_delivery_failed";
2165
- const line = `${new Date().toISOString()} event=${eventId} error=${code}\n`;
2183
+ const timestamp = new Date().toISOString();
2184
+ const prefix = `${timestamp} event=`;
2185
+ const suffix = ` error=${code}\n`;
2186
+ const token = webhookEventDiagnosticToken(eventId, EVENT_WEBHOOK_DIAGNOSTIC_CAP - prefix.length - suffix.length);
2187
+ const line = `${prefix}${token}${suffix}`;
2166
2188
  try {
2167
- await fs.appendFile(path.join(namespaceDir, "event-webhook-errors.log"), line.slice(0, 512), { mode: 0o600 });
2189
+ await fs.appendFile(
2190
+ path.join(namespaceDir, "event-webhook-errors.log"),
2191
+ line.slice(0, EVENT_WEBHOOK_DIAGNOSTIC_CAP),
2192
+ { mode: 0o600 },
2193
+ );
2168
2194
  } catch {
2169
2195
  // Diagnostics are best-effort; sink problems must never propagate upward.
2170
2196
  }
2171
2197
  }
2172
2198
 
2199
+ /** Test-only access for diagnostic record boundary coverage. */
2200
+ export async function appendEventWebhookDiagnosticForTest(
2201
+ namespaceDir: string,
2202
+ eventId: string,
2203
+ error: unknown,
2204
+ ): Promise<void> {
2205
+ await appendEventWebhookDiagnostic(namespaceDir, eventId, error);
2206
+ }
2207
+
2173
2208
  function enqueueEventWebhook(namespaceDir: string, event: CoordinatorEvent): void {
2174
2209
  const config = eventWebhookConfigs.get(namespaceDir);
2175
2210
  if (config === null || config === undefined) return;
@@ -99,8 +99,10 @@ export function sanitizeExternalCrashV1(text: string, maxBytes: number = CRASH_F
99
99
  let value = text.replace(/\r\n?/g, "\n");
100
100
  value = value.replace(OSC_PATTERN, " ").replace(CSI_PATTERN, "");
101
101
  value = value.replace(CONTROL_PATTERN, "").replace(INVISIBLE_PATTERN, "");
102
+ // Redact each URL separately so an unquoted query value cannot absorb prose
103
+ // after the URL; then sanitize the remaining crash text normally.
104
+ value = value.replace(URL_PATTERN, match => sanitizeUrl(redactCrashSecrets(match)));
102
105
  value = redactCrashSecrets(value);
103
- value = value.replace(URL_PATTERN, match => sanitizeUrl(match));
104
106
  value = replaceAbsolutePaths(value);
105
107
  value = value.replace(UUID_PATTERN, "<uuid>");
106
108
  value = value.replace(HEX_RUN_PATTERN, match => (/[a-fA-F]/.test(match) ? "<hex>" : "<num>"));
@@ -8,7 +8,8 @@
8
8
  * - Hooks: Claude `.claude/hooks/<pre|post>/<file>` and Codex flat
9
9
  * `.codex/hooks/<phase>-<tool>.ts|js` conventions, normalized through the
10
10
  * canonical hook IR (`normalizeDirectoryHook`, #4286/#4289) and written to
11
- * the runtime-discovered `<root>/hooks/<pre|post>/` layout.
11
+ * the canonical `<root>/hooks/<pre|post>/` layout. Project-scope hooks are
12
+ * stored-only; ordinary sessions do not load repository hook modules.
12
13
  * - MCPs: `normalizeClaudeMcpJson` / `normalizeCodexMcpToml` plus
13
14
  * `validateMCPCompatServer` (#4284 bounded compatibility adapters), merged
14
15
  * through the canonical atomic config writer.
@@ -17,12 +18,16 @@
17
18
  * secret values live only in the separate `ImportPlan`. `applyImport` runs as
18
19
  * a validated transaction: every destination path is containment- and
19
20
  * symlink-checked before any write, destinations are revalidated against the
20
- * preview decisions, files are published with same-directory temp+rename, and
21
- * any failure restores exactly the files this transaction wrote (pre-existing
22
- * symlinks are never touched). Verification failures roll back too.
21
+ * preview decisions, and skill/hook files are published through a no-follow
22
+ * directory descriptor with same-directory temp+rename. Any failure restores
23
+ * exactly the files this transaction wrote (pre-existing symlinks are never
24
+ * touched). Verification failures roll back too.
23
25
  */
26
+ import { dlopen, FFIType, read } from "bun:ffi";
24
27
  import type { Dirent } from "node:fs";
28
+ import * as fsSync from "node:fs";
25
29
  import * as fs from "node:fs/promises";
30
+ import * as os from "node:os";
26
31
  import * as path from "node:path";
27
32
  import { parseFrontmatter, tryParseJson } from "@gajae-code/utils";
28
33
  import { YAML } from "bun";
@@ -351,6 +356,11 @@ export async function buildImportPreview(options: BuildImportPreviewOptions): Pr
351
356
  const entries: ImportPreviewEntry[] = [];
352
357
  const payloads: (NormalizedPayload | undefined)[] = [];
353
358
  const warnings: string[] = [];
359
+ if (surfaces.includes("hooks") && options.destinationScope === "project") {
360
+ warnings.push(
361
+ "Project-scope hooks are stored-only: ordinary sessions do not load repository hook modules. Import hooks into Global .gjc or configure an explicit hook path to activate them.",
362
+ );
363
+ }
354
364
  const productName = productLabel(options.product);
355
365
  const sourceLabel = `${productName} ${sourceScopeLabel(options.sourceScope)}`;
356
366
 
@@ -636,6 +646,9 @@ interface FileSnapshot {
636
646
  path: string;
637
647
  /** Previous content, or null when the file did not exist. */
638
648
  previous: string | null;
649
+ /** Set when publication was bound to this directory descriptor. */
650
+ parentFd?: number;
651
+ name?: string;
639
652
  }
640
653
 
641
654
  interface PlannedFileWrite {
@@ -675,6 +688,13 @@ async function findSymlinkedAncestor(root: string, target: string): Promise<stri
675
688
 
676
689
  /** Remove stale staging temp files from an interrupted earlier apply. */
677
690
  async function sweepStagingTemps(dir: string): Promise<void> {
691
+ try {
692
+ const stat = await fs.lstat(dir);
693
+ // readdir follows a swapped directory symlink; never clean through one.
694
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return;
695
+ } catch {
696
+ return;
697
+ }
678
698
  let names: string[];
679
699
  try {
680
700
  names = await fs.readdir(dir);
@@ -687,6 +707,209 @@ async function sweepStagingTemps(dir: string): Promise<void> {
687
707
  }
688
708
  }
689
709
 
710
+ const { ENOENT, EEXIST, ELOOP, ENOTDIR } = os.constants.errno;
711
+ const CLOEXEC = (fsSync.constants as { O_CLOEXEC?: number }).O_CLOEXEC ?? 0;
712
+ const NOFOLLOW = fsSync.constants.O_NOFOLLOW ?? 0;
713
+ const DIR_FLAGS = fsSync.constants.O_RDONLY | (fsSync.constants.O_DIRECTORY ?? 0) | NOFOLLOW | CLOEXEC;
714
+ const READ_FLAGS = fsSync.constants.O_RDONLY | (fsSync.constants.O_NONBLOCK ?? 0) | NOFOLLOW | CLOEXEC;
715
+ const WRITE_FLAGS = fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | NOFOLLOW | CLOEXEC;
716
+ const MAX_PINNED_IMPORT_PARENTS = 128;
717
+
718
+ interface PinnedDestination {
719
+ fds: number[];
720
+ parentFd: number;
721
+ name: string;
722
+ }
723
+
724
+ type DirectoryOps = {
725
+ mkdirat: (dirfd: number, name: string, mode: number) => number;
726
+ openat: (dirfd: number, name: string, flags: number, mode: number) => number;
727
+ renameat: (fromfd: number, from: string, tofd: number, to: string) => number;
728
+ unlinkat: (dirfd: number, name: string, flags: number) => number;
729
+ errno: () => number;
730
+ };
731
+
732
+ let directoryOpsCache: DirectoryOps | undefined;
733
+
734
+ /** POSIX only. Windows has no O_NOFOLLOW, so publication keeps the lexical recheck. */
735
+ function pinnedPublicationAvailable(): boolean {
736
+ return (
737
+ process.platform !== "win32" &&
738
+ fsSync.constants.O_NOFOLLOW !== undefined &&
739
+ fsSync.constants.O_DIRECTORY !== undefined
740
+ );
741
+ }
742
+
743
+ function directoryOps(): DirectoryOps {
744
+ if (directoryOpsCache) return directoryOpsCache;
745
+ const darwin = process.platform === "darwin";
746
+ const lib = dlopen(darwin ? "/usr/lib/libSystem.B.dylib" : "libc.so.6", {
747
+ mkdirat: { args: [FFIType.i32, FFIType.cstring, FFIType.u32], returns: FFIType.i32 },
748
+ openat: { args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.u32], returns: FFIType.i32 },
749
+ renameat: { args: [FFIType.i32, FFIType.cstring, FFIType.i32, FFIType.cstring], returns: FFIType.i32 },
750
+ unlinkat: { args: [FFIType.i32, FFIType.cstring, FFIType.i32], returns: FFIType.i32 },
751
+ ...(darwin
752
+ ? { __error: { args: [], returns: FFIType.ptr } }
753
+ : { __errno_location: { args: [], returns: FFIType.ptr } }),
754
+ });
755
+ const symbols = lib.symbols as unknown as DirectoryOps & {
756
+ __error?: () => Parameters<typeof read.i32>[0];
757
+ __errno_location?: () => Parameters<typeof read.i32>[0];
758
+ };
759
+ directoryOpsCache = {
760
+ mkdirat: symbols.mkdirat,
761
+ openat: symbols.openat,
762
+ renameat: symbols.renameat,
763
+ unlinkat: symbols.unlinkat,
764
+ errno: () => read.i32(darwin ? symbols.__error!() : symbols.__errno_location!()),
765
+ };
766
+ return directoryOpsCache;
767
+ }
768
+
769
+ function closeRawFds(fds: number[]): void {
770
+ for (const fd of fds.splice(0).reverse()) fsSync.closeSync(fd);
771
+ }
772
+
773
+ function symlinkRefusal(location: string): Error {
774
+ return new Error(`refusing to write through symlinked ancestor: ${location}`);
775
+ }
776
+
777
+ /** Lexical shape of an import destination. Symlink checks are separate. */
778
+ export function importDestinationShapeAllowed(root: string, target: string): boolean {
779
+ const relative = path.relative(root, path.dirname(target));
780
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
781
+ const segments = relative === "" ? [] : relative.split(path.sep);
782
+ return [...segments, path.basename(target)].every(isSafeName);
783
+ }
784
+
785
+ /** Walk from `root` with openat/mkdirat and retain only the final parent descriptor. */
786
+ function pinImportParent(root: string, target: string): PinnedDestination {
787
+ if (!importDestinationShapeAllowed(root, target)) throw new Error(`refusing unsafe import destination: ${target}`);
788
+ const relative = path.relative(root, path.dirname(target));
789
+ const segments = relative === "" ? [] : relative.split(path.sep);
790
+ const name = path.basename(target);
791
+ const ops = directoryOps();
792
+ const fds: number[] = [];
793
+ try {
794
+ let parentFd: number;
795
+ try {
796
+ parentFd = fsSync.openSync(root, DIR_FLAGS);
797
+ } catch (error) {
798
+ const code = (error as NodeJS.ErrnoException).code;
799
+ if (code === "ELOOP" || code === "ENOTDIR") throw symlinkRefusal(root);
800
+ throw error;
801
+ }
802
+ fds.push(parentFd);
803
+ let cursor = root;
804
+ for (const segment of segments) {
805
+ cursor = path.join(cursor, segment);
806
+ let fd = ops.openat(parentFd, segment, DIR_FLAGS, 0);
807
+ if (fd < 0 && ops.errno() === ENOENT) {
808
+ if (ops.mkdirat(parentFd, segment, 0o700) < 0 && ops.errno() !== EEXIST) {
809
+ throw new Error(`cannot create import directory ${cursor}: errno ${ops.errno()}`);
810
+ }
811
+ fd = ops.openat(parentFd, segment, DIR_FLAGS, 0);
812
+ }
813
+ if (fd < 0) {
814
+ const err = ops.errno();
815
+ if (err === ELOOP || err === ENOTDIR) throw symlinkRefusal(cursor);
816
+ throw new Error(`cannot open import directory ${cursor}: errno ${err}`);
817
+ }
818
+ let isDirectory: boolean;
819
+ try {
820
+ isDirectory = fsSync.fstatSync(fd).isDirectory();
821
+ } catch (error) {
822
+ fsSync.closeSync(fd);
823
+ throw error;
824
+ }
825
+ if (!isDirectory) {
826
+ fsSync.closeSync(fd);
827
+ throw symlinkRefusal(cursor);
828
+ }
829
+ closeRawFds(fds);
830
+ fds.push(fd);
831
+ parentFd = fd;
832
+ }
833
+ const named = fsSync.lstatSync(path.dirname(target));
834
+ const opened = fsSync.fstatSync(parentFd);
835
+ if (named.isSymbolicLink() || !named.isDirectory() || named.dev !== opened.dev || named.ino !== opened.ino) {
836
+ throw symlinkRefusal(path.dirname(target));
837
+ }
838
+ return { fds, parentFd, name };
839
+ } catch (error) {
840
+ closeRawFds(fds);
841
+ throw error;
842
+ }
843
+ }
844
+
845
+ function readRegularAt(parentFd: number, name: string, lexicalPath: string): string | null {
846
+ const ops = directoryOps();
847
+ const fd = ops.openat(parentFd, name, READ_FLAGS, 0);
848
+ if (fd < 0) {
849
+ const err = ops.errno();
850
+ if (err === ENOENT) return null;
851
+ if (err === ELOOP || err === ENOTDIR) throw new Error(`refusing to write through symlink: ${lexicalPath}`);
852
+ throw new Error(`cannot read ${lexicalPath}: errno ${err}`);
853
+ }
854
+ try {
855
+ if (!fsSync.fstatSync(fd).isFile()) throw new Error(`not a regular file: ${lexicalPath}`);
856
+ return fsSync.readFileSync(fd, "utf-8");
857
+ } finally {
858
+ fsSync.closeSync(fd);
859
+ }
860
+ }
861
+
862
+ function writeRegularAt(parentFd: number, name: string, content: string, index: number, lexicalPath: string): void {
863
+ const ops = directoryOps();
864
+ const tmp = `.gjc-import-${process.pid}-${index}-${name}.tmp`;
865
+ const fd = ops.openat(parentFd, tmp, WRITE_FLAGS, 0o600);
866
+ if (fd < 0) throw new Error(`cannot stage import file ${lexicalPath}: errno ${ops.errno()}`);
867
+ try {
868
+ fsSync.writeFileSync(fd, content, "utf-8");
869
+ fsSync.fchmodSync(fd, 0o600);
870
+ } finally {
871
+ fsSync.closeSync(fd);
872
+ }
873
+ if (ops.renameat(parentFd, tmp, parentFd, name) < 0) {
874
+ const err = ops.errno();
875
+ ops.unlinkat(parentFd, tmp, 0);
876
+ throw new Error(`cannot publish import file ${lexicalPath}: errno ${err}`);
877
+ }
878
+ }
879
+
880
+ /** The lexical parent must still be the directory the descriptor published into. */
881
+ function assertPinnedParentStillAt(parentFd: number, lexicalParent: string, lexicalPath: string): void {
882
+ let named: fsSync.Stats;
883
+ try {
884
+ named = fsSync.lstatSync(lexicalParent);
885
+ } catch {
886
+ throw new Error(`post-import verification failed: ${lexicalPath} was not persisted`);
887
+ }
888
+ const opened = fsSync.fstatSync(parentFd);
889
+ if (named.isSymbolicLink() || !named.isDirectory() || named.dev !== opened.dev || named.ino !== opened.ino) {
890
+ throw new Error(`post-import verification failed: ${lexicalPath} was not persisted`);
891
+ }
892
+ try {
893
+ const leaf = fsSync.lstatSync(lexicalPath);
894
+ if (leaf.isSymbolicLink() || !leaf.isFile()) {
895
+ throw new Error(`post-import verification failed: ${lexicalPath} was not persisted`);
896
+ }
897
+ } catch (error) {
898
+ if (error instanceof Error && error.message.startsWith("post-import verification failed:")) throw error;
899
+ throw new Error(`post-import verification failed: ${lexicalPath} was not persisted`);
900
+ }
901
+ }
902
+
903
+ function restoreRegularAt(parentFd: number, name: string, previous: string | null, lexicalPath: string): void {
904
+ if (previous === null) {
905
+ if (directoryOps().unlinkat(parentFd, name, 0) < 0 && directoryOps().errno() !== ENOENT) {
906
+ throw new Error(`cannot remove ${lexicalPath}: errno ${directoryOps().errno()}`);
907
+ }
908
+ return;
909
+ }
910
+ writeRegularAt(parentFd, name, previous, 1_000_000 + process.pid, lexicalPath);
911
+ }
912
+
690
913
  /**
691
914
  * Apply a confirmed plan. The transaction contract:
692
915
  *
@@ -850,12 +1073,44 @@ export async function applyImport(plan: ImportPlan, options: { cwd: string }): P
850
1073
 
851
1074
  // --- Phase 2: publication ------------------------------------------------
852
1075
  const snapshots: FileSnapshot[] = [];
1076
+ const pins: PinnedDestination[] = [];
1077
+ const pinsByParent = new Map<string, PinnedDestination>();
853
1078
  for (const dir of [destination.skillsDir, destination.hooksDir]) {
854
1079
  await sweepStagingTemps(dir).catch(() => {});
855
1080
  }
856
1081
 
857
1082
  try {
1083
+ if (fileWrites.length > 0 && pinnedPublicationAvailable()) {
1084
+ // Scope root only. Children are created with mkdirat on the pinned fd.
1085
+ await fs.mkdir(destination.root, { recursive: true, mode: 0o700 });
1086
+ for (const write of fileWrites) {
1087
+ const parentPath = path.dirname(write.path);
1088
+ if (pinsByParent.has(parentPath)) continue;
1089
+ if (pinsByParent.size >= MAX_PINNED_IMPORT_PARENTS) {
1090
+ throw new Error(
1091
+ `import batch exceeds the limit of ${MAX_PINNED_IMPORT_PARENTS} distinct destination directories`,
1092
+ );
1093
+ }
1094
+ const pin = pinImportParent(destination.root, write.path);
1095
+ pins.push(pin);
1096
+ pinsByParent.set(parentPath, pin);
1097
+ }
1098
+ // Fail before publishing anything if pinned directories leave no
1099
+ // descriptor available for staging or rollback.
1100
+ const descriptorProbe = fsSync.openSync(os.devNull, fsSync.constants.O_RDONLY | CLOEXEC);
1101
+ fsSync.closeSync(descriptorProbe);
1102
+ }
858
1103
  for (const [index, write] of fileWrites.entries()) {
1104
+ if (pinnedPublicationAvailable()) {
1105
+ const pin = pinsByParent.get(path.dirname(write.path));
1106
+ if (!pin) throw new Error(`cannot pin import destination directory: ${path.dirname(write.path)}`);
1107
+ const previous = readRegularAt(pin.parentFd, pin.name, write.path);
1108
+ writeRegularAt(pin.parentFd, pin.name, write.content, index, write.path);
1109
+ snapshots.push({ path: write.path, previous, parentFd: pin.parentFd, name: pin.name });
1110
+ continue;
1111
+ }
1112
+ const symlinked = await findSymlinkedAncestor(destination.root, write.path);
1113
+ if (symlinked) throw new Error(`refusing to write through symlinked ancestor: ${symlinked}`);
859
1114
  await fs.mkdir(path.dirname(write.path), { recursive: true, mode: 0o700 });
860
1115
  const stat = await fs.lstat(write.path).catch(() => null);
861
1116
  if (stat?.isSymbolicLink()) throw new Error(`refusing to write through symlink: ${write.path}`);
@@ -891,8 +1146,18 @@ export async function applyImport(plan: ImportPlan, options: { cwd: string }): P
891
1146
 
892
1147
  // --- Phase 3: verification (inside the transaction) ------------------
893
1148
  for (const write of fileWrites) {
894
- const persisted = await readStructured(write.path);
895
- if (persisted.kind !== "value" || persisted.content !== write.content) {
1149
+ const pinned = snapshots.find(snapshot => snapshot.path === write.path && snapshot.parentFd !== undefined);
1150
+ let persisted: string | null;
1151
+ if (pinned?.parentFd !== undefined && pinned.name) {
1152
+ assertPinnedParentStillAt(pinned.parentFd, path.dirname(write.path), write.path);
1153
+ persisted = readRegularAt(pinned.parentFd, pinned.name, write.path);
1154
+ assertPinnedParentStillAt(pinned.parentFd, path.dirname(write.path), write.path);
1155
+ } else {
1156
+ persisted = await readStructured(write.path).then(result =>
1157
+ result.kind === "value" ? result.content : null,
1158
+ );
1159
+ }
1160
+ if (persisted !== write.content) {
896
1161
  throw new Error(`post-import verification failed: ${write.path} was not persisted`);
897
1162
  }
898
1163
  }
@@ -910,7 +1175,9 @@ export async function applyImport(plan: ImportPlan, options: { cwd: string }): P
910
1175
  const rollbackErrors: string[] = [];
911
1176
  for (const snapshot of snapshots.reverse()) {
912
1177
  try {
913
- if (snapshot.previous === null) {
1178
+ if (snapshot.parentFd !== undefined && snapshot.name) {
1179
+ restoreRegularAt(snapshot.parentFd, snapshot.name, snapshot.previous, snapshot.path);
1180
+ } else if (snapshot.previous === null) {
914
1181
  await fs.rm(snapshot.path, { force: true });
915
1182
  } else {
916
1183
  const staged = `${snapshot.path}.gjc-rollback-${process.pid}.tmp`;
@@ -928,6 +1195,8 @@ export async function applyImport(plan: ImportPlan, options: { cwd: string }): P
928
1195
  `import failed and was rolled back: ${reason}` +
929
1196
  (rollbackErrors.length > 0 ? `; ROLLBACK ERRORS (manual repair needed): ${rollbackErrors.join("; ")}` : ""),
930
1197
  );
1198
+ } finally {
1199
+ for (const pin of pins) closeRawFds(pin.fds);
931
1200
  }
932
1201
 
933
1202
  for (const { entry } of writable) {