@gajae-code/coding-agent 0.15.4 → 0.15.6

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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
package/src/tools/bash.ts CHANGED
@@ -18,7 +18,11 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
18
18
  import { highlightCode, type Theme } from "../modes/theme/theme";
19
19
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
20
20
  import type { ArtifactManager } from "../session/artifacts";
21
- import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
21
+ import type {
22
+ ClientBridgeTerminalExitStatus,
23
+ ClientBridgeTerminalHandle,
24
+ ClientBridgeTerminalOutput,
25
+ } from "../session/client-bridge";
22
26
  import {
23
27
  DEFAULT_ARTIFACT_MAX_BYTES,
24
28
  OutputSink,
@@ -59,8 +63,23 @@ import {
59
63
  } from "./output-meta";
60
64
  import { resolveToCwd } from "./path-utils";
61
65
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
66
+ import { checkTmuxSelfInjection } from "./tmux-self-injection-guard";
62
67
  import { ToolAbortError, ToolError } from "./tool-errors";
63
68
  import { toolResult } from "./tool-result";
69
+
70
+ function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
71
+ if (offsetBytes <= 0) return text;
72
+ let consumed = 0;
73
+ let index = 0;
74
+ while (index < text.length && consumed < offsetBytes) {
75
+ const codePoint = text.codePointAt(index);
76
+ if (codePoint === undefined) break;
77
+ consumed += Buffer.byteLength(String.fromCodePoint(codePoint), "utf8");
78
+ index += codePoint > 0xffff ? 2 : 1;
79
+ }
80
+ return text.slice(index);
81
+ }
82
+
64
83
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
65
84
 
66
85
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
@@ -69,6 +88,7 @@ const BASH_ERROR_MAX_BYTES = 4096;
69
88
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
70
89
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
71
90
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
91
+ const ACP_RELEASE_TIMEOUT_MS = 1_000;
72
92
  const READ_ONLY_BASH_ENV: Record<string, string> = {
73
93
  GREP_OPTIONS: "",
74
94
  GREP_COLOR: "",
@@ -150,6 +170,27 @@ function artifactReferenceIsReachable(text: string, result: BashResult | BashInt
150
170
  );
151
171
  }
152
172
 
173
+ function suffixPrefixOverlap(source: string, target: string): number {
174
+ if (source.length === 0 || target.length === 0) return 0;
175
+ const prefix = new Uint32Array(target.length);
176
+ for (let index = 1; index < target.length; index += 1) {
177
+ let length = prefix[index - 1] ?? 0;
178
+ const code = target.charCodeAt(index);
179
+ while (length > 0 && code !== target.charCodeAt(length)) length = prefix[length - 1] ?? 0;
180
+ if (code === target.charCodeAt(length)) length += 1;
181
+ prefix[index] = length;
182
+ }
183
+
184
+ let overlap = 0;
185
+ for (let index = 0; index < source.length; index += 1) {
186
+ const code = source.charCodeAt(index);
187
+ while (overlap > 0 && code !== target.charCodeAt(overlap)) overlap = prefix[overlap - 1] ?? 0;
188
+ if (code === target.charCodeAt(overlap)) overlap += 1;
189
+ if (overlap === target.length && index < source.length - 1) overlap = prefix[overlap - 1] ?? 0;
190
+ }
191
+ return overlap;
192
+ }
193
+
153
194
  function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
154
195
  const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
155
196
  return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
@@ -290,6 +331,48 @@ function formatClientTerminalAbortFailure(
290
331
  return formatBashFailureMessage(result, outputText, "Command aborted");
291
332
  }
292
333
 
334
+ function formatClientTerminalReadFailure(
335
+ prepared: PreparedClientTerminalOutput,
336
+ readDiagnostic: string,
337
+ pendingNotices: readonly string[] = [],
338
+ ): string {
339
+ const notices = [
340
+ ...pendingNotices,
341
+ ...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
342
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
343
+ `Terminal output recovery failed: ${readDiagnostic}`,
344
+ ];
345
+ const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
346
+ const result: BashResult = {
347
+ ...prepared.summary,
348
+ exitCode: undefined,
349
+ cancelled: false,
350
+ };
351
+ return formatBashFailureMessage(result, outputText, "Terminal output read failed");
352
+ }
353
+
354
+ function formatClientTerminalWaitFailure(
355
+ prepared: PreparedClientTerminalOutput,
356
+ waitDiagnostic: string,
357
+ readDiagnostic?: string,
358
+ pendingNotices: readonly string[] = [],
359
+ ): string {
360
+ const notices = [
361
+ ...pendingNotices,
362
+ ...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
363
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
364
+ `Terminal wait failed: ${waitDiagnostic}`,
365
+ ...(readDiagnostic ? [`Terminal output recovery failed: ${readDiagnostic}`] : []),
366
+ ];
367
+ const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
368
+ const result: BashResult = {
369
+ ...prepared.summary,
370
+ exitCode: undefined,
371
+ cancelled: false,
372
+ };
373
+ return formatBashFailureMessage(result, outputText, "Terminal wait failed");
374
+ }
375
+
293
376
  function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
294
377
  const suffixParts: string[] = [];
295
378
  const reference = artifactReferenceForResult(result);
@@ -467,6 +550,21 @@ interface ManagedBashJobHandle {
467
550
  setBackgrounded: (backgrounded: boolean) => void;
468
551
  }
469
552
 
553
+ /** The placeholder a folded PTY hands back to its (now finished) foreground call. */
554
+ function interactiveResultFromText(text: string): BashInteractiveResult {
555
+ return {
556
+ exitCode: undefined,
557
+ cancelled: false,
558
+ timedOut: false,
559
+ output: text,
560
+ outputBytes: 0,
561
+ outputLines: 0,
562
+ totalBytes: 0,
563
+ totalLines: 0,
564
+ truncated: false,
565
+ };
566
+ }
567
+
470
568
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
471
569
  return result.output || "";
472
570
  }
@@ -647,6 +745,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
647
745
  this.description = prompt.render(bashDescription, {
648
746
  asyncEnabled: this.#asyncEnabled,
649
747
  autoBackgroundEnabled: this.#autoBackgroundEnabled,
748
+ foregroundFoldEnabled: this.session.registerForegroundFoldParticipant !== undefined,
650
749
  autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
651
750
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
652
751
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
@@ -731,12 +830,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
731
830
  label: string,
732
831
  previewText: string,
733
832
  timeoutSec: number,
734
- options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
833
+ options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
735
834
  ): AgentToolResult<BashToolDetails> {
736
835
  const details: BashToolDetails = {
737
836
  timeoutSeconds: timeoutSec,
738
837
  async: { state: "running", jobId, type: "bash" },
739
838
  };
839
+ // A folded client terminal keeps its remote handle, so the editor's live
840
+ // terminal card stays bound to the same id after the fold.
841
+ if (options.terminalId !== undefined) details.terminalId = options.terminalId;
740
842
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
741
843
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
742
844
  }
@@ -774,6 +876,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
774
876
  resolvedEnv?: Record<string, string>;
775
877
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
776
878
  startBackgrounded: boolean;
879
+ onCompletion?: () => void;
777
880
  /** Immutable attempt-scoped tool call id, when executed via a tool call. */
778
881
  toolCallId?: string;
779
882
  }): ManagedBashJobHandle {
@@ -844,12 +947,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
844
947
  });
845
948
  const finalText = this.#extractTextResult(finalResult);
846
949
  latestText = finalText;
950
+ options.onCompletion?.();
847
951
  completion.resolve({ kind: "completed", result: finalResult });
848
952
  await reportProgress(finalText, completedDetails(jobId));
849
953
  return finalText;
850
954
  } catch (error) {
851
955
  const message = error instanceof Error ? error.message : String(error);
852
956
  latestText = message;
957
+ options.onCompletion?.();
853
958
  completion.resolve({ kind: "failed", error, result: executionResult });
854
959
  await reportProgress(message, failedDetails(jobId));
855
960
  throw error;
@@ -866,6 +971,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
866
971
  },
867
972
  },
868
973
  );
974
+ const jobGeneration = manager.getJob(jobId)?.generation ?? jobId;
975
+ const unregisterOwnerCleanup = manager.registerOwnerCleanup(this.session.getAgentId?.() ?? "0-Main", () => {
976
+ manager.failNow(jobId, jobGeneration, "Bash job owner was torn down.");
977
+ });
978
+ void completion.promise.finally(unregisterOwnerCleanup);
869
979
  registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
870
980
 
871
981
  return {
@@ -892,9 +1002,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
892
1002
  */
893
1003
  #resolveOwnedJobManager(): AsyncJobManager | undefined {
894
1004
  const endpointId = this.session.getSessionId?.() ?? undefined;
895
- return (
896
- this.session.getAsyncJobManager?.() ?? AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance()
897
- );
1005
+ if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
1006
+ return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
898
1007
  }
899
1008
 
900
1009
  async #waitForManagedBashJob(
@@ -1053,6 +1162,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1053
1162
  ...internalUrlOptions,
1054
1163
  ensureLocalParentDirs: this.session.bashRestrictionProfile !== "read-only",
1055
1164
  });
1165
+ // Issue #5039: refuse tmux write verbs whose target resolves to this
1166
+ // agent's own tmux pane — such bytes forge a human `user` turn in the
1167
+ // transcript. This is after command expansion but before any spawn, so
1168
+ // blocked attempts inject no bytes and create no transcript turn.
1169
+ if (this.session.bashRestrictionProfile !== "read-only") {
1170
+ const injection = await checkTmuxSelfInjection(command, {
1171
+ env: process.env,
1172
+ cwd: this.session.cwd,
1173
+ });
1174
+ if (injection.block) {
1175
+ throw new ToolError(injection.reason ?? "Blocked: tmux self-pane injection.");
1176
+ }
1177
+ }
1056
1178
  const expandedEnv = env
1057
1179
  ? Object.fromEntries(
1058
1180
  await Promise.all(
@@ -1151,6 +1273,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1151
1273
  if (!manager) {
1152
1274
  throw new ToolError("Async job manager unavailable for this session.");
1153
1275
  }
1276
+ if (!manager.hasCapacity()) {
1277
+ throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
1278
+ }
1154
1279
  const prepared = await this.#prepareBashExecution(input, opts.ctx);
1155
1280
  const label =
1156
1281
  opts.label ?? (prepared.command.length > 120 ? `${prepared.command.slice(0, 117)}...` : prepared.command);
@@ -1275,6 +1400,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1275
1400
  if (this.session.bashRestrictionProfile === "read-only" && pty) {
1276
1401
  throw new ToolError("Read-only bash does not allow PTY mode.");
1277
1402
  }
1403
+ const admissionManager = this.#resolveOwnedJobManager();
1404
+ if (asyncRequested && !admissionManager) {
1405
+ throw new ToolError("Async job manager unavailable for this session.");
1406
+ }
1407
+ if (admissionManager && !admissionManager.hasCapacity()) {
1408
+ throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
1409
+ }
1278
1410
 
1279
1411
  const prepared = await this.#prepareBashExecution(
1280
1412
  { command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
@@ -1295,7 +1427,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1295
1427
  // that was the process-global instance may have been disposed,
1296
1428
  // clearing instance() while THIS session's manager stays
1297
1429
  // registered by endpoint (review thread P1).
1298
- if (!this.#resolveOwnedJobManager()) {
1430
+ const asyncManager = admissionManager ?? this.#resolveOwnedJobManager();
1431
+ if (!asyncManager) {
1299
1432
  throw new ToolError("Async job manager unavailable for this session.");
1300
1433
  }
1301
1434
  const job = this.#startManagedBashJob({
@@ -1311,6 +1444,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1311
1444
  startBackgrounded: true,
1312
1445
  toolCallId,
1313
1446
  });
1447
+ const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
1448
+ job.setBackgrounded(true);
1449
+ asyncManager.markBackgrounded(job.jobId, jobGeneration);
1314
1450
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1315
1451
  requestedTimeoutSec,
1316
1452
  notices: pendingNotices,
@@ -1331,13 +1467,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1331
1467
  // and unregistering — the process-global instance may belong to a different
1332
1468
  // concurrent top-level session (review thread P1).
1333
1469
  const ownedManager = this.#resolveOwnedJobManager();
1334
- if (!pty && ownedManager && (this.#autoBackgroundEnabled || !clientTerminalActive)) {
1470
+ // The client-terminal path is now itself manager-backed and foldable, so it no
1471
+ // longer has to be bypassed to make folding work. A capable ACP session keeps
1472
+ // its terminal contract and still folds.
1473
+ if (!pty && ownedManager && !clientTerminalActive) {
1335
1474
  // With auto-background off, wait past the command's own timeout so the job only
1336
1475
  // leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
1337
1476
  const autoBackgroundWaitMs = this.#autoBackgroundEnabled
1338
1477
  ? this.#resolveAutoBackgroundWaitMs(timeoutMs)
1339
1478
  : timeoutMs + 1_000;
1340
1479
  const startBackgrounded = autoBackgroundWaitMs === 0;
1480
+ let managedForegroundSettled = false;
1341
1481
  const job = this.#startManagedBashJob({
1342
1482
  command,
1343
1483
  commandCwd,
@@ -1349,18 +1489,59 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1349
1489
  resolvedEnv,
1350
1490
  onUpdate,
1351
1491
  startBackgrounded,
1492
+ onCompletion: () => {
1493
+ managedForegroundSettled = true;
1494
+ },
1352
1495
  toolCallId,
1353
1496
  });
1497
+ const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
1354
1498
  if (startBackgrounded) {
1499
+ job.setBackgrounded(true);
1500
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1355
1501
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1356
1502
  requestedTimeoutSec,
1357
1503
  notices: pendingNotices,
1358
1504
  });
1359
1505
  }
1360
1506
  const backgroundRequest = Promise.withResolvers<void>();
1361
- const unregisterBackgroundRequest = this.session.registerForegroundBashBackgroundRequestHandler?.(() => {
1362
- job.setBackgrounded(true);
1363
- backgroundRequest.resolve();
1507
+ // Exactly one party may settle the foreground caller. detachObserver (the
1508
+ // fold won) and resolveForegroundObserver (a completion was handed back
1509
+ // after a failed fold) share this flag, so a race cannot double-settle.
1510
+ let foregroundSettled = false;
1511
+ const settleForeground = (): "resolved" | "already-settled" => {
1512
+ if (foregroundSettled || managedForegroundSettled) return "already-settled";
1513
+ foregroundSettled = true;
1514
+ return "resolved";
1515
+ };
1516
+ const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
1517
+ kind: "bash-managed",
1518
+ jobId: job.jobId,
1519
+ jobGeneration,
1520
+ label: job.label,
1521
+ cwdSensitive: true,
1522
+ signal,
1523
+ originatingTurn: ctx?.attemptScope !== undefined,
1524
+ outputRef: {
1525
+ jobId: job.jobId,
1526
+ generation: jobGeneration,
1527
+ instruction: `Use the job tool's tail operation for ${job.jobId} to read this command's output.`,
1528
+ },
1529
+ // Bound at registration over the manager that registered THIS job, so
1530
+ // identity can never resolve to another manager's identically-named job.
1531
+ getJob: () => {
1532
+ const current = ownedManager.getJob(job.jobId);
1533
+ return current?.generation === jobGeneration ? current : undefined;
1534
+ },
1535
+ detachObserver: () => {
1536
+ const outcome = settleForeground();
1537
+ if (outcome === "resolved") {
1538
+ job.setBackgrounded(true);
1539
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1540
+ backgroundRequest.resolve();
1541
+ }
1542
+ return outcome;
1543
+ },
1544
+ resolveForegroundObserver: () => settleForeground(),
1364
1545
  });
1365
1546
  let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
1366
1547
  try {
@@ -1396,6 +1577,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1396
1577
  throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
1397
1578
  }
1398
1579
  job.setBackgrounded(true);
1580
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1399
1581
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
1400
1582
  requestedTimeoutSec,
1401
1583
  notices: pendingNotices,
@@ -1403,63 +1585,200 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1403
1585
  }
1404
1586
 
1405
1587
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
1588
+ const clientAdmission = ownedManager?.reserveCapacity();
1406
1589
  const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1407
1590
  const clientTailBytes = resolveBashOutputSinkTailBytes(this.session.settings);
1408
- const handle = await clientBridge.createTerminal({
1409
- command,
1410
- cwd: commandCwd,
1411
- env: resolvedEnv
1412
- ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
1413
- : undefined,
1414
- outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
1415
- });
1416
-
1417
- // Emit partial update so the editor can embed the live terminal card.
1418
- onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1419
-
1420
- const exitPromise = handle.waitForExit();
1421
- let exitStatus!: ClientBridgeTerminalExitStatus;
1422
-
1423
- type BridgeRaceResult =
1424
- | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1425
- | { kind: "poll" }
1591
+ const runBoundedCleanup = async (
1592
+ terminal: ClientBridgeTerminalHandle,
1593
+ operation: () => Promise<void>,
1594
+ label: "kill" | "release",
1595
+ ): Promise<void> => {
1596
+ const attempt = Promise.resolve()
1597
+ .then(operation)
1598
+ .catch((error: unknown) => {
1599
+ logger.warn(`ACP terminal ${label} failed`, { terminalId: terminal.terminalId, error });
1600
+ });
1601
+ const completed = await Promise.race([
1602
+ attempt.then(() => true),
1603
+ Bun.sleep(ACP_RELEASE_TIMEOUT_MS).then(() => false),
1604
+ ]);
1605
+ if (!completed) logger.warn(`ACP terminal ${label} timed out`, { terminalId: terminal.terminalId });
1606
+ };
1607
+ const createPromise = Promise.resolve().then(() =>
1608
+ clientBridge.createTerminal!({
1609
+ command,
1610
+ cwd: commandCwd,
1611
+ env: resolvedEnv
1612
+ ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
1613
+ : undefined,
1614
+ outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
1615
+ }),
1616
+ );
1617
+ const timeout = Promise.withResolvers<{ kind: "timeout" }>();
1618
+ const abort = Promise.withResolvers<{ kind: "aborted" }>();
1619
+ const onCreateAbort = (): void => abort.resolve({ kind: "aborted" });
1620
+ if (signal?.aborted) onCreateAbort();
1621
+ else signal?.addEventListener("abort", onCreateAbort, { once: true });
1622
+ const createTimer = setTimeout(() => timeout.resolve({ kind: "timeout" }), timeoutMs);
1623
+ let createOutcome:
1624
+ | { kind: "created"; handle: ClientBridgeTerminalHandle }
1426
1625
  | { kind: "timeout" }
1427
1626
  | { kind: "aborted" };
1627
+ try {
1628
+ createOutcome = await Promise.race([
1629
+ createPromise.then(handle => ({ kind: "created" as const, handle })),
1630
+ timeout.promise,
1631
+ abort.promise,
1632
+ ]);
1633
+ } catch (error) {
1634
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
1635
+ throw error;
1636
+ } finally {
1637
+ clearTimeout(createTimer);
1638
+ signal?.removeEventListener("abort", onCreateAbort);
1639
+ }
1640
+
1641
+ if (createOutcome.kind !== "created") {
1642
+ void createPromise
1643
+ .then(async lateHandle => {
1644
+ await runBoundedCleanup(lateHandle, () => lateHandle.kill(), "kill");
1645
+ await runBoundedCleanup(lateHandle, () => lateHandle.release(), "release");
1646
+ })
1647
+ .catch((error: unknown) => {
1648
+ logger.warn("ACP terminal late create cleanup failed", { error });
1649
+ });
1650
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
1651
+ if (createOutcome.kind === "aborted") throw new ToolAbortError("Command aborted");
1652
+ throw new ToolError(`Command timed out after ${timeoutSec} seconds`);
1653
+ }
1654
+
1655
+ let handle: ClientBridgeTerminalHandle;
1656
+ handle = createOutcome.handle;
1428
1657
 
1429
- // Set up abort listener before entering the poll loop. The listener
1430
- // kicks off `handle.kill()` synchronously so a `session/cancel`
1431
- // arriving mid-poll terminates the remote command immediately,
1432
- // instead of waiting for the next `currentOutput()` to return.
1433
- const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1658
+ // The remote terminal is released exactly once, by whichever path settles
1659
+ // it: a foreground return, a folded background completion, or a failure.
1660
+ let released = false;
1661
+ const releaseTerminalOnce = async (): Promise<void> => {
1662
+ if (released) return;
1663
+ released = true;
1664
+ await runBoundedCleanup(handle, () => handle.release(), "release");
1665
+ };
1434
1666
  let killPromise: Promise<void> | undefined;
1435
1667
  const fireKill = (): Promise<void> => {
1436
1668
  if (killPromise) return killPromise;
1437
- killPromise = handle.kill().catch((error: unknown) => {
1669
+ try {
1670
+ killPromise = Promise.resolve(handle.kill()).catch((error: unknown) => {
1671
+ logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1672
+ });
1673
+ } catch (error) {
1438
1674
  logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1439
- });
1675
+ killPromise = Promise.resolve();
1676
+ }
1440
1677
  return killPromise;
1441
1678
  };
1442
- const onAbortSignal = () => {
1443
- resolveAborted();
1444
- void fireKill();
1679
+ const boundedKill = async (): Promise<void> => {
1680
+ await runBoundedCleanup(handle, fireKill, "kill");
1445
1681
  };
1446
- signal?.addEventListener("abort", onAbortSignal, { once: true });
1447
1682
 
1683
+ // Emit partial update so the editor can embed the live terminal card.
1448
1684
  try {
1685
+ onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1686
+ } catch (error) {
1687
+ await boundedKill();
1688
+ await releaseTerminalOnce();
1689
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
1690
+ throw error;
1691
+ }
1692
+ let latestText = "";
1693
+ let bridgeJobId!: string;
1694
+ let bridgeBackgrounded = false;
1695
+ let retainedAcpSnapshot = "";
1696
+ let retainedAcpTruncated = false;
1697
+ const ACP_RAW_OVERLAP_BYTES = 512 * 1024;
1698
+ const appendAcpSnapshot = (snapshot: string, upstreamTruncated = false): void => {
1699
+ retainedAcpTruncated ||= upstreamTruncated;
1700
+ if (!snapshot) return;
1701
+ const snapshotBytes = Buffer.byteLength(snapshot, "utf8");
1702
+ const retainedBytes = Buffer.byteLength(retainedAcpSnapshot, "utf8");
1703
+ const boundedSnapshot =
1704
+ snapshotBytes > ACP_RAW_OVERLAP_BYTES
1705
+ ? sliceTextAfterUtf8ByteOffset(snapshot, snapshotBytes - ACP_RAW_OVERLAP_BYTES)
1706
+ : snapshot;
1707
+ const boundedRetained =
1708
+ retainedBytes > ACP_RAW_OVERLAP_BYTES
1709
+ ? sliceTextAfterUtf8ByteOffset(retainedAcpSnapshot, retainedBytes - ACP_RAW_OVERLAP_BYTES)
1710
+ : retainedAcpSnapshot;
1711
+ const overlap = suffixPrefixOverlap(boundedRetained, boundedSnapshot);
1712
+ const delta = boundedSnapshot.slice(overlap);
1713
+ if (bridgeJobId && delta) ownedManager?.appendOutput(bridgeJobId, delta);
1714
+ retainedAcpSnapshot = boundedSnapshot;
1715
+ retainedAcpTruncated ||= snapshotBytes > ACP_RAW_OVERLAP_BYTES;
1716
+ };
1717
+ const retainedAcpOutput = (): ClientBridgeTerminalOutput => ({
1718
+ output: retainedAcpSnapshot,
1719
+ truncated: retainedAcpTruncated,
1720
+ });
1721
+
1722
+ const runToCompletion = async (
1723
+ runSignal: AbortSignal | undefined,
1724
+ ): Promise<AgentToolResult<BashToolDetails>> => {
1725
+ type BridgeRaceResult =
1726
+ | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1727
+ | { kind: "wait-failed"; error: unknown }
1728
+ | { kind: "poll" }
1729
+ | { kind: "timeout" }
1730
+ | { kind: "aborted" };
1731
+ const exitPromise = Promise.resolve().then(() => handle.waitForExit());
1732
+ const exitRacePromise: Promise<BridgeRaceResult> = exitPromise.then(
1733
+ status => ({ kind: "exit" as const, status }),
1734
+ error => ({ kind: "wait-failed" as const, error }),
1735
+ );
1736
+ let exitStatus!: ClientBridgeTerminalExitStatus;
1737
+
1738
+ // Set up abort listener before entering the poll loop. The listener
1739
+ // kicks off `handle.kill()` synchronously so a `session/cancel`
1740
+ // arriving mid-poll terminates the remote command immediately,
1741
+ // instead of waiting for the next `currentOutput()` to return.
1742
+ const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1743
+ const deadlineAt = Date.now() + timeoutMs;
1744
+ const readOutput = async (
1745
+ limitMs: number,
1746
+ includeAbort = true,
1747
+ includeExit = false,
1748
+ ): Promise<ClientBridgeTerminalOutput | undefined> => {
1749
+ const timeout = Promise.withResolvers<undefined>();
1750
+ const timer = setTimeout(() => timeout.resolve(undefined), Math.max(1, limitMs));
1751
+ return Promise.race([
1752
+ handle.currentOutput(),
1753
+ ...(includeAbort ? [abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined)] : []),
1754
+ ...(includeExit
1755
+ ? [exitRacePromise.then(() => undefined as ClientBridgeTerminalOutput | undefined)]
1756
+ : []),
1757
+ timeout.promise,
1758
+ ]).finally(() => clearTimeout(timer));
1759
+ };
1760
+ const onAbortSignal = () => {
1761
+ resolveAborted();
1762
+ void fireKill();
1763
+ };
1764
+ runSignal?.addEventListener("abort", onAbortSignal, { once: true });
1765
+
1449
1766
  try {
1450
- if (signal?.aborted) {
1451
- await fireKill();
1767
+ if (runSignal?.aborted) {
1768
+ await boundedKill();
1452
1769
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1453
1770
  let readDiagnostic: string | undefined;
1454
1771
  try {
1455
- current = await handle.currentOutput();
1772
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1456
1773
  } catch (error) {
1774
+ current = retainedAcpOutput();
1457
1775
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1458
1776
  logger.warn("ACP terminal aborted output read failed", {
1459
1777
  terminalId: handle.terminalId,
1460
1778
  error,
1461
1779
  });
1462
1780
  }
1781
+ appendAcpSnapshot(current.output, current.truncated);
1463
1782
  const prepared = await prepareClientTerminalOutput(this.session, current);
1464
1783
  throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1465
1784
  }
@@ -1468,51 +1787,81 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1468
1787
  // Poll until the process exits, times out, or the caller aborts.
1469
1788
  for (;;) {
1470
1789
  const racers: Array<Promise<BridgeRaceResult>> = [
1471
- exitPromise.then(s => ({ kind: "exit" as const, status: s })),
1790
+ exitRacePromise,
1472
1791
  timeoutPromise,
1473
1792
  Bun.sleep(250).then(() => ({ kind: "poll" as const })),
1474
1793
  ];
1475
- if (signal) {
1794
+ if (runSignal) {
1476
1795
  racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
1477
1796
  }
1478
1797
  const raced = await Promise.race(racers);
1479
1798
 
1480
- if (raced.kind === "aborted" || signal?.aborted) {
1481
- await fireKill();
1799
+ if (raced.kind === "aborted" || runSignal?.aborted) {
1800
+ await boundedKill();
1482
1801
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1483
1802
  let readDiagnostic: string | undefined;
1484
1803
  try {
1485
- current = await handle.currentOutput();
1804
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1486
1805
  } catch (error) {
1806
+ current = retainedAcpOutput();
1487
1807
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1488
1808
  logger.warn("ACP terminal aborted output read failed", {
1489
1809
  terminalId: handle.terminalId,
1490
1810
  error,
1491
1811
  });
1492
1812
  }
1813
+ appendAcpSnapshot(current.output, current.truncated);
1493
1814
  const prepared = await prepareClientTerminalOutput(this.session, current);
1494
1815
  throw new ToolAbortError(
1495
1816
  formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
1496
1817
  );
1497
1818
  }
1498
1819
 
1820
+ if (raced.kind === "wait-failed") {
1821
+ await boundedKill();
1822
+ let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1823
+ let readDiagnostic: string | undefined;
1824
+ try {
1825
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1826
+ } catch (error) {
1827
+ current = retainedAcpOutput();
1828
+ readDiagnostic = boundArtifactSaveDiagnostic(error);
1829
+ logger.warn("ACP terminal output recovery after wait failure failed", {
1830
+ terminalId: handle.terminalId,
1831
+ error,
1832
+ });
1833
+ }
1834
+ appendAcpSnapshot(current.output, current.truncated);
1835
+ const prepared = await prepareClientTerminalOutput(this.session, current);
1836
+ throw new ToolError(
1837
+ formatClientTerminalWaitFailure(
1838
+ prepared,
1839
+ boundArtifactSaveDiagnostic(raced.error),
1840
+ readDiagnostic,
1841
+ pendingNotices,
1842
+ ),
1843
+ );
1844
+ }
1845
+
1499
1846
  if (raced.kind === "timeout") {
1500
1847
  // Kill before reading final output so a slow `terminal/output`
1501
1848
  // RPC cannot let a timed-out command keep running past the
1502
1849
  // enforced timeout. The handle stays valid post-kill so the
1503
1850
  // buffered output is still readable.
1504
- await fireKill();
1851
+ await boundedKill();
1505
1852
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1506
1853
  let readDiagnostic: string | undefined;
1507
1854
  try {
1508
- current = await handle.currentOutput();
1855
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1509
1856
  } catch (error) {
1857
+ current = retainedAcpOutput();
1510
1858
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1511
1859
  logger.warn("ACP terminal final output read failed", {
1512
1860
  terminalId: handle.terminalId,
1513
1861
  error,
1514
1862
  });
1515
1863
  }
1864
+ appendAcpSnapshot(current.output, current.truncated);
1516
1865
  const prepared = await prepareClientTerminalOutput(this.session, current);
1517
1866
  const timeoutNotices = [
1518
1867
  ...pendingNotices,
@@ -1541,13 +1890,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1541
1890
  // Poll tick: push current output so agent-loop transcript stays consistent.
1542
1891
  // Race the read against abort so a stuck `terminal/output` RPC does not
1543
1892
  // delay cancellation.
1544
- const pollOutput = await Promise.race([
1545
- handle.currentOutput(),
1546
- abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
1547
- ]);
1893
+ let pollOutput: ClientBridgeTerminalOutput | undefined;
1894
+ try {
1895
+ pollOutput = await readOutput(Math.max(1, deadlineAt - Date.now()), true, true);
1896
+ } catch (error) {
1897
+ await boundedKill();
1898
+ const diagnostic = boundArtifactSaveDiagnostic(error);
1899
+ const recoveredOutput = retainedAcpOutput();
1900
+ appendAcpSnapshot(recoveredOutput.output, recoveredOutput.truncated);
1901
+ const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
1902
+ logger.warn("ACP terminal poll output read failed", {
1903
+ terminalId: handle.terminalId,
1904
+ error,
1905
+ });
1906
+ throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
1907
+ }
1548
1908
  if (pollOutput === undefined) {
1549
1909
  // Abort fired during the poll-tick read; let the next loop iteration
1550
- // observe `signal?.aborted` and exit via the abort branch.
1910
+ // observe `runSignal?.aborted` and exit via the abort branch.
1551
1911
  continue;
1552
1912
  }
1553
1913
  const { summary, locallyTruncated } = await boundClientTerminalOutput(
@@ -1559,40 +1919,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1559
1919
  pollOutput.truncated || locallyTruncated
1560
1920
  ? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
1561
1921
  : summary.output;
1562
- onUpdate?.({
1563
- content: [{ type: "text", text: pollText }],
1564
- details: { terminalId: handle.terminalId },
1565
- });
1922
+ latestText = pollText;
1923
+ appendAcpSnapshot(pollOutput.output, pollOutput.truncated);
1924
+ if (!bridgeBackgrounded) {
1925
+ try {
1926
+ onUpdate?.({
1927
+ content: [{ type: "text", text: pollText }],
1928
+ details: { terminalId: handle.terminalId },
1929
+ });
1930
+ } catch (error) {
1931
+ await boundedKill();
1932
+ const diagnostic = boundArtifactSaveDiagnostic(error);
1933
+ const recoveredOutput = retainedAcpOutput();
1934
+ const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
1935
+ throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
1936
+ }
1937
+ }
1566
1938
  }
1567
1939
  } finally {
1568
- signal?.removeEventListener("abort", onAbortSignal);
1940
+ runSignal?.removeEventListener("abort", onAbortSignal);
1569
1941
  }
1570
1942
 
1571
- if (signal?.aborted) {
1572
- await fireKill();
1943
+ if (runSignal?.aborted) {
1944
+ await boundedKill();
1573
1945
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1574
1946
  let readDiagnostic: string | undefined;
1575
1947
  try {
1576
- current = await handle.currentOutput();
1948
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1577
1949
  } catch (error) {
1950
+ current = retainedAcpOutput();
1578
1951
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1579
1952
  logger.warn("ACP terminal aborted output read failed", {
1580
1953
  terminalId: handle.terminalId,
1581
1954
  error,
1582
1955
  });
1583
1956
  }
1957
+ appendAcpSnapshot(current.output, current.truncated);
1584
1958
  const prepared = await prepareClientTerminalOutput(this.session, current);
1585
1959
  throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1586
1960
  }
1587
1961
 
1588
1962
  // Fetch final output; the terminal is released in the outer finally.
1589
- const finalOutput = await handle.currentOutput();
1963
+ let finalReadDiagnostic: string | undefined;
1964
+ let finalOutput: ClientBridgeTerminalOutput;
1965
+ try {
1966
+ const recovered = await readOutput(1_000, false);
1967
+ if (recovered === undefined) {
1968
+ finalOutput = retainedAcpOutput();
1969
+ finalReadDiagnostic = "terminal/output read timed out";
1970
+ } else {
1971
+ finalOutput = recovered;
1972
+ }
1973
+ } catch (error) {
1974
+ finalOutput = retainedAcpOutput();
1975
+ finalReadDiagnostic = boundArtifactSaveDiagnostic(error);
1976
+ logger.warn("ACP terminal final output read failed", {
1977
+ terminalId: handle.terminalId,
1978
+ error,
1979
+ });
1980
+ }
1981
+ if (runSignal?.aborted) {
1982
+ await boundedKill();
1983
+ appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
1984
+ const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
1985
+ throw new ToolAbortError(
1986
+ formatClientTerminalAbortFailure(prepared, finalReadDiagnostic, pendingNotices),
1987
+ );
1988
+ }
1590
1989
 
1591
1990
  // Map exit status: null exitCode with a signal → treat as signal kill (137).
1592
1991
  const rawExitCode = exitStatus.exitCode;
1593
1992
  const exitCode: number | undefined =
1594
1993
  rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
1595
1994
 
1995
+ appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
1596
1996
  const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
1597
1997
 
1598
1998
  const bridgeResult: BashResult = {
@@ -1605,19 +2005,188 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1605
2005
  if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
1606
2006
  for (const notice of pendingNotices) bridgeNotices.push(notice);
1607
2007
  if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
2008
+ if (finalReadDiagnostic) bridgeNotices.push(`Terminal output recovery failed: ${finalReadDiagnostic}`);
1608
2009
 
1609
2010
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
1610
2011
  requestedTimeoutSec,
1611
2012
  notices: bridgeNotices,
1612
2013
  terminalId: handle.terminalId,
1613
2014
  });
1614
- } finally {
2015
+ };
2016
+
2017
+ // Without a job manager nothing can fold this, so ownership never leaves the
2018
+ // foreground call.
2019
+ if (!ownedManager) {
1615
2020
  try {
1616
- await handle.release();
1617
- } catch (error) {
1618
- logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
2021
+ return await runToCompletion(signal);
2022
+ } finally {
2023
+ await releaseTerminalOnce();
2024
+ }
2025
+ }
2026
+
2027
+ // Manager-backed runner: the remote command keeps running after a fold and
2028
+ // its completion is delivered like any other background job. createTerminal
2029
+ // already ran above, so exactly ONE remote handle exists and is retained
2030
+ // across the fold.
2031
+ const bridgeManager = ownedManager;
2032
+ const bridgeEndpointId = this.session.getSessionId?.() ?? "local";
2033
+ const bridgeLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
2034
+ const bridgeCompletion = Promise.withResolvers<ManagedBashJobCompletion>();
2035
+ let bridgeForegroundSettled = false;
2036
+ const settleBridgeForeground = (): "resolved" | "already-settled" => {
2037
+ if (bridgeForegroundSettled) return "already-settled";
2038
+ bridgeForegroundSettled = true;
2039
+ return "resolved";
2040
+ };
2041
+
2042
+ try {
2043
+ bridgeJobId = bridgeManager.register(
2044
+ "bash",
2045
+ bridgeLabel,
2046
+ async ({ jobId, signal: runSignal, reportProgress }) => {
2047
+ try {
2048
+ const result = await runToCompletion(runSignal);
2049
+ const finalText = this.#extractTextResult(result);
2050
+ latestText = finalText;
2051
+ if (!bridgeBackgrounded) settleBridgeForeground();
2052
+ bridgeCompletion.resolve({ kind: "completed", result });
2053
+ await reportProgress(
2054
+ finalText,
2055
+ bridgeBackgrounded ? { async: { state: "completed", jobId, type: "bash" } } : undefined,
2056
+ );
2057
+ return finalText;
2058
+ } catch (error) {
2059
+ const message = error instanceof Error ? error.message : String(error);
2060
+ latestText = message;
2061
+ if (!bridgeBackgrounded) settleBridgeForeground();
2062
+ bridgeCompletion.resolve({ kind: "failed", error });
2063
+ await reportProgress(
2064
+ message,
2065
+ bridgeBackgrounded ? { async: { state: "failed", jobId, type: "bash" } } : undefined,
2066
+ );
2067
+ throw error;
2068
+ } finally {
2069
+ await releaseTerminalOnce();
2070
+ }
2071
+ },
2072
+ {
2073
+ ownerId: this.session.getAgentId?.() ?? undefined,
2074
+ admissionToken: clientAdmission,
2075
+ onProgress: async (text: string) => {
2076
+ latestText = text;
2077
+ },
2078
+ },
2079
+ );
2080
+ } catch (error) {
2081
+ await boundedKill();
2082
+ await releaseTerminalOnce();
2083
+ if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
2084
+ throw error;
2085
+ }
2086
+
2087
+ const bridgeGeneration = bridgeManager.getJob(bridgeJobId)?.generation ?? bridgeJobId;
2088
+ // Owner teardown must not swallow a live remote command. failNow is the one
2089
+ // transition that both fails the job AND enqueues its delivery, so the
2090
+ // failure stays visible instead of becoming a cancelled job that delivers
2091
+ // nothing.
2092
+ const unregisterOwnerCleanup = bridgeManager.registerOwnerCleanup(
2093
+ this.session.getAgentId?.() ?? "0-Main",
2094
+ () => {
2095
+ void boundedKill().then(() => releaseTerminalOnce());
2096
+ bridgeManager.failNow(bridgeJobId, bridgeGeneration, "Client terminal owner was torn down.");
2097
+ },
2098
+ );
2099
+
2100
+ const bridgeHandle: ManagedBashJobHandle = {
2101
+ jobId: bridgeJobId,
2102
+ label: bridgeLabel,
2103
+ completion: bridgeCompletion.promise,
2104
+ getLatestText: () => latestText,
2105
+ setBackgrounded: (next: boolean) => {
2106
+ bridgeBackgrounded = next;
2107
+ },
2108
+ };
2109
+ void bridgeHandle.completion.finally(unregisterOwnerCleanup);
2110
+ registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
2111
+
2112
+ const bridgeFoldRequest = Promise.withResolvers<void>();
2113
+ const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
2114
+ const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
2115
+ kind: "client-terminal",
2116
+ jobId: bridgeJobId,
2117
+ jobGeneration: bridgeGeneration,
2118
+ label: bridgeLabel,
2119
+ cwdSensitive: true,
2120
+ signal,
2121
+ originatingTurn: bridgeOriginatingTurn,
2122
+ outputRef: {
2123
+ jobId: bridgeJobId,
2124
+ generation: bridgeGeneration,
2125
+ instruction: `Use the job tool's tail operation for ${bridgeJobId} to read this command's output.`,
2126
+ },
2127
+ getJob: () => {
2128
+ const current = bridgeManager.getJob(bridgeJobId);
2129
+ return current?.generation === bridgeGeneration ? current : undefined;
2130
+ },
2131
+ detachObserver: () => {
2132
+ const outcome = settleBridgeForeground();
2133
+ if (outcome === "resolved") {
2134
+ bridgeHandle.setBackgrounded(true);
2135
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2136
+ bridgeFoldRequest.resolve();
2137
+ }
2138
+ return outcome;
2139
+ },
2140
+ resolveForegroundObserver: () => settleBridgeForeground(),
2141
+ });
2142
+
2143
+ let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
2144
+ try {
2145
+ bridgeWait = await this.#waitForManagedBashJob(
2146
+ bridgeHandle,
2147
+ this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
2148
+ signal,
2149
+ bridgeFoldRequest.promise,
2150
+ );
2151
+ } finally {
2152
+ unregisterBridgeFold?.();
2153
+ }
2154
+
2155
+ if (bridgeWait.kind === "completed") {
2156
+ unregisterOwnerCleanup();
2157
+ bridgeManager.acknowledgeDeliveries([bridgeJobId]);
2158
+ unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
2159
+ return bridgeWait.result;
2160
+ }
2161
+ if (bridgeWait.kind === "failed") {
2162
+ unregisterOwnerCleanup();
2163
+ bridgeManager.acknowledgeDeliveries([bridgeJobId]);
2164
+ unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
2165
+ throw bridgeWait.error;
2166
+ }
2167
+ if (bridgeWait.kind === "aborted") {
2168
+ unregisterOwnerCleanup();
2169
+ bridgeManager.cancel(bridgeJobId);
2170
+ const terminal = await bridgeHandle.completion;
2171
+ bridgeManager.acknowledgeDeliveries([bridgeJobId]);
2172
+ unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
2173
+ if (terminal.kind === "failed") {
2174
+ throw new ToolAbortError(
2175
+ formatManagedAbortFailure(terminal.error, undefined, bridgeHandle.getLatestText()),
2176
+ );
1619
2177
  }
2178
+ throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, bridgeHandle.getLatestText()));
1620
2179
  }
2180
+
2181
+ // Folded (or auto-backgrounded): the remote terminal keeps running and its
2182
+ // result arrives as a background completion.
2183
+ bridgeHandle.setBackgrounded(true);
2184
+ bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
2185
+ return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
2186
+ requestedTimeoutSec,
2187
+ notices: pendingNotices,
2188
+ terminalId: handle.terminalId,
2189
+ });
1621
2190
  }
1622
2191
 
1623
2192
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
@@ -1636,6 +2205,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1636
2205
  : canUseInteractiveBashPty(pty, ctx)
1637
2206
  ? ctx?.ui
1638
2207
  : undefined;
2208
+ const ptyAdmission = interactiveUi && ownedManager ? ownedManager.reserveCapacity() : undefined;
2209
+ // A PTY command is foldable too: the runner owns the session, so the fold
2210
+ // registers a job that delivers the run's real outcome after the foreground
2211
+ // has already returned.
2212
+ let ptyFoldUnregister: (() => void) | undefined;
2213
+ let ptyFoldResult: AgentToolResult<BashToolDetails> | undefined;
2214
+ let ptyBackgrounded = false;
2215
+ let lastPtyFoldKeyTime = 0;
2216
+ let ptyJobId!: string;
2217
+ let ptyOutputSeen = false;
2218
+ let ptyOutcome: BashInteractiveResult | undefined;
1639
2219
  const result: BashResult | BashInteractiveResult = interactiveUi
1640
2220
  ? await runInteractiveBashPty(interactiveUi, {
1641
2221
  command,
@@ -1649,6 +2229,140 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1649
2229
  artifactPublisher,
1650
2230
  spillThreshold,
1651
2231
  headBytes,
2232
+ onControls: controls => {
2233
+ if (!ownedManager) return;
2234
+ const ptyManager = ownedManager;
2235
+ const ptyLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
2236
+ const ptyOwnerId = this.session.getAgentId?.() ?? "0-Main";
2237
+ let ptyKillStarted = false;
2238
+ const killPtyOnce = (): void => {
2239
+ if (ptyKillStarted) return;
2240
+ ptyKillStarted = true;
2241
+ try {
2242
+ controls.kill();
2243
+ } catch (error) {
2244
+ logger.warn("PTY kill failed", {
2245
+ jobId: ptyJobId,
2246
+ error: error instanceof Error ? error.message : String(error),
2247
+ });
2248
+ }
2249
+ };
2250
+ const ownerTeardownText = (): string => {
2251
+ const retained = ptyManager.readOutputSince(ptyJobId, 0, { ownerId: ptyOwnerId });
2252
+ const output = ptyOutcome?.output || retained?.text || "";
2253
+ if (!output && !ptyOutcome) return "PTY job owner was torn down.";
2254
+ const summary = ptyOutcome ? { ...ptyOutcome, output } : interactiveResultFromText(output);
2255
+ const body = [
2256
+ output || "(no output)",
2257
+ ...(retained?.truncated && !ptyOutcome?.truncated ? ["(output truncated)"] : []),
2258
+ ].join("\n");
2259
+ return formatBashFailureMessage(summary, body, "PTY job owner was torn down.");
2260
+ };
2261
+ // The job's runner simply awaits the run that is ALREADY executing,
2262
+ // so registering never re-executes the command.
2263
+ try {
2264
+ ptyJobId = ptyManager.register(
2265
+ "bash",
2266
+ ptyLabel,
2267
+ async () => {
2268
+ let outcome: BashInteractiveResult | undefined;
2269
+ try {
2270
+ outcome = await controls.terminalCompletion;
2271
+ const completed = this.#buildCompletedResult(outcome, timeoutSec, {
2272
+ requestedTimeoutSec,
2273
+ });
2274
+ return this.#extractTextResult(completed);
2275
+ } finally {
2276
+ if (!ptyOutputSeen && outcome)
2277
+ ptyManager.appendOutput(ptyJobId, this.#formatResultOutput(outcome));
2278
+ if (!ptyBackgrounded) ptyManager.cancel(ptyJobId);
2279
+ }
2280
+ },
2281
+ {
2282
+ ownerId: this.session.getAgentId?.() ?? undefined,
2283
+ admissionToken: ptyAdmission,
2284
+ lifecycle: { onCancel: killPtyOnce },
2285
+ },
2286
+ );
2287
+ } catch (error) {
2288
+ killPtyOnce();
2289
+ if (ptyAdmission) ptyManager.releaseCapacity(ptyAdmission);
2290
+ throw error;
2291
+ }
2292
+ const ptyGeneration = ptyManager.getJob(ptyJobId)?.generation ?? ptyJobId;
2293
+ registerOwnedIfLineaged(ptyManager, toolCallId, ptyJobId, this.session.getSessionId?.() ?? undefined);
2294
+ const unregisterPtyOwnerCleanup = ptyManager.registerOwnerCleanup(ptyOwnerId, () => {
2295
+ killPtyOnce();
2296
+ ptyManager.failNow(ptyJobId, ptyGeneration, ownerTeardownText());
2297
+ });
2298
+ void controls.terminalCompletion.then(
2299
+ outcome => {
2300
+ ptyOutcome = outcome;
2301
+ },
2302
+ (error: unknown) => {
2303
+ logger.warn("PTY terminal completion failed", {
2304
+ jobId: ptyJobId,
2305
+ error: error instanceof Error ? error.message : String(error),
2306
+ });
2307
+ },
2308
+ );
2309
+ void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
2310
+ ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
2311
+ kind: "bash-pty",
2312
+ jobId: ptyJobId,
2313
+ jobGeneration: ptyGeneration,
2314
+ label: ptyLabel,
2315
+ cwdSensitive: true,
2316
+ signal,
2317
+ originatingTurn: ctx?.attemptScope !== undefined,
2318
+ outputRef: {
2319
+ jobId: ptyJobId,
2320
+ generation: ptyGeneration,
2321
+ instruction: `Use the job tool's tail operation for ${ptyJobId} to read this command's output.`,
2322
+ },
2323
+ getJob: () => {
2324
+ const current = ptyManager.getJob(ptyJobId);
2325
+ return current?.generation === ptyGeneration ? current : undefined;
2326
+ },
2327
+ detachObserver: () => {
2328
+ // Output-only continuation: stdin forwarding ends here and the
2329
+ // process is never killed or restarted by folding.
2330
+ const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
2331
+ requestedTimeoutSec,
2332
+ notices: pendingNotices,
2333
+ });
2334
+ const outcome = controls.detachObserver(
2335
+ interactiveResultFromText(this.#extractTextResult(started)),
2336
+ );
2337
+ if (outcome === "resolved") {
2338
+ controls.detachForegroundCancellation();
2339
+ ptyBackgrounded = true;
2340
+ ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
2341
+ ptyFoldResult = started;
2342
+ }
2343
+ return outcome;
2344
+ },
2345
+ resolveForegroundObserver: () => "already-settled",
2346
+ });
2347
+ },
2348
+ onFoldKey: () => {
2349
+ const now = Date.now();
2350
+ if (now - lastPtyFoldKeyTime > 750) {
2351
+ lastPtyFoldKeyTime = now;
2352
+ return true;
2353
+ }
2354
+ lastPtyFoldKeyTime = 0;
2355
+ if (!this.session.hasForegroundBashBackgroundRequestHandler?.()) return false;
2356
+ void this.session.requestForegroundBashBackground?.();
2357
+ return true;
2358
+ },
2359
+ onOutput: chunk => {
2360
+ ptyOutputSeen = true;
2361
+ if (ptyJobId) ownedManager?.appendOutput(ptyJobId, chunk);
2362
+ },
2363
+ }).catch(error => {
2364
+ if (ptyAdmission) ownedManager?.releaseCapacity(ptyAdmission);
2365
+ throw error;
1652
2366
  })
1653
2367
  : await executeBash(command, {
1654
2368
  cwd: commandCwd,
@@ -1668,6 +2382,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1668
2382
  ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1669
2383
  disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1670
2384
  });
2385
+ ptyFoldUnregister?.();
2386
+ // Folded: the foreground was settled by the fold, so return the background
2387
+ // -start result verbatim instead of presenting a half-finished command.
2388
+ if (ptyFoldResult) return ptyFoldResult;
2389
+
1671
2390
  if (result.cancelled) {
1672
2391
  const noticeSuffix = pendingNotices.length > 0 ? `\n\n${pendingNotices.join("\n")}` : "";
1673
2392
  const baseCancelledText = normalizeResultOutput(result) || "Command aborted";