@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -145,6 +145,8 @@ async function assertDirectoryNotSymlink(directoryPath: string): Promise<void> {
145
145
  const LEGACY_MIGRATION_MARKER = ".gjc-local-legacy-migrated-v1";
146
146
  const MAX_LEGACY_LOCAL_BYTES = 64 * 1024 * 1024;
147
147
 
148
+ type LegacyMigrationState = "complete" | "cleanup_pending";
149
+
148
150
  interface LegacyEntrySnapshot {
149
151
  readonly relativePath: string;
150
152
  readonly dev: bigint;
@@ -169,6 +171,26 @@ async function snapshotDirectory(directoryPath: string, relativePath: string): P
169
171
  return { relativePath, dev: stat.dev, ino: stat.ino, size: stat.size };
170
172
  }
171
173
 
174
+ interface CanonicalLegacyRoot {
175
+ readonly root: string;
176
+ readonly identity: LegacyEntrySnapshot;
177
+ }
178
+
179
+ async function canonicalLegacyRoot(artifactsDir: string): Promise<CanonicalLegacyRoot> {
180
+ const lexicalRoot = path.resolve(artifactsDir, "local");
181
+ const lexicalIdentity = await snapshotDirectory(lexicalRoot, "");
182
+ const [canonicalArtifactsDir, canonicalRoot] = await Promise.all([
183
+ fs.realpath(path.resolve(artifactsDir)),
184
+ fs.realpath(lexicalRoot),
185
+ ]);
186
+ if (canonicalRoot === canonicalArtifactsDir) throw new Error("Unsafe legacy local:// migration source");
187
+ ensureWithinRoot(canonicalRoot, canonicalArtifactsDir);
188
+ const canonicalIdentity = await snapshotDirectory(canonicalRoot, "");
189
+ if (!matchesSnapshot(canonicalIdentity, lexicalIdentity))
190
+ throw new Error("Legacy local:// migration source changed during capture");
191
+ return { root: canonicalRoot, identity: lexicalIdentity };
192
+ }
193
+
172
194
  async function snapshotRegularFile(filePath: string, relativePath: string): Promise<LegacyEntrySnapshot> {
173
195
  const before = await fs.lstat(filePath, { bigint: true });
174
196
  if (!before.isFile() || before.isSymbolicLink()) throw new Error("Unsafe legacy local:// migration source");
@@ -202,11 +224,16 @@ function manifestsMatch(left: readonly LegacyEntrySnapshot[], right: readonly Le
202
224
  );
203
225
  }
204
226
 
205
- async function captureLegacyManifest(root: string): Promise<readonly LegacyEntrySnapshot[]> {
227
+ async function captureLegacyManifest(
228
+ root: string,
229
+ expectedRootIdentity: LegacyEntrySnapshot,
230
+ ): Promise<readonly LegacyEntrySnapshot[]> {
206
231
  const manifest: LegacyEntrySnapshot[] = [];
207
232
  let copiedBytes = 0n;
208
233
  const captureDirectory = async (directoryPath: string, relativePath: string): Promise<void> => {
209
234
  const directory = await snapshotDirectory(directoryPath, relativePath);
235
+ if (relativePath === "" && !matchesSnapshot(directory, expectedRootIdentity))
236
+ throw new Error("Legacy local:// migration source changed during capture");
210
237
  manifest.push(directory);
211
238
  const entries = (await fs.readdir(directoryPath, { withFileTypes: true })).sort((a, b) =>
212
239
  a.name.localeCompare(b.name),
@@ -309,7 +336,7 @@ async function migrateManagedLegacyLocal(
309
336
  }
310
337
  const staging = path.join(scratchParent, `.gjc-local-migration-${randomUUID()}`);
311
338
  const installed: Array<{ readonly path: string; readonly dev: bigint; readonly ino: bigint }> = [];
312
- let installedMarker: { readonly dev: bigint; readonly ino: bigint } | null = null;
339
+ let sourceRetired = false;
313
340
  try {
314
341
  await fs.mkdir(staging, { mode: 0o700 });
315
342
  for (const entry of captured.entries) {
@@ -335,18 +362,22 @@ async function migrateManagedLegacyLocal(
335
362
  const identity = await fs.lstat(destination, { bigint: true });
336
363
  installed.push({ path: destination, dev: identity.dev, ino: identity.ino });
337
364
  }
365
+ try {
366
+ source.retire(captured.snapshot);
367
+ sourceRetired = true;
368
+ } catch (error) {
369
+ if (!(error instanceof Error) || error.message !== "cleanup_pending") throw error;
370
+ await fs.writeFile(marker, "cleanup_pending\n", { mode: 0o600, flag: "wx" });
371
+ return;
372
+ }
338
373
  await fs.writeFile(marker, "verified\n", { mode: 0o600, flag: "wx" });
339
- const markerIdentity = await fs.lstat(marker, { bigint: true });
340
- installedMarker = { dev: markerIdentity.dev, ino: markerIdentity.ino };
341
- source.retire(captured.snapshot);
342
374
  } catch (error) {
343
- const currentMarker = await fs.lstat(marker, { bigint: true }).catch(() => null);
344
- if (installedMarker && currentMarker?.dev === installedMarker.dev && currentMarker.ino === installedMarker.ino)
345
- await fs.rm(marker, { force: true });
346
- for (const destination of installed.reverse()) {
347
- const current = await fs.lstat(destination.path, { bigint: true }).catch(() => null);
348
- if (current?.dev === destination.dev && current.ino === destination.ino)
349
- await fs.rm(destination.path, { recursive: true, force: true });
375
+ if (!sourceRetired) {
376
+ for (const destination of installed.reverse()) {
377
+ const current = await fs.lstat(destination.path, { bigint: true }).catch(() => null);
378
+ if (current?.dev === destination.dev && current.ino === destination.ino)
379
+ await fs.rm(destination.path, { recursive: true, force: true });
380
+ }
350
381
  }
351
382
  throw error;
352
383
  } finally {
@@ -354,15 +385,16 @@ async function migrateManagedLegacyLocal(
354
385
  }
355
386
  }
356
387
 
357
- async function readMigrationMarker(marker: string): Promise<boolean> {
388
+ async function readMigrationMarker(marker: string): Promise<LegacyMigrationState | null> {
358
389
  try {
359
390
  const snapshot = await snapshotRegularFile(marker, LEGACY_MIGRATION_MARKER);
360
391
  if (snapshot.size > 32n || snapshot.bytes === undefined) throw new Error("Unsafe local:// migration marker");
361
392
  const value = snapshot.bytes.toString("utf8");
362
- if (value !== "verified\n" && value !== "absent\n") throw new Error("Unsafe local:// migration marker");
363
- return true;
393
+ if (value === "cleanup_pending\n") return "cleanup_pending";
394
+ if (value === "verified\n" || value === "absent\n") return "complete";
395
+ throw new Error("Unsafe local:// migration marker");
364
396
  } catch (error) {
365
- if (isEnoent(error)) return false;
397
+ if (isEnoent(error)) return null;
366
398
  throw error;
367
399
  }
368
400
  }
@@ -384,10 +416,11 @@ async function migrateLegacyLocal(
384
416
  await fs.writeFile(marker, "absent\n", { mode: 0o600, flag: "wx" });
385
417
  return;
386
418
  }
387
- const legacyRoot = path.resolve(artifactsDir, "local");
419
+ let legacySource: CanonicalLegacyRoot;
388
420
  let manifest: readonly LegacyEntrySnapshot[];
389
421
  try {
390
- manifest = await captureLegacyManifest(legacyRoot);
422
+ legacySource = await canonicalLegacyRoot(artifactsDir);
423
+ manifest = await captureLegacyManifest(legacySource.root, legacySource.identity);
391
424
  } catch (error) {
392
425
  if (isEnoent(error)) {
393
426
  await fs.writeFile(marker, "absent\n", { mode: 0o600, flag: "wx" });
@@ -397,8 +430,8 @@ async function migrateLegacyLocal(
397
430
  }
398
431
  const staging = path.join(scratchParent, `.gjc-local-migration-${randomUUID()}`);
399
432
  try {
400
- await copyLegacyManifest(legacyRoot, staging, manifest);
401
- const verifiedManifest = await captureLegacyManifest(legacyRoot);
433
+ await copyLegacyManifest(legacySource.root, staging, manifest);
434
+ const verifiedManifest = await captureLegacyManifest(legacySource.root, legacySource.identity);
402
435
  if (!manifestsMatch(verifiedManifest, manifest))
403
436
  throw new Error("Legacy local:// migration source changed during capture");
404
437
  for (const entry of await fs.readdir(staging)) {
@@ -410,7 +443,17 @@ async function migrateLegacyLocal(
410
443
  }
411
444
  await fs.rename(path.join(staging, entry), path.join(localRoot, entry));
412
445
  }
413
- await retireLegacyTree(legacyRoot, manifest);
446
+ try {
447
+ await retireLegacyTree(legacySource.root, manifest);
448
+ } catch (error) {
449
+ if (
450
+ !(error instanceof Error) ||
451
+ error.message !== "Legacy local:// migration retirement failed: cleanup_pending"
452
+ )
453
+ throw error;
454
+ await fs.writeFile(marker, "cleanup_pending\n", { mode: 0o600, flag: "wx" });
455
+ return;
456
+ }
414
457
  await fs.writeFile(marker, "verified\n", { mode: 0o600, flag: "wx" });
415
458
  } finally {
416
459
  await fs.rm(staging, { recursive: true, force: true });
@@ -2,29 +2,91 @@ import { MCPManager } from "../runtime-mcp/manager";
2
2
  import type { MCPResourceReadResult } from "../runtime-mcp/types";
3
3
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
4
4
 
5
- function escapeRegex(text: string): string {
6
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ const MAX_MCP_RESOURCE_URI_CHARS = 16_384;
6
+ const MAX_MCP_URI_TEMPLATE_CHARS = 8_192;
7
+ const MAX_MCP_URI_TEMPLATE_EXPRESSIONS = 32;
8
+
9
+ interface CompiledUriTemplate {
10
+ literalSegments: string[];
11
+ literalChars: number;
12
+ expressionCount: number;
13
+ }
14
+
15
+ function containsExpressionLineTerminator(text: string, start: number, end: number): boolean {
16
+ for (let index = start; index < end; index++) {
17
+ const code = text.charCodeAt(index);
18
+ if (code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ function compileUriTemplate(uriTemplate: string): CompiledUriTemplate | undefined {
24
+ if (uriTemplate.length > MAX_MCP_URI_TEMPLATE_CHARS) return undefined;
25
+
26
+ const literalSegments: string[] = [];
27
+ let expressionCount = 0;
28
+ let literalChars = 0;
29
+ let segmentStart = 0;
30
+ let scanPosition = 0;
31
+ while (scanPosition < uriTemplate.length) {
32
+ const expressionStart = uriTemplate.indexOf("{", scanPosition);
33
+ if (expressionStart < 0) break;
34
+ const expressionEnd = uriTemplate.indexOf("}", expressionStart + 1);
35
+ if (expressionEnd < 0) break;
36
+ if (expressionEnd === expressionStart + 1) {
37
+ scanPosition = expressionStart + 1;
38
+ continue;
39
+ }
40
+ expressionCount++;
41
+ if (expressionCount > MAX_MCP_URI_TEMPLATE_EXPRESSIONS) return undefined;
42
+ const segment = uriTemplate.slice(segmentStart, expressionStart);
43
+ literalSegments.push(segment);
44
+ literalChars += segment.length;
45
+ segmentStart = expressionEnd + 1;
46
+ scanPosition = segmentStart;
47
+ }
48
+ const finalSegment = uriTemplate.slice(segmentStart);
49
+ literalSegments.push(finalSegment);
50
+ literalChars += finalSegment.length;
51
+ return { literalSegments, literalChars, expressionCount };
52
+ }
53
+
54
+ function matchesUriTemplate(uri: string, template: CompiledUriTemplate): boolean {
55
+ const { literalSegments, expressionCount } = template;
56
+ if (expressionCount === 0) return uri === literalSegments[0];
57
+ if (!uri.startsWith(literalSegments[0])) return false;
58
+
59
+ let cursor = literalSegments[0].length;
60
+ for (let index = 1; index < literalSegments.length - 1; index++) {
61
+ const segment = literalSegments[index];
62
+ const matchIndex = uri.indexOf(segment, cursor);
63
+ if (matchIndex < 0 || containsExpressionLineTerminator(uri, cursor, matchIndex)) return false;
64
+ cursor = matchIndex + segment.length;
65
+ }
66
+
67
+ const suffix = literalSegments.at(-1) ?? "";
68
+ const suffixStart = uri.length - suffix.length;
69
+ return suffixStart >= cursor && uri.endsWith(suffix) && !containsExpressionLineTerminator(uri, cursor, suffixStart);
7
70
  }
8
71
 
9
72
  function getUriTemplateMatchScore(
10
73
  uri: string,
11
74
  uriTemplate: string,
12
75
  ): { literalChars: number; expressionCount: number } | undefined {
13
- const expressionPattern = /\{[^}]+\}/g;
14
- const literalSegments = uriTemplate.split(expressionPattern);
15
- const expressionCount = (uriTemplate.match(expressionPattern) ?? []).length;
16
- const pattern = literalSegments.map(escapeRegex).join("(.*?)");
17
- const regex = new RegExp(`^${pattern}$`);
18
- if (!regex.test(uri)) return undefined;
19
- const literalChars = literalSegments.reduce((total, segment) => total + segment.length, 0);
20
- return { literalChars, expressionCount };
76
+ const template = compileUriTemplate(uriTemplate);
77
+ if (!template || !matchesUriTemplate(uri, template)) return undefined;
78
+ return { literalChars: template.literalChars, expressionCount: template.expressionCount };
21
79
  }
22
80
 
23
81
  function extractResourceUri(url: InternalUrl): string {
24
82
  const host = url.rawHost || url.hostname;
25
83
  const rawPathname = url.rawPathname ?? url.pathname;
26
84
  const hasPath = rawPathname && rawPathname !== "/";
27
- const uri = `${host}${hasPath ? rawPathname : ""}${url.search}${url.hash}`.trim();
85
+ const rawUri = `${host}${hasPath ? rawPathname : ""}${url.search}${url.hash}`;
86
+ if (rawUri.length > MAX_MCP_RESOURCE_URI_CHARS) {
87
+ throw new Error("MCP resource URI exceeds the 16384-character limit.");
88
+ }
89
+ const uri = rawUri.trim();
28
90
  if (!uri) {
29
91
  throw new Error("mcp:// URL requires a resource URI: mcp://<resource-uri>");
30
92
  }
package/src/main.ts CHANGED
@@ -11,7 +11,8 @@ import * as path from "node:path";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import type { ImageContent } from "@gajae-code/ai";
13
13
  import {
14
- $env,
14
+ $pickenv,
15
+ getAgentDir,
15
16
  getProjectDir,
16
17
  logger,
17
18
  normalizePathForComparison,
@@ -534,7 +535,16 @@ interface InteractiveModeFactoryOptions {
534
535
  type CreateInteractiveMode = (options: InteractiveModeFactoryOptions) => InteractiveMode;
535
536
 
536
537
  type ResumePickerTerminalCheck = () => boolean;
537
- type ListForResumePickerReadOnly = (cwd: string, sessionDir?: string) => Promise<SessionInfo[]>;
538
+ type ListForResumePickerReadOnly = (cwd: string, sessionDir?: string, storage?: undefined) => Promise<SessionInfo[]>;
539
+
540
+ type ListManagedForResumePickerReadOnly = (
541
+ cwd: string,
542
+ managedAgentDir?: string,
543
+ storage?: undefined,
544
+ ) => Promise<SessionInfo[]>;
545
+
546
+ type ResolveManagedAgentDirForScope = (cwd: string) => string;
547
+
538
548
  type SelectResumeSession = (sessions: SessionInfo[]) => Promise<SessionSelectionResult>;
539
549
  type OpenExistingSessionStrict = (
540
550
  identity: ResumeSessionIdentity,
@@ -546,6 +556,11 @@ type OpenExistingSessionStrict = (
546
556
  export const BARE_RESUME_CONFLICT_ERROR =
547
557
  "--resume without a session cannot be combined with --continue, --fork, or --no-session.";
548
558
  export const BARE_RESUME_INTERACTIVE_ERROR = "--resume requires an interactive terminal; use --resume <id>.";
559
+
560
+ /** Resolves only the managed agent root needed for pre-consent picker inventory. */
561
+ export function resolveManagedAgentDirForScope(_cwd: string): string {
562
+ return getAgentDir();
563
+ }
549
564
  export const BARE_RESUME_OPEN_ERROR = "Could not open the selected session. Use --resume <id>.";
550
565
 
551
566
  function isBareResume(parsed: Args): boolean {
@@ -741,7 +756,13 @@ export async function createSessionManager(
741
756
  if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
742
757
  return await SessionManager.forkFrom(forkSource, cwd, sessionDestination(), undefined, migrationPolicy);
743
758
  }
744
- const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
759
+ const match = await resolveResumableSession(
760
+ forkSource,
761
+ cwd,
762
+ parsed.sessionDir,
763
+ undefined,
764
+ parsed.sessionDir ? undefined : activeSettings.getAgentDir(),
765
+ );
745
766
  if (!match) {
746
767
  throw new Error(`Session "${forkSource}" not found.`);
747
768
  }
@@ -754,14 +775,18 @@ export async function createSessionManager(
754
775
  if (typeof parsed.resume === "string") {
755
776
  const sessionArg = parsed.resume;
756
777
  if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
757
- return await SessionManager.open(
758
- sessionArg,
759
- SessionManager.explicitDestination(path.dirname(sessionArg)),
760
- undefined,
761
- migrationPolicy,
762
- );
778
+ const destination = parsed.sessionDir
779
+ ? SessionManager.explicitDestination(parsed.sessionDir)
780
+ : SessionManager.explicitDestination(path.dirname(sessionArg));
781
+ return await SessionManager.open(sessionArg, destination, undefined, migrationPolicy);
763
782
  }
764
- const match = await resolveResumableSession(sessionArg, cwd, parsed.sessionDir);
783
+ const match = await resolveResumableSession(
784
+ sessionArg,
785
+ cwd,
786
+ parsed.sessionDir,
787
+ undefined,
788
+ parsed.sessionDir ? undefined : activeSettings.getAgentDir(),
789
+ );
765
790
  if (!match) {
766
791
  throw new Error(`Session "${sessionArg}" not found.`);
767
792
  }
@@ -1058,12 +1083,62 @@ export interface RunRootCommandDependencies {
1058
1083
  runPrintMode?: RunPrintMode;
1059
1084
  isResumePickerTerminal?: ResumePickerTerminalCheck;
1060
1085
  listForResumePickerReadOnly?: ListForResumePickerReadOnly;
1086
+ listManagedForResumePickerReadOnly?: ListManagedForResumePickerReadOnly;
1087
+ resolveManagedAgentDirForScope?: ResolveManagedAgentDirForScope;
1061
1088
  selectResumeSession?: SelectResumeSession;
1062
1089
  openExistingSessionStrict?: OpenExistingSessionStrict;
1063
1090
  initializeSettings?: typeof Settings.init;
1064
1091
  loadSettingsForScope?: typeof Settings.loadForScope;
1065
1092
  }
1066
1093
 
1094
+ export interface ModelRoleOverrides {
1095
+ smol?: string;
1096
+ slow?: string;
1097
+ plan?: string;
1098
+ }
1099
+
1100
+ /**
1101
+ * Resolve the ephemeral `smol`/`slow`/`plan` model-role overrides.
1102
+ *
1103
+ * Precedence per role is CLI flag > documented `GJC_*_MODEL` > legacy
1104
+ * `PI_*_MODEL`, matching the repo-wide GJC-first/PI-fallback convention.
1105
+ * Resolution reads the process environment via `$pickenv`, which trims values
1106
+ * and treats empty/whitespace as unset; it is deliberately kept separate from
1107
+ * credential env resolution (`$credentialEnv`/`$pickCredentialEnv`). The
1108
+ * function is pure and stateless, so it reads fresh each call and a later
1109
+ * invocation never inherits an earlier one's values.
1110
+ */
1111
+ export function resolveModelRoleOverrides(parsed: Pick<Args, "smol" | "slow" | "plan">): ModelRoleOverrides {
1112
+ const overrides: ModelRoleOverrides = {};
1113
+ const smol = parsed.smol ?? $pickenv("GJC_SMOL_MODEL", "PI_SMOL_MODEL");
1114
+ const slow = parsed.slow ?? $pickenv("GJC_SLOW_MODEL", "PI_SLOW_MODEL");
1115
+ const plan = parsed.plan ?? $pickenv("GJC_PLAN_MODEL", "PI_PLAN_MODEL");
1116
+ if (smol) overrides.smol = smol;
1117
+ if (slow) overrides.slow = slow;
1118
+ if (plan) overrides.plan = plan;
1119
+ return overrides;
1120
+ }
1121
+
1122
+ /**
1123
+ * Apply the `--no-pty` / `--no-title` terminal-control flags to the environment.
1124
+ *
1125
+ * Sets the canonical `GJC_*` name (so an explicit flag wins over a user-set
1126
+ * `GJC_*` value under the GJC-first resolver — CLI authority) and the legacy
1127
+ * `PI_*` name for backward compatibility. `--acp` mode implies `--no-title`.
1128
+ */
1129
+ export function applyTerminalControlFlagsToEnv(
1130
+ parsed: Pick<Args, "noPty" | "noTitle" | "mode">,
1131
+ env: NodeJS.ProcessEnv = Bun.env,
1132
+ ): void {
1133
+ if (parsed.noPty) {
1134
+ env.GJC_NO_PTY = "1";
1135
+ env.PI_NO_PTY = "1";
1136
+ }
1137
+ if (parsed.noTitle || parsed.mode === "acp") {
1138
+ env.GJC_NO_TITLE = "1";
1139
+ env.PI_NO_TITLE = "1";
1140
+ }
1141
+ }
1067
1142
  export async function runRootCommand(
1068
1143
  parsed: Args,
1069
1144
  rawArgs: string[],
@@ -1094,10 +1169,18 @@ export async function runRootCommand(
1094
1169
  await logger.time("maybeAutoChdir", maybeAutoChdir, parsedArgs);
1095
1170
  autoChdirApplied = true;
1096
1171
  const resumeCwd = getProjectDir();
1097
- const sessions = await (deps.listForResumePickerReadOnly ?? SessionManager.listForResumePickerReadOnly)(
1098
- resumeCwd,
1099
- parsedArgs.sessionDir,
1100
- );
1172
+ const managedAgentDir = parsedArgs.sessionDir
1173
+ ? undefined
1174
+ : (deps.resolveManagedAgentDirForScope ?? resolveManagedAgentDirForScope)(resumeCwd);
1175
+ const sessions = parsedArgs.sessionDir
1176
+ ? await (deps.listForResumePickerReadOnly ?? SessionManager.listForResumePickerReadOnly)(
1177
+ resumeCwd,
1178
+ parsedArgs.sessionDir,
1179
+ )
1180
+ : await (deps.listManagedForResumePickerReadOnly ?? SessionManager.listManagedForResumePickerReadOnly)(
1181
+ resumeCwd,
1182
+ managedAgentDir,
1183
+ );
1101
1184
  if (sessions.length === 0) {
1102
1185
  process.stdout.write(`${chalk.dim("No sessions found")}\n`);
1103
1186
  return;
@@ -1108,19 +1191,16 @@ export async function runRootCommand(
1108
1191
  if (selection.kind === "cancelled") {
1109
1192
  return;
1110
1193
  }
1194
+ const scopedSettings = await (deps.loadSettingsForScope ?? Settings.loadForScope)({ cwd: resumeCwd });
1111
1195
  const resumeMigrationPolicy =
1112
- (await (deps.loadSettingsForScope ?? Settings.loadForScope)({ cwd: resumeCwd })).get(
1113
- "session.directoryMigration",
1114
- ) === "disabled"
1115
- ? "disabled"
1116
- : "copy-retain";
1196
+ scopedSettings.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
1117
1197
  let opened: StrictSessionOpenResult;
1118
1198
  try {
1119
1199
  opened = await (deps.openExistingSessionStrict ?? SessionManager.openExistingStrict)(
1120
1200
  selection.identity,
1121
1201
  parsedArgs.sessionDir
1122
1202
  ? SessionManager.explicitDestination(parsedArgs.sessionDir)
1123
- : SessionManager.managedDestination(resumeCwd, settings.getAgentDir()),
1203
+ : SessionManager.managedDestination(resumeCwd, scopedSettings.getAgentDir()),
1124
1204
  undefined,
1125
1205
  resumeMigrationPolicy,
1126
1206
  );
@@ -1198,12 +1278,7 @@ export async function runRootCommand(
1198
1278
  applyAcpDefaultSettingOverrides(settingsInstance);
1199
1279
  }
1200
1280
  modelRegistry.applyConfiguredModelBindings(settingsInstance);
1201
- if (parsedArgs.noPty) {
1202
- Bun.env.PI_NO_PTY = "1";
1203
- }
1204
- if (parsedArgs.noTitle || parsedArgs.mode === "acp") {
1205
- Bun.env.PI_NO_TITLE = "1";
1206
- }
1281
+ applyTerminalControlFlagsToEnv(parsedArgs);
1207
1282
  const hasPreparedInput = parsedArgs.messages.length > 0 || parsedArgs.fileArgs.length > 0;
1208
1283
  const { pipedInput, fileText, fileImages } = await logger.time("prepareInitialMessage", async () => {
1209
1284
  const pipedInput =
@@ -1252,15 +1327,14 @@ export async function runRootCommand(
1252
1327
  // Initialize discovery system with settings for provider persistence
1253
1328
  logger.time("initializeWithSettings", initializeWithSettings, settingsInstance);
1254
1329
 
1255
- // Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
1256
- const smolModel = parsedArgs.smol ?? $env.PI_SMOL_MODEL;
1257
- const slowModel = parsedArgs.slow ?? $env.PI_SLOW_MODEL;
1258
- const planModel = parsedArgs.plan ?? $env.PI_PLAN_MODEL;
1259
- if (smolModel || slowModel || planModel) {
1330
+ // Apply model role overrides from CLI args or env vars (ephemeral, not persisted).
1331
+ // Precedence per role: CLI flag > documented GJC_*_MODEL > legacy PI_*_MODEL.
1332
+ const roleOverrides = resolveModelRoleOverrides(parsedArgs);
1333
+ if (roleOverrides.smol || roleOverrides.slow || roleOverrides.plan) {
1260
1334
  settingsInstance.overrideModelRoles({
1261
- smol: smolModel,
1262
- slow: slowModel,
1263
- plan: planModel,
1335
+ ...(roleOverrides.smol ? { smol: roleOverrides.smol } : {}),
1336
+ ...(roleOverrides.slow ? { slow: roleOverrides.slow } : {}),
1337
+ ...(roleOverrides.plan ? { plan: roleOverrides.plan } : {}),
1264
1338
  });
1265
1339
  }
1266
1340
 
@@ -1554,9 +1628,9 @@ export async function runRootCommand(
1554
1628
  process.stdout.write(`${chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Alt+N to cycle)")}`)}\n`);
1555
1629
  }
1556
1630
 
1557
- if ($env.PI_TIMING) {
1631
+ if ($pickenv("GJC_TIMING", "PI_TIMING")) {
1558
1632
  logger.printTimings();
1559
- exitForTiming = $env.PI_TIMING === "x";
1633
+ exitForTiming = $pickenv("GJC_TIMING", "PI_TIMING") === "x";
1560
1634
  }
1561
1635
 
1562
1636
  if (!exitForTiming) {
@@ -1601,7 +1675,7 @@ export async function runRootCommand(
1601
1675
  initialImages,
1602
1676
  suppressProcessExit: deps.suppressProcessExit,
1603
1677
  });
1604
- if ($env.PI_TIMING) {
1678
+ if ($pickenv("GJC_TIMING", "PI_TIMING")) {
1605
1679
  logger.printTimings();
1606
1680
  }
1607
1681
  } finally {
@@ -94,6 +94,11 @@ empty space and list density for an operational setup flow.
94
94
  removing line breaks. Notification secrets will use the dedicated masked
95
95
  input from Work item 6; they must never appear in list values, descriptions,
96
96
  previews, artifacts, or logs.
97
+ ### Shortcut labels and binding authority
98
+
99
+ Keybinding configuration is a portable canonical grammar: textual key IDs use `ctrl`, `alt`, `shift`, and `super` plus a key name (for example, `ctrl+p` or `alt+enter`). Do not serialize or require display-only labels. Runtime UI renders those IDs through the shared formatter for its explicit platform context; macOS uses MacBook-style glyphs (`⌃`, `⌥`, `⇧`, `⌘`, `↩`, `⎋`, `⇥`, `⌫`, `⌦`, and arrow glyphs) while other platforms use textual labels. A glyph is never configuration syntax.
100
+
101
+ Static onboarding and generated documentation have authority only over shipped defaults. Keep generated tables host-independent by showing canonical textual IDs, not the capture host's labels. The runtime `KeybindingsManager` owns the effective binding set after user remaps and extensions load; `/hotkeys` and runtime hints must render that effective set with the platform context injected by their host. Do not let a static onboarding hint imply that it reflects remaps.
97
102
 
98
103
  ### Status, errors, confirmation, and disabled work
99
104
 
@@ -42,7 +42,7 @@ import {
42
42
  AcpSdkAdapterError,
43
43
  } from "../../sdk/acp";
44
44
  import { ensureBroker } from "../../sdk/broker/ensure";
45
- import { readSdkBrokerDiscovery, SdkClient } from "../../sdk/client";
45
+ import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
46
46
  import { mapAgentWireEventPayloadToAcpSessionUpdates } from "./acp-event-mapper";
47
47
  import { resolveAcpPermissionMode } from "./permission-mode";
48
48
  import type { AcpStartupOptions } from "./startup-options";
@@ -88,6 +88,7 @@ type PendingAttachment = { epoch: number; task: Promise<void> };
88
88
  type SessionRecord = {
89
89
  cwd: string;
90
90
  adapter: AcpSdkAdapter;
91
+ closeIdempotencyKey: string;
91
92
  unsubscribe: () => void;
92
93
  reconnectUnsubscribe: () => void;
93
94
  /** Per-session frame work queue; callbacks never race prompt ownership. */
@@ -571,8 +572,10 @@ export class AcpAgent implements Agent {
571
572
  readonly #attaching = new Map<string, PendingAttachment>();
572
573
  readonly #resolvingExisting = new Map<string, PendingAttachment>();
573
574
  readonly #knownSessionCwds = new Map<string, string>();
575
+ readonly #pendingCloseIdempotencyKeys = new Map<string, string>();
574
576
  readonly #sessionEpochs = new Map<string, number>();
575
577
  readonly #tearingDown = new Map<string, number>();
578
+ readonly #closing = new Map<string, Promise<CloseSessionResponse>>();
576
579
  #clientCapabilities: ClientCapabilities | undefined;
577
580
  #broker: Promise<BrokerConnection> | undefined;
578
581
  readonly #startupOptions: AcpStartupOptions | undefined;
@@ -730,19 +733,21 @@ export class AcpAgent implements Agent {
730
733
  return paginateAcpSessions(listed, params.cwd ?? undefined, this.#cursor(params.cursor));
731
734
  }
732
735
 
733
- async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
736
+ closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
734
737
  const record = this.#sessions.get(params.sessionId);
735
738
  const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
736
739
  // ACP close has no cwd. Only connection-owned sessions may reach broker lifecycle control.
737
- if (!cwd) return {};
738
- this.#beginTeardown(params.sessionId);
739
- try {
740
- await this.#teardownSession(params.sessionId, "closed", true);
741
- this.#knownSessionCwds.delete(params.sessionId);
742
- return {};
743
- } finally {
744
- this.#finishTeardown(params.sessionId);
745
- }
740
+ if (!cwd) return Promise.resolve({});
741
+ const existing = this.#closing.get(params.sessionId);
742
+ if (existing) return existing;
743
+ const deferred = Promise.withResolvers<CloseSessionResponse>();
744
+ this.#closing.set(params.sessionId, deferred.promise);
745
+ void this.#closeOwnedSession(params.sessionId).then(deferred.resolve, deferred.reject);
746
+ const cleanup = deferred.promise.finally(() => {
747
+ if (this.#closing.get(params.sessionId) === deferred.promise) this.#closing.delete(params.sessionId);
748
+ });
749
+ void cleanup.catch(() => undefined);
750
+ return deferred.promise;
746
751
  }
747
752
 
748
753
  async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
@@ -932,6 +937,15 @@ export class AcpAgent implements Agent {
932
937
  );
933
938
  }
934
939
 
940
+ #isDefinitiveBrokerResponse(error: unknown): boolean {
941
+ // Response-derived client errors retain the broker error as details. Responses
942
+ // that represent ongoing or ambiguous lifecycle work must keep their key.
943
+ if (!(error instanceof SdkClientError)) return false;
944
+ const details = object(error.details);
945
+ if (details?.code !== error.code || details.message !== error.message) return false;
946
+ return !["terminal_uncertain", "cleanup_pending", "broker_restarting", "unavailable"].includes(error.code);
947
+ }
948
+
935
949
  async #attachExisting(id: string, cwd: string): Promise<void> {
936
950
  const epoch = this.#sessionEpoch(id);
937
951
  const attached = this.#sessions.get(id);
@@ -1045,6 +1059,7 @@ export class AcpAgent implements Agent {
1045
1059
  const record: SessionRecord = {
1046
1060
  cwd,
1047
1061
  adapter,
1062
+ closeIdempotencyKey: randomUUID(),
1048
1063
  unsubscribe: () => {},
1049
1064
  reconnectUnsubscribe: () => {},
1050
1065
  frameTail: Promise.resolve(),
@@ -1059,6 +1074,7 @@ export class AcpAgent implements Agent {
1059
1074
  this.#knownSessionCwds.set(id, cwd);
1060
1075
  await applyAcpPermissionMode(adapter, this.#clientCapabilities);
1061
1076
  this.#assertSessionEpoch(id, epoch);
1077
+ this.#pendingCloseIdempotencyKeys.delete(id);
1062
1078
  } catch (error) {
1063
1079
  if (adapter && this.#sessions.get(id)?.adapter === adapter) {
1064
1080
  try {
@@ -1103,6 +1119,21 @@ export class AcpAgent implements Agent {
1103
1119
  this.#knownSessionCwds.delete(id);
1104
1120
  }
1105
1121
 
1122
+ async #closeOwnedSession(id: string): Promise<CloseSessionResponse> {
1123
+ this.#beginTeardown(id);
1124
+ try {
1125
+ const attaching = this.#attaching.get(id);
1126
+ // The record is published before permission initialization. Let a canceled
1127
+ // provisional attachment retire it before selecting the generation key.
1128
+ if (attaching) await Promise.allSettled([attaching.task]);
1129
+ await this.#teardownSession(id, "closed", true);
1130
+ this.#knownSessionCwds.delete(id);
1131
+ return {};
1132
+ } finally {
1133
+ this.#finishTeardown(id);
1134
+ }
1135
+ }
1136
+
1106
1137
  /**
1107
1138
  * All local session disposal follows one path: remove ownership and reject a
1108
1139
  * waiting prompt before any awaited socket or broker work. A failed close is
@@ -1130,13 +1161,13 @@ export class AcpAgent implements Agent {
1130
1161
  failures.push(error);
1131
1162
  }
1132
1163
  if (closeRemote) {
1164
+ const closeIdempotencyKey =
1165
+ record?.closeIdempotencyKey ?? this.#pendingCloseIdempotencyKeys.get(id) ?? randomUUID();
1166
+ this.#pendingCloseIdempotencyKeys.set(id, closeIdempotencyKey);
1133
1167
  try {
1134
- await (await this.#brokerAdapter()).global(
1135
- "session.close",
1136
- { sessionId: id },
1137
- this.#lifecycleIdempotencyKey(id, "session.close"),
1138
- );
1168
+ await (await this.#brokerAdapter()).global("session.close", { sessionId: id }, closeIdempotencyKey);
1139
1169
  } catch (error) {
1170
+ if (this.#isDefinitiveBrokerResponse(error)) this.#pendingCloseIdempotencyKeys.delete(id);
1140
1171
  if (!(ownershipBound && this.#isAlreadyGone(error))) failures.push(error);
1141
1172
  }
1142
1173
  }
@@ -1146,6 +1177,7 @@ export class AcpAgent implements Agent {
1146
1177
  .join("; ");
1147
1178
  throw aggregateAcpFailure("terminal_uncertain", `ACP session cleanup is uncertain: ${detail}`, failures);
1148
1179
  }
1180
+ if (closeRemote) this.#pendingCloseIdempotencyKeys.delete(id);
1149
1181
  } finally {
1150
1182
  this.#finishTeardown(id);
1151
1183
  }
@@ -1494,6 +1526,8 @@ export class AcpAgent implements Agent {
1494
1526
  this.#attaching.clear();
1495
1527
  this.#resolvingExisting.clear();
1496
1528
  this.#knownSessionCwds.clear();
1529
+ this.#pendingCloseIdempotencyKeys.clear();
1530
+ if (this.#closing.size === 0) this.#closing.clear();
1497
1531
  this.#tearingDown.clear();
1498
1532
  if (this.#broker) {
1499
1533
  const broker = this.#broker;