@gajae-code/coding-agent 0.12.4 → 0.12.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 (139) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +9 -1
  4. package/dist/types/eval/backend.d.ts +2 -2
  5. package/dist/types/exec/bash-executor.d.ts +10 -8
  6. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  7. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  8. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  9. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  10. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  11. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  12. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  15. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  16. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  17. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  19. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  20. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  21. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  23. package/dist/types/sdk/broker/broker.d.ts +18 -0
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  25. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  26. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
  27. package/dist/types/sdk/bus/index.d.ts +4 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  30. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  31. package/dist/types/sdk/host/host.d.ts +23 -2
  32. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  33. package/dist/types/session/agent-session.d.ts +5 -4
  34. package/dist/types/session/artifacts.d.ts +8 -5
  35. package/dist/types/session/attempt-record-store.d.ts +19 -0
  36. package/dist/types/session/client-bridge.d.ts +14 -0
  37. package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
  38. package/dist/types/session/messages.d.ts +4 -1
  39. package/dist/types/session/session-manager.d.ts +7 -5
  40. package/dist/types/session/session-storage.d.ts +8 -0
  41. package/dist/types/session/streaming-output.d.ts +32 -0
  42. package/dist/types/task/executor.d.ts +1 -1
  43. package/dist/types/task/index.d.ts +2 -1
  44. package/dist/types/task/receipt.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +194 -0
  46. package/dist/types/task/worktree.d.ts +13 -6
  47. package/dist/types/tools/bash.d.ts +1 -1
  48. package/dist/types/tools/fetch.d.ts +3 -0
  49. package/dist/types/tools/output-meta.d.ts +37 -5
  50. package/dist/types/tools/subagent.d.ts +2 -0
  51. package/dist/types/tools/tool-result.d.ts +1 -0
  52. package/dist/types/utils/artifact-id.d.ts +2 -0
  53. package/dist/types/utils/git.d.ts +1 -0
  54. package/package.json +8 -8
  55. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  56. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  57. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  58. package/src/async/job-manager.ts +13 -5
  59. package/src/config/model-profiles.ts +8 -0
  60. package/src/config/settings-schema.ts +17 -3
  61. package/src/cursor.ts +20 -13
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  63. package/src/eval/backend.ts +2 -2
  64. package/src/exec/bash-executor.ts +425 -151
  65. package/src/extensibility/extensions/runner.ts +80 -6
  66. package/src/extensibility/extensions/wrapper.ts +22 -15
  67. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  68. package/src/gjc-runtime/launch-tmux.ts +309 -293
  69. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  70. package/src/gjc-runtime/psmux-detect.ts +52 -6
  71. package/src/gjc-runtime/team-launch.ts +50 -2
  72. package/src/gjc-runtime/team-runtime.ts +716 -251
  73. package/src/gjc-runtime/team-store.ts +25 -7
  74. package/src/gjc-runtime/team-workers.ts +161 -1
  75. package/src/gjc-runtime/tmux-common.ts +16 -4
  76. package/src/gjc-runtime/tmux-gc.ts +37 -5
  77. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  78. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  79. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  80. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  82. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  83. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  84. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  85. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  86. package/src/internal-urls/agent-protocol.ts +98 -38
  87. package/src/internal-urls/artifact-protocol.ts +28 -17
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/internal-urls/local-protocol.ts +39 -18
  90. package/src/internal-urls/registry-helpers.ts +62 -0
  91. package/src/modes/acp/acp-agent.ts +51 -13
  92. package/src/modes/acp/acp-client-bridge.ts +154 -11
  93. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  94. package/src/modes/components/plugin-settings.ts +5 -0
  95. package/src/modes/components/settings-selector.ts +4 -0
  96. package/src/modes/controllers/selector-controller.ts +1 -0
  97. package/src/prompts/tools/bash.md +2 -2
  98. package/src/prompts/tools/monitor.md +4 -4
  99. package/src/sdk/acp/adapter.ts +200 -9
  100. package/src/sdk/broker/broker.ts +93 -10
  101. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  102. package/src/sdk/broker/lifecycle.ts +692 -98
  103. package/src/sdk/bus/chat-daemon-control.ts +33 -5
  104. package/src/sdk/bus/index.ts +347 -14
  105. package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
  106. package/src/sdk/bus/telegram-daemon.ts +283 -44
  107. package/src/sdk/bus/topic-registry.ts +133 -10
  108. package/src/sdk/host/host.ts +187 -9
  109. package/src/sdk/host/reverse-leases.ts +249 -42
  110. package/src/sdk/session.ts +8 -6
  111. package/src/session/agent-session.ts +930 -354
  112. package/src/session/artifacts.ts +211 -74
  113. package/src/session/attempt-record-store.ts +106 -0
  114. package/src/session/client-bridge.ts +15 -0
  115. package/src/session/internal/managed-session-scope.ts +332 -198
  116. package/src/session/internal/managed-session-storage.ts +162 -450
  117. package/src/session/messages.ts +32 -7
  118. package/src/session/session-manager.ts +31 -28
  119. package/src/session/session-storage.ts +190 -53
  120. package/src/session/streaming-output.ts +198 -40
  121. package/src/task/executor.ts +5 -2
  122. package/src/task/index.ts +277 -64
  123. package/src/task/provider-retry-status.ts +4 -1
  124. package/src/task/receipt.ts +26 -2
  125. package/src/task/render.ts +42 -1
  126. package/src/task/types.ts +52 -0
  127. package/src/task/worktree.ts +122 -43
  128. package/src/tools/bash.ts +818 -239
  129. package/src/tools/eval.ts +99 -4
  130. package/src/tools/fetch.ts +77 -15
  131. package/src/tools/find.ts +7 -3
  132. package/src/tools/monitor.ts +7 -1
  133. package/src/tools/output-meta.ts +706 -116
  134. package/src/tools/search.ts +9 -4
  135. package/src/tools/subagent-render.ts +5 -2
  136. package/src/tools/subagent.ts +8 -0
  137. package/src/tools/tool-result.ts +1 -0
  138. package/src/utils/artifact-id.ts +6 -0
  139. package/src/utils/git.ts +2 -0
package/src/tools/bash.ts CHANGED
@@ -18,7 +18,12 @@ 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
+ ClientBridge,
23
+ ClientBridgeTerminalExitStatus,
24
+ ClientBridgeTerminalHandle,
25
+ ClientBridgeTerminalOutput,
26
+ } from "../session/client-bridge";
22
27
  import {
23
28
  DEFAULT_ARTIFACT_MAX_BYTES,
24
29
  OutputSink,
@@ -43,8 +48,10 @@ import { canUseInteractiveBashPty } from "./bash-pty-selection";
43
48
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
44
49
  import { checkComposerBashPolicy } from "./composer-bash-policy";
45
50
  import {
51
+ formatArtifactEvidenceNotice,
46
52
  formatArtifactReference,
47
53
  formatStyledTruncationWarning,
54
+ isValidArtifactId,
48
55
  type OutputMeta,
49
56
  resolveBashOutputSinkHeadBytes,
50
57
  resolveBashOutputSinkTailBytes,
@@ -62,6 +69,81 @@ const BASH_ERROR_MAX_BYTES = 4096;
62
69
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
63
70
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
64
71
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
72
+
73
+ function maxBashErrorBytes(session: ToolSession): number {
74
+ const configuredKiB = session.settings.get("tools.maxInlineResultBytes");
75
+ if (configuredKiB <= 0) return BASH_ERROR_MAX_BYTES;
76
+ return Math.min(BASH_ERROR_MAX_BYTES, Math.max(1024, Math.floor(configuredKiB * 1024)));
77
+ }
78
+ const CLIENT_TERMINAL_RPC_WAIT_MS = 500;
79
+ const BASH_PENDING_OPERATION_LIMIT = 64;
80
+ const pendingBashOperations = new Set<Promise<unknown>>();
81
+ interface BashCleanupOwner {
82
+ pending: Set<Promise<unknown>>;
83
+ reservations: number;
84
+ }
85
+ const pendingBashCleanupOwners = new WeakMap<object, BashCleanupOwner>();
86
+
87
+ function getBashCleanupOwner(owner: object): BashCleanupOwner {
88
+ let state = pendingBashCleanupOwners.get(owner);
89
+ if (!state) {
90
+ state = { pending: new Set(), reservations: 0 };
91
+ pendingBashCleanupOwners.set(owner, state);
92
+ }
93
+ return state;
94
+ }
95
+
96
+ function reserveBashCleanup(owner: object): boolean {
97
+ const state = getBashCleanupOwner(owner);
98
+ if (state.pending.size + state.reservations >= BASH_PENDING_OPERATION_LIMIT) return false;
99
+ state.reservations++;
100
+ return true;
101
+ }
102
+
103
+ function consumeBashCleanupReservation(owner: object): void {
104
+ const state = getBashCleanupOwner(owner);
105
+ state.reservations = Math.max(0, state.reservations - 1);
106
+ }
107
+ interface BashReleaseOwner {
108
+ pending: Set<Promise<unknown>>;
109
+ reservations: number;
110
+ }
111
+ const pendingBashReleaseOwners = new WeakMap<object, BashReleaseOwner>();
112
+
113
+ function getBashReleaseOwner(owner: object): BashReleaseOwner {
114
+ let state = pendingBashReleaseOwners.get(owner);
115
+ if (!state) {
116
+ state = { pending: new Set(), reservations: 0 };
117
+ pendingBashReleaseOwners.set(owner, state);
118
+ }
119
+ return state;
120
+ }
121
+
122
+ function reserveBashRelease(owner: object): boolean {
123
+ const state = getBashReleaseOwner(owner);
124
+ if (state.pending.size + state.reservations >= BASH_PENDING_OPERATION_LIMIT) return false;
125
+ state.reservations++;
126
+ return true;
127
+ }
128
+
129
+ function consumeBashReleaseReservation(owner: object): void {
130
+ const state = getBashReleaseOwner(owner);
131
+ state.reservations = Math.max(0, state.reservations - 1);
132
+ }
133
+
134
+ function trackPendingOperation<T>(pending: Set<Promise<unknown>>, limit: number, promise: Promise<T>): boolean {
135
+ if (pending.size >= limit) return false;
136
+ pending.add(promise);
137
+ void promise.then(
138
+ () => pending.delete(promise),
139
+ () => pending.delete(promise),
140
+ );
141
+ return true;
142
+ }
143
+
144
+ function hasBoundedOperationCapacity(): boolean {
145
+ return pendingBashOperations.size < BASH_PENDING_OPERATION_LIMIT;
146
+ }
65
147
  const READ_ONLY_BASH_ENV: Record<string, string> = {
66
148
  GREP_OPTIONS: "",
67
149
  GREP_COLOR: "",
@@ -77,10 +159,118 @@ function boundArtifactSaveDiagnostic(error: unknown): string {
77
159
  return truncateHeadBytes(normalized, ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES).text;
78
160
  }
79
161
 
80
- function summarizeOriginalArtifactSave(
81
- artifactId: string,
82
- originalText: string,
83
- ): Extract<BashOriginalArtifactSaveResult, { status: "saved" }> {
162
+ type BoundedOperationOutcome<T> =
163
+ | { status: "ok"; value: T }
164
+ | { status: "failed"; diagnostic: string }
165
+ | { status: "timeout"; diagnostic: string };
166
+
167
+ async function awaitBoundedOperationIn<T>(
168
+ pending: Set<Promise<unknown>>,
169
+ operation: () => Promise<T>,
170
+ limitDiagnostic: string,
171
+ reserved = false,
172
+ ): Promise<BoundedOperationOutcome<T>> {
173
+ const operationLimit = reserved ? BASH_PENDING_OPERATION_LIMIT + 1 : BASH_PENDING_OPERATION_LIMIT;
174
+ if (pending.size >= operationLimit) {
175
+ return { status: "failed", diagnostic: limitDiagnostic };
176
+ }
177
+ const promise = Promise.resolve().then(operation);
178
+ if (!trackPendingOperation(pending, operationLimit, promise)) {
179
+ return { status: "failed", diagnostic: limitDiagnostic };
180
+ }
181
+ return Promise.race([
182
+ promise.then(
183
+ value => ({ status: "ok", value }) satisfies BoundedOperationOutcome<T>,
184
+ error =>
185
+ ({ status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) }) satisfies BoundedOperationOutcome<T>,
186
+ ),
187
+ Bun.sleep(CLIENT_TERMINAL_RPC_WAIT_MS).then(
188
+ () => ({ status: "timeout", diagnostic: `did not settle within ${CLIENT_TERMINAL_RPC_WAIT_MS}ms` }) as const,
189
+ ),
190
+ ]);
191
+ }
192
+
193
+ function awaitBoundedOperation<T>(operation: () => Promise<T>): Promise<BoundedOperationOutcome<T>> {
194
+ return awaitBoundedOperationIn(pendingBashOperations, operation, "pending operation limit reached");
195
+ }
196
+
197
+ function awaitBoundedCleanup<T>(
198
+ owner: object,
199
+ operation: () => Promise<T>,
200
+ reserved = false,
201
+ ): Promise<BoundedOperationOutcome<T>> {
202
+ return awaitBoundedOperationIn(
203
+ getBashCleanupOwner(owner).pending,
204
+ operation,
205
+ "pending cleanup limit reached",
206
+ reserved,
207
+ );
208
+ }
209
+
210
+ function awaitBoundedRelease<T>(owner: object, operation: () => Promise<T>): Promise<BoundedOperationOutcome<T>> {
211
+ return awaitBoundedOperationIn(getBashReleaseOwner(owner).pending, operation, "pending release limit reached");
212
+ }
213
+
214
+ async function quarantineClientTerminalLease(
215
+ bridge: ClientBridge,
216
+ handle: ClientBridgeTerminalHandle,
217
+ reason: string,
218
+ ): Promise<BoundedOperationOutcome<void>> {
219
+ if (!bridge.quarantineProviderLease) {
220
+ return { status: "failed", diagnostic: "terminal provider lease quarantine is unavailable" };
221
+ }
222
+ return awaitBoundedCleanup(bridge, () =>
223
+ bridge.quarantineProviderLease!(handle.providerLeaseIdentity ?? handle.providerLeaseId, reason),
224
+ );
225
+ }
226
+ const quarantinedClientTerminalProviders = new WeakMap<ClientBridge, Set<string>>();
227
+
228
+ function clientTerminalProviderKey(handle: ClientBridgeTerminalHandle): string {
229
+ const identity = handle.providerLeaseIdentity;
230
+ return identity
231
+ ? `${identity.leaseId}\0${identity.connectionId}\0${identity.connectionGeneration}\0${identity.fence}`
232
+ : handle.providerLeaseId;
233
+ }
234
+
235
+ function markClientTerminalProviderQuarantined(bridge: ClientBridge, handle: ClientBridgeTerminalHandle): void {
236
+ let quarantined = quarantinedClientTerminalProviders.get(bridge);
237
+ if (!quarantined) {
238
+ quarantined = new Set();
239
+ quarantinedClientTerminalProviders.set(bridge, quarantined);
240
+ }
241
+ quarantined.add(clientTerminalProviderKey(handle));
242
+ }
243
+
244
+ function hasQuarantinedClientTerminalProvider(bridge: ClientBridge): boolean {
245
+ return (quarantinedClientTerminalProviders.get(bridge)?.size ?? 0) > 0;
246
+ }
247
+ function clearClientTerminalProviderQuarantine(bridge: ClientBridge, handle: ClientBridgeTerminalHandle): void {
248
+ const quarantined = quarantinedClientTerminalProviders.get(bridge);
249
+ if (!quarantined) return;
250
+ quarantined.delete(clientTerminalProviderKey(handle));
251
+ if (quarantined.size === 0) quarantinedClientTerminalProviders.delete(bridge);
252
+ }
253
+
254
+ async function recoverClientTerminalOutput(
255
+ handle: ClientBridgeTerminalHandle,
256
+ phase: string,
257
+ readCurrentOutput: () => Promise<BoundedOperationOutcome<ClientBridgeTerminalOutput>> = () =>
258
+ awaitBoundedOperation(() => handle.currentOutput()),
259
+ ): Promise<{ current: ClientBridgeTerminalOutput; diagnostic?: string }> {
260
+ const outcome = await readCurrentOutput();
261
+ if (outcome.status === "ok") return { current: outcome.value };
262
+ logger.warn("ACP terminal output recovery failed", {
263
+ terminalId: handle.terminalId,
264
+ phase,
265
+ diagnostic: outcome.diagnostic,
266
+ });
267
+ return { current: { output: "", truncated: true }, diagnostic: outcome.diagnostic };
268
+ }
269
+
270
+ function summarizeOriginalArtifactSave(artifactId: string, originalText: string): BashOriginalArtifactSaveResult {
271
+ if (!isValidArtifactId(artifactId)) {
272
+ return { status: "failed", diagnostic: "storage returned an invalid artifact id" };
273
+ }
84
274
  const originalBytes = Buffer.byteLength(originalText, "utf-8");
85
275
  if (originalBytes <= DEFAULT_ARTIFACT_MAX_BYTES) {
86
276
  return { status: "saved", artifactId, complete: true };
@@ -94,6 +284,26 @@ function summarizeOriginalArtifactSave(
94
284
  };
95
285
  }
96
286
 
287
+ async function allocateBashOutputArtifact(
288
+ session: ToolSession,
289
+ toolType: "bash" | "bash-original",
290
+ ): Promise<{ id?: string; path?: string }> {
291
+ const allocateOutputArtifact = session.allocateOutputArtifact;
292
+ if (!allocateOutputArtifact) return {};
293
+ const outcome = await awaitBoundedOperation(() => allocateOutputArtifact(toolType));
294
+ if (outcome.status !== "ok") {
295
+ logger.warn("Bash artifact allocation failed", { toolType, error: outcome.diagnostic });
296
+ return {};
297
+ }
298
+ const allocation = outcome.value;
299
+ if (!allocation) return {};
300
+ if (!isValidArtifactId(allocation.id)) {
301
+ logger.warn("Bash artifact allocation returned an invalid id", { toolType });
302
+ return {};
303
+ }
304
+ return allocation;
305
+ }
306
+
97
307
  function artifactSaveResultNotice(
98
308
  result: BashOriginalArtifactSaveResult,
99
309
  hasAlternateCompleteArtifact = false,
@@ -110,36 +320,51 @@ function artifactTruncatedBytesForResult(result: BashResult | BashInteractiveRes
110
320
  return typeof bytes === "number" && bytes > 0 ? bytes : undefined;
111
321
  }
112
322
 
323
+ function sourceTruncatedBytesForResult(result: BashResult | BashInteractiveResult): number | undefined {
324
+ const bytes = (result as OutputSummary).sourceTruncatedBytes;
325
+ return typeof bytes === "number" && bytes > 0 ? bytes : undefined;
326
+ }
327
+
328
+ function sourceCaptureIncompleteForResult(result: BashResult | BashInteractiveResult): boolean {
329
+ return (result as OutputSummary).sourceCaptureIncomplete === true;
330
+ }
331
+
113
332
  function artifactReferenceForResult(result: BashResult | BashInteractiveResult): string | undefined {
114
333
  return result.artifactId
115
- ? formatArtifactReference(result.artifactId, artifactTruncatedBytesForResult(result))
334
+ ? formatArtifactReference(
335
+ result.artifactId,
336
+ artifactTruncatedBytesForResult(result),
337
+ sourceTruncatedBytesForResult(result),
338
+ sourceCaptureIncompleteForResult(result),
339
+ )
116
340
  : undefined;
117
341
  }
118
342
 
119
- function rawArtifactReferenceForSavedResult(
120
- result: Extract<BashOriginalArtifactSaveResult, { status: "saved" }>,
121
- ): string {
122
- return result.complete
123
- ? `artifact://${result.artifactId}`
124
- : formatArtifactReference(result.artifactId, result.omittedBytes);
125
- }
126
-
127
343
  function appendRawArtifactFooter(
128
344
  summary: OutputSummary,
129
345
  result: Extract<BashOriginalArtifactSaveResult, { status: "saved" }>,
130
346
  ): void {
347
+ summary.artifactFailureDiagnostic = "artifact save could not be proven at the current delivery boundary";
131
348
  summary.artifactId = result.artifactId;
349
+ summary.artifactVerified = false;
132
350
  if (!result.complete) summary.artifactTruncatedBytes = result.omittedBytes;
133
- const separator = summary.output.endsWith("\n") ? "" : "\n";
134
- summary.output = `${summary.output}${separator}[raw output: ${rawArtifactReferenceForSavedResult(result)}]`;
135
351
  }
136
352
 
137
353
  function artifactReferenceIsReachable(text: string, result: BashResult | BashInteractiveResult): boolean {
138
354
  if (!result.artifactId || !text.includes(`artifact://${result.artifactId}`)) return false;
139
355
  const artifactTruncatedBytes = artifactTruncatedBytesForResult(result);
356
+ const sourceTruncatedBytes = sourceTruncatedBytesForResult(result);
357
+ const sourceCaptureIncomplete = sourceCaptureIncompleteForResult(result);
140
358
  return (
141
- artifactTruncatedBytes === undefined ||
142
- text.includes(formatArtifactReference(result.artifactId, artifactTruncatedBytes))
359
+ (artifactTruncatedBytes === undefined && sourceTruncatedBytes === undefined && !sourceCaptureIncomplete) ||
360
+ text.includes(
361
+ formatArtifactReference(
362
+ result.artifactId,
363
+ artifactTruncatedBytes,
364
+ sourceTruncatedBytes,
365
+ sourceCaptureIncomplete,
366
+ ),
367
+ )
143
368
  );
144
369
  }
145
370
 
@@ -159,11 +384,20 @@ function artifactWriterFailureNotice(result: BashResult | BashInteractiveResult)
159
384
  }
160
385
 
161
386
  function completeOutputArtifactAvailable(
162
- result: Pick<OutputSummary, "artifactId" | "artifactTruncatedBytes" | "artifactFailureDiagnostic">,
387
+ result: Pick<
388
+ OutputSummary,
389
+ | "artifactId"
390
+ | "artifactTruncatedBytes"
391
+ | "sourceTruncatedBytes"
392
+ | "sourceCaptureIncomplete"
393
+ | "artifactFailureDiagnostic"
394
+ >,
163
395
  ): boolean {
164
396
  return (
165
397
  result.artifactId !== undefined &&
166
398
  (typeof result.artifactTruncatedBytes !== "number" || result.artifactTruncatedBytes <= 0) &&
399
+ (typeof result.sourceTruncatedBytes !== "number" || result.sourceTruncatedBytes <= 0) &&
400
+ !result.sourceCaptureIncomplete &&
167
401
  (typeof result.artifactFailureDiagnostic !== "string" || result.artifactFailureDiagnostic.length === 0)
168
402
  );
169
403
  }
@@ -196,29 +430,57 @@ function formatBashFailureMessage(
196
430
  result: BashResult | BashInteractiveResult,
197
431
  text: string,
198
432
  explicitCause?: string,
433
+ maxBytes = BASH_ERROR_MAX_BYTES,
199
434
  ): string {
200
435
  const statusCause = failureStatusCause(result, text, explicitCause);
201
436
  const bodyText = removeTrailingFailureCause(text, statusCause);
437
+ const bodyStatusEvidence = Array.from(
438
+ bodyText.matchAll(/Command (?:timed out(?: after \d+ seconds?)?|cancelled|aborted)/gu),
439
+ )
440
+ .map(match => match[0])
441
+ .findLast(candidate => candidate !== statusCause);
202
442
  const suffixParts: string[] = [];
203
- const reference = artifactReferenceForResult(result);
204
- if (reference) suffixParts.push(reference);
443
+ const artifactEvidence = formatArtifactEvidenceNotice({ ...result, artifactFailureDiagnostic: undefined });
444
+ if (artifactEvidence) suffixParts.push(artifactEvidence);
205
445
  const writerNotice = artifactWriterFailureNotice(result);
206
446
  if (writerNotice) suffixParts.push(writerNotice);
447
+ if (bodyStatusEvidence) suffixParts.push(bodyStatusEvidence);
207
448
  if (statusCause) suffixParts.push(statusCause);
208
449
  const suffix = suffixParts.join("\n\n");
209
450
  const separator = bodyText.length > 0 && suffix.length > 0 ? "\n\n" : "";
210
451
  const bodyBudget = Math.max(
211
452
  0,
212
- BASH_ERROR_MAX_BYTES - Buffer.byteLength(suffix, "utf-8") - Buffer.byteLength(separator, "utf-8"),
453
+ maxBytes - Buffer.byteLength(suffix, "utf-8") - Buffer.byteLength(separator, "utf-8"),
213
454
  );
214
455
  const body = truncateTailBytes(bodyText, bodyBudget).text;
215
456
  return `${body}${body.length > 0 ? separator : ""}${suffix}`;
216
457
  }
217
458
 
459
+ function normalizeClientTerminalOutput(current: ClientBridgeTerminalOutput): {
460
+ current: ClientBridgeTerminalOutput;
461
+ malformed: boolean;
462
+ } {
463
+ const runtimeCurrent = current as unknown;
464
+ const outputRecord =
465
+ typeof runtimeCurrent === "object" && runtimeCurrent !== null
466
+ ? (runtimeCurrent as Record<string, unknown>)
467
+ : undefined;
468
+ const outputValid = typeof outputRecord?.output === "string";
469
+ const truncatedValid = typeof outputRecord?.truncated === "boolean";
470
+ return {
471
+ current: {
472
+ output: outputValid ? (outputRecord.output as string) : "",
473
+ truncated: truncatedValid ? (outputRecord.truncated as boolean) : true,
474
+ },
475
+ malformed: !outputValid || !truncatedValid,
476
+ };
477
+ }
478
+
218
479
  async function boundClientTerminalOutput(
219
480
  output: string,
220
481
  alreadyTruncated: boolean,
221
482
  settings: ToolSession["settings"],
483
+ sourceMalformed = false,
222
484
  ): Promise<{ summary: OutputSummary; locallyTruncated: boolean }> {
223
485
  const tailBytes = resolveBashOutputSinkTailBytes(settings);
224
486
  const headBytes = resolveBashOutputSinkHeadBytes(settings);
@@ -228,7 +490,8 @@ async function boundClientTerminalOutput(
228
490
  return {
229
491
  summary: {
230
492
  ...bounded,
231
- truncated: alreadyTruncated || bounded.truncated,
493
+ truncated: alreadyTruncated || sourceMalformed || bounded.truncated,
494
+ ...(alreadyTruncated || sourceMalformed ? { sourceCaptureIncomplete: true } : {}),
232
495
  },
233
496
  locallyTruncated: bounded.truncated,
234
497
  };
@@ -245,27 +508,52 @@ interface PreparedClientTerminalOutput {
245
508
  async function prepareClientTerminalOutput(
246
509
  session: ToolSession,
247
510
  current: ClientBridgeTerminalOutput,
511
+ forceSourceIncomplete = false,
248
512
  ): Promise<PreparedClientTerminalOutput> {
513
+ const normalized = normalizeClientTerminalOutput(current);
514
+ const normalizedCurrent = normalized.current;
515
+ const sourceMalformed = normalized.malformed;
249
516
  const { summary, locallyTruncated } = await boundClientTerminalOutput(
250
- current.output,
251
- current.truncated,
517
+ normalizedCurrent.output,
518
+ normalizedCurrent.truncated,
252
519
  session.settings,
520
+ sourceMalformed,
253
521
  );
522
+ if (forceSourceIncomplete) {
523
+ summary.truncated = true;
524
+ summary.sourceCaptureIncomplete = true;
525
+ }
254
526
  let artifactSaveResult: BashOriginalArtifactSaveResult | undefined;
255
- if (locallyTruncated && !current.truncated) {
256
- artifactSaveResult = await saveBashOriginalArtifact(session, current.output);
527
+ if (locallyTruncated && !normalizedCurrent.truncated && !sourceMalformed) {
528
+ artifactSaveResult = await saveBashOriginalArtifact(session, normalizedCurrent.output);
257
529
  if (artifactSaveResult.status === "saved") appendRawArtifactFooter(summary, artifactSaveResult);
258
530
  }
259
531
  const artifactSaveNotice = artifactSaveResult
260
532
  ? artifactSaveResultNotice(artifactSaveResult, completeOutputArtifactAvailable(summary))
261
533
  : undefined;
262
- return { current, summary, locallyTruncated, artifactSaveResult, artifactSaveNotice };
534
+ return { current: normalizedCurrent, summary, locallyTruncated, artifactSaveResult, artifactSaveNotice };
535
+ }
536
+
537
+ function markClientTerminalSourceIncomplete(prepared: PreparedClientTerminalOutput): void {
538
+ prepared.summary.truncated = true;
539
+ prepared.summary.sourceCaptureIncomplete = true;
540
+ if (!prepared.summary.artifactId) return;
541
+ const fullFooter = `[raw output: artifact://${prepared.summary.artifactId}]`;
542
+ if (!prepared.summary.output.endsWith(fullFooter)) return;
543
+ const retainedReference = formatArtifactReference(
544
+ prepared.summary.artifactId,
545
+ prepared.summary.artifactTruncatedBytes,
546
+ prepared.summary.sourceTruncatedBytes,
547
+ true,
548
+ );
549
+ prepared.summary.output = `${prepared.summary.output.slice(0, -fullFooter.length)}${retainedReference}`;
263
550
  }
264
551
 
265
552
  function formatClientTerminalAbortFailure(
266
553
  prepared: PreparedClientTerminalOutput,
267
554
  readDiagnostic?: string,
268
555
  pendingNotices: readonly string[] = [],
556
+ maxBytes = BASH_ERROR_MAX_BYTES,
269
557
  ): string {
270
558
  const notices = [
271
559
  ...pendingNotices,
@@ -280,7 +568,27 @@ function formatClientTerminalAbortFailure(
280
568
  exitCode: undefined,
281
569
  cancelled: true,
282
570
  };
283
- return formatBashFailureMessage(result, outputText, "Command aborted");
571
+ return formatBashFailureMessage(result, outputText, "Command aborted", maxBytes);
572
+ }
573
+
574
+ function formatClientTerminalOwnershipFailure(
575
+ prepared: PreparedClientTerminalOutput,
576
+ diagnostic: string,
577
+ maxBytes = BASH_ERROR_MAX_BYTES,
578
+ ): string {
579
+ markClientTerminalSourceIncomplete(prepared);
580
+ const notices = [
581
+ ...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
582
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
583
+ `Terminal release failed: ${diagnostic}`,
584
+ ];
585
+ const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
586
+ const result: BashResult = {
587
+ ...prepared.summary,
588
+ exitCode: undefined,
589
+ cancelled: false,
590
+ };
591
+ return formatBashFailureMessage(result, outputText, "Terminal release ownership incomplete", maxBytes);
284
592
  }
285
593
 
286
594
  function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
@@ -303,41 +611,41 @@ async function saveBashOriginalArtifact(
303
611
  return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
304
612
  }
305
613
  if (manager) {
306
- try {
307
- const artifactId = await manager.save(originalText, "bash-original");
308
- return artifactId
309
- ? summarizeOriginalArtifactSave(artifactId, originalText)
310
- : { status: "failed", diagnostic: "storage returned no artifact id" };
311
- } catch (error) {
312
- return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
313
- }
614
+ const outcome = await awaitBoundedOperation(() => manager.save(originalText, "bash-original"));
615
+ if (outcome.status !== "ok") return { status: "failed", diagnostic: outcome.diagnostic };
616
+ return outcome.value
617
+ ? summarizeOriginalArtifactSave(outcome.value, originalText)
618
+ : { status: "failed", diagnostic: "storage returned no artifact id" };
314
619
  }
315
620
 
316
621
  if (!session.allocateOutputArtifact) return { status: "unavailable" };
317
- let alloc: { id?: string; path?: string } | undefined;
318
- try {
319
- alloc = await session.allocateOutputArtifact("bash-original");
320
- } catch (error) {
321
- return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
322
- }
622
+ const allocateOutputArtifact = session.allocateOutputArtifact;
623
+ const allocation = await awaitBoundedOperation(() => allocateOutputArtifact("bash-original"));
624
+ if (allocation.status !== "ok") return { status: "failed", diagnostic: allocation.diagnostic };
625
+ const alloc = allocation.value;
323
626
  if (!alloc?.path || !alloc.id) return { status: "unavailable" };
324
- try {
325
- const saveResult = summarizeOriginalArtifactSave(alloc.id, originalText);
326
- const payload = saveResult.complete
327
- ? originalText
328
- : (() => {
329
- const retained = truncateHeadBytes(originalText, DEFAULT_ARTIFACT_MAX_BYTES);
330
- return `${retained.text}\n[artifact truncated after ${retained.bytes} bytes; omitted at least ${saveResult.omittedBytes} bytes]\n`;
331
- })();
332
- await Bun.write(alloc.path, payload);
333
- return saveResult;
334
- } catch (error) {
335
- return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
627
+ if (!isValidArtifactId(alloc.id)) {
628
+ return { status: "failed", diagnostic: "storage returned an invalid artifact id" };
336
629
  }
630
+ const artifactPath = alloc.path;
631
+ const saveResult = summarizeOriginalArtifactSave(alloc.id, originalText);
632
+ if (saveResult.status !== "saved") return saveResult;
633
+ const payload = saveResult.complete
634
+ ? originalText
635
+ : (() => {
636
+ const retained = truncateHeadBytes(originalText, DEFAULT_ARTIFACT_MAX_BYTES);
637
+ return `${retained.text}\n[artifact truncated after ${retained.bytes} bytes; omitted at least ${saveResult.omittedBytes} bytes]\n`;
638
+ })();
639
+ const writeOutcome = await awaitBoundedOperation(() => Bun.write(artifactPath, payload));
640
+ return writeOutcome.status === "ok" ? saveResult : { status: "failed", diagnostic: writeOutcome.diagnostic };
337
641
  }
338
642
 
643
+ const bashArtifactPublishers = new WeakMap<ToolSession, TerminalArtifactPublisher>();
644
+
339
645
  function createBashArtifactPublisher(session: ToolSession): TerminalArtifactPublisher {
340
- return async (content, _info): Promise<TerminalArtifactPublishResult> => {
646
+ const existing = bashArtifactPublishers.get(session);
647
+ if (existing) return existing;
648
+ const publisher: TerminalArtifactPublisher = async (content, _info): Promise<TerminalArtifactPublishResult> => {
341
649
  let manager: ArtifactManager | null | undefined;
342
650
  try {
343
651
  manager = session.getArtifactManager?.();
@@ -345,15 +653,15 @@ function createBashArtifactPublisher(session: ToolSession): TerminalArtifactPubl
345
653
  return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
346
654
  }
347
655
  if (!manager) return { status: "unavailable" };
348
- try {
349
- const artifactId = await manager.save(content, "bash");
350
- return artifactId
351
- ? { status: "published", artifactId }
352
- : { status: "failed", diagnostic: "storage returned no artifact id" };
353
- } catch (error) {
354
- return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
355
- }
656
+ const outcome = await awaitBoundedOperation(() => manager.save(content, "bash"));
657
+ if (outcome.status !== "ok") return { status: "failed", diagnostic: outcome.diagnostic };
658
+ return isValidArtifactId(outcome.value)
659
+ ? { status: "published", artifactId: outcome.value }
660
+ : { status: "failed", diagnostic: "storage returned an invalid artifact id" };
356
661
  };
662
+ Object.defineProperty(publisher, "owner", { value: session });
663
+ bashArtifactPublishers.set(session, publisher);
664
+ return publisher;
357
665
  }
358
666
 
359
667
  export async function saveBashOriginalArtifactForTests(
@@ -771,7 +1079,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
771
1079
  "bash",
772
1080
  label,
773
1081
  async ({ jobId, signal: runSignal, reportProgress }) => {
774
- const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
1082
+ const { path: artifactPath, id: artifactId } = await allocateBashOutputArtifact(this.session, "bash");
775
1083
  const artifactPublisher = createBashArtifactPublisher(this.session);
776
1084
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
777
1085
  const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
@@ -1129,7 +1437,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1129
1437
  "bash",
1130
1438
  label,
1131
1439
  async ({ jobId: id, signal, reportProgress }) => {
1132
- const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
1440
+ const { path: artifactPath, id: artifactId } = await allocateBashOutputArtifact(this.session, "bash");
1133
1441
  const artifactPublisher = createBashArtifactPublisher(this.session);
1134
1442
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
1135
1443
  const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
@@ -1181,7 +1489,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1181
1489
  }
1182
1490
 
1183
1491
  async execute(
1184
- _toolCallId: string,
1492
+ toolCallId: string,
1185
1493
  {
1186
1494
  command: rawCommand,
1187
1495
  env: rawEnv,
@@ -1316,39 +1624,207 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1316
1624
  }
1317
1625
 
1318
1626
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
1319
- const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1320
- const clientTailBytes = resolveBashOutputSinkTailBytes(this.session.settings);
1321
- const handle = await clientBridge.createTerminal({
1322
- command,
1323
- cwd: commandCwd,
1324
- env: resolvedEnv
1325
- ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
1326
- : undefined,
1327
- outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
1328
- });
1329
-
1330
- // Emit partial update so the editor can embed the live terminal card.
1331
- onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1332
-
1333
- const exitPromise = handle.waitForExit();
1334
- let exitStatus!: ClientBridgeTerminalExitStatus;
1627
+ const createTerminal = clientBridge.createTerminal;
1628
+ if (hasQuarantinedClientTerminalProvider(clientBridge)) {
1629
+ throw new ToolError("ACP terminal creation failed: provider cleanup ownership incomplete");
1630
+ }
1631
+ if (signal?.aborted) {
1632
+ throw new ToolAbortError("Command aborted before ACP terminal creation");
1633
+ }
1634
+ if (!hasBoundedOperationCapacity()) {
1635
+ throw new ToolError("ACP terminal creation failed: pending operation limit reached");
1636
+ }
1637
+ if (!reserveBashRelease(clientBridge)) {
1638
+ throw new ToolError("ACP terminal creation failed: pending release limit reached");
1639
+ }
1640
+ if (!reserveBashCleanup(clientBridge)) {
1641
+ consumeBashReleaseReservation(clientBridge);
1642
+ throw new ToolError("ACP terminal creation failed: pending cleanup limit reached");
1643
+ }
1644
+ let killReserved = true;
1645
+ const killHandle = (handle: ClientBridgeTerminalHandle): Promise<BoundedOperationOutcome<void>> => {
1646
+ if (killReserved) {
1647
+ killReserved = false;
1648
+ consumeBashCleanupReservation(clientBridge);
1649
+ }
1650
+ return awaitBoundedCleanup(clientBridge, () => handle.kill());
1651
+ };
1652
+ let releaseReserved = true;
1653
+ const releaseHandle = (handle: ClientBridgeTerminalHandle): Promise<BoundedOperationOutcome<void>> => {
1654
+ if (releaseReserved) {
1655
+ releaseReserved = false;
1656
+ consumeBashReleaseReservation(clientBridge);
1657
+ }
1658
+ return awaitBoundedRelease(clientBridge, () => handle.release());
1659
+ };
1660
+ const terminalCreateAbort = new AbortController();
1661
+ const terminalCreateSignal = signal
1662
+ ? AbortSignal.any([signal, terminalCreateAbort.signal])
1663
+ : terminalCreateAbort.signal;
1664
+ const terminalCreatePromise = Promise.resolve().then(() =>
1665
+ createTerminal({
1666
+ toolCallId,
1667
+ command,
1668
+ cwd: commandCwd,
1669
+ env: resolvedEnv
1670
+ ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
1671
+ : undefined,
1672
+ outputByteLimit: DEFAULT_ARTIFACT_MAX_BYTES,
1673
+ signal: terminalCreateSignal,
1674
+ }),
1675
+ );
1676
+ const terminalCreateOperation = awaitBoundedOperation(() => terminalCreatePromise);
1677
+ const createAborted = Promise.withResolvers<{ status: "aborted" }>();
1678
+ const onCreateAbort = () => createAborted.resolve({ status: "aborted" });
1679
+ signal?.addEventListener("abort", onCreateAbort, { once: true });
1680
+ const terminalCreate = signal
1681
+ ? await Promise.race([terminalCreateOperation, createAborted.promise])
1682
+ : await terminalCreateOperation;
1683
+ signal?.removeEventListener("abort", onCreateAbort);
1684
+ terminalCreateAbort.abort();
1685
+ if (terminalCreate.status !== "ok") {
1686
+ void terminalCreatePromise.then(
1687
+ async lateHandle => {
1688
+ const killOutcome = await killHandle(lateHandle);
1689
+ const releaseOutcome = await releaseHandle(lateHandle);
1690
+ const cleanupFailed = killOutcome.status !== "ok" || releaseOutcome.status !== "ok";
1691
+ if (cleanupFailed) markClientTerminalProviderQuarantined(clientBridge, lateHandle);
1692
+ const quarantineOutcome = cleanupFailed
1693
+ ? await quarantineClientTerminalLease(
1694
+ clientBridge,
1695
+ lateHandle,
1696
+ "late ACP terminal creation cleanup did not settle",
1697
+ )
1698
+ : undefined;
1699
+ if (cleanupFailed) {
1700
+ logger.warn("Late ACP terminal cleanup failed", {
1701
+ terminalId: lateHandle.terminalId,
1702
+ killDiagnostic: killOutcome.status === "ok" ? undefined : killOutcome.diagnostic,
1703
+ releaseDiagnostic: releaseOutcome.status === "ok" ? undefined : releaseOutcome.diagnostic,
1704
+ quarantineDiagnostic:
1705
+ quarantineOutcome?.status === "ok" ? undefined : quarantineOutcome?.diagnostic,
1706
+ });
1707
+ }
1708
+ },
1709
+ () => {
1710
+ if (releaseReserved) {
1711
+ releaseReserved = false;
1712
+ consumeBashReleaseReservation(clientBridge);
1713
+ }
1714
+ if (killReserved) {
1715
+ killReserved = false;
1716
+ consumeBashCleanupReservation(clientBridge);
1717
+ }
1718
+ },
1719
+ );
1720
+ if (terminalCreate.status === "aborted") {
1721
+ throw new ToolAbortError("Command aborted during ACP terminal creation");
1722
+ }
1723
+ throw new ToolError(`ACP terminal creation failed: ${terminalCreate.diagnostic}`);
1724
+ }
1725
+ const handle = terminalCreate.value;
1726
+ pendingBashOperations.delete(terminalCreatePromise);
1727
+ if (!hasBoundedOperationCapacity()) {
1728
+ const killOutcome = await killHandle(handle);
1729
+ const releaseOutcome = await releaseHandle(handle);
1730
+ if (killOutcome.status !== "ok" || releaseOutcome.status !== "ok") {
1731
+ const quarantineOutcome = await quarantineClientTerminalLease(
1732
+ clientBridge,
1733
+ handle,
1734
+ "terminal exit observation admission failed after cleanup uncertainty",
1735
+ );
1736
+ if (quarantineOutcome.status !== "ok")
1737
+ throw new ToolError(`ACP terminal cleanup ownership incomplete: ${quarantineOutcome.diagnostic}`);
1738
+ }
1739
+ throw new ToolError("ACP terminal exit observation failed: pending operation limit reached");
1740
+ }
1741
+ let clientOutputCaptureIncomplete = false;
1742
+ let currentOutputOperation:
1743
+ | {
1744
+ raw: Promise<ClientBridgeTerminalOutput>;
1745
+ bounded: Promise<BoundedOperationOutcome<ClientBridgeTerminalOutput>>;
1746
+ }
1747
+ | undefined;
1748
+ let latestPreparedOutput: PreparedClientTerminalOutput | undefined;
1749
+ const prepareOutput = async (
1750
+ current: ClientBridgeTerminalOutput,
1751
+ forceSourceIncomplete = false,
1752
+ ): Promise<PreparedClientTerminalOutput> => {
1753
+ const prepared = await prepareClientTerminalOutput(this.session, current, forceSourceIncomplete);
1754
+ latestPreparedOutput = prepared;
1755
+ return prepared;
1756
+ };
1757
+ const readCurrentOutput = (): Promise<BoundedOperationOutcome<ClientBridgeTerminalOutput>> => {
1758
+ if (currentOutputOperation) return currentOutputOperation.bounded;
1759
+ if (!hasBoundedOperationCapacity()) {
1760
+ return Promise.resolve({ status: "failed", diagnostic: "pending operation limit reached" });
1761
+ }
1762
+ const raw = Promise.resolve().then(() => handle.currentOutput());
1763
+ const bounded = awaitBoundedOperation(() => raw);
1764
+ currentOutputOperation = { raw, bounded };
1765
+ void raw.then(
1766
+ () => {
1767
+ if (currentOutputOperation?.raw === raw) currentOutputOperation = undefined;
1768
+ },
1769
+ () => {
1770
+ if (currentOutputOperation?.raw === raw) currentOutputOperation = undefined;
1771
+ },
1772
+ );
1773
+ return bounded;
1774
+ };
1335
1775
 
1336
1776
  type BridgeRaceResult =
1337
1777
  | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1778
+ | { kind: "exit-error"; diagnostic: string }
1338
1779
  | { kind: "poll" }
1339
1780
  | { kind: "timeout" }
1340
1781
  | { kind: "aborted" };
1782
+ const rawExitPromise = Promise.resolve().then(() => handle.waitForExit());
1783
+ if (!trackPendingOperation(pendingBashOperations, BASH_PENDING_OPERATION_LIMIT, rawExitPromise)) {
1784
+ const killOutcome = await killHandle(handle);
1785
+ const releaseOutcome = await releaseHandle(handle);
1786
+ if (killOutcome.status !== "ok" || releaseOutcome.status !== "ok") {
1787
+ const quarantineOutcome = await quarantineClientTerminalLease(
1788
+ clientBridge,
1789
+ handle,
1790
+ "terminal exit observation registration failed after cleanup uncertainty",
1791
+ );
1792
+ if (quarantineOutcome.status !== "ok")
1793
+ throw new ToolError(`ACP terminal cleanup ownership incomplete: ${quarantineOutcome.diagnostic}`);
1794
+ }
1795
+ throw new ToolError("ACP terminal exit observation failed: pending operation limit reached");
1796
+ }
1797
+ const exitPromise: Promise<BridgeRaceResult> = rawExitPromise.then(
1798
+ status => ({ kind: "exit", status }),
1799
+ error => ({ kind: "exit-error", diagnostic: boundArtifactSaveDiagnostic(error) }),
1800
+ );
1801
+ let exitStatus!: ClientBridgeTerminalExitStatus;
1341
1802
 
1342
1803
  // Set up abort listener before entering the poll loop. The listener
1343
1804
  // kicks off `handle.kill()` synchronously so a `session/cancel`
1344
1805
  // arriving mid-poll terminates the remote command immediately,
1345
1806
  // instead of waiting for the next `currentOutput()` to return.
1346
1807
  const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1347
- let killPromise: Promise<void> | undefined;
1348
- const fireKill = (): Promise<void> => {
1808
+ let killPromise: Promise<string | undefined> | undefined;
1809
+ const fireKill = (): Promise<string | undefined> => {
1349
1810
  if (killPromise) return killPromise;
1350
- killPromise = handle.kill().catch((error: unknown) => {
1351
- logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1811
+ killPromise = killHandle(handle).then(async outcome => {
1812
+ if (outcome.status === "ok") return undefined;
1813
+ markClientTerminalProviderQuarantined(clientBridge, handle);
1814
+ const quarantineOutcome = await quarantineClientTerminalLease(
1815
+ clientBridge,
1816
+ handle,
1817
+ "terminal kill did not settle",
1818
+ );
1819
+ if (quarantineOutcome.status === "ok") clearClientTerminalProviderQuarantine(clientBridge, handle);
1820
+ logger.warn("ACP terminal kill failed", {
1821
+ terminalId: handle.terminalId,
1822
+ diagnostic: outcome.diagnostic,
1823
+ quarantineDiagnostic: quarantineOutcome.status === "ok" ? undefined : quarantineOutcome.diagnostic,
1824
+ });
1825
+ return quarantineOutcome.status === "ok"
1826
+ ? outcome.diagnostic
1827
+ : `${outcome.diagnostic}; lease quarantine failed: ${quarantineOutcome.diagnostic}`;
1352
1828
  });
1353
1829
  return killPromise;
1354
1830
  };
@@ -1358,179 +1834,282 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1358
1834
  };
1359
1835
  signal?.addEventListener("abort", onAbortSignal, { once: true });
1360
1836
 
1837
+ let operationError: unknown;
1838
+ let operationResult: AgentToolResult<BashToolDetails> | undefined;
1839
+ let releaseFailure: ToolError | undefined;
1361
1840
  try {
1362
- try {
1363
- if (signal?.aborted) {
1364
- await fireKill();
1365
- let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1366
- let readDiagnostic: string | undefined;
1367
- try {
1368
- current = await handle.currentOutput();
1369
- } catch (error) {
1370
- readDiagnostic = boundArtifactSaveDiagnostic(error);
1371
- logger.warn("ACP terminal aborted output read failed", {
1372
- terminalId: handle.terminalId,
1373
- error,
1374
- });
1375
- }
1376
- const prepared = await prepareClientTerminalOutput(this.session, current);
1377
- throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1378
- }
1379
-
1380
- const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
1381
- // Poll until the process exits, times out, or the caller aborts.
1382
- for (;;) {
1383
- const racers: Array<Promise<BridgeRaceResult>> = [
1384
- exitPromise.then(s => ({ kind: "exit" as const, status: s })),
1385
- timeoutPromise,
1386
- Bun.sleep(250).then(() => ({ kind: "poll" as const })),
1387
- ];
1388
- if (signal) {
1389
- racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
1390
- }
1391
- const raced = await Promise.race(racers);
1392
-
1393
- if (raced.kind === "aborted" || signal?.aborted) {
1394
- await fireKill();
1395
- let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1396
- let readDiagnostic: string | undefined;
1397
- try {
1398
- current = await handle.currentOutput();
1399
- } catch (error) {
1400
- readDiagnostic = boundArtifactSaveDiagnostic(error);
1401
- logger.warn("ACP terminal aborted output read failed", {
1402
- terminalId: handle.terminalId,
1403
- error,
1404
- });
1405
- }
1406
- const prepared = await prepareClientTerminalOutput(this.session, current);
1841
+ operationResult = await (async (): Promise<AgentToolResult<BashToolDetails>> => {
1842
+ // Emit partial update only after the release guard owns the handle.
1843
+ onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1844
+ try {
1845
+ if (signal?.aborted) {
1846
+ const killDiagnostic = await fireKill();
1847
+ const recovered = await recoverClientTerminalOutput(handle, "pre-aborted", readCurrentOutput);
1848
+ const prepared = await prepareOutput(recovered.current, true);
1407
1849
  throw new ToolAbortError(
1408
- formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
1850
+ formatClientTerminalAbortFailure(
1851
+ prepared,
1852
+ recovered.diagnostic,
1853
+ [...pendingNotices, ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : [])],
1854
+ maxBashErrorBytes(this.session),
1855
+ ),
1409
1856
  );
1410
1857
  }
1411
1858
 
1412
- if (raced.kind === "timeout") {
1413
- // Kill before reading final output so a slow `terminal/output`
1414
- // RPC cannot let a timed-out command keep running past the
1415
- // enforced timeout. The handle stays valid post-kill so the
1416
- // buffered output is still readable.
1417
- await fireKill();
1418
- let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1419
- let readDiagnostic: string | undefined;
1420
- try {
1421
- current = await handle.currentOutput();
1422
- } catch (error) {
1423
- readDiagnostic = boundArtifactSaveDiagnostic(error);
1424
- logger.warn("ACP terminal final output read failed", {
1859
+ const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
1860
+ // Poll until the process exits, times out, or the caller aborts.
1861
+ for (;;) {
1862
+ const racers: Array<Promise<BridgeRaceResult>> = [
1863
+ exitPromise,
1864
+ timeoutPromise,
1865
+ Bun.sleep(250).then(() => ({ kind: "poll" as const })),
1866
+ ];
1867
+ if (signal) {
1868
+ racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
1869
+ }
1870
+ const raced = await Promise.race(racers);
1871
+
1872
+ if (raced.kind === "aborted" || signal?.aborted) {
1873
+ const killDiagnostic = await fireKill();
1874
+ const recovered = await recoverClientTerminalOutput(handle, "aborted", readCurrentOutput);
1875
+ const prepared = await prepareOutput(recovered.current, true);
1876
+ throw new ToolAbortError(
1877
+ formatClientTerminalAbortFailure(
1878
+ prepared,
1879
+ recovered.diagnostic,
1880
+ [
1881
+ ...pendingNotices,
1882
+ ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : []),
1883
+ ],
1884
+ maxBashErrorBytes(this.session),
1885
+ ),
1886
+ );
1887
+ }
1888
+
1889
+ if (raced.kind === "timeout") {
1890
+ // Kill before reading final output so a slow `terminal/output`
1891
+ // RPC cannot let a timed-out command keep running past the
1892
+ // enforced timeout. The handle stays valid post-kill so the
1893
+ // buffered output is still readable.
1894
+ const killDiagnostic = await fireKill();
1895
+ const recovered = await recoverClientTerminalOutput(handle, "timeout", readCurrentOutput);
1896
+ const prepared = await prepareOutput(recovered.current, true);
1897
+ const timeoutNotices = [
1898
+ ...pendingNotices,
1899
+ ...(recovered.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
1900
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
1901
+ ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : []),
1902
+ ...(recovered.diagnostic
1903
+ ? [`Terminal output recovery failed: ${recovered.diagnostic}`]
1904
+ : []),
1905
+ ];
1906
+ const timedOutResult: BashInteractiveResult = {
1907
+ ...prepared.summary,
1908
+ exitCode: undefined,
1909
+ cancelled: false,
1910
+ timedOut: true,
1911
+ };
1912
+ return this.#buildCompletedResult(timedOutResult, timeoutSec, {
1913
+ requestedTimeoutSec,
1914
+ notices: timeoutNotices,
1425
1915
  terminalId: handle.terminalId,
1426
- error,
1427
1916
  });
1428
1917
  }
1429
- const prepared = await prepareClientTerminalOutput(this.session, current);
1430
- const timeoutNotices = [
1431
- ...pendingNotices,
1432
- ...(current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
1433
- ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
1434
- ...(readDiagnostic ? [`Terminal output recovery failed: ${readDiagnostic}`] : []),
1435
- ];
1436
- const timedOutResult: BashInteractiveResult = {
1437
- ...prepared.summary,
1438
- exitCode: undefined,
1439
- cancelled: false,
1440
- timedOut: true,
1441
- };
1442
- return this.#buildCompletedResult(timedOutResult, timeoutSec, {
1443
- requestedTimeoutSec,
1444
- notices: timeoutNotices,
1445
- terminalId: handle.terminalId,
1446
- });
1447
- }
1448
1918
 
1449
- if (raced.kind === "exit") {
1450
- exitStatus = raced.status;
1451
- break;
1452
- }
1919
+ if (raced.kind === "exit-error") {
1920
+ const killDiagnostic = await fireKill();
1921
+ const recovered = await recoverClientTerminalOutput(handle, "exit-error", readCurrentOutput);
1922
+ const prepared = await prepareOutput(recovered.current, true);
1923
+ const failureNotices = [
1924
+ ...pendingNotices,
1925
+ ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : []),
1926
+ ...(recovered.diagnostic
1927
+ ? [`Terminal output recovery failed: ${recovered.diagnostic}`]
1928
+ : []),
1929
+ ];
1930
+ const failureCause = `Terminal exit wait failed: ${raced.diagnostic}`;
1931
+ throw new ToolError(
1932
+ formatBashFailureMessage(
1933
+ { ...prepared.summary, exitCode: undefined, cancelled: true },
1934
+ [prepared.summary.output || "(no output)", ...failureNotices, failureCause].join("\n"),
1935
+ failureCause,
1936
+ ),
1937
+ );
1938
+ }
1939
+
1940
+ if (raced.kind === "exit") {
1941
+ exitStatus = raced.status;
1942
+ break;
1943
+ }
1453
1944
 
1454
- // Poll tick: push current output so agent-loop transcript stays consistent.
1455
- // Race the read against abort so a stuck `terminal/output` RPC does not
1456
- // delay cancellation.
1457
- const pollOutput = await Promise.race([
1458
- handle.currentOutput(),
1459
- abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
1460
- ]);
1461
- if (pollOutput === undefined) {
1462
- // Abort fired during the poll-tick read; let the next loop iteration
1463
- // observe `signal?.aborted` and exit via the abort branch.
1464
- continue;
1945
+ // Poll tick: push current output so agent-loop transcript stays consistent.
1946
+ // Race the read against abort so a stuck `terminal/output` RPC does not
1947
+ // delay cancellation.
1948
+ const pollRecovered = await Promise.race([
1949
+ recoverClientTerminalOutput(handle, "poll", readCurrentOutput),
1950
+ abortedP.then(() => undefined),
1951
+ ]);
1952
+ if (pollRecovered === undefined) {
1953
+ // Abort fired during the poll-tick read; let the next loop iteration
1954
+ // observe `signal?.aborted` and exit via the abort branch.
1955
+ continue;
1956
+ }
1957
+ const normalizedPoll = normalizeClientTerminalOutput(pollRecovered.current);
1958
+ const pollOutput = normalizedPoll.current;
1959
+ if (pollRecovered.diagnostic || normalizedPoll.malformed || pollOutput.truncated) {
1960
+ clientOutputCaptureIncomplete = true;
1961
+ }
1962
+ const { summary, locallyTruncated } = await boundClientTerminalOutput(
1963
+ pollOutput.output,
1964
+ pollOutput.truncated,
1965
+ this.session.settings,
1966
+ normalizedPoll.malformed,
1967
+ );
1968
+ const pollText =
1969
+ summary.truncated || locallyTruncated
1970
+ ? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
1971
+ : summary.output;
1972
+ onUpdate?.({
1973
+ content: [{ type: "text", text: pollText }],
1974
+ details: { terminalId: handle.terminalId },
1975
+ });
1465
1976
  }
1466
- const { summary, locallyTruncated } = await boundClientTerminalOutput(
1467
- pollOutput.output,
1468
- pollOutput.truncated,
1469
- this.session.settings,
1470
- );
1471
- const pollText =
1472
- pollOutput.truncated || locallyTruncated
1473
- ? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
1474
- : summary.output;
1475
- onUpdate?.({
1476
- content: [{ type: "text", text: pollText }],
1477
- details: { terminalId: handle.terminalId },
1478
- });
1977
+ } finally {
1978
+ // Keep the abort listener active through final output recovery and artifact save.
1479
1979
  }
1480
- } finally {
1481
- signal?.removeEventListener("abort", onAbortSignal);
1482
- }
1483
1980
 
1484
- if (signal?.aborted) {
1485
- await fireKill();
1486
- let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1487
- let readDiagnostic: string | undefined;
1488
- try {
1489
- current = await handle.currentOutput();
1490
- } catch (error) {
1491
- readDiagnostic = boundArtifactSaveDiagnostic(error);
1492
- logger.warn("ACP terminal aborted output read failed", {
1493
- terminalId: handle.terminalId,
1494
- error,
1495
- });
1981
+ if (signal?.aborted) {
1982
+ const killDiagnostic = await fireKill();
1983
+ const recovered = await recoverClientTerminalOutput(handle, "post-exit-abort", readCurrentOutput);
1984
+ const prepared = await prepareOutput(
1985
+ recovered.current,
1986
+ clientOutputCaptureIncomplete || killDiagnostic !== undefined,
1987
+ );
1988
+ throw new ToolAbortError(
1989
+ formatClientTerminalAbortFailure(
1990
+ prepared,
1991
+ recovered.diagnostic,
1992
+ [...pendingNotices, ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : [])],
1993
+ maxBashErrorBytes(this.session),
1994
+ ),
1995
+ );
1496
1996
  }
1497
- const prepared = await prepareClientTerminalOutput(this.session, current);
1498
- throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1499
- }
1500
1997
 
1501
- // Fetch final output; the terminal is released in the outer finally.
1502
- const finalOutput = await handle.currentOutput();
1998
+ // Fetch final output; the terminal is released in the outer finally.
1999
+ const finalRecovered = await recoverClientTerminalOutput(handle, "final", readCurrentOutput);
2000
+ const finalOutput = finalRecovered.current;
1503
2001
 
1504
- // Map exit status: null exitCode with a signal → treat as signal kill (137).
1505
- const rawExitCode = exitStatus.exitCode;
1506
- const exitCode: number | undefined =
1507
- rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
2002
+ // Map exit status: null exitCode with a signal → treat as signal kill (137).
2003
+ const rawExitCode = exitStatus.exitCode;
2004
+ const exitCode: number | undefined =
2005
+ rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
1508
2006
 
1509
- const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
1510
-
1511
- const bridgeResult: BashResult = {
1512
- ...prepared.summary,
1513
- exitCode,
1514
- cancelled: false,
1515
- };
2007
+ const prepared = await prepareOutput(finalOutput, clientOutputCaptureIncomplete);
2008
+ if (signal?.aborted) {
2009
+ const killDiagnostic = await fireKill();
2010
+ markClientTerminalSourceIncomplete(prepared);
2011
+ throw new ToolAbortError(
2012
+ formatClientTerminalAbortFailure(
2013
+ prepared,
2014
+ finalRecovered.diagnostic,
2015
+ [...pendingNotices, ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : [])],
2016
+ maxBashErrorBytes(this.session),
2017
+ ),
2018
+ );
2019
+ }
1516
2020
 
1517
- const bridgeNotices: string[] = [];
1518
- if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
1519
- for (const notice of pendingNotices) bridgeNotices.push(notice);
1520
- if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
2021
+ const bridgeResult: BashResult = {
2022
+ ...prepared.summary,
2023
+ exitCode,
2024
+ cancelled: false,
2025
+ };
2026
+
2027
+ const bridgeNotices: string[] = [];
2028
+ if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
2029
+ for (const notice of pendingNotices) bridgeNotices.push(notice);
2030
+ if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
2031
+ if (finalRecovered.diagnostic) {
2032
+ bridgeNotices.push(`Terminal output recovery failed: ${finalRecovered.diagnostic}`);
2033
+ }
1521
2034
 
1522
- return this.#buildCompletedResult(bridgeResult, timeoutSec, {
1523
- requestedTimeoutSec,
1524
- notices: bridgeNotices,
1525
- terminalId: handle.terminalId,
1526
- });
2035
+ return this.#buildCompletedResult(bridgeResult, timeoutSec, {
2036
+ requestedTimeoutSec,
2037
+ notices: bridgeNotices,
2038
+ terminalId: handle.terminalId,
2039
+ });
2040
+ })();
2041
+ } catch (error) {
2042
+ operationError = error;
1527
2043
  } finally {
1528
- try {
1529
- await handle.release();
1530
- } catch (error) {
1531
- logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
2044
+ signal?.removeEventListener("abort", onAbortSignal);
2045
+ if (killReserved) {
2046
+ killReserved = false;
2047
+ consumeBashCleanupReservation(clientBridge);
2048
+ }
2049
+ const releaseOutcome = await releaseHandle(handle);
2050
+ if (releaseOutcome.status !== "ok") {
2051
+ markClientTerminalProviderQuarantined(clientBridge, handle);
2052
+ const quarantineOutcome = await quarantineClientTerminalLease(
2053
+ clientBridge,
2054
+ handle,
2055
+ "terminal release did not settle",
2056
+ );
2057
+ if (quarantineOutcome.status === "ok") clearClientTerminalProviderQuarantine(clientBridge, handle);
2058
+ const ownershipDiagnostic =
2059
+ quarantineOutcome.status === "ok"
2060
+ ? releaseOutcome.diagnostic
2061
+ : `${releaseOutcome.diagnostic}; lease quarantine failed: ${quarantineOutcome.diagnostic}`;
2062
+ logger.warn("ACP terminal release failed", {
2063
+ terminalId: handle.terminalId,
2064
+ diagnostic: releaseOutcome.diagnostic,
2065
+ quarantineDiagnostic: quarantineOutcome.status === "ok" ? undefined : quarantineOutcome.diagnostic,
2066
+ });
2067
+ if (latestPreparedOutput) {
2068
+ releaseFailure = new ToolError(
2069
+ formatClientTerminalOwnershipFailure(
2070
+ latestPreparedOutput,
2071
+ ownershipDiagnostic,
2072
+ maxBashErrorBytes(this.session),
2073
+ ),
2074
+ );
2075
+ } else {
2076
+ releaseFailure = new ToolError(
2077
+ truncateHeadBytes(
2078
+ `Terminal release ownership incomplete: ${ownershipDiagnostic}`,
2079
+ maxBashErrorBytes(this.session),
2080
+ ).text,
2081
+ );
2082
+ }
2083
+ }
2084
+ }
2085
+ if (operationError) {
2086
+ if (releaseFailure && operationError instanceof Error) {
2087
+ const operationCause = Array.from(
2088
+ operationError.message.matchAll(/Command (?:timed out(?: after \d+ seconds?)?|cancelled|aborted)/gu),
2089
+ ).at(-1)?.[0];
2090
+ operationError.message = formatBashFailureMessage(
2091
+ {
2092
+ ...(latestPreparedOutput?.summary ?? {
2093
+ totalLines: 0,
2094
+ totalBytes: 0,
2095
+ outputLines: 0,
2096
+ outputBytes: 0,
2097
+ }),
2098
+ output: operationError.message,
2099
+ truncated: true,
2100
+ exitCode: undefined,
2101
+ cancelled: true,
2102
+ },
2103
+ `${removeTrailingFailureCause(operationError.message, operationCause)}\n\n${releaseFailure.message}`,
2104
+ operationCause,
2105
+ maxBashErrorBytes(this.session),
2106
+ );
1532
2107
  }
2108
+ throw operationError;
1533
2109
  }
2110
+ if (releaseFailure) throw releaseFailure;
2111
+ if (!operationResult) throw new ToolError("ACP terminal execution did not settle");
2112
+ return operationResult;
1534
2113
  }
1535
2114
 
1536
2115
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
@@ -1540,7 +2119,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1540
2119
  const tailBuffer = new TailBuffer(spillThreshold);
1541
2120
 
1542
2121
  // Allocate artifact for truncated output storage
1543
- const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
2122
+ const { path: artifactPath, id: artifactId } = await allocateBashOutputArtifact(this.session, "bash");
1544
2123
  const artifactPublisher = createBashArtifactPublisher(this.session);
1545
2124
 
1546
2125
  const interactiveUi =