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