@gajae-code/coding-agent 0.18.10 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +295 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. package/src/web/search/providers/searxng.ts +62 -10
@@ -66,3 +66,67 @@ export async function resolveAstEditPreviewWritePaths(
66
66
  }
67
67
  return resolved;
68
68
  }
69
+
70
+ /** Refusal prefix when a single-file real path leaves its reference root. */
71
+ export const AST_EDIT_OUTSIDE_ROOT_MESSAGE = "AST edit target resolves outside its root";
72
+
73
+ function isEnoent(error: unknown): boolean {
74
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
75
+ }
76
+
77
+ function staysInside(root: string, target: string): boolean {
78
+ const relative = path.relative(root, target);
79
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
80
+ }
81
+
82
+ async function realOrLexical(target: string): Promise<string> {
83
+ try {
84
+ return await fs.realpath(target);
85
+ } catch (error) {
86
+ if (isEnoent(error)) return path.resolve(target);
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Native astEdit canonicalizes one file and writes that real path. Directory
93
+ * scans do not follow links. A lexical path inside cwd uses the real cwd as
94
+ * the reference root; an explicit path outside cwd uses its real parent, so a
95
+ * direct file there still applies. Returns the lexical path that would leave
96
+ * that root, if any.
97
+ */
98
+ export async function astEditSingleFileOutsideReferenceRoot(
99
+ cwd: string,
100
+ candidatePaths: readonly string[],
101
+ signal?: AbortSignal,
102
+ ): Promise<string | undefined> {
103
+ signal?.throwIfAborted();
104
+ const resolvedCwd = path.resolve(cwd);
105
+ const realCwd = await realOrLexical(resolvedCwd);
106
+ for (const candidate of candidatePaths) {
107
+ signal?.throwIfAborted();
108
+ const lexical = path.resolve(resolvedCwd, candidate);
109
+ let followed: string;
110
+ try {
111
+ followed = await fs.realpath(lexical);
112
+ } catch (error) {
113
+ if (isEnoent(error)) continue;
114
+ throw error;
115
+ }
116
+ let stat: Awaited<ReturnType<typeof fs.stat>>;
117
+ try {
118
+ stat = await fs.stat(followed);
119
+ } catch (error) {
120
+ if (isEnoent(error)) continue;
121
+ throw error;
122
+ }
123
+ if (!stat.isFile()) continue;
124
+ const root =
125
+ staysInside(resolvedCwd, lexical) || staysInside(realCwd, lexical)
126
+ ? realCwd
127
+ : await realOrLexical(path.dirname(lexical));
128
+ signal?.throwIfAborted();
129
+ if (!staysInside(root, followed)) return lexical;
130
+ }
131
+ return undefined;
132
+ }
@@ -13,7 +13,11 @@ import { assertWorkflowMutationRawPathsAllowed } from "../skill-state/workflow-m
13
13
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
- import { resolveAstEditPreviewWritePaths } from "./ast-edit-write-path";
16
+ import {
17
+ AST_EDIT_OUTSIDE_ROOT_MESSAGE,
18
+ astEditSingleFileOutsideReferenceRoot,
19
+ resolveAstEditPreviewWritePaths,
20
+ } from "./ast-edit-write-path";
17
21
  import { assertEditableFile } from "./auto-generated-guard";
18
22
  import { createFileRecorder, formatResultPath } from "./file-recorder";
19
23
  import { formatGroupedFiles } from "./grouped-file-output";
@@ -220,6 +224,18 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
220
224
  });
221
225
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
222
226
 
227
+ const assertSingleFileReferenceRoot = async (flowSignal?: AbortSignal): Promise<void> => {
228
+ const nativeWritePaths = multiTargets ? multiTargets.map(target => target.basePath) : [resolvedSearchPath];
229
+ const outsideRoot = await astEditSingleFileOutsideReferenceRoot(
230
+ this.session.cwd,
231
+ nativeWritePaths,
232
+ flowSignal,
233
+ );
234
+ if (outsideRoot) {
235
+ throw new ToolError(`${AST_EDIT_OUTSIDE_ROOT_MESSAGE}: ${outsideRoot}`);
236
+ }
237
+ };
238
+
223
239
  const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
224
240
  rewrites: normalizedRewrites,
225
241
  dryRun: true,
@@ -351,6 +367,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
351
367
  for (const filePath of previewedFiles) {
352
368
  await assertEditableFile(resolvePlanPath(this.session, filePath), filePath, this.session.settings);
353
369
  }
370
+ await assertSingleFileReferenceRoot();
354
371
  const applyResult = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
355
372
  rewrites: normalizedRewrites,
356
373
  dryRun: false,
@@ -25,10 +25,11 @@
25
25
  *
26
26
  * Destination symlinks are followed: the referent is replaced, the link stays.
27
27
  * The referent is re-resolved immediately before publication so a retargeted
28
- * link cannot silently repoint the write, and when the lexical destination
29
- * sits inside a session-scoped `gjc-local` root the resolved referent and its
30
- * parent must remain inside that root (a link there must not redirect the
31
- * write out of the trust boundary).
28
+ * link cannot silently repoint the write. When the lexical destination sits
29
+ * inside the active explicit artifacts local root (`<artifacts>/local`) or a
30
+ * session-scoped `gjc-local` root, the resolved referent and its parent must
31
+ * remain inside that root (a link there must not redirect the write out of
32
+ * the trust boundary).
32
33
  *
33
34
  * Staging uses exclusive create (`wx`) so a colliding leftover temp is never
34
35
  * truncated or unlinked; only the temp this call created is cleaned on failure.
@@ -44,6 +45,7 @@ import * as fs from "node:fs/promises";
44
45
  import * as os from "node:os";
45
46
  import * as path from "node:path";
46
47
  import { hasFsCode, isEacces, isEisdir, isEnoent, isFsError } from "@gajae-code/utils";
48
+ import { LocalProtocolHandler } from "../internal-urls/local-protocol";
47
49
 
48
50
  const TEMP_CREATE_ATTEMPTS = 8;
49
51
  const DEFAULT_FILE_MODE = 0o666;
@@ -111,9 +113,10 @@ export function formatFileWriteError(error: unknown, dest: string, options: { de
111
113
  export interface WriteFileAtomicallyOptions {
112
114
  /**
113
115
  * Trusted root that a resolved symlink referent and its parent must not
114
- * leave. When omitted, the helper still enforces the session-scoped
115
- * `gjc-local` boundary implied by a lexical destination under
116
- * `<tmpdir>/gjc-local/<session-id>`.
116
+ * leave. When omitted, a destination inside the active explicit artifacts
117
+ * local root (`<artifacts>/local`) uses that root. Otherwise the helper
118
+ * still enforces the session-scoped `gjc-local` boundary implied by a
119
+ * lexical destination under `<tmpdir>/gjc-local/<session-id>`.
117
120
  */
118
121
  trustBoundary?: string;
119
122
  /** Platform override used by deterministic retry tests. */
@@ -170,11 +173,20 @@ function pathIsWithin(target: string, root: string): boolean {
170
173
  }
171
174
 
172
175
  async function realpathOrSelf(p: string): Promise<string> {
173
- try {
174
- return await fs.realpath(p);
175
- } catch (error) {
176
- if (!isEnoent(error)) throw error;
177
- return path.resolve(p);
176
+ const resolved = path.resolve(p);
177
+ const missing: string[] = [];
178
+ let cursor = resolved;
179
+ for (;;) {
180
+ try {
181
+ const real = await fs.realpath(cursor);
182
+ return missing.length === 0 ? real : path.join(real, ...missing.reverse());
183
+ } catch (error) {
184
+ if (!isEnoent(error)) throw error;
185
+ const parent = path.dirname(cursor);
186
+ if (parent === cursor) return resolved;
187
+ missing.push(path.basename(cursor));
188
+ cursor = parent;
189
+ }
178
190
  }
179
191
  }
180
192
 
@@ -193,6 +205,25 @@ function sessionLocalRootFor(lexicalDest: string): string | undefined {
193
205
  return path.join(localParent, sessionSegment);
194
206
  }
195
207
 
208
+ /**
209
+ * Active explicit artifacts root (`<artifacts>/local`) when `lexicalDest` is
210
+ * inside it. That root is not under `<tmpdir>/gjc-local`, so the tmp helper
211
+ * above would leave the write unbounded. Managed sessions keep the tmp root.
212
+ */
213
+ function explicitLocalRootFor(lexicalDest: string): string | undefined {
214
+ const options = LocalProtocolHandler.resolveOptions();
215
+ if (!options || options.isManagedDestination?.()) return undefined;
216
+ const artifactsDir = options.getArtifactsDir?.();
217
+ if (!artifactsDir) return undefined;
218
+ const root = path.resolve(artifactsDir, "local");
219
+ if (!pathIsWithin(path.resolve(lexicalDest), root)) return undefined;
220
+ return root;
221
+ }
222
+
223
+ function trustBoundaryForDestination(lexicalDest: string): string | undefined {
224
+ return explicitLocalRootFor(lexicalDest) ?? sessionLocalRootFor(lexicalDest);
225
+ }
226
+
196
227
  /**
197
228
  * Reject publication when the resolved referent's real parent (and therefore
198
229
  * the referent itself) leaves the trust boundary. The boundary root is
@@ -201,8 +232,8 @@ function sessionLocalRootFor(lexicalDest: string): string | undefined {
201
232
  * This must run before any directory is created. A dangling symlink inside a
202
233
  * trusted root resolves to a path outside it, so creating parents first would
203
234
  * let an attacker-selected directory tree be materialized outside the sandbox
204
- * before publication is refused. Missing ancestors therefore resolve
205
- * lexically (`realpathOrSelf` tolerates ENOENT) and are still boundary-checked.
235
+ * before publication is refused. A missing path is resolved through the
236
+ * nearest existing ancestor's realpath, then the missing suffix is appended.
206
237
  */
207
238
  async function assertWithinTrustBoundary(publishPath: string, trustBoundary: string): Promise<void> {
208
239
  const boundary = path.resolve(trustBoundary);
@@ -502,7 +533,7 @@ export async function writeFileAtomically(
502
533
  try {
503
534
  const platform = options.platform ?? process.platform;
504
535
  const sleep = options.sleep ?? (async (delayMs: number): Promise<void> => await Bun.sleep(delayMs));
505
- const trustBoundary = options.trustBoundary ?? sessionLocalRootFor(dest);
536
+ const trustBoundary = options.trustBoundary ?? trustBoundaryForDestination(dest);
506
537
  const resolved = await resolvePublishPath(dest);
507
538
  publishPath = resolved.publishPath;
508
539
  // Boundary first, then create parents. A dangling symlink inside a trusted
@@ -36,6 +36,8 @@ const AUTO_GENERATED_HEADER_MARKERS: readonly RegExp[] = [
36
36
  /\bcode\s+generated\s+by\s+[a-z0-9_.-]+/i,
37
37
  /\bthis\s+file\s+was\s+automatically\s+generated\b/i,
38
38
  new RegExp(`\\bgenerated\\s+by\\s+${KNOWN_GENERATOR_PATTERN}\\b`, "i"),
39
+ /\bgenerated\s+by\s+scripts\/[a-z0-9_./-]+/i,
40
+ /\bauto-generated\s+by\s+[a-z0-9_./-]+/i,
39
41
  ];
40
42
 
41
43
  type CommentStyle = "slash" | "hash" | "sql" | "html";
@@ -100,6 +102,7 @@ const AUTO_GENERATED_FILENAME_PATTERNS = [
100
102
  /_pb2\.py$/,
101
103
  /_pb2_grpc\.py$/,
102
104
  /\.gen\.(go|ts|js|py)$/,
105
+ /\.generated\.ts$/,
103
106
  /^generated\.(go|ts|js|py)$/,
104
107
  /\.swagger\.json$/,
105
108
  /\.openapi\.json$/,
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import {
2
4
  classifyStateArgv,
3
5
  STATE_ACTION_NAMES,
@@ -13,8 +15,20 @@ export interface BashAllowedPrefixesCheck {
13
15
 
14
16
  export type BashRestrictionProfile = "workflow" | "read-only";
15
17
 
18
+ /** Session identities and workspace the restricted `gjc` command may name. */
19
+ export interface GjcSessionBinding {
20
+ sessionId?: string | null;
21
+ /** Owner identity already on the tool session. Role-agent ralplan writes use it. */
22
+ credentialSessionId?: string | null;
23
+ /** Session workspace. A `--worktree-root` must be this repo, not a foreign one. */
24
+ worktreeRoot: string;
25
+ /** Child cwd. Relative `--worktree-root` resolves against it, as ralplan does. */
26
+ invocationCwd: string;
27
+ }
28
+
16
29
  export interface BashRestrictionOptions {
17
30
  profile?: BashRestrictionProfile;
31
+ gjcSessionBinding?: GjcSessionBinding;
18
32
  }
19
33
 
20
34
  const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
@@ -231,6 +245,33 @@ function isLongOption(word: string, option: string): boolean {
231
245
  return word === option || word.startsWith(`${option}=`);
232
246
  }
233
247
 
248
+ function fileCompilesMagic(words: readonly string[]): boolean {
249
+ const valueOptions = ["--exclude", "--exclude-quiet", "--files-from", "--separator", "--magic-file", "--parameter"];
250
+ for (let index = 1; index < words.length; index += 1) {
251
+ const word = words[index]!;
252
+ if (word === "--") break;
253
+ if (word.startsWith("--")) {
254
+ const equalsAt = word.indexOf("=");
255
+ const name = equalsAt === -1 ? word : word.slice(0, equalsAt);
256
+ if (name.length > 2 && "--compile".startsWith(name)) return true;
257
+ if (equalsAt === -1 && valueOptions.some(option => name.length > 2 && option.startsWith(name))) {
258
+ index += 1;
259
+ }
260
+ } else if (word.startsWith("-")) {
261
+ for (let offset = 1; offset < word.length; offset += 1) {
262
+ const flag = word[offset]!;
263
+ if (flag === "C") return true;
264
+ // Required arguments consume the rest of the cluster, or the next word (even `--`).
265
+ if ("eFfmMP".includes(flag)) {
266
+ if (offset === word.length - 1) index += 1;
267
+ break;
268
+ }
269
+ }
270
+ }
271
+ }
272
+ return false;
273
+ }
274
+
234
275
  function hasShortOption(word: string, option: string): boolean {
235
276
  return word.startsWith("-") && !word.startsWith("--") && word.slice(1).includes(option);
236
277
  }
@@ -254,6 +295,9 @@ function validateReadOnlyCommand(words: readonly string[]): BashAllowedPrefixesC
254
295
  if (isLongOption(option, "--pre") || isLongOption(option, "--pre-glob")) {
255
296
  return { allowed: false, reason: "read-only bash does not allow ripgrep preprocessors" };
256
297
  }
298
+ if (isLongOption(option, "--hostname-bin")) {
299
+ return { allowed: false, reason: "read-only bash does not allow ripgrep hostname commands" };
300
+ }
257
301
  if (isLongOption(option, "--search-zip") || hasShortOption(option, "z")) {
258
302
  return { allowed: false, reason: "read-only bash does not allow ripgrep compressed-file subprocesses" };
259
303
  }
@@ -268,6 +312,10 @@ function validateReadOnlyCommand(words: readonly string[]): BashAllowedPrefixesC
268
312
  }
269
313
  }
270
314
 
315
+ if (command === "file" && fileCompilesMagic(words)) {
316
+ return { allowed: false, reason: "read-only bash does not allow file magic compilation" };
317
+ }
318
+
271
319
  const gitWrite = rejectGitHostAccess(words);
272
320
  if (gitWrite) return gitWrite;
273
321
 
@@ -284,7 +332,107 @@ function rejectGitHostAccess(words: readonly string[]): BashAllowedPrefixesCheck
284
332
  return undefined;
285
333
  }
286
334
 
287
- function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixesCheck {
335
+ const GJC_SESSION_BIND_REASON = "restricted role-agent bash binds gjc to the current session";
336
+ const GJC_WORKTREE_BIND_REASON = "restricted role-agent bash binds gjc --worktree-root to the current repository";
337
+
338
+ function separateFlagValues(words: readonly string[], flag: string): string[] {
339
+ const values: string[] = [];
340
+ for (let index = 0; index < words.length; index += 1) {
341
+ if (words[index] !== flag) continue;
342
+ values.push(words[index + 1] ?? "");
343
+ index += 1;
344
+ }
345
+ return values;
346
+ }
347
+
348
+ function boundSessionIds(binding: GjcSessionBinding): ReadonlySet<string> {
349
+ const ids = new Set<string>();
350
+ for (const value of [binding.sessionId, binding.credentialSessionId]) {
351
+ if (typeof value !== "string") continue;
352
+ const trimmed = value.trim();
353
+ if (trimmed) ids.add(trimmed);
354
+ }
355
+ return ids;
356
+ }
357
+
358
+ function inlineStateSessionIds(words: readonly string[]): string[] {
359
+ if (words[1] !== "state") return [];
360
+ const ids: string[] = [];
361
+ for (const input of classifyStateArgv(words.slice(2)).inputs) {
362
+ if (input.kind !== "inline") continue;
363
+ let payload: unknown;
364
+ try {
365
+ payload = JSON.parse(input.raw);
366
+ } catch {
367
+ continue;
368
+ }
369
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue;
370
+ const sessionId = (payload as Record<string, unknown>).session_id;
371
+ if (typeof sessionId === "string") ids.push(sessionId);
372
+ }
373
+ return ids;
374
+ }
375
+
376
+ function gitCommonDir(directory: string): string | undefined {
377
+ const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--git-common-dir"], {
378
+ stdout: "pipe",
379
+ stderr: "ignore",
380
+ });
381
+ if (result.exitCode !== 0) return undefined;
382
+ const text = result.stdout.toString().trim();
383
+ if (!text) return undefined;
384
+ const absolute = path.isAbsolute(text) ? text : path.resolve(directory, text);
385
+ try {
386
+ return fs.realpathSync(absolute);
387
+ } catch {
388
+ return undefined;
389
+ }
390
+ }
391
+
392
+ /** Same directory, or another worktree of the same git common dir (isolated tasks). */
393
+ function sameRepository(workspace: string, target: string): boolean {
394
+ let workspaceReal: string;
395
+ let targetReal: string;
396
+ try {
397
+ workspaceReal = fs.realpathSync(workspace);
398
+ targetReal = fs.realpathSync(target);
399
+ } catch {
400
+ return false;
401
+ }
402
+ if (workspaceReal === targetReal) return true;
403
+ const workspaceCommon = gitCommonDir(workspaceReal);
404
+ const targetCommon = gitCommonDir(targetReal);
405
+ return workspaceCommon !== undefined && workspaceCommon === targetCommon;
406
+ }
407
+
408
+ function worktreeSelectorAllowed(binding: GjcSessionBinding, rawTarget: string): boolean {
409
+ const trimmed = rawTarget.trim();
410
+ if (!trimmed) return false;
411
+ const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(binding.invocationCwd, trimmed);
412
+ return sameRepository(binding.worktreeRoot, resolved);
413
+ }
414
+
415
+ function rejectUnboundGjcCommand(
416
+ words: readonly string[],
417
+ options: BashRestrictionOptions,
418
+ ): BashAllowedPrefixesCheck | undefined {
419
+ const binding = options.gjcSessionBinding;
420
+ if (!binding) return undefined;
421
+ const allowed = boundSessionIds(binding);
422
+ const sessionSelectors = [...separateFlagValues(words, "--session-id"), ...inlineStateSessionIds(words)];
423
+ if (sessionSelectors.some(value => !allowed.has(value.trim()))) {
424
+ return { allowed: false, reason: GJC_SESSION_BIND_REASON };
425
+ }
426
+ if (separateFlagValues(words, "--worktree-root").some(value => !worktreeSelectorAllowed(binding, value))) {
427
+ return { allowed: false, reason: GJC_WORKTREE_BIND_REASON };
428
+ }
429
+ return undefined;
430
+ }
431
+
432
+ function validateMatchedGjcCommand(
433
+ words: readonly string[],
434
+ options: BashRestrictionOptions,
435
+ ): BashAllowedPrefixesCheck {
288
436
  const gitWrite = rejectGitHostAccess(words);
289
437
  if (gitWrite) return gitWrite;
290
438
  if (words[0] !== "gjc") return { allowed: true };
@@ -293,7 +441,7 @@ function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixe
293
441
  if (!words.includes("--write")) {
294
442
  return { allowed: false, reason: "restricted role-agent bash only allows `gjc ralplan --write ...`" };
295
443
  }
296
- return { allowed: true };
444
+ return rejectUnboundGjcCommand(words, options) ?? { allowed: true };
297
445
  }
298
446
 
299
447
  if (words[1] === "state") {
@@ -311,10 +459,10 @@ function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixe
311
459
  reason: `restricted role-agent bash does not allow \`gjc state ${classification.parsed.action}\``,
312
460
  };
313
461
  }
314
- return { allowed: true };
462
+ return rejectUnboundGjcCommand(words, options) ?? { allowed: true };
315
463
  }
316
464
 
317
- return { allowed: true };
465
+ return rejectUnboundGjcCommand(words, options) ?? { allowed: true };
318
466
  }
319
467
 
320
468
  function commandAllowedPrefixesReason(normalizedPrefixes: readonly string[], options: BashRestrictionOptions): string {
@@ -360,5 +508,5 @@ export function checkBashAllowedPrefixes(
360
508
  if (options.profile === "read-only") {
361
509
  return validateReadOnlyCommand(parsed.words);
362
510
  }
363
- return validateMatchedGjcCommand(parsed.words);
511
+ return validateMatchedGjcCommand(parsed.words, options);
364
512
  }
@@ -12,6 +12,7 @@ import {
12
12
  visibleWidth,
13
13
  } from "@gajae-code/tui";
14
14
  import { logger, sanitizeText } from "@gajae-code/utils";
15
+ import { projectDeclaredSpawnHookNames, projectSafeHomeOverride } from "@gajae-code/utils/shell-config";
15
16
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
16
17
  import { Settings } from "../config/settings";
17
18
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
@@ -323,6 +324,14 @@ export interface InteractivePtyControls {
323
324
  detachForegroundCancellation: () => void;
324
325
  }
325
326
 
327
+ function ptyUnsetHookNames(explicit: Record<string, string> | undefined): string[] {
328
+ return projectDeclaredSpawnHookNames().filter(name => !Object.hasOwn(explicit ?? {}, name));
329
+ }
330
+
331
+ function ptyHomeOverride(explicit: Record<string, string> | undefined): Record<string, string> {
332
+ return projectSafeHomeOverride(explicit);
333
+ }
334
+
326
335
  export async function runInteractiveBashPty(
327
336
  ui: NonNullable<AgentToolContext["ui"]>,
328
337
  options: {
@@ -422,8 +431,11 @@ export async function runInteractiveBashPty(
422
431
  command: options.command,
423
432
  cwd: options.cwd,
424
433
  timeoutMs: options.timeoutMs,
425
- env: { ...NON_INTERACTIVE_ENV, ...options.env },
426
- unsetEnv: options.unsetEnv,
434
+ // The PTY child inherits the process env; drop project hook variables
435
+ // unless this call sets them explicitly. A dropped HOME is replaced by
436
+ // the trusted account home so the shell and git still have one.
437
+ env: { ...ptyHomeOverride(options.env), ...NON_INTERACTIVE_ENV, ...options.env },
438
+ unsetEnv: [...(options.unsetEnv ?? []), ...ptyUnsetHookNames(options.env)],
427
439
  signal: processAbortController.signal,
428
440
  cols: initialPtySize.cols,
429
441
  rows: initialPtySize.rows,
package/src/tools/bash.ts CHANGED
@@ -1379,9 +1379,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1379
1379
  }
1380
1380
  if (allowedPrefixes && allowedPrefixes.length > 0) {
1381
1381
  const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
1382
+ const invocationCwd =
1383
+ cwd && !cwd.includes("://") && !cwd.includes("local:/")
1384
+ ? resolveToCwd(cwd, this.session.cwd)
1385
+ : this.session.cwd;
1382
1386
  for (const commandToCheck of commandsToCheck) {
1383
1387
  const allowlist = checkBashAllowedPrefixes(commandToCheck, allowedPrefixes, {
1384
1388
  profile: this.session.bashRestrictionProfile,
1389
+ ...(this.session.bashRestrictionProfile === "read-only"
1390
+ ? {}
1391
+ : {
1392
+ gjcSessionBinding: {
1393
+ sessionId: this.session.getSessionId?.() ?? null,
1394
+ credentialSessionId: this.session.getCredentialSessionId?.() ?? null,
1395
+ worktreeRoot: this.session.cwd,
1396
+ invocationCwd,
1397
+ },
1398
+ }),
1385
1399
  });
1386
1400
  if (!allowlist.allowed) {
1387
1401
  throw new ToolError(allowlist.reason ?? "Command blocked by restricted role-agent bash allowlist.");
@@ -466,8 +466,8 @@ function buildOutcomeText(outcome: DapContinueOutcome, timeoutSec: number, verb:
466
466
  return lines.join("\n");
467
467
  }
468
468
 
469
- function getConfiguredAdapters(cwd: string): string {
470
- const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name);
469
+ function getConfiguredAdapters(cwd: string, trustCwds?: readonly string[]): string {
470
+ const adapters = getAvailableAdapters(cwd, trustCwds).map(adapter => adapter.name);
471
471
  return adapters.length > 0 ? adapters.join(", ") : "none";
472
472
  }
473
473
  async function validateLaunchProgram(program: string, cwd: string): Promise<void> {
@@ -637,6 +637,7 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
637
637
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
638
638
  const details: DebugToolDetails = { action: params.action, success: true };
639
639
  const result = toolResult(details);
640
+ const trustCwds = [this.session.cwd];
640
641
  switch (params.action) {
641
642
  case "launch": {
642
643
  if (!params.program) {
@@ -645,10 +646,10 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
645
646
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
646
647
  const program = resolveToCwd(params.program, commandCwd);
647
648
  await validateLaunchProgram(program, commandCwd);
648
- const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
649
+ const adapter = selectLaunchAdapter(program, commandCwd, params.adapter, trustCwds);
649
650
  if (!adapter) {
650
651
  throw new ToolError(
651
- `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
652
+ `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd, trustCwds)}`,
652
653
  );
653
654
  }
654
655
  const snapshot = await dapSessionManager.launch(
@@ -665,10 +666,10 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
665
666
  throw new ToolError("attach requires pid or port");
666
667
  }
667
668
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
668
- const adapter = selectAttachAdapter(commandCwd, params.adapter, params.port);
669
+ const adapter = selectAttachAdapter(commandCwd, params.adapter, params.port, trustCwds);
669
670
  if (!adapter) {
670
671
  throw new ToolError(
671
- `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
672
+ `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd, trustCwds)}`,
672
673
  );
673
674
  }
674
675
  const snapshot = await dapSessionManager.attach(
@@ -35,6 +35,7 @@ function makeSession(overrides: Record<string, unknown> = {}): any {
35
35
  "astGrep.enabled": true,
36
36
  "astEdit.enabled": true,
37
37
  "renderMermaid.enabled": true,
38
+ "reportIssue.enabled": true,
38
39
  "web_search.enabled": true,
39
40
  "calc.enabled": true,
40
41
  "skill.enabled": true,
@@ -128,6 +129,7 @@ describe("tool descriptor compatibility gate", () => {
128
129
  "skill",
129
130
  "goal",
130
131
  "move_session",
132
+ "report_issue",
131
133
  ];
132
134
  expect(Object.keys(BUILTIN_TOOLS)).toEqual(expectedBuiltin);
133
135
  expect(Object.keys(BUILTIN_TOOL_DESCRIPTORS)).toEqual(expectedBuiltin);
@@ -291,6 +293,7 @@ describe("tool descriptor compatibility gate", () => {
291
293
  "astGrep.enabled": false,
292
294
  "astEdit.enabled": false,
293
295
  "renderMermaid.enabled": false,
296
+ "reportIssue.enabled": false,
294
297
  "web_search.enabled": false,
295
298
  "calc.enabled": false,
296
299
  "skill.enabled": false,
@@ -313,6 +316,7 @@ describe("tool descriptor compatibility gate", () => {
313
316
  "ast_grep",
314
317
  "ast_edit",
315
318
  "render_mermaid",
319
+ "report_issue",
316
320
  "web_search",
317
321
  "calc",
318
322
  "skill",
@@ -961,6 +965,46 @@ describe("tool descriptor compatibility gate", () => {
961
965
  expect((normalizedBisect.parameters as any).properties?._i).toBeUndefined();
962
966
  expect((normalizedWrite.parameters as any).properties?._i).toBeDefined();
963
967
  });
968
+ for (const staleOutcome of ["resolved", "rejected"] as const) {
969
+ test(`session move invalidation keeps a newer load when the old load ${staleOutcome}`, async () => {
970
+ const first = Promise.withResolvers<AgentTool>();
971
+ const second = Promise.withResolvers<AgentTool>();
972
+ const oldTool = syntheticTool();
973
+ const newTool = syntheticTool();
974
+ let loads = 0;
975
+ const session = makeSession();
976
+ const descriptor: ToolDescriptor = {
977
+ metadata: { name: "synthetic" },
978
+ presentation: { label: "Synthetic" },
979
+ isAvailable: () => true,
980
+ load: () => (++loads === 1 ? first.promise : second.promise),
981
+ };
982
+ const lazy = new LazyAgentTool(descriptor, undefined, () => descriptor.load(session), session);
983
+ const oldLoad = lazy.materializeForTests().then(
984
+ tool => ({ tool, error: undefined }),
985
+ error => ({ tool: undefined, error }),
986
+ );
987
+ await Promise.resolve();
988
+ expect(loads).toBe(1);
989
+ lazy.invalidateForSessionMove();
990
+ const newLoad = lazy.materializeForTests();
991
+ await Promise.resolve();
992
+ expect(loads).toBe(2);
993
+ if (staleOutcome === "resolved") first.resolve(oldTool);
994
+ else first.reject(new Error("old context load failed"));
995
+ const oldResult = await oldLoad;
996
+ if (staleOutcome === "resolved") expect(oldResult.tool).toBe(oldTool);
997
+ else expect(String(oldResult.error)).toContain("old context load failed");
998
+ const joinedLoad = lazy.materializeForTests();
999
+ await Promise.resolve();
1000
+ expect(loads).toBe(2);
1001
+ second.resolve(newTool);
1002
+ expect(await newLoad).toBe(newTool);
1003
+ expect(await joinedLoad).toBe(newTool);
1004
+ expect(await lazy.materializeForTests()).toBe(newTool);
1005
+ });
1006
+ }
1007
+
964
1008
  test("concurrent lazy first use shares one load and cleanup registration", async () => {
965
1009
  let loads = 0;
966
1010
  let cleanupRegistrations = 0;