@gajae-code/coding-agent 0.15.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
package/src/tools/bash.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
3
4
  import type { Component } from "@gajae-code/tui";
4
5
  import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
@@ -17,8 +18,13 @@ import { InternalUrlRouter } from "../internal-urls";
17
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
18
19
  import { highlightCode, type Theme } from "../modes/theme/theme";
19
20
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
21
+ import { renderSpawnTable, runSdkSpawn, type SdkSpawnArgs } from "../sdk/cli/master-cli";
20
22
  import type { ArtifactManager } from "../session/artifacts";
21
- import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
23
+ import type {
24
+ ClientBridgeTerminalExitStatus,
25
+ ClientBridgeTerminalHandle,
26
+ ClientBridgeTerminalOutput,
27
+ } from "../session/client-bridge";
22
28
  import {
23
29
  DEFAULT_ARTIFACT_MAX_BYTES,
24
30
  OutputSink,
@@ -62,6 +68,20 @@ import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
62
68
  import { checkTmuxSelfInjection } from "./tmux-self-injection-guard";
63
69
  import { ToolAbortError, ToolError } from "./tool-errors";
64
70
  import { toolResult } from "./tool-result";
71
+
72
+ function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
73
+ if (offsetBytes <= 0) return text;
74
+ let consumed = 0;
75
+ let index = 0;
76
+ while (index < text.length && consumed < offsetBytes) {
77
+ const codePoint = text.codePointAt(index);
78
+ if (codePoint === undefined) break;
79
+ consumed += Buffer.byteLength(String.fromCodePoint(codePoint), "utf8");
80
+ index += codePoint > 0xffff ? 2 : 1;
81
+ }
82
+ return text.slice(index);
83
+ }
84
+
65
85
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
66
86
 
67
87
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
@@ -69,7 +89,9 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
69
89
  const BASH_ERROR_MAX_BYTES = 4096;
70
90
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
71
91
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
92
+ const MASTER_CAPABILITY_ENV = "GJC_MASTER_CAPABILITY";
72
93
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
94
+ const ACP_RELEASE_TIMEOUT_MS = 1_000;
73
95
  const READ_ONLY_BASH_ENV: Record<string, string> = {
74
96
  GREP_OPTIONS: "",
75
97
  GREP_COLOR: "",
@@ -151,6 +173,27 @@ function artifactReferenceIsReachable(text: string, result: BashResult | BashInt
151
173
  );
152
174
  }
153
175
 
176
+ function suffixPrefixOverlap(source: string, target: string): number {
177
+ if (source.length === 0 || target.length === 0) return 0;
178
+ const prefix = new Uint32Array(target.length);
179
+ for (let index = 1; index < target.length; index += 1) {
180
+ let length = prefix[index - 1] ?? 0;
181
+ const code = target.charCodeAt(index);
182
+ while (length > 0 && code !== target.charCodeAt(length)) length = prefix[length - 1] ?? 0;
183
+ if (code === target.charCodeAt(length)) length += 1;
184
+ prefix[index] = length;
185
+ }
186
+
187
+ let overlap = 0;
188
+ for (let index = 0; index < source.length; index += 1) {
189
+ const code = source.charCodeAt(index);
190
+ while (overlap > 0 && code !== target.charCodeAt(overlap)) overlap = prefix[overlap - 1] ?? 0;
191
+ if (code === target.charCodeAt(overlap)) overlap += 1;
192
+ if (overlap === target.length && index < source.length - 1) overlap = prefix[overlap - 1] ?? 0;
193
+ }
194
+ return overlap;
195
+ }
196
+
154
197
  function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
155
198
  const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
156
199
  return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
@@ -291,6 +334,48 @@ function formatClientTerminalAbortFailure(
291
334
  return formatBashFailureMessage(result, outputText, "Command aborted");
292
335
  }
293
336
 
337
+ function formatClientTerminalReadFailure(
338
+ prepared: PreparedClientTerminalOutput,
339
+ readDiagnostic: string,
340
+ pendingNotices: readonly string[] = [],
341
+ ): string {
342
+ const notices = [
343
+ ...pendingNotices,
344
+ ...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
345
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
346
+ `Terminal output recovery failed: ${readDiagnostic}`,
347
+ ];
348
+ const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
349
+ const result: BashResult = {
350
+ ...prepared.summary,
351
+ exitCode: undefined,
352
+ cancelled: false,
353
+ };
354
+ return formatBashFailureMessage(result, outputText, "Terminal output read failed");
355
+ }
356
+
357
+ function formatClientTerminalWaitFailure(
358
+ prepared: PreparedClientTerminalOutput,
359
+ waitDiagnostic: string,
360
+ readDiagnostic?: string,
361
+ pendingNotices: readonly string[] = [],
362
+ ): string {
363
+ const notices = [
364
+ ...pendingNotices,
365
+ ...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
366
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
367
+ `Terminal wait failed: ${waitDiagnostic}`,
368
+ ...(readDiagnostic ? [`Terminal output recovery failed: ${readDiagnostic}`] : []),
369
+ ];
370
+ const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
371
+ const result: BashResult = {
372
+ ...prepared.summary,
373
+ exitCode: undefined,
374
+ cancelled: false,
375
+ };
376
+ return formatBashFailureMessage(result, outputText, "Terminal wait failed");
377
+ }
378
+
294
379
  function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
295
380
  const suffixParts: string[] = [];
296
381
  const reference = artifactReferenceForResult(result);
@@ -468,6 +553,21 @@ interface ManagedBashJobHandle {
468
553
  setBackgrounded: (backgrounded: boolean) => void;
469
554
  }
470
555
 
556
+ /** The placeholder a folded PTY hands back to its (now finished) foreground call. */
557
+ function interactiveResultFromText(text: string): BashInteractiveResult {
558
+ return {
559
+ exitCode: undefined,
560
+ cancelled: false,
561
+ timedOut: false,
562
+ output: text,
563
+ outputBytes: 0,
564
+ outputLines: 0,
565
+ totalBytes: 0,
566
+ totalLines: 0,
567
+ truncated: false,
568
+ };
569
+ }
570
+
471
571
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
472
572
  return result.output || "";
473
573
  }
@@ -504,6 +604,107 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
504
604
  return normalized;
505
605
  }
506
606
 
607
+ /**
608
+ * Return true only for a direct, shell-syntax-free `gjc sdk spawn` command.
609
+ *
610
+ * Master authority is process-local and must not become ambient state for an
611
+ * arbitrary shell pipeline. Keep a tiny lexer here instead of a prefix check:
612
+ * shell quoting is allowed for ordinary argument values, while operators,
613
+ * expansions, escapes, globbing, and line breaks all refuse the privileged
614
+ * environment injection path.
615
+ */
616
+ function strictDirectSdkSpawnTokens(command: string): string[] | undefined {
617
+ if (command.length === 0) return undefined;
618
+ const tokens: string[] = [];
619
+ let token = "";
620
+ let tokenStarted = false;
621
+ let quote: "'" | '"' | undefined;
622
+ for (const character of command) {
623
+ if (quote !== undefined) {
624
+ if (character === quote) {
625
+ quote = undefined;
626
+ tokenStarted = true;
627
+ } else {
628
+ token += character;
629
+ tokenStarted = true;
630
+ }
631
+ continue;
632
+ }
633
+ if (";&|<>()$`\\*?[]{}#!\n\r".includes(character)) return undefined;
634
+ if (character === "'" || character === '"') {
635
+ quote = character;
636
+ tokenStarted = true;
637
+ continue;
638
+ }
639
+ if (/\s/u.test(character)) {
640
+ if (tokenStarted) {
641
+ tokens.push(token);
642
+ token = "";
643
+ tokenStarted = false;
644
+ }
645
+ continue;
646
+ }
647
+ token += character;
648
+ tokenStarted = true;
649
+ }
650
+ if (quote !== undefined) return undefined;
651
+ if (tokenStarted) tokens.push(token);
652
+ return tokens.length >= 3 && tokens[0] === "gjc" && tokens[1] === "sdk" && tokens[2] === "spawn"
653
+ ? tokens
654
+ : undefined;
655
+ }
656
+
657
+ export function isStrictDirectSdkSpawnCommand(command: string): boolean {
658
+ return strictDirectSdkSpawnTokens(command) !== undefined;
659
+ }
660
+
661
+ export function parseDirectSdkSpawnArgs(command: string): SdkSpawnArgs {
662
+ const tokens = strictDirectSdkSpawnTokens(command);
663
+ if (tokens === undefined) throw new ToolError("Master spawn command is not a direct shell-safe invocation.");
664
+ const args: SdkSpawnArgs = {};
665
+ for (let index = 3; index < tokens.length; index++) {
666
+ const token = tokens[index]!;
667
+ if (token === "--json") {
668
+ args.json = true;
669
+ continue;
670
+ }
671
+ const equals = token.indexOf("=");
672
+ const name = equals < 0 ? token : token.slice(0, equals);
673
+ const value = equals < 0 ? tokens[++index] : token.slice(equals + 1);
674
+ if (value === undefined || value.length === 0) throw new ToolError(`Master spawn flag ${name} requires a value.`);
675
+ switch (name) {
676
+ case "--cwd":
677
+ args.cwd = value;
678
+ break;
679
+ case "--prompt":
680
+ args.prompt = value;
681
+ break;
682
+ case "--model":
683
+ args.model = value;
684
+ break;
685
+ case "--profile":
686
+ args.profile = value;
687
+ break;
688
+ case "--idempotency-key":
689
+ args.idempotencyKey = value;
690
+ break;
691
+ case "--agent-dir":
692
+ throw new ToolError("Master spawn cannot override the session agent directory.");
693
+ default:
694
+ throw new ToolError(`Unsupported master spawn flag: ${name}`);
695
+ }
696
+ }
697
+ return args;
698
+ }
699
+
700
+ export function masterCommandEnvOverrides(
701
+ env: Record<string, string> | undefined,
702
+ directMasterSpawn: boolean,
703
+ ): Record<string, string> {
704
+ if (directMasterSpawn) return {};
705
+ return Object.fromEntries(Object.entries(env ?? {}).filter(([key]) => key !== MASTER_CAPABILITY_ENV));
706
+ }
707
+
507
708
  function escapeBashEnvValueForDisplay(value: string): string {
508
709
  return value
509
710
  .replaceAll("\\", "\\\\")
@@ -648,6 +849,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
648
849
  this.description = prompt.render(bashDescription, {
649
850
  asyncEnabled: this.#asyncEnabled,
650
851
  autoBackgroundEnabled: this.#autoBackgroundEnabled,
852
+ foregroundFoldEnabled: this.session.registerForegroundFoldParticipant !== undefined,
651
853
  autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
652
854
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
653
855
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
@@ -732,12 +934,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
732
934
  label: string,
733
935
  previewText: string,
734
936
  timeoutSec: number,
735
- options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
937
+ options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
736
938
  ): AgentToolResult<BashToolDetails> {
737
939
  const details: BashToolDetails = {
738
940
  timeoutSeconds: timeoutSec,
739
941
  async: { state: "running", jobId, type: "bash" },
740
942
  };
943
+ // A folded client terminal keeps its remote handle, so the editor's live
944
+ // terminal card stays bound to the same id after the fold.
945
+ if (options.terminalId !== undefined) details.terminalId = options.terminalId;
741
946
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
742
947
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
743
948
  }
@@ -773,8 +978,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
773
978
  notices?: readonly string[];
774
979
 
775
980
  resolvedEnv?: Record<string, string>;
981
+ directMasterSpawn: boolean;
776
982
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
777
983
  startBackgrounded: boolean;
984
+ onCompletion?: () => void;
778
985
  /** Immutable attempt-scoped tool call id, when executed via a tool call. */
779
986
  toolCallId?: string;
780
987
  }): ManagedBashJobHandle {
@@ -783,7 +990,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
783
990
  throw new ToolError("Background job manager unavailable for this session.");
784
991
  }
785
992
 
786
- const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
993
+ const label = options.directMasterSpawn
994
+ ? "gjc sdk spawn"
995
+ : options.command.length > 120
996
+ ? `${options.command.slice(0, 117)}...`
997
+ : options.command;
787
998
  let latestText = "";
788
999
  let backgrounded = options.startBackgrounded;
789
1000
  const runningDetails = (jobId: string): Record<string, unknown> | undefined =>
@@ -806,37 +1017,37 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
806
1017
 
807
1018
  let executionResult: BashResult | BashInteractiveResult | undefined;
808
1019
  try {
809
- const result = await executeBash(options.command, {
810
- cwd: options.commandCwd,
811
- settings: this.session.settings,
812
- sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
813
- timeout: options.timeoutMs,
814
- signal: runSignal,
815
- env: options.resolvedEnv,
816
- artifactPath,
817
- artifactId,
818
- artifactPublisher,
819
- spillThreshold,
820
- headBytes,
821
- oneShot: true,
822
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
823
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
824
- onChunk: chunk => {
825
- tailBuffer.append(chunk);
826
- latestText = tailBuffer.text();
827
- void reportProgress(latestText, runningDetails(jobId));
828
- },
829
- onRawChunk: chunk => {
830
- // Forward the unthrottled sanitized chunk to the async-job
831
- // substrate so the Monitor tool can read the complete process
832
- // stream by byte offset, independent of the throttled preview
833
- // path above.
834
- manager.appendOutput(jobId, chunk);
835
- },
836
- onMinimizedSave: async originalText => {
837
- return saveBashOriginalArtifact(this.session, originalText);
838
- },
839
- });
1020
+ const result = options.directMasterSpawn
1021
+ ? await this.#runDirectMasterSpawn(
1022
+ options.command,
1023
+ options.commandCwd,
1024
+ options.timeoutMs,
1025
+ runSignal,
1026
+ options.resolvedEnv,
1027
+ )
1028
+ : await executeBash(options.command, {
1029
+ cwd: options.commandCwd,
1030
+ settings: this.session.settings,
1031
+ sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
1032
+ timeout: options.timeoutMs,
1033
+ signal: runSignal,
1034
+ env: options.resolvedEnv,
1035
+ artifactPath,
1036
+ artifactId,
1037
+ artifactPublisher,
1038
+ spillThreshold,
1039
+ headBytes,
1040
+ oneShot: true,
1041
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1042
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1043
+ onChunk: chunk => {
1044
+ tailBuffer.append(chunk);
1045
+ latestText = tailBuffer.text();
1046
+ void reportProgress(latestText, runningDetails(jobId));
1047
+ },
1048
+ onRawChunk: chunk => manager.appendOutput(jobId, chunk),
1049
+ onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
1050
+ });
840
1051
  executionResult = result;
841
1052
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
842
1053
  requestedTimeoutSec: options.requestedTimeoutSec,
@@ -845,12 +1056,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
845
1056
  });
846
1057
  const finalText = this.#extractTextResult(finalResult);
847
1058
  latestText = finalText;
1059
+ options.onCompletion?.();
848
1060
  completion.resolve({ kind: "completed", result: finalResult });
849
1061
  await reportProgress(finalText, completedDetails(jobId));
850
1062
  return finalText;
851
1063
  } catch (error) {
852
1064
  const message = error instanceof Error ? error.message : String(error);
853
1065
  latestText = message;
1066
+ options.onCompletion?.();
854
1067
  completion.resolve({ kind: "failed", error, result: executionResult });
855
1068
  await reportProgress(message, failedDetails(jobId));
856
1069
  throw error;
@@ -867,6 +1080,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
867
1080
  },
868
1081
  },
869
1082
  );
1083
+ const jobGeneration = manager.getJob(jobId)?.generation ?? jobId;
1084
+ const unregisterOwnerCleanup = manager.registerOwnerCleanup(this.session.getAgentId?.() ?? "0-Main", () => {
1085
+ manager.failNow(jobId, jobGeneration, "Bash job owner was torn down.");
1086
+ });
1087
+ void completion.promise.finally(unregisterOwnerCleanup);
870
1088
  registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
871
1089
 
872
1090
  return {
@@ -893,9 +1111,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
893
1111
  */
894
1112
  #resolveOwnedJobManager(): AsyncJobManager | undefined {
895
1113
  const endpointId = this.session.getSessionId?.() ?? undefined;
896
- return (
897
- this.session.getAsyncJobManager?.() ?? AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance()
898
- );
1114
+ if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
1115
+ return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
899
1116
  }
900
1117
 
901
1118
  async #waitForManagedBashJob(
@@ -953,6 +1170,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
953
1170
  command: string;
954
1171
  commandCwd: string;
955
1172
  resolvedEnv: Record<string, string>;
1173
+ directMasterSpawn: boolean;
956
1174
  requestedTimeoutSec: number;
957
1175
  timeoutSec: number;
958
1176
  timeoutMs: number;
@@ -1098,14 +1316,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1098
1316
  // silently ignore the caller's override.
1099
1317
  const explicitAgentDirOverride =
1100
1318
  expandedEnv?.GJC_CODING_AGENT_DIR !== undefined || expandedEnv?.PI_CODING_AGENT_DIR !== undefined;
1319
+ // The master capability is a one-shot authority for the direct spawn CLI,
1320
+ // never ambient state for ordinary Bash or a chained/pipelined descendant.
1321
+ // Check both spellings so cwd normalization or URL expansion cannot turn a
1322
+ // command that contained shell syntax into a privileged one.
1323
+ const directMasterSpawn = isStrictDirectSdkSpawnCommand(rawCommand) && isStrictDirectSdkSpawnCommand(command);
1324
+ const masterCapability = directMasterSpawn ? this.session.getMasterBashCapability?.() : undefined;
1325
+ const masterOwnerSessionId = directMasterSpawn ? this.session.getMasterOwnerSessionId?.() : undefined;
1326
+ const commandEnvOverrides = masterCommandEnvOverrides(expandedEnv, directMasterSpawn);
1101
1327
  const resolvedEnv = {
1102
1328
  ...buildGjcRuntimeSessionEnv({
1103
1329
  sessionFile: null,
1104
1330
  sessionId: this.session.getSessionId?.(),
1105
1331
  cwd: this.session.cwd,
1106
1332
  }),
1107
- ...(sessionAgentDir && !explicitAgentDirOverride ? { GJC_CODING_AGENT_DIR: sessionAgentDir } : {}),
1108
- ...expandedEnv,
1333
+ ...(sessionAgentDir && (!explicitAgentDirOverride || directMasterSpawn)
1334
+ ? { GJC_CODING_AGENT_DIR: sessionAgentDir }
1335
+ : {}),
1336
+ ...commandEnvOverrides,
1337
+ ...(masterCapability ? { [MASTER_CAPABILITY_ENV]: masterCapability } : {}),
1338
+ ...(masterOwnerSessionId ? { GJC_MASTER_OWNER_SESSION_ID: masterOwnerSessionId } : {}),
1109
1339
  ...(this.session.bashRestrictionProfile === "read-only" ? READ_ONLY_BASH_ENV : {}),
1110
1340
  ...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),
1111
1341
  };
@@ -1137,7 +1367,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1137
1367
  const sleepAdvisory = longSleepAdvisory(command, timeoutSec);
1138
1368
  if (sleepAdvisory) notices.push(sleepAdvisory);
1139
1369
 
1140
- return { command, commandCwd, resolvedEnv, requestedTimeoutSec, timeoutSec, timeoutMs, notices };
1370
+ return {
1371
+ command,
1372
+ commandCwd,
1373
+ resolvedEnv,
1374
+ directMasterSpawn,
1375
+ requestedTimeoutSec,
1376
+ timeoutSec,
1377
+ timeoutMs,
1378
+ notices,
1379
+ };
1380
+ }
1381
+
1382
+ async #runDirectMasterSpawn(
1383
+ command: string,
1384
+ commandCwd: string,
1385
+ timeoutMs: number,
1386
+ signal: AbortSignal | undefined,
1387
+ resolvedEnv: Record<string, string> | undefined,
1388
+ ): Promise<BashResult> {
1389
+ const masterCapability = resolvedEnv?.[MASTER_CAPABILITY_ENV];
1390
+ const sessionAgentDir = resolvedEnv?.GJC_CODING_AGENT_DIR;
1391
+ if (masterCapability === undefined || sessionAgentDir === undefined)
1392
+ throw new ToolError("Master spawn authority context is unavailable.");
1393
+ const args = parseDirectSdkSpawnArgs(command);
1394
+ args.agentDir = sessionAgentDir;
1395
+ if (args.cwd !== undefined && !path.isAbsolute(args.cwd)) args.cwd = path.resolve(commandCwd, args.cwd);
1396
+ const request = runSdkSpawn(args, {
1397
+ env: {
1398
+ ...process.env,
1399
+ GJC_MASTER_CAPABILITY: masterCapability,
1400
+ GJC_SESSION_ID: resolvedEnv?.GJC_MASTER_OWNER_SESSION_ID ?? this.session.getSessionId?.() ?? undefined,
1401
+ GJC_CODING_AGENT_DIR: sessionAgentDir,
1402
+ },
1403
+ });
1404
+ const timeout = Bun.sleep(timeoutMs).then(() => {
1405
+ throw new ToolError(`Command timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
1406
+ });
1407
+ const aborted = Promise.withResolvers<never>();
1408
+ const onAbort = (): void => aborted.reject(new ToolAbortError("Command aborted"));
1409
+ if (signal?.aborted) onAbort();
1410
+ else signal?.addEventListener("abort", onAbort, { once: true });
1411
+ let result: Awaited<typeof request>;
1412
+ try {
1413
+ result = await Promise.race([request, timeout, aborted.promise]);
1414
+ } finally {
1415
+ signal?.removeEventListener("abort", onAbort);
1416
+ }
1417
+ const output = args.json ? JSON.stringify(result.rendered) : renderSpawnTable(result.rendered);
1418
+ const outputBytes = Buffer.byteLength(output, "utf8");
1419
+ const outputLines = output.split("\n").length;
1420
+ return {
1421
+ output,
1422
+ exitCode: result.exitCode,
1423
+ cancelled: false,
1424
+ truncated: false,
1425
+ totalLines: outputLines,
1426
+ totalBytes: outputBytes,
1427
+ outputLines,
1428
+ outputBytes,
1429
+ };
1430
+ }
1431
+
1432
+ async #executeDirectMasterSpawn(
1433
+ command: string,
1434
+ commandCwd: string,
1435
+ timeoutSec: number,
1436
+ timeoutMs: number,
1437
+ requestedTimeoutSec: number,
1438
+ notices: readonly string[],
1439
+ signal: AbortSignal | undefined,
1440
+ resolvedEnv: Record<string, string>,
1441
+ ): Promise<AgentToolResult<BashToolDetails>> {
1442
+ const result = await this.#runDirectMasterSpawn(command, commandCwd, timeoutMs, signal, resolvedEnv);
1443
+ return this.#buildCompletedResult(result, timeoutSec, { requestedTimeoutSec, notices });
1141
1444
  }
1142
1445
 
1143
1446
  /**
@@ -1165,6 +1468,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1165
1468
  if (!manager) {
1166
1469
  throw new ToolError("Async job manager unavailable for this session.");
1167
1470
  }
1471
+ if (!manager.hasCapacity()) {
1472
+ throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
1473
+ }
1168
1474
  const prepared = await this.#prepareBashExecution(input, opts.ctx);
1169
1475
  const label =
1170
1476
  opts.label ?? (prepared.command.length > 120 ? `${prepared.command.slice(0, 117)}...` : prepared.command);
@@ -1232,8 +1538,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1232
1538
  spillThreshold,
1233
1539
  headBytes,
1234
1540
  oneShot: true,
1235
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1236
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1541
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1542
+ disableShellSnapshot:
1543
+ this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1237
1544
  onChunk: chunk => {
1238
1545
  tailBuffer.append(chunk);
1239
1546
  void reportProgress(tailBuffer.text(), {
@@ -1289,6 +1596,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1289
1596
  if (this.session.bashRestrictionProfile === "read-only" && pty) {
1290
1597
  throw new ToolError("Read-only bash does not allow PTY mode.");
1291
1598
  }
1599
+ const admissionManager = this.#resolveOwnedJobManager();
1600
+ if (asyncRequested && !admissionManager) {
1601
+ throw new ToolError("Async job manager unavailable for this session.");
1602
+ }
1603
+ if (admissionManager && !admissionManager.hasCapacity()) {
1604
+ throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
1605
+ }
1292
1606
 
1293
1607
  const prepared = await this.#prepareBashExecution(
1294
1608
  { command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
@@ -1298,18 +1612,33 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1298
1612
  command,
1299
1613
  commandCwd,
1300
1614
  resolvedEnv,
1615
+ directMasterSpawn,
1301
1616
  requestedTimeoutSec,
1302
1617
  timeoutSec,
1303
1618
  timeoutMs,
1304
1619
  notices: pendingNotices,
1305
1620
  } = prepared;
1621
+ const masterCapability = directMasterSpawn ? resolvedEnv[MASTER_CAPABILITY_ENV] : undefined;
1622
+ if (directMasterSpawn && masterCapability !== undefined && !asyncRequested) {
1623
+ return await this.#executeDirectMasterSpawn(
1624
+ command,
1625
+ commandCwd,
1626
+ timeoutSec,
1627
+ timeoutMs,
1628
+ requestedTimeoutSec,
1629
+ pendingNotices,
1630
+ signal,
1631
+ resolvedEnv,
1632
+ );
1633
+ }
1306
1634
 
1307
1635
  if (asyncRequested) {
1308
1636
  // Availability is endpoint-first: a concurrent top-level session
1309
1637
  // that was the process-global instance may have been disposed,
1310
1638
  // clearing instance() while THIS session's manager stays
1311
1639
  // registered by endpoint (review thread P1).
1312
- if (!this.#resolveOwnedJobManager()) {
1640
+ const asyncManager = admissionManager ?? this.#resolveOwnedJobManager();
1641
+ if (!asyncManager) {
1313
1642
  throw new ToolError("Async job manager unavailable for this session.");
1314
1643
  }
1315
1644
  const job = this.#startManagedBashJob({
@@ -1321,10 +1650,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1321
1650
  notices: pendingNotices,
1322
1651
 
1323
1652
  resolvedEnv,
1653
+ directMasterSpawn,
1324
1654
  onUpdate,
1325
1655
  startBackgrounded: true,
1326
1656
  toolCallId,
1327
1657
  });
1658
+ const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
1659
+ job.setBackgrounded(true);
1660
+ asyncManager.markBackgrounded(job.jobId, jobGeneration);
1328
1661
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1329
1662
  requestedTimeoutSec,
1330
1663
  notices: pendingNotices,
@@ -1335,7 +1668,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1335
1668
  // Skip when pty=true (PTY needs the local terminal UI).
1336
1669
  const clientBridge =
1337
1670
  this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
1338
- const clientTerminalActive = Boolean(clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty);
1671
+ const clientTerminalActive = Boolean(
1672
+ !directMasterSpawn && clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty,
1673
+ );
1339
1674
 
1340
1675
  // Run non-PTY bash through the managed job path so Ctrl+B-twice fold-on-demand works
1341
1676
  // even when auto-background is disabled. When a client terminal will handle the
@@ -1345,13 +1680,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1345
1680
  // and unregistering — the process-global instance may belong to a different
1346
1681
  // concurrent top-level session (review thread P1).
1347
1682
  const ownedManager = this.#resolveOwnedJobManager();
1348
- if (!pty && ownedManager && (this.#autoBackgroundEnabled || !clientTerminalActive)) {
1683
+ // The client-terminal path is now itself manager-backed and foldable, so it no
1684
+ // longer has to be bypassed to make folding work. A capable ACP session keeps
1685
+ // its terminal contract and still folds.
1686
+ if (!pty && ownedManager && !clientTerminalActive) {
1349
1687
  // With auto-background off, wait past the command's own timeout so the job only
1350
1688
  // leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
1351
1689
  const autoBackgroundWaitMs = this.#autoBackgroundEnabled
1352
1690
  ? this.#resolveAutoBackgroundWaitMs(timeoutMs)
1353
1691
  : timeoutMs + 1_000;
1354
1692
  const startBackgrounded = autoBackgroundWaitMs === 0;
1693
+ let managedForegroundSettled = false;
1355
1694
  const job = this.#startManagedBashJob({
1356
1695
  command,
1357
1696
  commandCwd,
@@ -1361,20 +1700,62 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1361
1700
  notices: pendingNotices,
1362
1701
 
1363
1702
  resolvedEnv,
1703
+ directMasterSpawn,
1364
1704
  onUpdate,
1365
1705
  startBackgrounded,
1706
+ onCompletion: () => {
1707
+ managedForegroundSettled = true;
1708
+ },
1366
1709
  toolCallId,
1367
1710
  });
1711
+ const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
1368
1712
  if (startBackgrounded) {
1713
+ job.setBackgrounded(true);
1714
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1369
1715
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1370
1716
  requestedTimeoutSec,
1371
1717
  notices: pendingNotices,
1372
1718
  });
1373
1719
  }
1374
1720
  const backgroundRequest = Promise.withResolvers<void>();
1375
- const unregisterBackgroundRequest = this.session.registerForegroundBashBackgroundRequestHandler?.(() => {
1376
- job.setBackgrounded(true);
1377
- backgroundRequest.resolve();
1721
+ // Exactly one party may settle the foreground caller. detachObserver (the
1722
+ // fold won) and resolveForegroundObserver (a completion was handed back
1723
+ // after a failed fold) share this flag, so a race cannot double-settle.
1724
+ let foregroundSettled = false;
1725
+ const settleForeground = (): "resolved" | "already-settled" => {
1726
+ if (foregroundSettled || managedForegroundSettled) return "already-settled";
1727
+ foregroundSettled = true;
1728
+ return "resolved";
1729
+ };
1730
+ const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
1731
+ kind: "bash-managed",
1732
+ jobId: job.jobId,
1733
+ jobGeneration,
1734
+ label: job.label,
1735
+ cwdSensitive: true,
1736
+ signal,
1737
+ originatingTurn: ctx?.attemptScope !== undefined,
1738
+ outputRef: {
1739
+ jobId: job.jobId,
1740
+ generation: jobGeneration,
1741
+ instruction: `Use the job tool's tail operation for ${job.jobId} to read this command's output.`,
1742
+ },
1743
+ // Bound at registration over the manager that registered THIS job, so
1744
+ // identity can never resolve to another manager's identically-named job.
1745
+ getJob: () => {
1746
+ const current = ownedManager.getJob(job.jobId);
1747
+ return current?.generation === jobGeneration ? current : undefined;
1748
+ },
1749
+ detachObserver: () => {
1750
+ const outcome = settleForeground();
1751
+ if (outcome === "resolved") {
1752
+ job.setBackgrounded(true);
1753
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1754
+ backgroundRequest.resolve();
1755
+ }
1756
+ return outcome;
1757
+ },
1758
+ resolveForegroundObserver: () => settleForeground(),
1378
1759
  });
1379
1760
  let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
1380
1761
  try {
@@ -1410,6 +1791,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1410
1791
  throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
1411
1792
  }
1412
1793
  job.setBackgrounded(true);
1794
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1413
1795
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
1414
1796
  requestedTimeoutSec,
1415
1797
  notices: pendingNotices,
@@ -1417,63 +1799,200 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1417
1799
  }
1418
1800
 
1419
1801
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
1802
+ const clientAdmission = ownedManager?.reserveCapacity();
1420
1803
  const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1421
1804
  const clientTailBytes = resolveBashOutputSinkTailBytes(this.session.settings);
1422
- const handle = await clientBridge.createTerminal({
1423
- command,
1424
- cwd: commandCwd,
1425
- env: resolvedEnv
1426
- ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
1427
- : undefined,
1428
- outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
1429
- });
1430
-
1431
- // Emit partial update so the editor can embed the live terminal card.
1432
- onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1433
-
1434
- const exitPromise = handle.waitForExit();
1435
- let exitStatus!: ClientBridgeTerminalExitStatus;
1436
-
1437
- type BridgeRaceResult =
1438
- | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1439
- | { kind: "poll" }
1805
+ const runBoundedCleanup = async (
1806
+ terminal: ClientBridgeTerminalHandle,
1807
+ operation: () => Promise<void>,
1808
+ label: "kill" | "release",
1809
+ ): Promise<void> => {
1810
+ const attempt = Promise.resolve()
1811
+ .then(operation)
1812
+ .catch((error: unknown) => {
1813
+ logger.warn(`ACP terminal ${label} failed`, { terminalId: terminal.terminalId, error });
1814
+ });
1815
+ const completed = await Promise.race([
1816
+ attempt.then(() => true),
1817
+ Bun.sleep(ACP_RELEASE_TIMEOUT_MS).then(() => false),
1818
+ ]);
1819
+ if (!completed) logger.warn(`ACP terminal ${label} timed out`, { terminalId: terminal.terminalId });
1820
+ };
1821
+ const createPromise = Promise.resolve().then(() =>
1822
+ clientBridge.createTerminal!({
1823
+ command,
1824
+ cwd: commandCwd,
1825
+ env: resolvedEnv
1826
+ ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
1827
+ : undefined,
1828
+ outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
1829
+ }),
1830
+ );
1831
+ const timeout = Promise.withResolvers<{ kind: "timeout" }>();
1832
+ const abort = Promise.withResolvers<{ kind: "aborted" }>();
1833
+ const onCreateAbort = (): void => abort.resolve({ kind: "aborted" });
1834
+ if (signal?.aborted) onCreateAbort();
1835
+ else signal?.addEventListener("abort", onCreateAbort, { once: true });
1836
+ const createTimer = setTimeout(() => timeout.resolve({ kind: "timeout" }), timeoutMs);
1837
+ let createOutcome:
1838
+ | { kind: "created"; handle: ClientBridgeTerminalHandle }
1440
1839
  | { kind: "timeout" }
1441
1840
  | { kind: "aborted" };
1841
+ try {
1842
+ createOutcome = await Promise.race([
1843
+ createPromise.then(handle => ({ kind: "created" as const, handle })),
1844
+ timeout.promise,
1845
+ abort.promise,
1846
+ ]);
1847
+ } catch (error) {
1848
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
1849
+ throw error;
1850
+ } finally {
1851
+ clearTimeout(createTimer);
1852
+ signal?.removeEventListener("abort", onCreateAbort);
1853
+ }
1854
+
1855
+ if (createOutcome.kind !== "created") {
1856
+ void createPromise
1857
+ .then(async lateHandle => {
1858
+ await runBoundedCleanup(lateHandle, () => lateHandle.kill(), "kill");
1859
+ await runBoundedCleanup(lateHandle, () => lateHandle.release(), "release");
1860
+ })
1861
+ .catch((error: unknown) => {
1862
+ logger.warn("ACP terminal late create cleanup failed", { error });
1863
+ });
1864
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
1865
+ if (createOutcome.kind === "aborted") throw new ToolAbortError("Command aborted");
1866
+ throw new ToolError(`Command timed out after ${timeoutSec} seconds`);
1867
+ }
1868
+
1869
+ let handle: ClientBridgeTerminalHandle;
1870
+ handle = createOutcome.handle;
1442
1871
 
1443
- // Set up abort listener before entering the poll loop. The listener
1444
- // kicks off `handle.kill()` synchronously so a `session/cancel`
1445
- // arriving mid-poll terminates the remote command immediately,
1446
- // instead of waiting for the next `currentOutput()` to return.
1447
- const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1872
+ // The remote terminal is released exactly once, by whichever path settles
1873
+ // it: a foreground return, a folded background completion, or a failure.
1874
+ let released = false;
1875
+ const releaseTerminalOnce = async (): Promise<void> => {
1876
+ if (released) return;
1877
+ released = true;
1878
+ await runBoundedCleanup(handle, () => handle.release(), "release");
1879
+ };
1448
1880
  let killPromise: Promise<void> | undefined;
1449
1881
  const fireKill = (): Promise<void> => {
1450
1882
  if (killPromise) return killPromise;
1451
- killPromise = handle.kill().catch((error: unknown) => {
1883
+ try {
1884
+ killPromise = Promise.resolve(handle.kill()).catch((error: unknown) => {
1885
+ logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1886
+ });
1887
+ } catch (error) {
1452
1888
  logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1453
- });
1889
+ killPromise = Promise.resolve();
1890
+ }
1454
1891
  return killPromise;
1455
1892
  };
1456
- const onAbortSignal = () => {
1457
- resolveAborted();
1458
- void fireKill();
1893
+ const boundedKill = async (): Promise<void> => {
1894
+ await runBoundedCleanup(handle, fireKill, "kill");
1459
1895
  };
1460
- signal?.addEventListener("abort", onAbortSignal, { once: true });
1461
1896
 
1897
+ // Emit partial update so the editor can embed the live terminal card.
1462
1898
  try {
1899
+ onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1900
+ } catch (error) {
1901
+ await boundedKill();
1902
+ await releaseTerminalOnce();
1903
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
1904
+ throw error;
1905
+ }
1906
+ let latestText = "";
1907
+ let bridgeJobId!: string;
1908
+ let bridgeBackgrounded = false;
1909
+ let retainedAcpSnapshot = "";
1910
+ let retainedAcpTruncated = false;
1911
+ const ACP_RAW_OVERLAP_BYTES = 512 * 1024;
1912
+ const appendAcpSnapshot = (snapshot: string, upstreamTruncated = false): void => {
1913
+ retainedAcpTruncated ||= upstreamTruncated;
1914
+ if (!snapshot) return;
1915
+ const snapshotBytes = Buffer.byteLength(snapshot, "utf8");
1916
+ const retainedBytes = Buffer.byteLength(retainedAcpSnapshot, "utf8");
1917
+ const boundedSnapshot =
1918
+ snapshotBytes > ACP_RAW_OVERLAP_BYTES
1919
+ ? sliceTextAfterUtf8ByteOffset(snapshot, snapshotBytes - ACP_RAW_OVERLAP_BYTES)
1920
+ : snapshot;
1921
+ const boundedRetained =
1922
+ retainedBytes > ACP_RAW_OVERLAP_BYTES
1923
+ ? sliceTextAfterUtf8ByteOffset(retainedAcpSnapshot, retainedBytes - ACP_RAW_OVERLAP_BYTES)
1924
+ : retainedAcpSnapshot;
1925
+ const overlap = suffixPrefixOverlap(boundedRetained, boundedSnapshot);
1926
+ const delta = boundedSnapshot.slice(overlap);
1927
+ if (bridgeJobId && delta) ownedManager?.appendOutput(bridgeJobId, delta);
1928
+ retainedAcpSnapshot = boundedSnapshot;
1929
+ retainedAcpTruncated ||= snapshotBytes > ACP_RAW_OVERLAP_BYTES;
1930
+ };
1931
+ const retainedAcpOutput = (): ClientBridgeTerminalOutput => ({
1932
+ output: retainedAcpSnapshot,
1933
+ truncated: retainedAcpTruncated,
1934
+ });
1935
+
1936
+ const runToCompletion = async (
1937
+ runSignal: AbortSignal | undefined,
1938
+ ): Promise<AgentToolResult<BashToolDetails>> => {
1939
+ type BridgeRaceResult =
1940
+ | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1941
+ | { kind: "wait-failed"; error: unknown }
1942
+ | { kind: "poll" }
1943
+ | { kind: "timeout" }
1944
+ | { kind: "aborted" };
1945
+ const exitPromise = Promise.resolve().then(() => handle.waitForExit());
1946
+ const exitRacePromise: Promise<BridgeRaceResult> = exitPromise.then(
1947
+ status => ({ kind: "exit" as const, status }),
1948
+ error => ({ kind: "wait-failed" as const, error }),
1949
+ );
1950
+ let exitStatus!: ClientBridgeTerminalExitStatus;
1951
+
1952
+ // Set up abort listener before entering the poll loop. The listener
1953
+ // kicks off `handle.kill()` synchronously so a `session/cancel`
1954
+ // arriving mid-poll terminates the remote command immediately,
1955
+ // instead of waiting for the next `currentOutput()` to return.
1956
+ const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1957
+ const deadlineAt = Date.now() + timeoutMs;
1958
+ const readOutput = async (
1959
+ limitMs: number,
1960
+ includeAbort = true,
1961
+ includeExit = false,
1962
+ ): Promise<ClientBridgeTerminalOutput | undefined> => {
1963
+ const timeout = Promise.withResolvers<undefined>();
1964
+ const timer = setTimeout(() => timeout.resolve(undefined), Math.max(1, limitMs));
1965
+ return Promise.race([
1966
+ handle.currentOutput(),
1967
+ ...(includeAbort ? [abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined)] : []),
1968
+ ...(includeExit
1969
+ ? [exitRacePromise.then(() => undefined as ClientBridgeTerminalOutput | undefined)]
1970
+ : []),
1971
+ timeout.promise,
1972
+ ]).finally(() => clearTimeout(timer));
1973
+ };
1974
+ const onAbortSignal = () => {
1975
+ resolveAborted();
1976
+ void fireKill();
1977
+ };
1978
+ runSignal?.addEventListener("abort", onAbortSignal, { once: true });
1979
+
1463
1980
  try {
1464
- if (signal?.aborted) {
1465
- await fireKill();
1981
+ if (runSignal?.aborted) {
1982
+ await boundedKill();
1466
1983
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1467
1984
  let readDiagnostic: string | undefined;
1468
1985
  try {
1469
- current = await handle.currentOutput();
1986
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1470
1987
  } catch (error) {
1988
+ current = retainedAcpOutput();
1471
1989
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1472
1990
  logger.warn("ACP terminal aborted output read failed", {
1473
1991
  terminalId: handle.terminalId,
1474
1992
  error,
1475
1993
  });
1476
1994
  }
1995
+ appendAcpSnapshot(current.output, current.truncated);
1477
1996
  const prepared = await prepareClientTerminalOutput(this.session, current);
1478
1997
  throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1479
1998
  }
@@ -1482,51 +2001,81 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1482
2001
  // Poll until the process exits, times out, or the caller aborts.
1483
2002
  for (;;) {
1484
2003
  const racers: Array<Promise<BridgeRaceResult>> = [
1485
- exitPromise.then(s => ({ kind: "exit" as const, status: s })),
2004
+ exitRacePromise,
1486
2005
  timeoutPromise,
1487
2006
  Bun.sleep(250).then(() => ({ kind: "poll" as const })),
1488
2007
  ];
1489
- if (signal) {
2008
+ if (runSignal) {
1490
2009
  racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
1491
2010
  }
1492
2011
  const raced = await Promise.race(racers);
1493
2012
 
1494
- if (raced.kind === "aborted" || signal?.aborted) {
1495
- await fireKill();
2013
+ if (raced.kind === "aborted" || runSignal?.aborted) {
2014
+ await boundedKill();
1496
2015
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1497
2016
  let readDiagnostic: string | undefined;
1498
2017
  try {
1499
- current = await handle.currentOutput();
2018
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1500
2019
  } catch (error) {
2020
+ current = retainedAcpOutput();
1501
2021
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1502
2022
  logger.warn("ACP terminal aborted output read failed", {
1503
2023
  terminalId: handle.terminalId,
1504
2024
  error,
1505
2025
  });
1506
2026
  }
2027
+ appendAcpSnapshot(current.output, current.truncated);
1507
2028
  const prepared = await prepareClientTerminalOutput(this.session, current);
1508
2029
  throw new ToolAbortError(
1509
2030
  formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
1510
2031
  );
1511
2032
  }
1512
2033
 
2034
+ if (raced.kind === "wait-failed") {
2035
+ await boundedKill();
2036
+ let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
2037
+ let readDiagnostic: string | undefined;
2038
+ try {
2039
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
2040
+ } catch (error) {
2041
+ current = retainedAcpOutput();
2042
+ readDiagnostic = boundArtifactSaveDiagnostic(error);
2043
+ logger.warn("ACP terminal output recovery after wait failure failed", {
2044
+ terminalId: handle.terminalId,
2045
+ error,
2046
+ });
2047
+ }
2048
+ appendAcpSnapshot(current.output, current.truncated);
2049
+ const prepared = await prepareClientTerminalOutput(this.session, current);
2050
+ throw new ToolError(
2051
+ formatClientTerminalWaitFailure(
2052
+ prepared,
2053
+ boundArtifactSaveDiagnostic(raced.error),
2054
+ readDiagnostic,
2055
+ pendingNotices,
2056
+ ),
2057
+ );
2058
+ }
2059
+
1513
2060
  if (raced.kind === "timeout") {
1514
2061
  // Kill before reading final output so a slow `terminal/output`
1515
2062
  // RPC cannot let a timed-out command keep running past the
1516
2063
  // enforced timeout. The handle stays valid post-kill so the
1517
2064
  // buffered output is still readable.
1518
- await fireKill();
2065
+ await boundedKill();
1519
2066
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1520
2067
  let readDiagnostic: string | undefined;
1521
2068
  try {
1522
- current = await handle.currentOutput();
2069
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1523
2070
  } catch (error) {
2071
+ current = retainedAcpOutput();
1524
2072
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1525
2073
  logger.warn("ACP terminal final output read failed", {
1526
2074
  terminalId: handle.terminalId,
1527
2075
  error,
1528
2076
  });
1529
2077
  }
2078
+ appendAcpSnapshot(current.output, current.truncated);
1530
2079
  const prepared = await prepareClientTerminalOutput(this.session, current);
1531
2080
  const timeoutNotices = [
1532
2081
  ...pendingNotices,
@@ -1555,13 +2104,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1555
2104
  // Poll tick: push current output so agent-loop transcript stays consistent.
1556
2105
  // Race the read against abort so a stuck `terminal/output` RPC does not
1557
2106
  // delay cancellation.
1558
- const pollOutput = await Promise.race([
1559
- handle.currentOutput(),
1560
- abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
1561
- ]);
2107
+ let pollOutput: ClientBridgeTerminalOutput | undefined;
2108
+ try {
2109
+ pollOutput = await readOutput(Math.max(1, deadlineAt - Date.now()), true, true);
2110
+ } catch (error) {
2111
+ await boundedKill();
2112
+ const diagnostic = boundArtifactSaveDiagnostic(error);
2113
+ const recoveredOutput = retainedAcpOutput();
2114
+ appendAcpSnapshot(recoveredOutput.output, recoveredOutput.truncated);
2115
+ const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
2116
+ logger.warn("ACP terminal poll output read failed", {
2117
+ terminalId: handle.terminalId,
2118
+ error,
2119
+ });
2120
+ throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
2121
+ }
1562
2122
  if (pollOutput === undefined) {
1563
2123
  // Abort fired during the poll-tick read; let the next loop iteration
1564
- // observe `signal?.aborted` and exit via the abort branch.
2124
+ // observe `runSignal?.aborted` and exit via the abort branch.
1565
2125
  continue;
1566
2126
  }
1567
2127
  const { summary, locallyTruncated } = await boundClientTerminalOutput(
@@ -1573,40 +2133,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1573
2133
  pollOutput.truncated || locallyTruncated
1574
2134
  ? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
1575
2135
  : summary.output;
1576
- onUpdate?.({
1577
- content: [{ type: "text", text: pollText }],
1578
- details: { terminalId: handle.terminalId },
1579
- });
2136
+ latestText = pollText;
2137
+ appendAcpSnapshot(pollOutput.output, pollOutput.truncated);
2138
+ if (!bridgeBackgrounded) {
2139
+ try {
2140
+ onUpdate?.({
2141
+ content: [{ type: "text", text: pollText }],
2142
+ details: { terminalId: handle.terminalId },
2143
+ });
2144
+ } catch (error) {
2145
+ await boundedKill();
2146
+ const diagnostic = boundArtifactSaveDiagnostic(error);
2147
+ const recoveredOutput = retainedAcpOutput();
2148
+ const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
2149
+ throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
2150
+ }
2151
+ }
1580
2152
  }
1581
2153
  } finally {
1582
- signal?.removeEventListener("abort", onAbortSignal);
2154
+ runSignal?.removeEventListener("abort", onAbortSignal);
1583
2155
  }
1584
2156
 
1585
- if (signal?.aborted) {
1586
- await fireKill();
2157
+ if (runSignal?.aborted) {
2158
+ await boundedKill();
1587
2159
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1588
2160
  let readDiagnostic: string | undefined;
1589
2161
  try {
1590
- current = await handle.currentOutput();
2162
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1591
2163
  } catch (error) {
2164
+ current = retainedAcpOutput();
1592
2165
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1593
2166
  logger.warn("ACP terminal aborted output read failed", {
1594
2167
  terminalId: handle.terminalId,
1595
2168
  error,
1596
2169
  });
1597
2170
  }
2171
+ appendAcpSnapshot(current.output, current.truncated);
1598
2172
  const prepared = await prepareClientTerminalOutput(this.session, current);
1599
2173
  throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1600
2174
  }
1601
2175
 
1602
2176
  // Fetch final output; the terminal is released in the outer finally.
1603
- const finalOutput = await handle.currentOutput();
2177
+ let finalReadDiagnostic: string | undefined;
2178
+ let finalOutput: ClientBridgeTerminalOutput;
2179
+ try {
2180
+ const recovered = await readOutput(1_000, false);
2181
+ if (recovered === undefined) {
2182
+ finalOutput = retainedAcpOutput();
2183
+ finalReadDiagnostic = "terminal/output read timed out";
2184
+ } else {
2185
+ finalOutput = recovered;
2186
+ }
2187
+ } catch (error) {
2188
+ finalOutput = retainedAcpOutput();
2189
+ finalReadDiagnostic = boundArtifactSaveDiagnostic(error);
2190
+ logger.warn("ACP terminal final output read failed", {
2191
+ terminalId: handle.terminalId,
2192
+ error,
2193
+ });
2194
+ }
2195
+ if (runSignal?.aborted) {
2196
+ await boundedKill();
2197
+ appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
2198
+ const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
2199
+ throw new ToolAbortError(
2200
+ formatClientTerminalAbortFailure(prepared, finalReadDiagnostic, pendingNotices),
2201
+ );
2202
+ }
1604
2203
 
1605
2204
  // Map exit status: null exitCode with a signal → treat as signal kill (137).
1606
2205
  const rawExitCode = exitStatus.exitCode;
1607
2206
  const exitCode: number | undefined =
1608
2207
  rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
1609
2208
 
2209
+ appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
1610
2210
  const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
1611
2211
 
1612
2212
  const bridgeResult: BashResult = {
@@ -1619,19 +2219,188 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1619
2219
  if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
1620
2220
  for (const notice of pendingNotices) bridgeNotices.push(notice);
1621
2221
  if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
2222
+ if (finalReadDiagnostic) bridgeNotices.push(`Terminal output recovery failed: ${finalReadDiagnostic}`);
1622
2223
 
1623
2224
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
1624
2225
  requestedTimeoutSec,
1625
2226
  notices: bridgeNotices,
1626
2227
  terminalId: handle.terminalId,
1627
2228
  });
1628
- } finally {
2229
+ };
2230
+
2231
+ // Without a job manager nothing can fold this, so ownership never leaves the
2232
+ // foreground call.
2233
+ if (!ownedManager) {
1629
2234
  try {
1630
- await handle.release();
1631
- } catch (error) {
1632
- logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
2235
+ return await runToCompletion(signal);
2236
+ } finally {
2237
+ await releaseTerminalOnce();
1633
2238
  }
1634
2239
  }
2240
+
2241
+ // Manager-backed runner: the remote command keeps running after a fold and
2242
+ // its completion is delivered like any other background job. createTerminal
2243
+ // already ran above, so exactly ONE remote handle exists and is retained
2244
+ // across the fold.
2245
+ const bridgeManager = ownedManager;
2246
+ const bridgeEndpointId = this.session.getSessionId?.() ?? "local";
2247
+ const bridgeLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
2248
+ const bridgeCompletion = Promise.withResolvers<ManagedBashJobCompletion>();
2249
+ let bridgeForegroundSettled = false;
2250
+ const settleBridgeForeground = (): "resolved" | "already-settled" => {
2251
+ if (bridgeForegroundSettled) return "already-settled";
2252
+ bridgeForegroundSettled = true;
2253
+ return "resolved";
2254
+ };
2255
+
2256
+ try {
2257
+ bridgeJobId = bridgeManager.register(
2258
+ "bash",
2259
+ bridgeLabel,
2260
+ async ({ jobId, signal: runSignal, reportProgress }) => {
2261
+ try {
2262
+ const result = await runToCompletion(runSignal);
2263
+ const finalText = this.#extractTextResult(result);
2264
+ latestText = finalText;
2265
+ if (!bridgeBackgrounded) settleBridgeForeground();
2266
+ bridgeCompletion.resolve({ kind: "completed", result });
2267
+ await reportProgress(
2268
+ finalText,
2269
+ bridgeBackgrounded ? { async: { state: "completed", jobId, type: "bash" } } : undefined,
2270
+ );
2271
+ return finalText;
2272
+ } catch (error) {
2273
+ const message = error instanceof Error ? error.message : String(error);
2274
+ latestText = message;
2275
+ if (!bridgeBackgrounded) settleBridgeForeground();
2276
+ bridgeCompletion.resolve({ kind: "failed", error });
2277
+ await reportProgress(
2278
+ message,
2279
+ bridgeBackgrounded ? { async: { state: "failed", jobId, type: "bash" } } : undefined,
2280
+ );
2281
+ throw error;
2282
+ } finally {
2283
+ await releaseTerminalOnce();
2284
+ }
2285
+ },
2286
+ {
2287
+ ownerId: this.session.getAgentId?.() ?? undefined,
2288
+ admissionToken: clientAdmission,
2289
+ onProgress: async (text: string) => {
2290
+ latestText = text;
2291
+ },
2292
+ },
2293
+ );
2294
+ } catch (error) {
2295
+ await boundedKill();
2296
+ await releaseTerminalOnce();
2297
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
2298
+ throw error;
2299
+ }
2300
+
2301
+ const bridgeGeneration = bridgeManager.getJob(bridgeJobId)?.generation ?? bridgeJobId;
2302
+ // Owner teardown must not swallow a live remote command. failNow is the one
2303
+ // transition that both fails the job AND enqueues its delivery, so the
2304
+ // failure stays visible instead of becoming a cancelled job that delivers
2305
+ // nothing.
2306
+ const unregisterOwnerCleanup = bridgeManager.registerOwnerCleanup(
2307
+ this.session.getAgentId?.() ?? "0-Main",
2308
+ () => {
2309
+ void boundedKill().then(() => releaseTerminalOnce());
2310
+ bridgeManager.failNow(bridgeJobId, bridgeGeneration, "Client terminal owner was torn down.");
2311
+ },
2312
+ );
2313
+
2314
+ const bridgeHandle: ManagedBashJobHandle = {
2315
+ jobId: bridgeJobId,
2316
+ label: bridgeLabel,
2317
+ completion: bridgeCompletion.promise,
2318
+ getLatestText: () => latestText,
2319
+ setBackgrounded: (next: boolean) => {
2320
+ bridgeBackgrounded = next;
2321
+ },
2322
+ };
2323
+ void bridgeHandle.completion.finally(unregisterOwnerCleanup);
2324
+ registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
2325
+
2326
+ const bridgeFoldRequest = Promise.withResolvers<void>();
2327
+ const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
2328
+ const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
2329
+ kind: "client-terminal",
2330
+ jobId: bridgeJobId,
2331
+ jobGeneration: bridgeGeneration,
2332
+ label: bridgeLabel,
2333
+ cwdSensitive: true,
2334
+ signal,
2335
+ originatingTurn: bridgeOriginatingTurn,
2336
+ outputRef: {
2337
+ jobId: bridgeJobId,
2338
+ generation: bridgeGeneration,
2339
+ instruction: `Use the job tool's tail operation for ${bridgeJobId} to read this command's output.`,
2340
+ },
2341
+ getJob: () => {
2342
+ const current = bridgeManager.getJob(bridgeJobId);
2343
+ return current?.generation === bridgeGeneration ? current : undefined;
2344
+ },
2345
+ detachObserver: () => {
2346
+ const outcome = settleBridgeForeground();
2347
+ if (outcome === "resolved") {
2348
+ bridgeHandle.setBackgrounded(true);
2349
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2350
+ bridgeFoldRequest.resolve();
2351
+ }
2352
+ return outcome;
2353
+ },
2354
+ resolveForegroundObserver: () => settleBridgeForeground(),
2355
+ });
2356
+
2357
+ let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
2358
+ try {
2359
+ bridgeWait = await this.#waitForManagedBashJob(
2360
+ bridgeHandle,
2361
+ this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
2362
+ signal,
2363
+ bridgeFoldRequest.promise,
2364
+ );
2365
+ } finally {
2366
+ unregisterBridgeFold?.();
2367
+ }
2368
+
2369
+ if (bridgeWait.kind === "completed") {
2370
+ unregisterOwnerCleanup();
2371
+ bridgeManager.acknowledgeDeliveries([bridgeJobId]);
2372
+ unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
2373
+ return bridgeWait.result;
2374
+ }
2375
+ if (bridgeWait.kind === "failed") {
2376
+ unregisterOwnerCleanup();
2377
+ bridgeManager.acknowledgeDeliveries([bridgeJobId]);
2378
+ unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
2379
+ throw bridgeWait.error;
2380
+ }
2381
+ if (bridgeWait.kind === "aborted") {
2382
+ unregisterOwnerCleanup();
2383
+ bridgeManager.cancel(bridgeJobId);
2384
+ const terminal = await bridgeHandle.completion;
2385
+ bridgeManager.acknowledgeDeliveries([bridgeJobId]);
2386
+ unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
2387
+ if (terminal.kind === "failed") {
2388
+ throw new ToolAbortError(
2389
+ formatManagedAbortFailure(terminal.error, undefined, bridgeHandle.getLatestText()),
2390
+ );
2391
+ }
2392
+ throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, bridgeHandle.getLatestText()));
2393
+ }
2394
+
2395
+ // Folded (or auto-backgrounded): the remote terminal keeps running and its
2396
+ // result arrives as a background completion.
2397
+ bridgeHandle.setBackgrounded(true);
2398
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2399
+ return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
2400
+ requestedTimeoutSec,
2401
+ notices: pendingNotices,
2402
+ terminalId: handle.terminalId,
2403
+ });
1635
2404
  }
1636
2405
 
1637
2406
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
@@ -1645,11 +2414,22 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1645
2414
  const artifactPublisher = createBashArtifactPublisher(this.session);
1646
2415
 
1647
2416
  const interactiveUi =
1648
- this.session.bashRestrictionProfile === "read-only"
2417
+ this.session.bashRestrictionProfile === "read-only" || directMasterSpawn
1649
2418
  ? undefined
1650
2419
  : canUseInteractiveBashPty(pty, ctx)
1651
2420
  ? ctx?.ui
1652
2421
  : undefined;
2422
+ const ptyAdmission = interactiveUi && ownedManager ? ownedManager.reserveCapacity() : undefined;
2423
+ // A PTY command is foldable too: the runner owns the session, so the fold
2424
+ // registers a job that delivers the run's real outcome after the foreground
2425
+ // has already returned.
2426
+ let ptyFoldUnregister: (() => void) | undefined;
2427
+ let ptyFoldResult: AgentToolResult<BashToolDetails> | undefined;
2428
+ let ptyBackgrounded = false;
2429
+ let lastPtyFoldKeyTime = 0;
2430
+ let ptyJobId!: string;
2431
+ let ptyOutputSeen = false;
2432
+ let ptyOutcome: BashInteractiveResult | undefined;
1653
2433
  const result: BashResult | BashInteractiveResult = interactiveUi
1654
2434
  ? await runInteractiveBashPty(interactiveUi, {
1655
2435
  command,
@@ -1663,12 +2443,146 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1663
2443
  artifactPublisher,
1664
2444
  spillThreshold,
1665
2445
  headBytes,
2446
+ onControls: controls => {
2447
+ if (!ownedManager) return;
2448
+ const ptyManager = ownedManager;
2449
+ const ptyLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
2450
+ const ptyOwnerId = this.session.getAgentId?.() ?? "0-Main";
2451
+ let ptyKillStarted = false;
2452
+ const killPtyOnce = (): void => {
2453
+ if (ptyKillStarted) return;
2454
+ ptyKillStarted = true;
2455
+ try {
2456
+ controls.kill();
2457
+ } catch (error) {
2458
+ logger.warn("PTY kill failed", {
2459
+ jobId: ptyJobId,
2460
+ error: error instanceof Error ? error.message : String(error),
2461
+ });
2462
+ }
2463
+ };
2464
+ const ownerTeardownText = (): string => {
2465
+ const retained = ptyManager.readOutputSince(ptyJobId, 0, { ownerId: ptyOwnerId });
2466
+ const output = ptyOutcome?.output || retained?.text || "";
2467
+ if (!output && !ptyOutcome) return "PTY job owner was torn down.";
2468
+ const summary = ptyOutcome ? { ...ptyOutcome, output } : interactiveResultFromText(output);
2469
+ const body = [
2470
+ output || "(no output)",
2471
+ ...(retained?.truncated && !ptyOutcome?.truncated ? ["(output truncated)"] : []),
2472
+ ].join("\n");
2473
+ return formatBashFailureMessage(summary, body, "PTY job owner was torn down.");
2474
+ };
2475
+ // The job's runner simply awaits the run that is ALREADY executing,
2476
+ // so registering never re-executes the command.
2477
+ try {
2478
+ ptyJobId = ptyManager.register(
2479
+ "bash",
2480
+ ptyLabel,
2481
+ async () => {
2482
+ let outcome: BashInteractiveResult | undefined;
2483
+ try {
2484
+ outcome = await controls.terminalCompletion;
2485
+ const completed = this.#buildCompletedResult(outcome, timeoutSec, {
2486
+ requestedTimeoutSec,
2487
+ });
2488
+ return this.#extractTextResult(completed);
2489
+ } finally {
2490
+ if (!ptyOutputSeen && outcome)
2491
+ ptyManager.appendOutput(ptyJobId, this.#formatResultOutput(outcome));
2492
+ if (!ptyBackgrounded) ptyManager.cancel(ptyJobId);
2493
+ }
2494
+ },
2495
+ {
2496
+ ownerId: this.session.getAgentId?.() ?? undefined,
2497
+ admissionToken: ptyAdmission,
2498
+ lifecycle: { onCancel: killPtyOnce },
2499
+ },
2500
+ );
2501
+ } catch (error) {
2502
+ killPtyOnce();
2503
+ if (ptyAdmission) ptyManager.releaseCapacity(ptyAdmission);
2504
+ throw error;
2505
+ }
2506
+ const ptyGeneration = ptyManager.getJob(ptyJobId)?.generation ?? ptyJobId;
2507
+ registerOwnedIfLineaged(ptyManager, toolCallId, ptyJobId, this.session.getSessionId?.() ?? undefined);
2508
+ const unregisterPtyOwnerCleanup = ptyManager.registerOwnerCleanup(ptyOwnerId, () => {
2509
+ killPtyOnce();
2510
+ ptyManager.failNow(ptyJobId, ptyGeneration, ownerTeardownText());
2511
+ });
2512
+ void controls.terminalCompletion.then(
2513
+ outcome => {
2514
+ ptyOutcome = outcome;
2515
+ },
2516
+ (error: unknown) => {
2517
+ logger.warn("PTY terminal completion failed", {
2518
+ jobId: ptyJobId,
2519
+ error: error instanceof Error ? error.message : String(error),
2520
+ });
2521
+ },
2522
+ );
2523
+ void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
2524
+ ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
2525
+ kind: "bash-pty",
2526
+ jobId: ptyJobId,
2527
+ jobGeneration: ptyGeneration,
2528
+ label: ptyLabel,
2529
+ cwdSensitive: true,
2530
+ signal,
2531
+ originatingTurn: ctx?.attemptScope !== undefined,
2532
+ outputRef: {
2533
+ jobId: ptyJobId,
2534
+ generation: ptyGeneration,
2535
+ instruction: `Use the job tool's tail operation for ${ptyJobId} to read this command's output.`,
2536
+ },
2537
+ getJob: () => {
2538
+ const current = ptyManager.getJob(ptyJobId);
2539
+ return current?.generation === ptyGeneration ? current : undefined;
2540
+ },
2541
+ detachObserver: () => {
2542
+ // Output-only continuation: stdin forwarding ends here and the
2543
+ // process is never killed or restarted by folding.
2544
+ const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
2545
+ requestedTimeoutSec,
2546
+ notices: pendingNotices,
2547
+ });
2548
+ const outcome = controls.detachObserver(
2549
+ interactiveResultFromText(this.#extractTextResult(started)),
2550
+ );
2551
+ if (outcome === "resolved") {
2552
+ controls.detachForegroundCancellation();
2553
+ ptyBackgrounded = true;
2554
+ ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
2555
+ ptyFoldResult = started;
2556
+ }
2557
+ return outcome;
2558
+ },
2559
+ resolveForegroundObserver: () => "already-settled",
2560
+ });
2561
+ },
2562
+ onFoldKey: () => {
2563
+ const now = Date.now();
2564
+ if (now - lastPtyFoldKeyTime > 750) {
2565
+ lastPtyFoldKeyTime = now;
2566
+ return true;
2567
+ }
2568
+ lastPtyFoldKeyTime = 0;
2569
+ if (!this.session.hasForegroundBashBackgroundRequestHandler?.()) return false;
2570
+ void this.session.requestForegroundBashBackground?.();
2571
+ return true;
2572
+ },
2573
+ onOutput: chunk => {
2574
+ ptyOutputSeen = true;
2575
+ if (ptyJobId) ownedManager?.appendOutput(ptyJobId, chunk);
2576
+ },
2577
+ }).catch(error => {
2578
+ if (ptyAdmission) ownedManager?.releaseCapacity(ptyAdmission);
2579
+ throw error;
1666
2580
  })
1667
2581
  : await executeBash(command, {
1668
2582
  cwd: commandCwd,
1669
2583
  settings: this.session.settings,
1670
2584
  sessionKey: this.session.getSessionId?.() ?? undefined,
1671
- oneShot: this.session.bashRestrictionProfile === "read-only",
2585
+ oneShot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
1672
2586
  timeout: timeoutMs,
1673
2587
  signal,
1674
2588
  env: resolvedEnv,
@@ -1679,9 +2593,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1679
2593
  headBytes,
1680
2594
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
1681
2595
  onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
1682
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1683
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
2596
+ ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2597
+ disableShellSnapshot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
1684
2598
  });
2599
+ ptyFoldUnregister?.();
2600
+ // Folded: the foreground was settled by the fold, so return the background
2601
+ // -start result verbatim instead of presenting a half-finished command.
2602
+ if (ptyFoldResult) return ptyFoldResult;
2603
+
1685
2604
  if (result.cancelled) {
1686
2605
  const noticeSuffix = pendingNotices.length > 0 ? `\n\n${pendingNotices.join("\n")}` : "";
1687
2606
  const baseCancelledText = normalizeResultOutput(result) || "Command aborted";