@gajae-code/coding-agent 0.12.0 → 0.12.1

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 (223) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +54 -0
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
  16. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  18. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  19. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  21. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  22. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  23. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  24. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  25. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  26. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  27. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  28. package/dist/types/reminders/star-reminder.d.ts +3 -13
  29. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  30. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  31. package/dist/types/sdk/broker/transport.d.ts +2 -0
  32. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  33. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  34. package/dist/types/sdk/bus/config.d.ts +6 -2
  35. package/dist/types/sdk/bus/index.d.ts +29 -0
  36. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  37. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  38. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  39. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  40. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  41. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  42. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  43. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  44. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  45. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  46. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  47. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  48. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  49. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  50. package/dist/types/sdk/prompt-status.d.ts +23 -3
  51. package/dist/types/sdk/session.d.ts +6 -0
  52. package/dist/types/session/agent-session.d.ts +38 -2
  53. package/dist/types/session/artifacts.d.ts +8 -0
  54. package/dist/types/session/blob-store.d.ts +57 -4
  55. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  56. package/dist/types/session/session-manager.d.ts +23 -6
  57. package/dist/types/session/session-storage.d.ts +4 -0
  58. package/dist/types/session/streaming-output.d.ts +36 -7
  59. package/dist/types/setup/credential-import.d.ts +12 -0
  60. package/dist/types/skill-state/active-state.d.ts +12 -0
  61. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  62. package/dist/types/task/executor.d.ts +3 -2
  63. package/dist/types/task/index.d.ts +3 -1
  64. package/dist/types/task/provider-retry-status.d.ts +8 -0
  65. package/dist/types/task/receipt.d.ts +7 -1
  66. package/dist/types/task/types.d.ts +23 -0
  67. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  68. package/dist/types/tools/bash-interactive.d.ts +4 -1
  69. package/dist/types/tools/bash.d.ts +3 -1
  70. package/dist/types/tools/image-gen.d.ts +4 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/output-meta.d.ts +23 -2
  73. package/dist/types/tools/path-utils.d.ts +1 -0
  74. package/dist/types/tools/read.d.ts +0 -6
  75. package/dist/types/tools/review.d.ts +5 -7
  76. package/dist/types/tools/subagent.d.ts +2 -0
  77. package/dist/types/utils/gh.d.ts +13 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  80. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  81. package/dist/types/web/search/providers/xai.d.ts +2 -0
  82. package/package.json +10 -8
  83. package/scripts/acp-conformance-agent.ts +218 -0
  84. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  85. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  86. package/scripts/resident-memory-bench.ts +1341 -0
  87. package/scripts/run-acp-conformance.ts +285 -0
  88. package/src/async/job-manager.ts +78 -20
  89. package/src/cli/fast-help.ts +1 -1
  90. package/src/cli/plugin-cli.ts +176 -18
  91. package/src/commands/team.ts +19 -1
  92. package/src/config/file-lock.ts +11 -2
  93. package/src/config/settings-schema.ts +51 -0
  94. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  95. package/src/coordinator-mcp/server.ts +5 -12
  96. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  97. package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
  98. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  99. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
  100. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  101. package/src/defaults/gjc-defaults.ts +4 -4
  102. package/src/exa/mcp-client.ts +12 -3
  103. package/src/exec/bash-executor.ts +144 -27
  104. package/src/extensibility/extensions/runner.ts +15 -0
  105. package/src/extensibility/extensions/types.ts +18 -1
  106. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  107. package/src/extensibility/gjc-plugins/index.ts +19 -2
  108. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  109. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  110. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  111. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  112. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  113. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  114. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  115. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  116. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  117. package/src/extensibility/gjc-plugins/types.ts +141 -0
  118. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  119. package/src/gjc-runtime/launch-tmux.ts +25 -6
  120. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  121. package/src/gjc-runtime/ralplan-runtime.ts +411 -17
  122. package/src/gjc-runtime/team-launch.ts +8 -0
  123. package/src/gjc-runtime/team-runtime.ts +140 -32
  124. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  127. package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
  128. package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
  129. package/src/gjc-runtime/workflow-manifest.ts +4 -68
  130. package/src/internal-urls/docs-index.generated.ts +15 -15
  131. package/src/modes/DESIGN.md +51 -0
  132. package/src/modes/acp/acp-agent.ts +298 -186
  133. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  134. package/src/modes/components/notifications-settings-editor.ts +12 -1
  135. package/src/modes/components/oauth-selector.ts +68 -30
  136. package/src/modes/components/session-observer-overlay.ts +305 -86
  137. package/src/modes/components/settings-selector.ts +56 -5
  138. package/src/modes/components/status-line/gh.ts +16 -0
  139. package/src/modes/components/tool-status-header.ts +3 -14
  140. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  141. package/src/modes/controllers/command-controller.ts +4 -0
  142. package/src/modes/controllers/extension-ui-controller.ts +7 -0
  143. package/src/modes/controllers/selector-controller.ts +45 -0
  144. package/src/modes/runtime-init.ts +3 -0
  145. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  146. package/src/prompts/system/eager-todo.md +8 -4
  147. package/src/prompts/system/system-prompt.md +11 -4
  148. package/src/prompts/tools/bash.md +5 -5
  149. package/src/prompts/tools/search-tool-bm25.md +5 -0
  150. package/src/prompts/tools/task.md +1 -1
  151. package/src/reminders/star-reminder.ts +4 -42
  152. package/src/runtime-mcp/manager.ts +19 -4
  153. package/src/sdk/acp/adapter.ts +22 -6
  154. package/src/sdk/broker/transport.ts +35 -3
  155. package/src/sdk/bus/attachment-registry.ts +5 -1
  156. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  157. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  158. package/src/sdk/bus/config.ts +18 -2
  159. package/src/sdk/bus/index.ts +661 -126
  160. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  161. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  162. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  163. package/src/sdk/bus/reconciliation-store.ts +95 -16
  164. package/src/sdk/bus/rich-draft.ts +1 -1
  165. package/src/sdk/bus/rich-render.ts +2 -2
  166. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  167. package/src/sdk/bus/telegram-cli.ts +33 -8
  168. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  169. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  170. package/src/sdk/bus/telegram-daemon.ts +916 -23
  171. package/src/sdk/bus/telegram-reference.ts +40 -3
  172. package/src/sdk/bus/threaded-render.ts +48 -3
  173. package/src/sdk/bus/topic-registry.ts +20 -0
  174. package/src/sdk/host/query/revision-store.ts +36 -2
  175. package/src/sdk/host/reverse-leases.ts +44 -7
  176. package/src/sdk/prompt-status.ts +16 -3
  177. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  178. package/src/sdk/session.ts +88 -9
  179. package/src/session/agent-session.ts +822 -172
  180. package/src/session/artifacts.ts +22 -0
  181. package/src/session/auth-broker-config.ts +10 -3
  182. package/src/session/blob-store.ts +915 -24
  183. package/src/session/internal/managed-session-scope.ts +44 -6
  184. package/src/session/internal/managed-session-storage.ts +48 -6
  185. package/src/session/session-manager.ts +1614 -435
  186. package/src/session/session-storage.ts +9 -1
  187. package/src/session/streaming-output.ts +238 -68
  188. package/src/setup/credential-import.ts +75 -13
  189. package/src/skill-state/active-state.ts +32 -0
  190. package/src/skill-state/workflow-hud.ts +17 -0
  191. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  192. package/src/task/executor.ts +122 -39
  193. package/src/task/index.ts +23 -14
  194. package/src/task/provider-retry-status.ts +16 -0
  195. package/src/task/receipt.ts +23 -7
  196. package/src/task/render.ts +6 -2
  197. package/src/task/types.ts +108 -0
  198. package/src/tools/ask-answer-registry.ts +12 -6
  199. package/src/tools/ask.ts +86 -55
  200. package/src/tools/ast-edit.ts +1 -0
  201. package/src/tools/ast-grep.ts +1 -0
  202. package/src/tools/bash-interactive.ts +8 -3
  203. package/src/tools/bash.ts +503 -60
  204. package/src/tools/find.ts +1 -0
  205. package/src/tools/image-gen.ts +29 -5
  206. package/src/tools/index.ts +4 -0
  207. package/src/tools/output-meta.ts +108 -14
  208. package/src/tools/path-utils.ts +3 -2
  209. package/src/tools/read.ts +18 -0
  210. package/src/tools/review.ts +5 -7
  211. package/src/tools/search.ts +1 -0
  212. package/src/tools/subagent-render.ts +6 -2
  213. package/src/tools/subagent.ts +6 -0
  214. package/src/tools/telegram-send.ts +6 -3
  215. package/src/utils/gh.ts +39 -0
  216. package/src/utils/image-resize.ts +3 -0
  217. package/src/web/search/providers/anthropic.ts +4 -4
  218. package/src/web/search/providers/kimi.ts +15 -5
  219. package/src/web/search/providers/searxng.ts +47 -4
  220. package/src/web/search/providers/xai.ts +7 -2
  221. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  222. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  223. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
package/src/tools/bash.ts CHANGED
@@ -5,7 +5,8 @@ import { ImageProtocol, TERMINAL, Text } from "@gajae-code/tui";
5
5
  import { getProjectDir, isEnoent, logger, prompt } from "@gajae-code/utils";
6
6
  import * as z from "zod/v4";
7
7
  import { AsyncJobManager } from "../async";
8
- import { type BashResult, executeBash } from "../exec/bash-executor";
8
+ import { type BashArtifactSaveResult, type BashResult, executeBash } from "../exec/bash-executor";
9
+
9
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
11
  import { buildGjcRuntimeSessionEnv } from "../gjc-runtime/goal-mode-request";
11
12
  import {
@@ -16,8 +17,20 @@ import { InternalUrlRouter } from "../internal-urls";
16
17
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
17
18
  import { highlightCode, type Theme } from "../modes/theme/theme";
18
19
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
20
+ import type { ArtifactManager } from "../session/artifacts";
19
21
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
20
- import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
22
+ import {
23
+ DEFAULT_ARTIFACT_MAX_BYTES,
24
+ OutputSink,
25
+ type OutputSummary,
26
+ streamTailUpdates,
27
+ TailBuffer,
28
+ type TerminalArtifactPublisher,
29
+ type TerminalArtifactPublishResult,
30
+ truncateHeadBytes,
31
+ truncateTailBytes,
32
+ } from "../session/streaming-output";
33
+
21
34
  import { renderStatusLine } from "../tui";
22
35
  import { CachedOutputBlock } from "../tui/output-block";
23
36
  import { getSixelLineMask } from "../utils/sixel";
@@ -29,7 +42,14 @@ import { checkBashInterception } from "./bash-interceptor";
29
42
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
30
43
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
31
44
  import { checkComposerBashPolicy } from "./composer-bash-policy";
32
- import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
45
+ import {
46
+ formatArtifactReference,
47
+ formatStyledTruncationWarning,
48
+ type OutputMeta,
49
+ resolveBashOutputSinkHeadBytes,
50
+ resolveBashOutputSinkTailBytes,
51
+ stripOutputNotice,
52
+ } from "./output-meta";
33
53
  import { resolveToCwd } from "./path-utils";
34
54
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
35
55
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -38,6 +58,8 @@ import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
38
58
 
39
59
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
40
60
 
61
+ const BASH_ERROR_MAX_BYTES = 4096;
62
+ const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
41
63
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
64
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
43
65
  const READ_ONLY_BASH_ENV: Record<string, string> = {
@@ -47,20 +69,301 @@ const READ_ONLY_BASH_ENV: Record<string, string> = {
47
69
  RIPGREP_CONFIG_PATH: "",
48
70
  };
49
71
 
50
- export async function saveBashOriginalArtifactForTests(
72
+ export type BashOriginalArtifactSaveResult = BashArtifactSaveResult;
73
+
74
+ function boundArtifactSaveDiagnostic(error: unknown): string {
75
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/gu, " ").trim();
76
+ const normalized = message || "unknown storage error";
77
+ return truncateHeadBytes(normalized, ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES).text;
78
+ }
79
+
80
+ function summarizeOriginalArtifactSave(
81
+ artifactId: string,
82
+ originalText: string,
83
+ ): Extract<BashOriginalArtifactSaveResult, { status: "saved" }> {
84
+ const originalBytes = Buffer.byteLength(originalText, "utf-8");
85
+ if (originalBytes <= DEFAULT_ARTIFACT_MAX_BYTES) {
86
+ return { status: "saved", artifactId, complete: true };
87
+ }
88
+ const retainedBytes = truncateHeadBytes(originalText, DEFAULT_ARTIFACT_MAX_BYTES).bytes;
89
+ return {
90
+ status: "saved",
91
+ artifactId,
92
+ complete: false,
93
+ omittedBytes: originalBytes - retainedBytes,
94
+ };
95
+ }
96
+
97
+ function artifactSaveResultNotice(
98
+ result: BashOriginalArtifactSaveResult,
99
+ hasAlternateCompleteArtifact = false,
100
+ ): string | undefined {
101
+ if (result.status === "failed") return `Bash output artifact save failed: ${result.diagnostic}`;
102
+ if (result.status === "unavailable" && !hasAlternateCompleteArtifact) {
103
+ return "Bash output artifact unavailable: full original output could not be stored because artifact storage is unavailable.";
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function artifactTruncatedBytesForResult(result: BashResult | BashInteractiveResult): number | undefined {
109
+ const bytes = (result as OutputSummary).artifactTruncatedBytes;
110
+ return typeof bytes === "number" && bytes > 0 ? bytes : undefined;
111
+ }
112
+
113
+ function artifactReferenceForResult(result: BashResult | BashInteractiveResult): string | undefined {
114
+ return result.artifactId
115
+ ? formatArtifactReference(result.artifactId, artifactTruncatedBytesForResult(result))
116
+ : undefined;
117
+ }
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
+
127
+ function appendRawArtifactFooter(
128
+ summary: OutputSummary,
129
+ result: Extract<BashOriginalArtifactSaveResult, { status: "saved" }>,
130
+ ): void {
131
+ summary.artifactId = result.artifactId;
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)}]`;
135
+ }
136
+
137
+ function artifactReferenceIsReachable(text: string, result: BashResult | BashInteractiveResult): boolean {
138
+ if (!result.artifactId || !text.includes(`artifact://${result.artifactId}`)) return false;
139
+ const artifactTruncatedBytes = artifactTruncatedBytesForResult(result);
140
+ return (
141
+ artifactTruncatedBytes === undefined ||
142
+ text.includes(formatArtifactReference(result.artifactId, artifactTruncatedBytes))
143
+ );
144
+ }
145
+
146
+ function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
147
+ const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
148
+ return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
149
+ }
150
+
151
+ function artifactWriterFailureNotice(result: BashResult | BashInteractiveResult): string | undefined {
152
+ const diagnostic = artifactFailureDiagnosticForResult(result);
153
+ if (!diagnostic) return undefined;
154
+ if (diagnostic.startsWith("unavailable:")) {
155
+ return `Bash output artifact unavailable: ${diagnostic.slice("unavailable:".length).trim()}`;
156
+ }
157
+ const normalized = diagnostic.startsWith("failed:") ? diagnostic.slice("failed:".length).trim() : diagnostic;
158
+ return `Bash output artifact writer failed: ${normalized}`;
159
+ }
160
+
161
+ function completeOutputArtifactAvailable(
162
+ result: Pick<OutputSummary, "artifactId" | "artifactTruncatedBytes" | "artifactFailureDiagnostic">,
163
+ ): boolean {
164
+ return (
165
+ result.artifactId !== undefined &&
166
+ (typeof result.artifactTruncatedBytes !== "number" || result.artifactTruncatedBytes <= 0) &&
167
+ (typeof result.artifactFailureDiagnostic !== "string" || result.artifactFailureDiagnostic.length === 0)
168
+ );
169
+ }
170
+
171
+ function failureStatusCause(
172
+ result: BashResult | BashInteractiveResult,
173
+ text: string,
174
+ explicitCause?: string,
175
+ ): string | undefined {
176
+ if (explicitCause) return explicitCause;
177
+ const leadingStatus = /^\[(Command (?:timed out(?: after \d+ seconds?)?|cancelled))\]/u.exec(text)?.[1];
178
+ const statusMatches = Array.from(text.matchAll(/Command (?:timed out(?: after \d+ seconds?)?|cancelled|aborted)/gu));
179
+ const lastStatus = statusMatches[statusMatches.length - 1]?.[0];
180
+ if (result.cancelled) return leadingStatus ?? lastStatus ?? "Command cancelled";
181
+ if (isInteractiveResult(result) && result.timedOut) return lastStatus ?? "Command timed out";
182
+ if (result.exitCode === undefined) return "Command failed: missing exit status";
183
+ if (result.exitCode !== 0) return `Command exited with code ${result.exitCode}`;
184
+ return undefined;
185
+ }
186
+
187
+ function removeTrailingFailureCause(text: string, cause: string | undefined): string {
188
+ if (!cause) return text;
189
+ for (const suffix of [`\n\n${cause}`, `\n${cause}`, cause]) {
190
+ if (text.endsWith(suffix)) return text.slice(0, -suffix.length);
191
+ }
192
+ return text;
193
+ }
194
+
195
+ function formatBashFailureMessage(
196
+ result: BashResult | BashInteractiveResult,
197
+ text: string,
198
+ explicitCause?: string,
199
+ ): string {
200
+ const statusCause = failureStatusCause(result, text, explicitCause);
201
+ const bodyText = removeTrailingFailureCause(text, statusCause);
202
+ const suffixParts: string[] = [];
203
+ const reference = artifactReferenceForResult(result);
204
+ if (reference) suffixParts.push(reference);
205
+ const writerNotice = artifactWriterFailureNotice(result);
206
+ if (writerNotice) suffixParts.push(writerNotice);
207
+ if (statusCause) suffixParts.push(statusCause);
208
+ const suffix = suffixParts.join("\n\n");
209
+ const separator = bodyText.length > 0 && suffix.length > 0 ? "\n\n" : "";
210
+ const bodyBudget = Math.max(
211
+ 0,
212
+ BASH_ERROR_MAX_BYTES - Buffer.byteLength(suffix, "utf-8") - Buffer.byteLength(separator, "utf-8"),
213
+ );
214
+ const body = truncateTailBytes(bodyText, bodyBudget).text;
215
+ return `${body}${body.length > 0 ? separator : ""}${suffix}`;
216
+ }
217
+
218
+ async function boundClientTerminalOutput(
219
+ output: string,
220
+ alreadyTruncated: boolean,
221
+ settings: ToolSession["settings"],
222
+ ): Promise<{ summary: OutputSummary; locallyTruncated: boolean }> {
223
+ const tailBytes = resolveBashOutputSinkTailBytes(settings);
224
+ const headBytes = resolveBashOutputSinkHeadBytes(settings);
225
+ const sink = new OutputSink({ spillThreshold: tailBytes, headBytes });
226
+ sink.push(output);
227
+ const bounded = await sink.dump();
228
+ return {
229
+ summary: {
230
+ ...bounded,
231
+ truncated: alreadyTruncated || bounded.truncated,
232
+ },
233
+ locallyTruncated: bounded.truncated,
234
+ };
235
+ }
236
+
237
+ interface PreparedClientTerminalOutput {
238
+ current: ClientBridgeTerminalOutput;
239
+ summary: OutputSummary;
240
+ locallyTruncated: boolean;
241
+ artifactSaveResult?: BashOriginalArtifactSaveResult;
242
+ artifactSaveNotice?: string;
243
+ }
244
+
245
+ async function prepareClientTerminalOutput(
246
+ session: ToolSession,
247
+ current: ClientBridgeTerminalOutput,
248
+ ): Promise<PreparedClientTerminalOutput> {
249
+ const { summary, locallyTruncated } = await boundClientTerminalOutput(
250
+ current.output,
251
+ current.truncated,
252
+ session.settings,
253
+ );
254
+ let artifactSaveResult: BashOriginalArtifactSaveResult | undefined;
255
+ if (locallyTruncated && !current.truncated) {
256
+ artifactSaveResult = await saveBashOriginalArtifact(session, current.output);
257
+ if (artifactSaveResult.status === "saved") appendRawArtifactFooter(summary, artifactSaveResult);
258
+ }
259
+ const artifactSaveNotice = artifactSaveResult
260
+ ? artifactSaveResultNotice(artifactSaveResult, completeOutputArtifactAvailable(summary))
261
+ : undefined;
262
+ return { current, summary, locallyTruncated, artifactSaveResult, artifactSaveNotice };
263
+ }
264
+
265
+ function formatClientTerminalAbortFailure(
266
+ prepared: PreparedClientTerminalOutput,
267
+ readDiagnostic?: string,
268
+ pendingNotices: readonly string[] = [],
269
+ ): string {
270
+ const notices = [
271
+ ...pendingNotices,
272
+ ...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
273
+ ...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
274
+ ...(readDiagnostic ? [`Terminal output recovery failed: ${readDiagnostic}`] : []),
275
+ ];
276
+ const outputLines = [prepared.summary.output || "(no output)", ...notices].filter(Boolean);
277
+ const outputText = outputLines.join("\n");
278
+ const result: BashResult = {
279
+ ...prepared.summary,
280
+ exitCode: undefined,
281
+ cancelled: true,
282
+ };
283
+ return formatBashFailureMessage(result, outputText, "Command aborted");
284
+ }
285
+
286
+ function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
287
+ const suffixParts: string[] = [];
288
+ const reference = artifactReferenceForResult(result);
289
+ if (reference && !artifactReferenceIsReachable(text, result)) suffixParts.push(reference);
290
+ const writerNotice = artifactWriterFailureNotice(result);
291
+ if (writerNotice && !text.includes(writerNotice)) suffixParts.push(writerNotice);
292
+ return suffixParts.length > 0 ? `${text}${text.endsWith("\n") ? "" : "\n"}${suffixParts.join("\n\n")}` : text;
293
+ }
294
+
295
+ async function saveBashOriginalArtifact(
51
296
  session: ToolSession,
52
297
  originalText: string,
53
- ): Promise<string | undefined> {
298
+ ): Promise<BashOriginalArtifactSaveResult> {
299
+ let manager: ArtifactManager | null | undefined;
300
+ try {
301
+ manager = session.getArtifactManager?.();
302
+ } catch (error) {
303
+ return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
304
+ }
305
+ 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
+ }
314
+ }
315
+
316
+ if (!session.allocateOutputArtifact) return { status: "unavailable" };
317
+ let alloc: { id?: string; path?: string } | undefined;
54
318
  try {
55
- const manager = session.getArtifactManager?.();
56
- if (manager) return await manager.save(originalText, "bash-original");
57
- const alloc = await session.allocateOutputArtifact?.("bash-original");
58
- if (!alloc?.path || !alloc.id) return undefined;
59
- await Bun.write(alloc.path, originalText);
60
- return alloc.id;
61
- } catch {
62
- return undefined;
319
+ alloc = await session.allocateOutputArtifact("bash-original");
320
+ } catch (error) {
321
+ return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
63
322
  }
323
+ 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) };
336
+ }
337
+ }
338
+
339
+ function createBashArtifactPublisher(session: ToolSession): TerminalArtifactPublisher {
340
+ return async (content, _info): Promise<TerminalArtifactPublishResult> => {
341
+ let manager: ArtifactManager | null | undefined;
342
+ try {
343
+ manager = session.getArtifactManager?.();
344
+ } catch (error) {
345
+ return { status: "failed", diagnostic: boundArtifactSaveDiagnostic(error) };
346
+ }
347
+ 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
+ }
356
+ };
357
+ }
358
+
359
+ export async function saveBashOriginalArtifactForTests(
360
+ session: ToolSession,
361
+ originalText: string,
362
+ onResult?: (result: BashOriginalArtifactSaveResult) => void,
363
+ ): Promise<string | undefined> {
364
+ const result = await saveBashOriginalArtifact(session, originalText);
365
+ onResult?.(result);
366
+ return result.status === "saved" ? result.artifactId : undefined;
64
367
  }
65
368
 
66
369
  const bashSchemaBase = z.object({
@@ -146,6 +449,7 @@ type ManagedBashJobCompletion =
146
449
  | {
147
450
  kind: "failed";
148
451
  error: unknown;
452
+ result?: BashResult | BashInteractiveResult;
149
453
  };
150
454
 
151
455
  interface ManagedBashJobHandle {
@@ -164,6 +468,22 @@ function isInteractiveResult(result: BashResult | BashInteractiveResult): result
164
468
  return "timedOut" in result;
165
469
  }
166
470
 
471
+ function formatManagedAbortFailure(
472
+ error: unknown,
473
+ result: BashResult | BashInteractiveResult | undefined,
474
+ latestText: string,
475
+ ): string {
476
+ if (result) {
477
+ const output = normalizeResultOutput(result).replace(/\[Command (?:cancelled|aborted)\]\n?/gu, "");
478
+ return formatBashFailureMessage(result, output || "Command aborted", "Command aborted");
479
+ }
480
+ const raw = error instanceof Error ? error.message : String(error);
481
+ const bodyText = raw.replace(/Command cancelled(?: after \d+ seconds?)?/gu, "").trim() || latestText.trim();
482
+ const bodyBudget = Math.max(0, BASH_ERROR_MAX_BYTES - Buffer.byteLength("Command aborted", "utf-8") - 2);
483
+ const body = truncateTailBytes(bodyText, bodyBudget).text;
484
+ return body.length > 0 ? `${body}\n\nCommand aborted` : "Command aborted";
485
+ }
486
+
167
487
  function normalizeBashEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
168
488
  if (!env || Object.keys(env).length === 0) return undefined;
169
489
  const normalized: Record<string, string> = {};
@@ -319,17 +639,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
319
639
  }
320
640
 
321
641
  #buildResultText(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): string {
642
+ const trimmedOutput = outputText.trim();
643
+ const renderedOutput =
644
+ trimmedOutput && trimmedOutput !== "(no output)" ? outputText : normalizeResultOutput(result);
322
645
  if (result.cancelled) {
323
- throw new ToolError(normalizeResultOutput(result) || "Command aborted");
646
+ throw new ToolError(formatBashFailureMessage(result, renderedOutput || "Command aborted"));
324
647
  }
325
648
  if (isInteractiveResult(result) && result.timedOut) {
326
- throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
649
+ const timeoutMessage = `Command timed out after ${timeoutSec} seconds`;
650
+ const message = renderedOutput ? `${renderedOutput}\n\n${timeoutMessage}` : timeoutMessage;
651
+ throw new ToolError(formatBashFailureMessage(result, message));
327
652
  }
328
653
  if (result.exitCode === undefined) {
329
- throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
654
+ throw new ToolError(formatBashFailureMessage(result, `${outputText}\n\nCommand failed: missing exit status`));
330
655
  }
331
656
  if (result.exitCode !== 0) {
332
- throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
657
+ throw new ToolError(
658
+ formatBashFailureMessage(result, `${outputText}\n\nCommand exited with code ${result.exitCode}`),
659
+ );
333
660
  }
334
661
  return outputText;
335
662
  }
@@ -337,9 +664,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
337
664
  #buildCompletedResult(
338
665
  result: BashResult | BashInteractiveResult,
339
666
  timeoutSec: number,
340
- options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
667
+ options: {
668
+ requestedTimeoutSec?: number;
669
+ notices?: readonly string[];
670
+ terminalId?: string;
671
+ artifactReferenceInBody?: boolean;
672
+ } = {},
341
673
  ): AgentToolResult<BashToolDetails> {
342
- const outputLines = [this.#formatResultOutput(result)];
674
+ const shortArtifactInBody = !result.truncated && result.artifactId !== undefined;
675
+ const baseBody =
676
+ options.artifactReferenceInBody || shortArtifactInBody
677
+ ? appendArtifactDetails(this.#formatResultOutput(result), result)
678
+ : this.#formatResultOutput(result);
679
+ const writerNotice = artifactWriterFailureNotice(result);
680
+ const body =
681
+ writerNotice && !baseBody.includes(writerNotice)
682
+ ? `${baseBody}${baseBody.endsWith("\n") ? "" : "\n"}${writerNotice}`
683
+ : baseBody;
684
+ const outputLines = [body];
343
685
  const notices = options.notices?.filter(Boolean) ?? [];
344
686
  if (notices.length > 0) outputLines.push("", ...notices);
345
687
  const outputText = outputLines.join("\n");
@@ -350,7 +692,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
350
692
  if (options.terminalId !== undefined) {
351
693
  details.terminalId = options.terminalId;
352
694
  }
353
- const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
695
+ const resultBuilder = toolResult(details)
696
+ .text(outputText)
697
+ .truncationFromSummary(result, {
698
+ direction: "tail",
699
+ ...(shortArtifactInBody ? { noticeOwner: "body" } : {}),
700
+ });
354
701
  this.#buildResultText(result, timeoutSec, outputText);
355
702
  return resultBuilder.done();
356
703
  }
@@ -425,7 +772,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
425
772
  label,
426
773
  async ({ jobId, signal: runSignal, reportProgress }) => {
427
774
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
428
- const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
775
+ const artifactPublisher = createBashArtifactPublisher(this.session);
776
+ const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
777
+ const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
778
+ const tailBuffer = new TailBuffer(spillThreshold);
779
+
780
+ let executionResult: BashResult | BashInteractiveResult | undefined;
429
781
  try {
430
782
  const result = await executeBash(options.command, {
431
783
  cwd: options.commandCwd,
@@ -435,6 +787,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
435
787
  env: options.resolvedEnv,
436
788
  artifactPath,
437
789
  artifactId,
790
+ artifactPublisher,
791
+ spillThreshold,
792
+ headBytes,
438
793
  oneShot: true,
439
794
  ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
440
795
  disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
@@ -450,11 +805,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
450
805
  // path above.
451
806
  manager.appendOutput(jobId, chunk);
452
807
  },
453
- onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
808
+ onMinimizedSave: async originalText => {
809
+ return saveBashOriginalArtifact(this.session, originalText);
810
+ },
454
811
  });
812
+ executionResult = result;
455
813
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
456
814
  requestedTimeoutSec: options.requestedTimeoutSec,
457
- notices: options.notices ?? [],
815
+ notices: options.notices,
816
+ artifactReferenceInBody: true,
458
817
  });
459
818
  const finalText = this.#extractTextResult(finalResult);
460
819
  latestText = finalText;
@@ -464,7 +823,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
464
823
  } catch (error) {
465
824
  const message = error instanceof Error ? error.message : String(error);
466
825
  latestText = message;
467
- completion.resolve({ kind: "failed", error });
826
+ completion.resolve({ kind: "failed", error, result: executionResult });
468
827
  await reportProgress(message, failedDetails(jobId));
469
828
  throw error;
470
829
  }
@@ -771,7 +1130,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
771
1130
  label,
772
1131
  async ({ jobId: id, signal, reportProgress }) => {
773
1132
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
774
- const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
1133
+ const artifactPublisher = createBashArtifactPublisher(this.session);
1134
+ const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
1135
+ const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1136
+ const tailBuffer = new TailBuffer(spillThreshold);
1137
+
775
1138
  try {
776
1139
  const result = await executeBash(prepared.command, {
777
1140
  cwd: prepared.commandCwd,
@@ -781,6 +1144,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
781
1144
  env: prepared.resolvedEnv,
782
1145
  artifactPath,
783
1146
  artifactId,
1147
+ artifactPublisher,
1148
+ spillThreshold,
1149
+ headBytes,
784
1150
  oneShot: true,
785
1151
  ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
786
1152
  disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
@@ -797,11 +1163,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
797
1163
  cursorOffset = slice.nextOffset;
798
1164
  dispatchLines(slice.text);
799
1165
  },
800
- onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
1166
+ onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
801
1167
  });
802
1168
  flushTrailingLine();
803
- this.#buildResultText(result, prepared.timeoutSec, result.output || "(no output)");
804
- return result.output;
1169
+ const resultText = appendArtifactDetails(result.output || "(no output)", result);
1170
+ this.#buildResultText(result, prepared.timeoutSec, resultText);
1171
+ return resultText;
805
1172
  } catch (error) {
806
1173
  flushTrailingLine();
807
1174
  throw error instanceof Error ? error : new Error(String(error));
@@ -932,8 +1299,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
932
1299
  }
933
1300
  if (waitResult.kind === "aborted") {
934
1301
  autoBgManager.cancel(job.jobId);
1302
+ const terminal = await job.completion;
935
1303
  autoBgManager.acknowledgeDeliveries([job.jobId]);
936
- throw new ToolAbortError(job.getLatestText() || "Command aborted");
1304
+ if (terminal.kind === "failed") {
1305
+ throw new ToolAbortError(
1306
+ formatManagedAbortFailure(terminal.error, terminal.result, job.getLatestText()),
1307
+ );
1308
+ }
1309
+ throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
937
1310
  }
938
1311
  job.setBackgrounded(true);
939
1312
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
@@ -943,13 +1316,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
943
1316
  }
944
1317
 
945
1318
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
1319
+ const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1320
+ const clientTailBytes = resolveBashOutputSinkTailBytes(this.session.settings);
946
1321
  const handle = await clientBridge.createTerminal({
947
1322
  command,
948
1323
  cwd: commandCwd,
949
1324
  env: resolvedEnv
950
1325
  ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
951
1326
  : undefined,
952
- outputByteLimit: DEFAULT_MAX_BYTES,
1327
+ outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
953
1328
  });
954
1329
 
955
1330
  // Emit partial update so the editor can embed the live terminal card.
@@ -969,13 +1344,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
969
1344
  // arriving mid-poll terminates the remote command immediately,
970
1345
  // instead of waiting for the next `currentOutput()` to return.
971
1346
  const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
972
- let killStarted = false;
1347
+ let killPromise: Promise<void> | undefined;
973
1348
  const fireKill = (): Promise<void> => {
974
- if (killStarted) return Promise.resolve();
975
- killStarted = true;
976
- return handle.kill().catch((error: unknown) => {
1349
+ if (killPromise) return killPromise;
1350
+ killPromise = handle.kill().catch((error: unknown) => {
977
1351
  logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
978
1352
  });
1353
+ return killPromise;
979
1354
  };
980
1355
  const onAbortSignal = () => {
981
1356
  resolveAborted();
@@ -987,7 +1362,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
987
1362
  try {
988
1363
  if (signal?.aborted) {
989
1364
  await fireKill();
990
- throw new ToolAbortError("Command aborted");
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));
991
1378
  }
992
1379
 
993
1380
  const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
@@ -1005,7 +1392,21 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1005
1392
 
1006
1393
  if (raced.kind === "aborted" || signal?.aborted) {
1007
1394
  await fireKill();
1008
- throw new ToolAbortError("Command aborted");
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);
1407
+ throw new ToolAbortError(
1408
+ formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
1409
+ );
1009
1410
  }
1010
1411
 
1011
1412
  if (raced.kind === "timeout") {
@@ -1014,29 +1415,33 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1014
1415
  // enforced timeout. The handle stays valid post-kill so the
1015
1416
  // buffered output is still readable.
1016
1417
  await fireKill();
1017
- let current = { output: "", truncated: false };
1418
+ let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
1419
+ let readDiagnostic: string | undefined;
1018
1420
  try {
1019
1421
  current = await handle.currentOutput();
1020
1422
  } catch (error) {
1423
+ readDiagnostic = boundArtifactSaveDiagnostic(error);
1021
1424
  logger.warn("ACP terminal final output read failed", {
1022
1425
  terminalId: handle.terminalId,
1023
1426
  error,
1024
1427
  });
1025
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
+ ];
1026
1436
  const timedOutResult: BashInteractiveResult = {
1027
- output: current.output,
1437
+ ...prepared.summary,
1028
1438
  exitCode: undefined,
1029
1439
  cancelled: false,
1030
1440
  timedOut: true,
1031
- truncated: current.truncated,
1032
- totalLines: current.output.length > 0 ? current.output.split("\n").length : 0,
1033
- totalBytes: current.output.length,
1034
- outputLines: current.output.length > 0 ? current.output.split("\n").length : 0,
1035
- outputBytes: current.output.length,
1036
1441
  };
1037
1442
  return this.#buildCompletedResult(timedOutResult, timeoutSec, {
1038
1443
  requestedTimeoutSec,
1039
- notices: pendingNotices,
1444
+ notices: timeoutNotices,
1040
1445
  terminalId: handle.terminalId,
1041
1446
  });
1042
1447
  }
@@ -1058,8 +1463,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1058
1463
  // observe `signal?.aborted` and exit via the abort branch.
1059
1464
  continue;
1060
1465
  }
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;
1061
1475
  onUpdate?.({
1062
- content: [{ type: "text", text: pollOutput.output }],
1476
+ content: [{ type: "text", text: pollText }],
1063
1477
  details: { terminalId: handle.terminalId },
1064
1478
  });
1065
1479
  }
@@ -1067,6 +1481,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1067
1481
  signal?.removeEventListener("abort", onAbortSignal);
1068
1482
  }
1069
1483
 
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
+ });
1496
+ }
1497
+ const prepared = await prepareClientTerminalOutput(this.session, current);
1498
+ throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
1499
+ }
1500
+
1070
1501
  // Fetch final output; the terminal is released in the outer finally.
1071
1502
  const finalOutput = await handle.currentOutput();
1072
1503
 
@@ -1075,24 +1506,18 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1075
1506
  const exitCode: number | undefined =
1076
1507
  rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
1077
1508
 
1078
- const outputText = finalOutput.output;
1079
- const outputByteLen = outputText.length;
1080
- const outputLineCount = outputText.length > 0 ? outputText.split("\n").length : 0;
1509
+ const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
1081
1510
 
1082
1511
  const bridgeResult: BashResult = {
1083
- output: outputText,
1512
+ ...prepared.summary,
1084
1513
  exitCode,
1085
1514
  cancelled: false,
1086
- truncated: finalOutput.truncated,
1087
- totalLines: outputLineCount,
1088
- totalBytes: outputByteLen,
1089
- outputLines: outputLineCount,
1090
- outputBytes: outputByteLen,
1091
1515
  };
1092
1516
 
1093
1517
  const bridgeNotices: string[] = [];
1094
- if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
1518
+ if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
1095
1519
  for (const notice of pendingNotices) bridgeNotices.push(notice);
1520
+ if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
1096
1521
 
1097
1522
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
1098
1523
  requestedTimeoutSec,
@@ -1108,11 +1533,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1108
1533
  }
1109
1534
  }
1110
1535
 
1536
+ const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
1537
+ const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1538
+
1111
1539
  // Track output for streaming updates (tail only)
1112
- const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
1540
+ const tailBuffer = new TailBuffer(spillThreshold);
1113
1541
 
1114
1542
  // Allocate artifact for truncated output storage
1115
1543
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
1544
+ const artifactPublisher = createBashArtifactPublisher(this.session);
1116
1545
 
1117
1546
  const interactiveUi =
1118
1547
  this.session.bashRestrictionProfile === "read-only"
@@ -1129,6 +1558,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1129
1558
  env: resolvedEnv,
1130
1559
  artifactPath,
1131
1560
  artifactId,
1561
+ artifactPublisher,
1562
+ spillThreshold,
1563
+ headBytes,
1132
1564
  })
1133
1565
  : await executeBash(command, {
1134
1566
  cwd: commandCwd,
@@ -1139,19 +1571,30 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1139
1571
  env: resolvedEnv,
1140
1572
  artifactPath,
1141
1573
  artifactId,
1574
+ artifactPublisher,
1575
+ spillThreshold,
1576
+ headBytes,
1142
1577
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
1143
- onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
1578
+ onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
1144
1579
  ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1145
1580
  disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1146
1581
  });
1147
1582
  if (result.cancelled) {
1583
+ const failureText = formatBashFailureMessage(
1584
+ result,
1585
+ normalizeResultOutput(result) || "Command aborted",
1586
+ signal?.aborted ? "Command aborted" : undefined,
1587
+ );
1148
1588
  if (signal?.aborted) {
1149
- throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
1589
+ throw new ToolAbortError(failureText);
1150
1590
  }
1151
- throw new ToolError(normalizeResultOutput(result) || "Command aborted");
1591
+ throw new ToolError(failureText);
1152
1592
  }
1153
1593
  if (isInteractiveResult(result) && result.timedOut) {
1154
- throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
1594
+ const timeoutMessage = `Command timed out after ${timeoutSec} seconds`;
1595
+ const output = normalizeResultOutput(result);
1596
+ const failureText = output ? `${output}\n\n${timeoutMessage}` : timeoutMessage;
1597
+ throw new ToolError(formatBashFailureMessage(result, failureText, timeoutMessage));
1155
1598
  }
1156
1599
  return this.#buildCompletedResult(result, timeoutSec, {
1157
1600
  requestedTimeoutSec,