@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
@@ -6,9 +6,10 @@
6
6
  * discovery/policy model (trust, include/ignore, disabledExtensions,
7
7
  * bundled-name protection). A narrow supplemental scan flags SKILL.md files
8
8
  * the management loader rejects as `invalid` rows.
9
- * - Hooks: canonical `<root>/hooks/<pre|post>/` layout with the runtime's
10
- * project-first, first-wins dedupe key (`<type>:<tool>:<name>`); losers are
11
- * shown as `shadowed`.
9
+ * - Hooks: canonical `<root>/hooks/<pre|post>/` layout. Project hooks are
10
+ * stored-only and do not shadow user hooks because sessions never load
11
+ * repository hook modules. User-scope collisions follow the runtime's
12
+ * first-wins dedupe key (`<type>:<tool>:<name>`); losers are `shadowed`.
12
13
  * - MCPs: canonical config files for both scopes with the runtime's
13
14
  * project-first precedence and the union of disabled markers; malformed
14
15
  * files surface as `invalid` rows without leaking raw JSON.
@@ -173,7 +174,8 @@ const HOOK_PHASES = ["pre", "post"] as const;
173
174
 
174
175
  async function loadHookRows(options: LoadCustomizationInventoryOptions, _warnings: string[]): Promise<InventoryRow[]> {
175
176
  const rows: InventoryRow[] = [];
176
- // Runtime semantics: project-first, first-wins on `<type>:<tool>:<name>`.
177
+ // Repository hooks are deliberately excluded by session startup. They stay
178
+ // visible for management, but are stored-only and cannot shadow user hooks.
177
179
  const seen = new Set<string>();
178
180
  for (const scope of ["project", "global"] as const) {
179
181
  const hooksDir = resolveScopePaths(scope, options.cwd).hooksDir;
@@ -190,19 +192,33 @@ async function loadHookRows(options: LoadCustomizationInventoryOptions, _warning
190
192
  const hookPath = path.join(phaseDir, entry.name);
191
193
  const baseName = entry.name.includes(".") ? entry.name.slice(0, entry.name.lastIndexOf(".")) : entry.name;
192
194
  const key = `${phase}:${baseName}:${entry.name}`;
193
- const shadowed = seen.has(key);
194
- seen.add(key);
195
+ const storedOnly = scope === "project";
196
+ const shadowed = !storedOnly && seen.has(key);
197
+ if (!storedOnly) seen.add(key);
198
+ let status: InventoryStatus;
199
+ let diagnostics: string[] | undefined;
200
+ if (storedOnly) {
201
+ status = "stored-only";
202
+ diagnostics = [
203
+ "project hooks are stored only; sessions do not load repository hook modules automatically",
204
+ ];
205
+ } else if (shadowed) {
206
+ status = "shadowed";
207
+ diagnostics = ["shadowed by the same hook at higher-precedence scope"];
208
+ } else {
209
+ status = "enabled";
210
+ }
195
211
  rows.push({
196
212
  id: inventoryRowId("hooks", scope, hookPath),
197
213
  surface: "hooks",
198
214
  name: entry.name,
199
215
  displayName: `${phase}/${entry.name}`,
200
- status: shadowed ? "shadowed" : "enabled",
216
+ status,
201
217
  provenance: `${scopeLabel(scope)} hooks/${phase}`,
202
218
  path: hookPath,
203
219
  scope,
204
220
  description: `${phase}-tool hook for ${baseName}`,
205
- diagnostics: shadowed ? ["shadowed by the same hook at higher-precedence scope"] : undefined,
221
+ diagnostics,
206
222
  raw: { name: entry.name, type: phase, tool: baseName },
207
223
  });
208
224
  }
@@ -119,6 +119,7 @@ export function sourceConfigDir(
119
119
 
120
120
  export type InventoryStatus =
121
121
  | "enabled"
122
+ | "stored-only"
122
123
  | "disabled"
123
124
  | "invalid"
124
125
  | "shadowed"
package/src/dap/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { logger } from "@gajae-code/utils";
4
+ import { projectSafeSpawnEnv } from "@gajae-code/utils/shell-config";
4
5
  import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
5
6
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
6
7
  import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
@@ -134,7 +135,7 @@ export class DapClient {
134
135
  // debuggee children can reach /dev/tty and trigger SIGTTIN, suspending
135
136
  // the parent harness under shell job control.
136
137
  const env = {
137
- ...Bun.env,
138
+ ...projectSafeSpawnEnv(),
138
139
  ...NON_INTERACTIVE_ENV,
139
140
  };
140
141
  const owner = spawnOwnedProcess([adapter.resolvedCommand, ...adapter.args], {
@@ -159,7 +160,7 @@ export class DapClient {
159
160
  */
160
161
  static async #spawnSocket({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
161
162
  const env = {
162
- ...Bun.env,
163
+ ...projectSafeSpawnEnv(),
163
164
  ...NON_INTERACTIVE_ENV,
164
165
  };
165
166
  const isLinux = process.platform === "linux";
@@ -1,6 +1,8 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import * as timers from "node:timers/promises";
3
4
  import { logger, untilAborted } from "@gajae-code/utils";
5
+ import { projectSafeSpawnEnv } from "@gajae-code/utils/shell-config";
4
6
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
5
7
  import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
6
8
  import { DapClient } from "./client";
@@ -106,9 +108,19 @@ interface DapSession {
106
108
  initializedSeen: boolean;
107
109
  needsConfigurationDone: boolean;
108
110
  configurationDoneSent: boolean;
111
+ /** Containment root. Distinct from `cwd`, which is the caller execution directory. */
112
+ workspace: string;
113
+ closing: boolean;
114
+ disposed: boolean;
115
+ inflightTerminal: number;
116
+ terminalIdle?: Promise<void>;
117
+ resolveTerminalIdle?: () => void;
109
118
  runInTerminalProcesses: Set<OwnedProcess>;
110
119
  }
111
120
 
121
+ /** Test seam: pauses after cwd containment and before the debuggee spawn. */
122
+ export const runInTerminalTestHooks: { beforeSpawn?: () => Promise<void> } = {};
123
+
112
124
  export interface DapOutputSnapshot {
113
125
  snapshot: DapSessionSummary;
114
126
  output: string;
@@ -210,6 +222,53 @@ function buildSummary(session: DapSession): DapSessionSummary {
210
222
  };
211
223
  }
212
224
 
225
+ function isEnoent(error: unknown): boolean {
226
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
227
+ }
228
+
229
+ /** Walk missing leaves so a not-yet-created debug cwd can still be contained. */
230
+ async function canonicalizeRunInTerminalCwd(absolute: string): Promise<string> {
231
+ try {
232
+ return await fs.realpath(absolute);
233
+ } catch (error) {
234
+ if (!isEnoent(error)) throw error;
235
+ }
236
+ const missing: string[] = [];
237
+ let cursor = absolute;
238
+ for (;;) {
239
+ const parent = path.dirname(cursor);
240
+ if (parent === cursor) throw new Error("runInTerminal cwd escapes the workspace");
241
+ missing.unshift(path.basename(cursor));
242
+ cursor = parent;
243
+ try {
244
+ return path.join(await fs.realpath(cursor), ...missing);
245
+ } catch (error) {
246
+ if (!isEnoent(error)) throw error;
247
+ }
248
+ }
249
+ }
250
+
251
+ export function cwdEscapesWorkspace(root: string, candidate: string): boolean {
252
+ const relative = path.relative(root, candidate);
253
+ return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
254
+ }
255
+
256
+ /**
257
+ * The validated cwd is the cwd that is spawned. Relative paths resolve against
258
+ * the session workspace, then both sides are canonicalized before containment.
259
+ */
260
+ async function resolveRunInTerminalCwd(workspace: string, requested: string | undefined): Promise<string> {
261
+ if (typeof requested === "string" && requested.trim() === "") {
262
+ throw new Error("runInTerminal cwd escapes the workspace");
263
+ }
264
+ const root = await fs.realpath(workspace);
265
+ const candidate = await canonicalizeRunInTerminalCwd(path.resolve(workspace, requested ?? workspace));
266
+ if (cwdEscapesWorkspace(root, candidate)) {
267
+ throw new Error("runInTerminal cwd escapes the workspace");
268
+ }
269
+ return candidate;
270
+ }
271
+
213
272
  export class DapSessionManager {
214
273
  #sessions = new Map<string, DapSession>();
215
274
  #activeSessionId: string | null = null;
@@ -234,13 +293,19 @@ export class DapSessionManager {
234
293
  }
235
294
 
236
295
  async launch(
237
- options: DapLaunchSessionOptions,
296
+ options: DapLaunchSessionOptions & { workspace?: string },
238
297
  signal?: AbortSignal,
239
298
  timeoutMs: number = 30_000,
240
299
  ): Promise<DapSessionSummary> {
241
300
  await this.#ensureLaunchSlot();
242
301
  const client = await DapClient.spawn({ adapter: options.adapter, cwd: options.cwd });
243
- const session = this.#registerSession(client, options.adapter, options.cwd, options.program);
302
+ const session = this.#registerSession(
303
+ client,
304
+ options.adapter,
305
+ options.cwd,
306
+ options.workspace ?? options.cwd,
307
+ options.program,
308
+ );
244
309
  try {
245
310
  session.capabilities = await client.initialize(
246
311
  this.#buildInitializeArguments(options.adapter),
@@ -299,13 +364,13 @@ export class DapSessionManager {
299
364
  }
300
365
 
301
366
  async attach(
302
- options: DapAttachSessionOptions,
367
+ options: DapAttachSessionOptions & { workspace?: string },
303
368
  signal?: AbortSignal,
304
369
  timeoutMs: number = 30_000,
305
370
  ): Promise<DapSessionSummary> {
306
371
  await this.#ensureLaunchSlot();
307
372
  const client = await DapClient.spawn({ adapter: options.adapter, cwd: options.cwd });
308
- const session = this.#registerSession(client, options.adapter, options.cwd);
373
+ const session = this.#registerSession(client, options.adapter, options.cwd, options.workspace ?? options.cwd);
309
374
  try {
310
375
  session.capabilities = await client.initialize(
311
376
  this.#buildInitializeArguments(options.adapter),
@@ -886,24 +951,29 @@ export class DapSessionManager {
886
951
  const session = this.#getActiveSessionOrNull();
887
952
  if (!session) return null;
888
953
  session.lastUsedAt = Date.now();
889
- if (session.status !== "terminated") {
890
- if (session.capabilities?.supportsTerminateRequest) {
954
+ // Close before the first await. A runInTerminal paused on cwd resolution
955
+ // must not spawn while terminate or disconnect is still outstanding.
956
+ session.closing = true;
957
+ try {
958
+ if (session.status !== "terminated") {
959
+ if (session.capabilities?.supportsTerminateRequest) {
960
+ await untilAborted(
961
+ signal,
962
+ session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
963
+ );
964
+ }
891
965
  await untilAborted(
892
966
  signal,
893
- session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
967
+ session.client
968
+ .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
969
+ .catch(() => undefined),
894
970
  );
895
971
  }
896
- await untilAborted(
897
- signal,
898
- session.client
899
- .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
900
- .catch(() => undefined),
901
- );
972
+ session.status = "terminated";
973
+ return buildSummary(session);
974
+ } finally {
975
+ await this.#disposeSession(session);
902
976
  }
903
- session.status = "terminated";
904
- const summary = buildSummary(session);
905
- await this.#disposeSession(session);
906
- return summary;
907
977
  }
908
978
 
909
979
  #startCleanupTimer(): void {
@@ -945,12 +1015,22 @@ export class DapSessionManager {
945
1015
  throw new Error(`Debug session ${active.id} is still active. Terminate it before launching another.`);
946
1016
  }
947
1017
 
948
- #registerSession(client: DapClient, adapter: DapResolvedAdapter, cwd: string, program?: string): DapSession {
1018
+ #registerSession(
1019
+ client: DapClient,
1020
+ adapter: DapResolvedAdapter,
1021
+ cwd: string,
1022
+ workspace: string,
1023
+ program?: string,
1024
+ ): DapSession {
949
1025
  const session: DapSession = {
950
1026
  id: `debug-${++this.#nextId}`,
951
1027
  adapter,
952
1028
  cwd,
1029
+ workspace,
953
1030
  program,
1031
+ closing: false,
1032
+ disposed: false,
1033
+ inflightTerminal: 0,
954
1034
  client,
955
1035
  status: "launching",
956
1036
  launchedAt: Date.now(),
@@ -975,24 +1055,40 @@ export class DapSessionManager {
975
1055
  if (!Array.isArray(args.args) || args.args.length === 0) {
976
1056
  throw new Error("runInTerminal request did not include a command");
977
1057
  }
978
- const env = Object.fromEntries(
979
- Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
980
- );
981
- const owner = spawnOwnedProcess(args.args, {
982
- cwd: args.cwd ?? session.cwd,
983
- stdin: "ignore",
984
- env: {
985
- ...Bun.env,
986
- ...NON_INTERACTIVE_ENV,
987
- ...env,
988
- },
989
- name: `dap:${session.id}:runInTerminal`,
990
- });
991
- drainStream(owner.child.stdout);
992
- drainStream(owner.child.stderr);
993
- session.runInTerminalProcesses.add(owner);
994
- owner.exited.finally(() => session.runInTerminalProcesses.delete(owner));
995
- return { processId: owner.pid } satisfies DapRunInTerminalResponse;
1058
+ if (session.closing) throw new Error("runInTerminal session is closed");
1059
+ this.#beginTerminal(session);
1060
+ try {
1061
+ if (session.closing) throw new Error("runInTerminal session is closed");
1062
+ const env = Object.fromEntries(
1063
+ Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
1064
+ );
1065
+ const cwd = await resolveRunInTerminalCwd(session.workspace, args.cwd ?? session.cwd);
1066
+ await runInTerminalTestHooks.beforeSpawn?.();
1067
+ if (session.closing || !this.#sessions.has(session.id)) {
1068
+ throw new Error("runInTerminal session is closed");
1069
+ }
1070
+ const owner = spawnOwnedProcess(args.args, {
1071
+ cwd,
1072
+ stdin: "ignore",
1073
+ env: {
1074
+ ...projectSafeSpawnEnv(),
1075
+ ...NON_INTERACTIVE_ENV,
1076
+ ...env,
1077
+ },
1078
+ name: `dap:${session.id}:runInTerminal`,
1079
+ });
1080
+ if (session.closing || !this.#sessions.has(session.id)) {
1081
+ await owner.dispose();
1082
+ throw new Error("runInTerminal session is closed");
1083
+ }
1084
+ drainStream(owner.child.stdout);
1085
+ drainStream(owner.child.stderr);
1086
+ session.runInTerminalProcesses.add(owner);
1087
+ owner.exited.finally(() => session.runInTerminalProcesses.delete(owner));
1088
+ return { processId: owner.pid } satisfies DapRunInTerminalResponse;
1089
+ } finally {
1090
+ this.#endTerminal(session);
1091
+ }
996
1092
  });
997
1093
  client.onReverseRequest("startDebugging", async rawArgs => {
998
1094
  const startArgs = (rawArgs ?? {}) as Partial<DapStartDebuggingArguments>;
@@ -1319,7 +1415,36 @@ export class DapSessionManager {
1319
1415
  return session;
1320
1416
  }
1321
1417
 
1418
+ #beginTerminal(session: DapSession): void {
1419
+ session.inflightTerminal++;
1420
+ if (session.inflightTerminal !== 1) return;
1421
+ session.terminalIdle = new Promise(resolve => {
1422
+ session.resolveTerminalIdle = resolve;
1423
+ });
1424
+ }
1425
+
1426
+ #endTerminal(session: DapSession): void {
1427
+ session.inflightTerminal = Math.max(0, session.inflightTerminal - 1);
1428
+ if (session.inflightTerminal !== 0) return;
1429
+ session.resolveTerminalIdle?.();
1430
+ session.resolveTerminalIdle = undefined;
1431
+ session.terminalIdle = undefined;
1432
+ }
1433
+
1434
+ async #waitTerminalIdle(session: DapSession): Promise<void> {
1435
+ while (session.inflightTerminal > 0 && session.terminalIdle) {
1436
+ await session.terminalIdle;
1437
+ }
1438
+ }
1439
+
1322
1440
  async #disposeSession(session: DapSession) {
1441
+ session.closing = true;
1442
+ if (session.disposed) {
1443
+ await this.#waitTerminalIdle(session);
1444
+ return;
1445
+ }
1446
+ session.disposed = true;
1447
+ await this.#waitTerminalIdle(session);
1323
1448
  if (this.#activeSessionId === session.id) {
1324
1449
  this.#activeSessionId = null;
1325
1450
  }
@@ -1,3 +1,4 @@
1
+ import { constants as fsConstants } from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
@@ -180,10 +181,10 @@ export async function writeCrashReport(
180
181
  }
181
182
 
182
183
  try {
183
- const dir = getCrashDiagnosticsDirectory(options.env);
184
- await ensurePrivateDiagnosticsDirectory(dir);
184
+ const directory = diagnosticsDirectoryPath(getCrashDiagnosticsDirectory(options.env));
185
+ await ensurePrivateDiagnosticsDirectory(directory);
185
186
  const filename = `${report.createdAt.replace(/[:.]/g, "-")}-${report.kind}-${report.class}-${process.pid}.json`;
186
- const reportPath = path.join(dir, filename);
187
+ const reportPath = path.join(directory, filename);
187
188
  await writePrivateCrashReport(reportPath, `${JSON.stringify(report, null, 2)}\n`);
188
189
  return { report, path: reportPath, enabled };
189
190
  } catch {
@@ -197,19 +198,100 @@ export function formatCrashDiagnosticNotice(result: CrashReportWriteResult): str
197
198
  return `[crash:${result.report.kind}:${result.report.class}] ${result.report.reason}${location}`;
198
199
  }
199
200
 
201
+ const POSIX_NOFOLLOW = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
202
+
203
+ function isFilesystemRoot(dir: string): boolean {
204
+ const root = path.parse(dir).root;
205
+ return root.length > 0 && dir === root;
206
+ }
207
+
208
+ /** Drop trailing platform separators and "." segments. A backslash stays in the name on POSIX. ".." is unchanged. */
209
+ function diagnosticsDirectoryPath(dir: string): string {
210
+ const sep = path.sep;
211
+ let current = dir;
212
+ while (!isFilesystemRoot(current)) {
213
+ let end = current.length;
214
+ while (end > 0 && current[end - 1] === sep) end -= 1;
215
+ if (end !== current.length) {
216
+ const stripped = current.slice(0, end);
217
+ if (stripped === "" || isFilesystemRoot(stripped)) return path.parse(current).root || current;
218
+ if (process.platform === "win32" && /^[A-Za-z]:$/.test(stripped)) return `${stripped}\\`;
219
+ current = stripped;
220
+ continue;
221
+ }
222
+ const dotSuffix = `${sep}.`;
223
+ if (current.endsWith(dotSuffix) && !current.endsWith(`${sep}..`)) {
224
+ const parent = current.slice(0, -dotSuffix.length);
225
+ if (parent === "" || isFilesystemRoot(parent)) {
226
+ return parent === "" ? path.parse(current).root || current : parent;
227
+ }
228
+ if (process.platform === "win32" && /^[A-Za-z]:$/.test(parent)) return `${parent}\\`;
229
+ current = parent;
230
+ continue;
231
+ }
232
+ return current;
233
+ }
234
+ return current;
235
+ }
236
+
200
237
  async function ensurePrivateDiagnosticsDirectory(dir: string): Promise<void> {
238
+ await assertRealDiagnosticsDirectory(dir);
201
239
  await fs.mkdir(dir, { recursive: true, mode: DIRECTORY_MODE });
240
+ await assertRealDiagnosticsDirectory(dir);
241
+ await chmodRealDiagnosticsDirectory(dir);
242
+ }
243
+
244
+ async function assertRealDiagnosticsDirectory(dir: string): Promise<void> {
245
+ let stat: Awaited<ReturnType<typeof fs.lstat>>;
246
+ try {
247
+ stat = await fs.lstat(dir);
248
+ } catch (error) {
249
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return;
250
+ throw error;
251
+ }
252
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
253
+ throw new Error("crash diagnostics path is not a private directory");
254
+ }
255
+ }
256
+
257
+ async function chmodRealDiagnosticsDirectory(dir: string): Promise<void> {
258
+ const stat = await fs.lstat(dir);
259
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
260
+ throw new Error("crash diagnostics path is not a private directory");
261
+ }
262
+ // macOS lchmod sets the mode without read permission and without following a symlink.
263
+ // Linux has no equivalent here, so an unreadable directory is left unchanged. A path chmod would follow a swapped symlink.
264
+ if (process.platform === "darwin" && typeof fs.lchmod === "function") {
265
+ await fs.lchmod(dir, DIRECTORY_MODE);
266
+ return;
267
+ }
268
+ const directoryFlag = fsConstants.O_DIRECTORY ?? 0;
269
+ const pathFlag = (fsConstants as { O_PATH?: number }).O_PATH ?? 0;
270
+ if (POSIX_NOFOLLOW !== 0 && directoryFlag !== 0) {
271
+ const openFlag = pathFlag !== 0 ? pathFlag : fsConstants.O_RDONLY;
272
+ const handle = await fs.open(dir, openFlag | directoryFlag | POSIX_NOFOLLOW);
273
+ try {
274
+ await handle.chmod(DIRECTORY_MODE);
275
+ } finally {
276
+ await handle.close();
277
+ }
278
+ return;
279
+ }
202
280
  await fs.chmod(dir, DIRECTORY_MODE);
203
281
  }
204
282
 
205
283
  async function writePrivateCrashReport(reportPath: string, contents: string): Promise<void> {
206
- const file = await fs.open(reportPath, "wx", REPORT_FILE_MODE);
284
+ const file = await fs.open(
285
+ reportPath,
286
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | POSIX_NOFOLLOW,
287
+ REPORT_FILE_MODE,
288
+ );
207
289
  try {
208
290
  await file.writeFile(contents);
291
+ await file.chmod(REPORT_FILE_MODE);
209
292
  } finally {
210
293
  await file.close();
211
294
  }
212
- await fs.chmod(reportPath, REPORT_FILE_MODE);
213
295
  }
214
296
 
215
297
  function stringifyError(error: unknown): string {
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Creates a .tar.gz archive with session data, logs, system info, and optional profiling data.
5
5
  */
6
+ import { constants as fsConstants } from "node:fs";
6
7
  import * as fs from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  import type { WorkProfile } from "@gajae-code/natives";
@@ -13,6 +14,7 @@ import {
13
14
  getLogPath,
14
15
  getLogsDir,
15
16
  getReportsDir,
17
+ hasFsCode,
16
18
  isEnoent,
17
19
  redactCrashSecrets,
18
20
  resolveEquivalentPath,
@@ -133,6 +135,72 @@ export interface DebugLogSource {
133
135
  loadOlderLogs(limitDays?: number): Promise<string>;
134
136
  }
135
137
 
138
+ /** Owner-only mode. umask cannot widen this, and fchmod repeats it after open. */
139
+ const REPORT_BUNDLE_MODE = 0o600;
140
+
141
+ /**
142
+ * Open the archive path without following a final symlink.
143
+ * A missing path is created with O_EXCL. An existing regular file is replaced.
144
+ * A symlink fails the open (ELOOP, or EEXIST on macOS with O_EXCL) and is left in place.
145
+ */
146
+ async function openReportBundleForWrite(outputPath: string): Promise<fs.FileHandle> {
147
+ const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
148
+ const createFlags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow;
149
+ const replaceFlags = fsConstants.O_WRONLY | noFollow;
150
+
151
+ const openRegular = async (): Promise<fs.FileHandle> => {
152
+ const current = await fs.lstat(outputPath);
153
+ if (current.isSymbolicLink() || !current.isFile()) {
154
+ throw new Error("report bundle output is not a regular file");
155
+ }
156
+ try {
157
+ return await fs.open(outputPath, replaceFlags, REPORT_BUNDLE_MODE);
158
+ } catch (error) {
159
+ if (hasFsCode(error, "ELOOP")) throw new Error("report bundle output is not a regular file");
160
+ throw error;
161
+ }
162
+ };
163
+
164
+ let missing = false;
165
+ try {
166
+ await fs.lstat(outputPath);
167
+ } catch (error) {
168
+ if (!isEnoent(error)) throw error;
169
+ missing = true;
170
+ }
171
+ if (!missing) return openRegular();
172
+
173
+ try {
174
+ return await fs.open(outputPath, createFlags, REPORT_BUNDLE_MODE);
175
+ } catch (error) {
176
+ if (hasFsCode(error, "ELOOP")) throw new Error("report bundle output is not a regular file");
177
+ if (!hasFsCode(error, "EEXIST")) throw error;
178
+ }
179
+ return openRegular();
180
+ }
181
+
182
+ async function writeReportBundleFile(outputPath: string, bytes: Uint8Array): Promise<void> {
183
+ const handle = await openReportBundleForWrite(outputPath);
184
+ try {
185
+ const opened = await handle.stat();
186
+ const current = await fs.lstat(outputPath);
187
+ if (
188
+ current.isSymbolicLink() ||
189
+ !opened.isFile() ||
190
+ !current.isFile() ||
191
+ opened.dev !== current.dev ||
192
+ opened.ino !== current.ino
193
+ ) {
194
+ throw new Error("report bundle output is not a regular file");
195
+ }
196
+ if (process.platform !== "win32") await handle.chmod(REPORT_BUNDLE_MODE);
197
+ await handle.truncate(0);
198
+ await handle.writeFile(bytes);
199
+ } finally {
200
+ await handle.close();
201
+ }
202
+ }
203
+
136
204
  /**
137
205
  * Create a debug report bundle.
138
206
  *
@@ -237,8 +305,9 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
237
305
  data[archivePath] = redactCrashSecrets(content);
238
306
  }
239
307
 
240
- // Write archive
241
- await Bun.Archive.write(outputPath, data, { compress: "gzip" });
308
+ // Write archive. Bytes are built in memory so the path open can refuse a symlink.
309
+ const archiveBytes = await new Bun.Archive(data, { compress: "gzip" }).bytes();
310
+ await writeReportBundleFile(outputPath, archiveBytes);
242
311
 
243
312
  return { path: outputPath, files };
244
313
  }
@@ -399,10 +399,11 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
399
399
  const commandsDir = path.join(dir, "commands");
400
400
  const result = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, level, {
401
401
  extensions: ["md"],
402
+ recursive: true,
402
403
  scope: readScopeForLevel(level),
403
- transform: (name, content, path, source) => ({
404
- name: name.replace(/\.md$/, ""),
405
- path,
404
+ transform: (_name, content, filePath, source) => ({
405
+ name: path.relative(commandsDir, filePath).replace(/\.md$/, "").split(path.sep).join(":"),
406
+ path: filePath,
406
407
  content,
407
408
  level,
408
409
  _source: source,