@gajae-code/coding-agent 0.12.6 → 0.12.7

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 (70) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/dist/types/async/job-manager.d.ts +36 -1
  3. package/dist/types/eval/backend.d.ts +2 -2
  4. package/dist/types/exec/bash-executor.d.ts +8 -10
  5. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -36
  6. package/dist/types/internal-urls/registry-helpers.d.ts +0 -18
  7. package/dist/types/sdk/bus/chat-daemon-control.d.ts +1 -3
  8. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -4
  9. package/dist/types/sdk/host/host.d.ts +2 -23
  10. package/dist/types/sdk/host/reverse-leases.d.ts +4 -7
  11. package/dist/types/session/artifacts.d.ts +5 -8
  12. package/dist/types/session/client-bridge.d.ts +0 -14
  13. package/dist/types/session/internal/managed-session-storage.d.ts +2 -5
  14. package/dist/types/session/messages.d.ts +1 -4
  15. package/dist/types/session/session-manager.d.ts +4 -5
  16. package/dist/types/session/streaming-output.d.ts +0 -32
  17. package/dist/types/task/receipt.d.ts +1 -2
  18. package/dist/types/task/types.d.ts +43 -178
  19. package/dist/types/tools/bash.d.ts +1 -1
  20. package/dist/types/tools/fetch.d.ts +0 -3
  21. package/dist/types/tools/output-meta.d.ts +5 -37
  22. package/dist/types/tools/subagent.d.ts +15 -0
  23. package/dist/types/tools/todo-write.d.ts +3 -0
  24. package/dist/types/tools/tool-result.d.ts +0 -1
  25. package/package.json +8 -8
  26. package/src/async/job-manager.ts +430 -46
  27. package/src/cursor.ts +13 -20
  28. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -3
  29. package/src/eval/backend.ts +2 -2
  30. package/src/exec/bash-executor.ts +151 -425
  31. package/src/gjc-runtime/ultragoal-runtime.ts +3 -280
  32. package/src/gjc-runtime/workflow-manifest.generated.json +1 -40
  33. package/src/gjc-runtime/workflow-manifest.ts +0 -12
  34. package/src/internal-urls/agent-protocol.ts +38 -98
  35. package/src/internal-urls/artifact-protocol.ts +17 -28
  36. package/src/internal-urls/docs-index.generated.ts +1 -1
  37. package/src/internal-urls/registry-helpers.ts +0 -62
  38. package/src/modes/acp/acp-agent.ts +0 -24
  39. package/src/modes/acp/acp-client-bridge.ts +11 -154
  40. package/src/prompts/tools/bash.md +2 -2
  41. package/src/sdk/acp/adapter.ts +9 -200
  42. package/src/sdk/bus/chat-daemon-control.ts +3 -5
  43. package/src/sdk/bus/index.ts +8 -324
  44. package/src/sdk/bus/telegram-daemon-contract.ts +2 -4
  45. package/src/sdk/host/host.ts +9 -187
  46. package/src/sdk/host/reverse-leases.ts +42 -249
  47. package/src/session/agent-session.ts +15 -178
  48. package/src/session/artifacts.ts +74 -211
  49. package/src/session/client-bridge.ts +0 -15
  50. package/src/session/internal/managed-session-storage.ts +5 -104
  51. package/src/session/messages.ts +7 -32
  52. package/src/session/session-manager.ts +21 -12
  53. package/src/session/streaming-output.ts +40 -198
  54. package/src/task/index.ts +230 -64
  55. package/src/task/receipt.ts +2 -4
  56. package/src/task/render.ts +0 -30
  57. package/src/task/types.ts +10 -34
  58. package/src/tools/bash.ts +239 -818
  59. package/src/tools/eval.ts +4 -99
  60. package/src/tools/fetch.ts +15 -77
  61. package/src/tools/find.ts +3 -7
  62. package/src/tools/output-meta.ts +114 -696
  63. package/src/tools/search.ts +4 -9
  64. package/src/tools/subagent.ts +97 -75
  65. package/src/tools/todo-write.ts +47 -0
  66. package/src/tools/tool-result.ts +0 -1
  67. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +0 -83
  68. package/dist/types/utils/artifact-id.d.ts +0 -2
  69. package/src/gjc-runtime/ultragoal-review-source.ts +0 -378
  70. package/src/utils/artifact-id.ts +0 -6
package/src/tools/bash.ts CHANGED
@@ -18,12 +18,7 @@ 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 {
22
- ClientBridge,
23
- ClientBridgeTerminalExitStatus,
24
- ClientBridgeTerminalHandle,
25
- ClientBridgeTerminalOutput,
26
- } from "../session/client-bridge";
21
+ import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
27
22
  import {
28
23
  DEFAULT_ARTIFACT_MAX_BYTES,
29
24
  OutputSink,
@@ -48,10 +43,8 @@ import { canUseInteractiveBashPty } from "./bash-pty-selection";
48
43
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
49
44
  import { checkComposerBashPolicy } from "./composer-bash-policy";
50
45
  import {
51
- formatArtifactEvidenceNotice,
52
46
  formatArtifactReference,
53
47
  formatStyledTruncationWarning,
54
- isValidArtifactId,
55
48
  type OutputMeta,
56
49
  resolveBashOutputSinkHeadBytes,
57
50
  resolveBashOutputSinkTailBytes,
@@ -69,81 +62,6 @@ const BASH_ERROR_MAX_BYTES = 4096;
69
62
  const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
70
63
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
71
64
  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
- }
147
65
  const READ_ONLY_BASH_ENV: Record<string, string> = {
148
66
  GREP_OPTIONS: "",
149
67
  GREP_COLOR: "",
@@ -159,118 +77,10 @@ function boundArtifactSaveDiagnostic(error: unknown): string {
159
77
  return truncateHeadBytes(normalized, ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES).text;
160
78
  }
161
79
 
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
- }
80
+ function summarizeOriginalArtifactSave(
81
+ artifactId: string,
82
+ originalText: string,
83
+ ): Extract<BashOriginalArtifactSaveResult, { status: "saved" }> {
274
84
  const originalBytes = Buffer.byteLength(originalText, "utf-8");
275
85
  if (originalBytes <= DEFAULT_ARTIFACT_MAX_BYTES) {
276
86
  return { status: "saved", artifactId, complete: true };
@@ -284,26 +94,6 @@ function summarizeOriginalArtifactSave(artifactId: string, originalText: string)
284
94
  };
285
95
  }
286
96
 
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
-
307
97
  function artifactSaveResultNotice(
308
98
  result: BashOriginalArtifactSaveResult,
309
99
  hasAlternateCompleteArtifact = false,
@@ -320,51 +110,36 @@ function artifactTruncatedBytesForResult(result: BashResult | BashInteractiveRes
320
110
  return typeof bytes === "number" && bytes > 0 ? bytes : undefined;
321
111
  }
322
112
 
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
-
332
113
  function artifactReferenceForResult(result: BashResult | BashInteractiveResult): string | undefined {
333
114
  return result.artifactId
334
- ? formatArtifactReference(
335
- result.artifactId,
336
- artifactTruncatedBytesForResult(result),
337
- sourceTruncatedBytesForResult(result),
338
- sourceCaptureIncompleteForResult(result),
339
- )
115
+ ? formatArtifactReference(result.artifactId, artifactTruncatedBytesForResult(result))
340
116
  : undefined;
341
117
  }
342
118
 
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
+
343
127
  function appendRawArtifactFooter(
344
128
  summary: OutputSummary,
345
129
  result: Extract<BashOriginalArtifactSaveResult, { status: "saved" }>,
346
130
  ): void {
347
- summary.artifactFailureDiagnostic = "artifact save could not be proven at the current delivery boundary";
348
131
  summary.artifactId = result.artifactId;
349
- summary.artifactVerified = false;
350
132
  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)}]`;
351
135
  }
352
136
 
353
137
  function artifactReferenceIsReachable(text: string, result: BashResult | BashInteractiveResult): boolean {
354
138
  if (!result.artifactId || !text.includes(`artifact://${result.artifactId}`)) return false;
355
139
  const artifactTruncatedBytes = artifactTruncatedBytesForResult(result);
356
- const sourceTruncatedBytes = sourceTruncatedBytesForResult(result);
357
- const sourceCaptureIncomplete = sourceCaptureIncompleteForResult(result);
358
140
  return (
359
- (artifactTruncatedBytes === undefined && sourceTruncatedBytes === undefined && !sourceCaptureIncomplete) ||
360
- text.includes(
361
- formatArtifactReference(
362
- result.artifactId,
363
- artifactTruncatedBytes,
364
- sourceTruncatedBytes,
365
- sourceCaptureIncomplete,
366
- ),
367
- )
141
+ artifactTruncatedBytes === undefined ||
142
+ text.includes(formatArtifactReference(result.artifactId, artifactTruncatedBytes))
368
143
  );
369
144
  }
370
145
 
@@ -384,20 +159,11 @@ function artifactWriterFailureNotice(result: BashResult | BashInteractiveResult)
384
159
  }
385
160
 
386
161
  function completeOutputArtifactAvailable(
387
- result: Pick<
388
- OutputSummary,
389
- | "artifactId"
390
- | "artifactTruncatedBytes"
391
- | "sourceTruncatedBytes"
392
- | "sourceCaptureIncomplete"
393
- | "artifactFailureDiagnostic"
394
- >,
162
+ result: Pick<OutputSummary, "artifactId" | "artifactTruncatedBytes" | "artifactFailureDiagnostic">,
395
163
  ): boolean {
396
164
  return (
397
165
  result.artifactId !== undefined &&
398
166
  (typeof result.artifactTruncatedBytes !== "number" || result.artifactTruncatedBytes <= 0) &&
399
- (typeof result.sourceTruncatedBytes !== "number" || result.sourceTruncatedBytes <= 0) &&
400
- !result.sourceCaptureIncomplete &&
401
167
  (typeof result.artifactFailureDiagnostic !== "string" || result.artifactFailureDiagnostic.length === 0)
402
168
  );
403
169
  }
@@ -430,57 +196,29 @@ function formatBashFailureMessage(
430
196
  result: BashResult | BashInteractiveResult,
431
197
  text: string,
432
198
  explicitCause?: string,
433
- maxBytes = BASH_ERROR_MAX_BYTES,
434
199
  ): string {
435
200
  const statusCause = failureStatusCause(result, text, explicitCause);
436
201
  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);
442
202
  const suffixParts: string[] = [];
443
- const artifactEvidence = formatArtifactEvidenceNotice({ ...result, artifactFailureDiagnostic: undefined });
444
- if (artifactEvidence) suffixParts.push(artifactEvidence);
203
+ const reference = artifactReferenceForResult(result);
204
+ if (reference) suffixParts.push(reference);
445
205
  const writerNotice = artifactWriterFailureNotice(result);
446
206
  if (writerNotice) suffixParts.push(writerNotice);
447
- if (bodyStatusEvidence) suffixParts.push(bodyStatusEvidence);
448
207
  if (statusCause) suffixParts.push(statusCause);
449
208
  const suffix = suffixParts.join("\n\n");
450
209
  const separator = bodyText.length > 0 && suffix.length > 0 ? "\n\n" : "";
451
210
  const bodyBudget = Math.max(
452
211
  0,
453
- maxBytes - Buffer.byteLength(suffix, "utf-8") - Buffer.byteLength(separator, "utf-8"),
212
+ BASH_ERROR_MAX_BYTES - Buffer.byteLength(suffix, "utf-8") - Buffer.byteLength(separator, "utf-8"),
454
213
  );
455
214
  const body = truncateTailBytes(bodyText, bodyBudget).text;
456
215
  return `${body}${body.length > 0 ? separator : ""}${suffix}`;
457
216
  }
458
217
 
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
-
479
218
  async function boundClientTerminalOutput(
480
219
  output: string,
481
220
  alreadyTruncated: boolean,
482
221
  settings: ToolSession["settings"],
483
- sourceMalformed = false,
484
222
  ): Promise<{ summary: OutputSummary; locallyTruncated: boolean }> {
485
223
  const tailBytes = resolveBashOutputSinkTailBytes(settings);
486
224
  const headBytes = resolveBashOutputSinkHeadBytes(settings);
@@ -490,8 +228,7 @@ async function boundClientTerminalOutput(
490
228
  return {
491
229
  summary: {
492
230
  ...bounded,
493
- truncated: alreadyTruncated || sourceMalformed || bounded.truncated,
494
- ...(alreadyTruncated || sourceMalformed ? { sourceCaptureIncomplete: true } : {}),
231
+ truncated: alreadyTruncated || bounded.truncated,
495
232
  },
496
233
  locallyTruncated: bounded.truncated,
497
234
  };
@@ -508,52 +245,27 @@ interface PreparedClientTerminalOutput {
508
245
  async function prepareClientTerminalOutput(
509
246
  session: ToolSession,
510
247
  current: ClientBridgeTerminalOutput,
511
- forceSourceIncomplete = false,
512
248
  ): Promise<PreparedClientTerminalOutput> {
513
- const normalized = normalizeClientTerminalOutput(current);
514
- const normalizedCurrent = normalized.current;
515
- const sourceMalformed = normalized.malformed;
516
249
  const { summary, locallyTruncated } = await boundClientTerminalOutput(
517
- normalizedCurrent.output,
518
- normalizedCurrent.truncated,
250
+ current.output,
251
+ current.truncated,
519
252
  session.settings,
520
- sourceMalformed,
521
253
  );
522
- if (forceSourceIncomplete) {
523
- summary.truncated = true;
524
- summary.sourceCaptureIncomplete = true;
525
- }
526
254
  let artifactSaveResult: BashOriginalArtifactSaveResult | undefined;
527
- if (locallyTruncated && !normalizedCurrent.truncated && !sourceMalformed) {
528
- artifactSaveResult = await saveBashOriginalArtifact(session, normalizedCurrent.output);
255
+ if (locallyTruncated && !current.truncated) {
256
+ artifactSaveResult = await saveBashOriginalArtifact(session, current.output);
529
257
  if (artifactSaveResult.status === "saved") appendRawArtifactFooter(summary, artifactSaveResult);
530
258
  }
531
259
  const artifactSaveNotice = artifactSaveResult
532
260
  ? artifactSaveResultNotice(artifactSaveResult, completeOutputArtifactAvailable(summary))
533
261
  : undefined;
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}`;
262
+ return { current, summary, locallyTruncated, artifactSaveResult, artifactSaveNotice };
550
263
  }
551
264
 
552
265
  function formatClientTerminalAbortFailure(
553
266
  prepared: PreparedClientTerminalOutput,
554
267
  readDiagnostic?: string,
555
268
  pendingNotices: readonly string[] = [],
556
- maxBytes = BASH_ERROR_MAX_BYTES,
557
269
  ): string {
558
270
  const notices = [
559
271
  ...pendingNotices,
@@ -568,27 +280,7 @@ function formatClientTerminalAbortFailure(
568
280
  exitCode: undefined,
569
281
  cancelled: true,
570
282
  };
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);
283
+ return formatBashFailureMessage(result, outputText, "Command aborted");
592
284
  }
593
285
 
594
286
  function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
@@ -611,41 +303,41 @@ async function saveBashOriginalArtifact(
611
303
  return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
612
304
  }
613
305
  if (manager) {
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" };
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
+ }
619
314
  }
620
315
 
621
316
  if (!session.allocateOutputArtifact) return { status: "unavailable" };
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;
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
+ }
626
323
  if (!alloc?.path || !alloc.id) return { status: "unavailable" };
627
- if (!isValidArtifactId(alloc.id)) {
628
- return { status: "failed", diagnostic: "storage returned an invalid artifact id" };
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) };
629
336
  }
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 };
641
337
  }
642
338
 
643
- const bashArtifactPublishers = new WeakMap<ToolSession, TerminalArtifactPublisher>();
644
-
645
339
  function createBashArtifactPublisher(session: ToolSession): TerminalArtifactPublisher {
646
- const existing = bashArtifactPublishers.get(session);
647
- if (existing) return existing;
648
- const publisher: TerminalArtifactPublisher = async (content, _info): Promise<TerminalArtifactPublishResult> => {
340
+ return async (content, _info): Promise<TerminalArtifactPublishResult> => {
649
341
  let manager: ArtifactManager | null | undefined;
650
342
  try {
651
343
  manager = session.getArtifactManager?.();
@@ -653,15 +345,15 @@ function createBashArtifactPublisher(session: ToolSession): TerminalArtifactPubl
653
345
  return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
654
346
  }
655
347
  if (!manager) return { status: "unavailable" };
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" };
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
+ }
661
356
  };
662
- Object.defineProperty(publisher, "owner", { value: session });
663
- bashArtifactPublishers.set(session, publisher);
664
- return publisher;
665
357
  }
666
358
 
667
359
  export async function saveBashOriginalArtifactForTests(
@@ -1079,7 +771,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1079
771
  "bash",
1080
772
  label,
1081
773
  async ({ jobId, signal: runSignal, reportProgress }) => {
1082
- const { path: artifactPath, id: artifactId } = await allocateBashOutputArtifact(this.session, "bash");
774
+ const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
1083
775
  const artifactPublisher = createBashArtifactPublisher(this.session);
1084
776
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
1085
777
  const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
@@ -1437,7 +1129,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1437
1129
  "bash",
1438
1130
  label,
1439
1131
  async ({ jobId: id, signal, reportProgress }) => {
1440
- const { path: artifactPath, id: artifactId } = await allocateBashOutputArtifact(this.session, "bash");
1132
+ const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
1441
1133
  const artifactPublisher = createBashArtifactPublisher(this.session);
1442
1134
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
1443
1135
  const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
@@ -1489,7 +1181,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1489
1181
  }
1490
1182
 
1491
1183
  async execute(
1492
- toolCallId: string,
1184
+ _toolCallId: string,
1493
1185
  {
1494
1186
  command: rawCommand,
1495
1187
  env: rawEnv,
@@ -1624,207 +1316,39 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1624
1316
  }
1625
1317
 
1626
1318
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
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
- };
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;
1775
1335
 
1776
1336
  type BridgeRaceResult =
1777
1337
  | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1778
- | { kind: "exit-error"; diagnostic: string }
1779
1338
  | { kind: "poll" }
1780
1339
  | { kind: "timeout" }
1781
1340
  | { 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;
1802
1341
 
1803
1342
  // Set up abort listener before entering the poll loop. The listener
1804
1343
  // kicks off `handle.kill()` synchronously so a `session/cancel`
1805
1344
  // arriving mid-poll terminates the remote command immediately,
1806
1345
  // instead of waiting for the next `currentOutput()` to return.
1807
1346
  const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1808
- let killPromise: Promise<string | undefined> | undefined;
1809
- const fireKill = (): Promise<string | undefined> => {
1347
+ let killPromise: Promise<void> | undefined;
1348
+ const fireKill = (): Promise<void> => {
1810
1349
  if (killPromise) return killPromise;
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}`;
1350
+ killPromise = handle.kill().catch((error: unknown) => {
1351
+ logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1828
1352
  });
1829
1353
  return killPromise;
1830
1354
  };
@@ -1834,282 +1358,179 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1834
1358
  };
1835
1359
  signal?.addEventListener("abort", onAbortSignal, { once: true });
1836
1360
 
1837
- let operationError: unknown;
1838
- let operationResult: AgentToolResult<BashToolDetails> | undefined;
1839
- let releaseFailure: ToolError | undefined;
1840
1361
  try {
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);
1849
- throw new ToolAbortError(
1850
- formatClientTerminalAbortFailure(
1851
- prepared,
1852
- recovered.diagnostic,
1853
- [...pendingNotices, ...(killDiagnostic ? [`Terminal kill failed: ${killDiagnostic}`] : [])],
1854
- maxBashErrorBytes(this.session),
1855
- ),
1856
- );
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
+ });
1857
1375
  }
1376
+ const prepared = await prepareClientTerminalOutput(this.session, current);
1377
+ throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1378
+ }
1858
1379
 
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,
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", {
1915
1402
  terminalId: handle.terminalId,
1403
+ error,
1916
1404
  });
1917
1405
  }
1406
+ const prepared = await prepareClientTerminalOutput(this.session, current);
1407
+ throw new ToolAbortError(
1408
+ formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
1409
+ );
1410
+ }
1918
1411
 
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
- );
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", {
1425
+ terminalId: handle.terminalId,
1426
+ error,
1427
+ });
1938
1428
  }
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
+ }
1939
1448
 
1940
- if (raced.kind === "exit") {
1941
- exitStatus = raced.status;
1942
- break;
1943
- }
1449
+ if (raced.kind === "exit") {
1450
+ exitStatus = raced.status;
1451
+ break;
1452
+ }
1944
1453
 
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
- });
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;
1976
1465
  }
1977
- } finally {
1978
- // Keep the abort listener active through final output recovery and artifact save.
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
+ });
1979
1479
  }
1480
+ } finally {
1481
+ signal?.removeEventListener("abort", onAbortSignal);
1482
+ }
1980
1483
 
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
- );
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
+ });
1996
1496
  }
1497
+ const prepared = await prepareClientTerminalOutput(this.session, current);
1498
+ throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1499
+ }
1997
1500
 
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;
1501
+ // Fetch final output; the terminal is released in the outer finally.
1502
+ const finalOutput = await handle.currentOutput();
2001
1503
 
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;
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;
2006
1508
 
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
- }
1509
+ const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
2020
1510
 
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
- }
1511
+ const bridgeResult: BashResult = {
1512
+ ...prepared.summary,
1513
+ exitCode,
1514
+ cancelled: false,
1515
+ };
2034
1516
 
2035
- return this.#buildCompletedResult(bridgeResult, timeoutSec, {
2036
- requestedTimeoutSec,
2037
- notices: bridgeNotices,
2038
- terminalId: handle.terminalId,
2039
- });
2040
- })();
2041
- } catch (error) {
2042
- operationError = error;
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);
1521
+
1522
+ return this.#buildCompletedResult(bridgeResult, timeoutSec, {
1523
+ requestedTimeoutSec,
1524
+ notices: bridgeNotices,
1525
+ terminalId: handle.terminalId,
1526
+ });
2043
1527
  } finally {
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
- );
1528
+ try {
1529
+ await handle.release();
1530
+ } catch (error) {
1531
+ logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
2107
1532
  }
2108
- throw operationError;
2109
1533
  }
2110
- if (releaseFailure) throw releaseFailure;
2111
- if (!operationResult) throw new ToolError("ACP terminal execution did not settle");
2112
- return operationResult;
2113
1534
  }
2114
1535
 
2115
1536
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
@@ -2119,7 +1540,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2119
1540
  const tailBuffer = new TailBuffer(spillThreshold);
2120
1541
 
2121
1542
  // Allocate artifact for truncated output storage
2122
- const { path: artifactPath, id: artifactId } = await allocateBashOutputArtifact(this.session, "bash");
1543
+ const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
2123
1544
  const artifactPublisher = createBashArtifactPublisher(this.session);
2124
1545
 
2125
1546
  const interactiveUi =