@gajae-code/coding-agent 0.15.5 → 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 (155) hide show
  1. package/CHANGELOG.md +26 -1
  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 +2 -0
  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 +96 -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 +2 -5
  22. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  23. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  25. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/main.d.ts +3 -1
  28. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  29. package/dist/types/modes/jobs-observer.d.ts +36 -0
  30. package/dist/types/sdk/broker/broker.d.ts +3 -0
  31. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  32. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  33. package/dist/types/sdk/client/discovery.d.ts +1 -0
  34. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  35. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  36. package/dist/types/sdk/host/session-runtime.d.ts +15 -1
  37. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  38. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  39. package/dist/types/sdk/router/session-router.d.ts +9 -0
  40. package/dist/types/session/account-inventory.d.ts +1 -0
  41. package/dist/types/session/agent-session.d.ts +19 -7
  42. package/dist/types/session/fold-coordinator.d.ts +201 -0
  43. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  44. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  45. package/dist/types/telemetry/control.d.ts +11 -0
  46. package/dist/types/telemetry/events.d.ts +20 -0
  47. package/dist/types/telemetry/index.d.ts +3 -0
  48. package/dist/types/telemetry/transport.d.ts +22 -0
  49. package/dist/types/tools/bash-interactive.d.ts +23 -0
  50. package/dist/types/tools/index.d.ts +6 -5
  51. package/dist/types/web/search/providers/base.d.ts +9 -4
  52. package/dist/types/web/search/providers/xai.d.ts +2 -1
  53. package/dist/types/web/search/types.d.ts +14 -0
  54. package/package.json +10 -7
  55. package/scripts/generate-hotkeys-docs.ts +4 -0
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  57. package/src/async/job-manager.ts +671 -39
  58. package/src/cli/auth-broker-cli.ts +40 -12
  59. package/src/cli/auth-gateway-cli.ts +273 -97
  60. package/src/cli/config-cli.ts +2 -2
  61. package/src/cli/fast-help.ts +3 -0
  62. package/src/cli/model-presets-cli.ts +100 -0
  63. package/src/cli/update-cli.ts +40 -5
  64. package/src/cli.ts +6 -2
  65. package/src/commands/auth-gateway.ts +12 -8
  66. package/src/commands/gc.ts +19 -0
  67. package/src/commands/model-presets.ts +24 -0
  68. package/src/config/autorouting-tier-map.ts +32 -0
  69. package/src/config/file-lock-gc.ts +9 -4
  70. package/src/config/file-lock.ts +775 -59
  71. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  72. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  73. package/src/config/keybindings.ts +6 -2
  74. package/src/config/model-preset-registry.ts +2799 -0
  75. package/src/config/model-profile-activation.ts +115 -28
  76. package/src/config/model-profile-contract.ts +4 -1
  77. package/src/config/model-profiles.ts +51 -9
  78. package/src/config/model-registry.ts +1021 -122
  79. package/src/config/settings-schema.ts +11 -0
  80. package/src/config/settings.ts +6 -1
  81. package/src/coordinator-mcp/model-preset.ts +32 -17
  82. package/src/coordinator-mcp/projection-scan.ts +571 -0
  83. package/src/coordinator-mcp/server.ts +19 -24
  84. package/src/coordinator-mcp/session-reaper.ts +9 -3
  85. package/src/extensibility/extensions/loader.ts +0 -1
  86. package/src/extensibility/extensions/types.ts +2 -3
  87. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  88. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  89. package/src/gjc-runtime/gc-render.ts +29 -0
  90. package/src/gjc-runtime/gc-runtime.ts +174 -11
  91. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  92. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  93. package/src/index.ts +1 -0
  94. package/src/internal-urls/docs-index.generated.ts +9 -8
  95. package/src/lsp/config.ts +4 -3
  96. package/src/lsp/path-trust.ts +12 -6
  97. package/src/main.ts +9 -2
  98. package/src/modes/acp/acp-agent.ts +11 -5
  99. package/src/modes/components/jobs-overlay-model.ts +99 -16
  100. package/src/modes/components/model-selector.ts +158 -18
  101. package/src/modes/components/status-line/segments.ts +6 -1
  102. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  103. package/src/modes/controllers/input-controller.ts +23 -6
  104. package/src/modes/interactive-mode.ts +14 -1
  105. package/src/modes/jobs-observer.ts +149 -16
  106. package/src/modes/runtime-init.ts +1 -1
  107. package/src/modes/utils/injected-user-submission.ts +15 -1
  108. package/src/prompts/tools/bash.md +2 -2
  109. package/src/prompts/tools/fold-receipt.md +3 -0
  110. package/src/prompts/tools/job.md +1 -1
  111. package/src/sdk/acp/adapter.ts +17 -2
  112. package/src/sdk/broker/broker.ts +48 -21
  113. package/src/sdk/broker/lifecycle.ts +32 -6
  114. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  115. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  117. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  118. package/src/sdk/bus/slack-conversation.ts +5 -1
  119. package/src/sdk/cli/session-cli.ts +12 -1
  120. package/src/sdk/client/discovery.ts +3 -1
  121. package/src/sdk/host/control/dispatch.ts +3 -0
  122. package/src/sdk/host/model-pin.ts +185 -25
  123. package/src/sdk/host/sdk-run-capability.ts +1 -0
  124. package/src/sdk/host/session-runtime.test.ts +371 -37
  125. package/src/sdk/host/session-runtime.ts +364 -52
  126. package/src/sdk/prompt-deadline-manager.ts +16 -1
  127. package/src/sdk/protocol/adapter-validation.ts +27 -0
  128. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  129. package/src/sdk/protocol/operation-registry.ts +4 -1
  130. package/src/sdk/router/session-router.ts +228 -96
  131. package/src/sdk/session.ts +83 -12
  132. package/src/session/account-inventory.ts +27 -11
  133. package/src/session/agent-session.ts +678 -103
  134. package/src/session/fold-coordinator.ts +433 -0
  135. package/src/session/sdk-run-capability-internal.ts +15 -0
  136. package/src/session/sdk-run-capability.ts +2 -0
  137. package/src/session/session-manager.ts +5 -4
  138. package/src/setup/provider-presets.json +1 -1
  139. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  140. package/src/task/executor.ts +19 -3
  141. package/src/telemetry/control.ts +26 -0
  142. package/src/telemetry/events.ts +142 -0
  143. package/src/telemetry/index.ts +3 -0
  144. package/src/telemetry/transport.ts +87 -0
  145. package/src/tools/bash-interactive.ts +182 -85
  146. package/src/tools/bash.ts +772 -67
  147. package/src/tools/index.ts +7 -5
  148. package/src/tools/tool-catalog.generated.ts +1 -1
  149. package/src/web/search/provider.ts +120 -11
  150. package/src/web/search/providers/anthropic.ts +89 -17
  151. package/src/web/search/providers/base.ts +9 -4
  152. package/src/web/search/providers/gemini.ts +87 -15
  153. package/src/web/search/providers/openai-compatible.ts +23 -8
  154. package/src/web/search/providers/xai.ts +98 -12
  155. 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,
@@ -62,6 +66,20 @@ import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
62
66
  import { checkTmuxSelfInjection } from "./tmux-self-injection-guard";
63
67
  import { ToolAbortError, ToolError } from "./tool-errors";
64
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
+
65
83
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
66
84
 
67
85
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
@@ -70,6 +88,7 @@ const BASH_ERROR_MAX_BYTES = 4096;
70
88
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
71
89
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
72
90
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
91
+ const ACP_RELEASE_TIMEOUT_MS = 1_000;
73
92
  const READ_ONLY_BASH_ENV: Record<string, string> = {
74
93
  GREP_OPTIONS: "",
75
94
  GREP_COLOR: "",
@@ -151,6 +170,27 @@ function artifactReferenceIsReachable(text: string, result: BashResult | BashInt
151
170
  );
152
171
  }
153
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
+
154
194
  function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
155
195
  const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
156
196
  return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
@@ -291,6 +331,48 @@ function formatClientTerminalAbortFailure(
291
331
  return formatBashFailureMessage(result, outputText, "Command aborted");
292
332
  }
293
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
+
294
376
  function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
295
377
  const suffixParts: string[] = [];
296
378
  const reference = artifactReferenceForResult(result);
@@ -468,6 +550,21 @@ interface ManagedBashJobHandle {
468
550
  setBackgrounded: (backgrounded: boolean) => void;
469
551
  }
470
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
+
471
568
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
472
569
  return result.output || "";
473
570
  }
@@ -648,6 +745,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
648
745
  this.description = prompt.render(bashDescription, {
649
746
  asyncEnabled: this.#asyncEnabled,
650
747
  autoBackgroundEnabled: this.#autoBackgroundEnabled,
748
+ foregroundFoldEnabled: this.session.registerForegroundFoldParticipant !== undefined,
651
749
  autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
652
750
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
653
751
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
@@ -732,12 +830,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
732
830
  label: string,
733
831
  previewText: string,
734
832
  timeoutSec: number,
735
- options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
833
+ options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
736
834
  ): AgentToolResult<BashToolDetails> {
737
835
  const details: BashToolDetails = {
738
836
  timeoutSeconds: timeoutSec,
739
837
  async: { state: "running", jobId, type: "bash" },
740
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;
741
842
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
742
843
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
743
844
  }
@@ -775,6 +876,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
775
876
  resolvedEnv?: Record<string, string>;
776
877
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
777
878
  startBackgrounded: boolean;
879
+ onCompletion?: () => void;
778
880
  /** Immutable attempt-scoped tool call id, when executed via a tool call. */
779
881
  toolCallId?: string;
780
882
  }): ManagedBashJobHandle {
@@ -845,12 +947,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
845
947
  });
846
948
  const finalText = this.#extractTextResult(finalResult);
847
949
  latestText = finalText;
950
+ options.onCompletion?.();
848
951
  completion.resolve({ kind: "completed", result: finalResult });
849
952
  await reportProgress(finalText, completedDetails(jobId));
850
953
  return finalText;
851
954
  } catch (error) {
852
955
  const message = error instanceof Error ? error.message : String(error);
853
956
  latestText = message;
957
+ options.onCompletion?.();
854
958
  completion.resolve({ kind: "failed", error, result: executionResult });
855
959
  await reportProgress(message, failedDetails(jobId));
856
960
  throw error;
@@ -867,6 +971,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
867
971
  },
868
972
  },
869
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);
870
979
  registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
871
980
 
872
981
  return {
@@ -893,9 +1002,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
893
1002
  */
894
1003
  #resolveOwnedJobManager(): AsyncJobManager | undefined {
895
1004
  const endpointId = this.session.getSessionId?.() ?? undefined;
896
- return (
897
- this.session.getAsyncJobManager?.() ?? AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance()
898
- );
1005
+ if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
1006
+ return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
899
1007
  }
900
1008
 
901
1009
  async #waitForManagedBashJob(
@@ -1165,6 +1273,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1165
1273
  if (!manager) {
1166
1274
  throw new ToolError("Async job manager unavailable for this session.");
1167
1275
  }
1276
+ if (!manager.hasCapacity()) {
1277
+ throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
1278
+ }
1168
1279
  const prepared = await this.#prepareBashExecution(input, opts.ctx);
1169
1280
  const label =
1170
1281
  opts.label ?? (prepared.command.length > 120 ? `${prepared.command.slice(0, 117)}...` : prepared.command);
@@ -1289,6 +1400,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1289
1400
  if (this.session.bashRestrictionProfile === "read-only" && pty) {
1290
1401
  throw new ToolError("Read-only bash does not allow PTY mode.");
1291
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
+ }
1292
1410
 
1293
1411
  const prepared = await this.#prepareBashExecution(
1294
1412
  { command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
@@ -1309,7 +1427,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1309
1427
  // that was the process-global instance may have been disposed,
1310
1428
  // clearing instance() while THIS session's manager stays
1311
1429
  // registered by endpoint (review thread P1).
1312
- if (!this.#resolveOwnedJobManager()) {
1430
+ const asyncManager = admissionManager ?? this.#resolveOwnedJobManager();
1431
+ if (!asyncManager) {
1313
1432
  throw new ToolError("Async job manager unavailable for this session.");
1314
1433
  }
1315
1434
  const job = this.#startManagedBashJob({
@@ -1325,6 +1444,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1325
1444
  startBackgrounded: true,
1326
1445
  toolCallId,
1327
1446
  });
1447
+ const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
1448
+ job.setBackgrounded(true);
1449
+ asyncManager.markBackgrounded(job.jobId, jobGeneration);
1328
1450
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1329
1451
  requestedTimeoutSec,
1330
1452
  notices: pendingNotices,
@@ -1345,13 +1467,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1345
1467
  // and unregistering — the process-global instance may belong to a different
1346
1468
  // concurrent top-level session (review thread P1).
1347
1469
  const ownedManager = this.#resolveOwnedJobManager();
1348
- 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) {
1349
1474
  // With auto-background off, wait past the command's own timeout so the job only
1350
1475
  // leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
1351
1476
  const autoBackgroundWaitMs = this.#autoBackgroundEnabled
1352
1477
  ? this.#resolveAutoBackgroundWaitMs(timeoutMs)
1353
1478
  : timeoutMs + 1_000;
1354
1479
  const startBackgrounded = autoBackgroundWaitMs === 0;
1480
+ let managedForegroundSettled = false;
1355
1481
  const job = this.#startManagedBashJob({
1356
1482
  command,
1357
1483
  commandCwd,
@@ -1363,18 +1489,59 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1363
1489
  resolvedEnv,
1364
1490
  onUpdate,
1365
1491
  startBackgrounded,
1492
+ onCompletion: () => {
1493
+ managedForegroundSettled = true;
1494
+ },
1366
1495
  toolCallId,
1367
1496
  });
1497
+ const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
1368
1498
  if (startBackgrounded) {
1499
+ job.setBackgrounded(true);
1500
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1369
1501
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
1370
1502
  requestedTimeoutSec,
1371
1503
  notices: pendingNotices,
1372
1504
  });
1373
1505
  }
1374
1506
  const backgroundRequest = Promise.withResolvers<void>();
1375
- const unregisterBackgroundRequest = this.session.registerForegroundBashBackgroundRequestHandler?.(() => {
1376
- job.setBackgrounded(true);
1377
- 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(),
1378
1545
  });
1379
1546
  let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
1380
1547
  try {
@@ -1410,6 +1577,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1410
1577
  throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
1411
1578
  }
1412
1579
  job.setBackgrounded(true);
1580
+ ownedManager.markBackgrounded(job.jobId, jobGeneration);
1413
1581
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
1414
1582
  requestedTimeoutSec,
1415
1583
  notices: pendingNotices,
@@ -1417,63 +1585,200 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1417
1585
  }
1418
1586
 
1419
1587
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
1588
+ const clientAdmission = ownedManager?.reserveCapacity();
1420
1589
  const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1421
1590
  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" }
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 }
1440
1625
  | { kind: "timeout" }
1441
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;
1442
1657
 
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>();
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
+ };
1448
1666
  let killPromise: Promise<void> | undefined;
1449
1667
  const fireKill = (): Promise<void> => {
1450
1668
  if (killPromise) return killPromise;
1451
- 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) {
1452
1674
  logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1453
- });
1675
+ killPromise = Promise.resolve();
1676
+ }
1454
1677
  return killPromise;
1455
1678
  };
1456
- const onAbortSignal = () => {
1457
- resolveAborted();
1458
- void fireKill();
1679
+ const boundedKill = async (): Promise<void> => {
1680
+ await runBoundedCleanup(handle, fireKill, "kill");
1459
1681
  };
1460
- signal?.addEventListener("abort", onAbortSignal, { once: true });
1461
1682
 
1683
+ // Emit partial update so the editor can embed the live terminal card.
1462
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
+
1463
1766
  try {
1464
- if (signal?.aborted) {
1465
- await fireKill();
1767
+ if (runSignal?.aborted) {
1768
+ await boundedKill();
1466
1769
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1467
1770
  let readDiagnostic: string | undefined;
1468
1771
  try {
1469
- current = await handle.currentOutput();
1772
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1470
1773
  } catch (error) {
1774
+ current = retainedAcpOutput();
1471
1775
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1472
1776
  logger.warn("ACP terminal aborted output read failed", {
1473
1777
  terminalId: handle.terminalId,
1474
1778
  error,
1475
1779
  });
1476
1780
  }
1781
+ appendAcpSnapshot(current.output, current.truncated);
1477
1782
  const prepared = await prepareClientTerminalOutput(this.session, current);
1478
1783
  throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1479
1784
  }
@@ -1482,51 +1787,81 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1482
1787
  // Poll until the process exits, times out, or the caller aborts.
1483
1788
  for (;;) {
1484
1789
  const racers: Array<Promise<BridgeRaceResult>> = [
1485
- exitPromise.then(s => ({ kind: "exit" as const, status: s })),
1790
+ exitRacePromise,
1486
1791
  timeoutPromise,
1487
1792
  Bun.sleep(250).then(() => ({ kind: "poll" as const })),
1488
1793
  ];
1489
- if (signal) {
1794
+ if (runSignal) {
1490
1795
  racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
1491
1796
  }
1492
1797
  const raced = await Promise.race(racers);
1493
1798
 
1494
- if (raced.kind === "aborted" || signal?.aborted) {
1495
- await fireKill();
1799
+ if (raced.kind === "aborted" || runSignal?.aborted) {
1800
+ await boundedKill();
1496
1801
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1497
1802
  let readDiagnostic: string | undefined;
1498
1803
  try {
1499
- current = await handle.currentOutput();
1804
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1500
1805
  } catch (error) {
1806
+ current = retainedAcpOutput();
1501
1807
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1502
1808
  logger.warn("ACP terminal aborted output read failed", {
1503
1809
  terminalId: handle.terminalId,
1504
1810
  error,
1505
1811
  });
1506
1812
  }
1813
+ appendAcpSnapshot(current.output, current.truncated);
1507
1814
  const prepared = await prepareClientTerminalOutput(this.session, current);
1508
1815
  throw new ToolAbortError(
1509
1816
  formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
1510
1817
  );
1511
1818
  }
1512
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
+
1513
1846
  if (raced.kind === "timeout") {
1514
1847
  // Kill before reading final output so a slow `terminal/output`
1515
1848
  // RPC cannot let a timed-out command keep running past the
1516
1849
  // enforced timeout. The handle stays valid post-kill so the
1517
1850
  // buffered output is still readable.
1518
- await fireKill();
1851
+ await boundedKill();
1519
1852
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1520
1853
  let readDiagnostic: string | undefined;
1521
1854
  try {
1522
- current = await handle.currentOutput();
1855
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1523
1856
  } catch (error) {
1857
+ current = retainedAcpOutput();
1524
1858
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1525
1859
  logger.warn("ACP terminal final output read failed", {
1526
1860
  terminalId: handle.terminalId,
1527
1861
  error,
1528
1862
  });
1529
1863
  }
1864
+ appendAcpSnapshot(current.output, current.truncated);
1530
1865
  const prepared = await prepareClientTerminalOutput(this.session, current);
1531
1866
  const timeoutNotices = [
1532
1867
  ...pendingNotices,
@@ -1555,13 +1890,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1555
1890
  // Poll tick: push current output so agent-loop transcript stays consistent.
1556
1891
  // Race the read against abort so a stuck `terminal/output` RPC does not
1557
1892
  // delay cancellation.
1558
- const pollOutput = await Promise.race([
1559
- handle.currentOutput(),
1560
- abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
1561
- ]);
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
+ }
1562
1908
  if (pollOutput === undefined) {
1563
1909
  // Abort fired during the poll-tick read; let the next loop iteration
1564
- // observe `signal?.aborted` and exit via the abort branch.
1910
+ // observe `runSignal?.aborted` and exit via the abort branch.
1565
1911
  continue;
1566
1912
  }
1567
1913
  const { summary, locallyTruncated } = await boundClientTerminalOutput(
@@ -1573,40 +1919,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1573
1919
  pollOutput.truncated || locallyTruncated
1574
1920
  ? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
1575
1921
  : summary.output;
1576
- onUpdate?.({
1577
- content: [{ type: "text", text: pollText }],
1578
- details: { terminalId: handle.terminalId },
1579
- });
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
+ }
1580
1938
  }
1581
1939
  } finally {
1582
- signal?.removeEventListener("abort", onAbortSignal);
1940
+ runSignal?.removeEventListener("abort", onAbortSignal);
1583
1941
  }
1584
1942
 
1585
- if (signal?.aborted) {
1586
- await fireKill();
1943
+ if (runSignal?.aborted) {
1944
+ await boundedKill();
1587
1945
  let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1588
1946
  let readDiagnostic: string | undefined;
1589
1947
  try {
1590
- current = await handle.currentOutput();
1948
+ current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
1591
1949
  } catch (error) {
1950
+ current = retainedAcpOutput();
1592
1951
  readDiagnostic = boundArtifactSaveDiagnostic(error);
1593
1952
  logger.warn("ACP terminal aborted output read failed", {
1594
1953
  terminalId: handle.terminalId,
1595
1954
  error,
1596
1955
  });
1597
1956
  }
1957
+ appendAcpSnapshot(current.output, current.truncated);
1598
1958
  const prepared = await prepareClientTerminalOutput(this.session, current);
1599
1959
  throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1600
1960
  }
1601
1961
 
1602
1962
  // Fetch final output; the terminal is released in the outer finally.
1603
- 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
+ }
1604
1989
 
1605
1990
  // Map exit status: null exitCode with a signal → treat as signal kill (137).
1606
1991
  const rawExitCode = exitStatus.exitCode;
1607
1992
  const exitCode: number | undefined =
1608
1993
  rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
1609
1994
 
1995
+ appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
1610
1996
  const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
1611
1997
 
1612
1998
  const bridgeResult: BashResult = {
@@ -1619,19 +2005,188 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1619
2005
  if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
1620
2006
  for (const notice of pendingNotices) bridgeNotices.push(notice);
1621
2007
  if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
2008
+ if (finalReadDiagnostic) bridgeNotices.push(`Terminal output recovery failed: ${finalReadDiagnostic}`);
1622
2009
 
1623
2010
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
1624
2011
  requestedTimeoutSec,
1625
2012
  notices: bridgeNotices,
1626
2013
  terminalId: handle.terminalId,
1627
2014
  });
1628
- } finally {
2015
+ };
2016
+
2017
+ // Without a job manager nothing can fold this, so ownership never leaves the
2018
+ // foreground call.
2019
+ if (!ownedManager) {
1629
2020
  try {
1630
- await handle.release();
1631
- } catch (error) {
1632
- 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
+ );
1633
2177
  }
2178
+ throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, bridgeHandle.getLatestText()));
1634
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
+ });
1635
2190
  }
1636
2191
 
1637
2192
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
@@ -1650,6 +2205,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1650
2205
  : canUseInteractiveBashPty(pty, ctx)
1651
2206
  ? ctx?.ui
1652
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;
1653
2219
  const result: BashResult | BashInteractiveResult = interactiveUi
1654
2220
  ? await runInteractiveBashPty(interactiveUi, {
1655
2221
  command,
@@ -1663,6 +2229,140 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1663
2229
  artifactPublisher,
1664
2230
  spillThreshold,
1665
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;
1666
2366
  })
1667
2367
  : await executeBash(command, {
1668
2368
  cwd: commandCwd,
@@ -1682,6 +2382,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1682
2382
  ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1683
2383
  disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1684
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
+
1685
2390
  if (result.cancelled) {
1686
2391
  const noticeSuffix = pendingNotices.length > 0 ? `\n\n${pendingNotices.join("\n")}` : "";
1687
2392
  const baseCancelledText = normalizeResultOutput(result) || "Command aborted";