@gajae-code/coding-agent 0.15.5 → 0.16.0
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 +85 -1
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +120 -0
- package/dist/types/config/file-lock.d.ts +54 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +17 -5
- package/dist/types/config/settings-schema.d.ts +19 -1
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
- package/dist/types/crash/record-loader.d.ts +15 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -5
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/master-mode/context.d.ts +21 -0
- package/dist/types/master-mode/first-request.d.ts +34 -0
- package/dist/types/master-mode/snapshot.d.ts +32 -0
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
- package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
- package/dist/types/modes/components/status-line/segments.d.ts +10 -0
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +80 -1
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
- package/dist/types/sdk/broker/master-capability.d.ts +10 -0
- package/dist/types/sdk/broker/session-index.d.ts +40 -11
- package/dist/types/sdk/broker/session-scope.d.ts +72 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
- package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
- package/dist/types/sdk/broker/state-version.d.ts +4 -1
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
- package/dist/types/sdk/bus/index.d.ts +6 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +4 -3
- package/dist/types/sdk/cli/session-cli.d.ts +18 -2
- package/dist/types/sdk/client/client.d.ts +3 -2
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +2 -0
- package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
- package/dist/types/sdk/host/host.d.ts +10 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +67 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
- package/dist/types/sdk/lifecycle/service.d.ts +15 -3
- package/dist/types/sdk/lifecycle-session.d.ts +2 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +24 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +65 -0
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/bash.d.ts +4 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -5
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +9 -3
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +671 -39
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +4 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +117 -16
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/launch.ts +86 -18
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +113 -21
- package/src/config/autorouting-tier-map.ts +44 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +890 -99
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2795 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +56 -14
- package/src/config/model-registry.ts +1044 -122
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +19 -1
- package/src/config/settings.ts +11 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +592 -0
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +111 -42
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +1321 -147
- package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
- package/src/gjc-runtime/tmux-sessions.ts +252 -13
- package/src/hooks/skill-state.ts +6 -0
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +66 -4
- package/src/master-mode/context.ts +50 -0
- package/src/master-mode/first-request.ts +65 -0
- package/src/master-mode/snapshot.ts +205 -0
- package/src/modes/acp/acp-agent.ts +616 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/model-name.ts +53 -0
- package/src/modes/components/status-line/priority-row.ts +107 -0
- package/src/modes/components/status-line/segments.ts +29 -10
- package/src/modes/components/tool-status-header.ts +151 -15
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +51 -6
- package/src/modes/controllers/selector-controller.ts +191 -96
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/ui-language.ts +49 -2
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/system/master-mode.md +28 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +1671 -34
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +5 -0
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +217 -46
- package/src/sdk/broker/master-capability.ts +232 -0
- package/src/sdk/broker/session-index.ts +121 -17
- package/src/sdk/broker/session-scope.ts +272 -0
- package/src/sdk/broker/spawn-authority.ts +1031 -0
- package/src/sdk/broker/spawn-substrate.ts +548 -0
- package/src/sdk/broker/state-version.ts +26 -14
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/broker/worktree-occupancy.ts +90 -0
- package/src/sdk/bus/chat-daemon-control.ts +5 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
- package/src/sdk/bus/index.ts +51 -4
- package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
- package/src/sdk/bus/prompt-reconciliation.ts +28 -4
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +11 -8
- package/src/sdk/cli/session-cli.ts +276 -6
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +101 -37
- package/src/sdk/host/control/index.ts +2 -0
- package/src/sdk/host/control/operations.ts +2 -0
- package/src/sdk/host/control/runtime-gate.ts +203 -11
- package/src/sdk/host/host.ts +27 -2
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +581 -43
- package/src/sdk/host/session-runtime.ts +655 -101
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +10 -2
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +53 -2
- package/src/sdk/protocol/operation-registry.ts +17 -4
- package/src/sdk/router/session-router.ts +294 -154
- package/src/sdk/session.ts +440 -167
- package/src/sdk/startup-capability.ts +1 -0
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +1292 -180
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +342 -28
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +1268 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +1028 -109
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +11 -5
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
package/src/tools/bash.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
|
|
3
4
|
import type { Component } from "@gajae-code/tui";
|
|
4
5
|
import { getKeybindings, ImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui";
|
|
@@ -17,8 +18,13 @@ import { InternalUrlRouter } from "../internal-urls";
|
|
|
17
18
|
import { truncateToVisualLines } from "../modes/components/visual-truncate";
|
|
18
19
|
import { highlightCode, type Theme } from "../modes/theme/theme";
|
|
19
20
|
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
|
|
21
|
+
import { renderSpawnTable, runSdkSpawn, type SdkSpawnArgs } from "../sdk/cli/master-cli";
|
|
20
22
|
import type { ArtifactManager } from "../session/artifacts";
|
|
21
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
ClientBridgeTerminalExitStatus,
|
|
25
|
+
ClientBridgeTerminalHandle,
|
|
26
|
+
ClientBridgeTerminalOutput,
|
|
27
|
+
} from "../session/client-bridge";
|
|
22
28
|
import {
|
|
23
29
|
DEFAULT_ARTIFACT_MAX_BYTES,
|
|
24
30
|
OutputSink,
|
|
@@ -62,6 +68,20 @@ import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
|
|
|
62
68
|
import { checkTmuxSelfInjection } from "./tmux-self-injection-guard";
|
|
63
69
|
import { ToolAbortError, ToolError } from "./tool-errors";
|
|
64
70
|
import { toolResult } from "./tool-result";
|
|
71
|
+
|
|
72
|
+
function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
|
|
73
|
+
if (offsetBytes <= 0) return text;
|
|
74
|
+
let consumed = 0;
|
|
75
|
+
let index = 0;
|
|
76
|
+
while (index < text.length && consumed < offsetBytes) {
|
|
77
|
+
const codePoint = text.codePointAt(index);
|
|
78
|
+
if (codePoint === undefined) break;
|
|
79
|
+
consumed += Buffer.byteLength(String.fromCodePoint(codePoint), "utf8");
|
|
80
|
+
index += codePoint > 0xffff ? 2 : 1;
|
|
81
|
+
}
|
|
82
|
+
return text.slice(index);
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
|
|
66
86
|
|
|
67
87
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
@@ -69,7 +89,9 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
|
69
89
|
const BASH_ERROR_MAX_BYTES = 4096;
|
|
70
90
|
const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
|
|
71
91
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
92
|
+
const MASTER_CAPABILITY_ENV = "GJC_MASTER_CAPABILITY";
|
|
72
93
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
94
|
+
const ACP_RELEASE_TIMEOUT_MS = 1_000;
|
|
73
95
|
const READ_ONLY_BASH_ENV: Record<string, string> = {
|
|
74
96
|
GREP_OPTIONS: "",
|
|
75
97
|
GREP_COLOR: "",
|
|
@@ -151,6 +173,27 @@ function artifactReferenceIsReachable(text: string, result: BashResult | BashInt
|
|
|
151
173
|
);
|
|
152
174
|
}
|
|
153
175
|
|
|
176
|
+
function suffixPrefixOverlap(source: string, target: string): number {
|
|
177
|
+
if (source.length === 0 || target.length === 0) return 0;
|
|
178
|
+
const prefix = new Uint32Array(target.length);
|
|
179
|
+
for (let index = 1; index < target.length; index += 1) {
|
|
180
|
+
let length = prefix[index - 1] ?? 0;
|
|
181
|
+
const code = target.charCodeAt(index);
|
|
182
|
+
while (length > 0 && code !== target.charCodeAt(length)) length = prefix[length - 1] ?? 0;
|
|
183
|
+
if (code === target.charCodeAt(length)) length += 1;
|
|
184
|
+
prefix[index] = length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let overlap = 0;
|
|
188
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
189
|
+
const code = source.charCodeAt(index);
|
|
190
|
+
while (overlap > 0 && code !== target.charCodeAt(overlap)) overlap = prefix[overlap - 1] ?? 0;
|
|
191
|
+
if (code === target.charCodeAt(overlap)) overlap += 1;
|
|
192
|
+
if (overlap === target.length && index < source.length - 1) overlap = prefix[overlap - 1] ?? 0;
|
|
193
|
+
}
|
|
194
|
+
return overlap;
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
|
|
155
198
|
const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
|
|
156
199
|
return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
|
|
@@ -291,6 +334,48 @@ function formatClientTerminalAbortFailure(
|
|
|
291
334
|
return formatBashFailureMessage(result, outputText, "Command aborted");
|
|
292
335
|
}
|
|
293
336
|
|
|
337
|
+
function formatClientTerminalReadFailure(
|
|
338
|
+
prepared: PreparedClientTerminalOutput,
|
|
339
|
+
readDiagnostic: string,
|
|
340
|
+
pendingNotices: readonly string[] = [],
|
|
341
|
+
): string {
|
|
342
|
+
const notices = [
|
|
343
|
+
...pendingNotices,
|
|
344
|
+
...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
|
|
345
|
+
...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
|
|
346
|
+
`Terminal output recovery failed: ${readDiagnostic}`,
|
|
347
|
+
];
|
|
348
|
+
const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
|
|
349
|
+
const result: BashResult = {
|
|
350
|
+
...prepared.summary,
|
|
351
|
+
exitCode: undefined,
|
|
352
|
+
cancelled: false,
|
|
353
|
+
};
|
|
354
|
+
return formatBashFailureMessage(result, outputText, "Terminal output read failed");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatClientTerminalWaitFailure(
|
|
358
|
+
prepared: PreparedClientTerminalOutput,
|
|
359
|
+
waitDiagnostic: string,
|
|
360
|
+
readDiagnostic?: string,
|
|
361
|
+
pendingNotices: readonly string[] = [],
|
|
362
|
+
): string {
|
|
363
|
+
const notices = [
|
|
364
|
+
...pendingNotices,
|
|
365
|
+
...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
|
|
366
|
+
...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
|
|
367
|
+
`Terminal wait failed: ${waitDiagnostic}`,
|
|
368
|
+
...(readDiagnostic ? [`Terminal output recovery failed: ${readDiagnostic}`] : []),
|
|
369
|
+
];
|
|
370
|
+
const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
|
|
371
|
+
const result: BashResult = {
|
|
372
|
+
...prepared.summary,
|
|
373
|
+
exitCode: undefined,
|
|
374
|
+
cancelled: false,
|
|
375
|
+
};
|
|
376
|
+
return formatBashFailureMessage(result, outputText, "Terminal wait failed");
|
|
377
|
+
}
|
|
378
|
+
|
|
294
379
|
function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
|
|
295
380
|
const suffixParts: string[] = [];
|
|
296
381
|
const reference = artifactReferenceForResult(result);
|
|
@@ -468,6 +553,21 @@ interface ManagedBashJobHandle {
|
|
|
468
553
|
setBackgrounded: (backgrounded: boolean) => void;
|
|
469
554
|
}
|
|
470
555
|
|
|
556
|
+
/** The placeholder a folded PTY hands back to its (now finished) foreground call. */
|
|
557
|
+
function interactiveResultFromText(text: string): BashInteractiveResult {
|
|
558
|
+
return {
|
|
559
|
+
exitCode: undefined,
|
|
560
|
+
cancelled: false,
|
|
561
|
+
timedOut: false,
|
|
562
|
+
output: text,
|
|
563
|
+
outputBytes: 0,
|
|
564
|
+
outputLines: 0,
|
|
565
|
+
totalBytes: 0,
|
|
566
|
+
totalLines: 0,
|
|
567
|
+
truncated: false,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
471
571
|
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
|
|
472
572
|
return result.output || "";
|
|
473
573
|
}
|
|
@@ -504,6 +604,107 @@ function normalizeBashEnv(env: Record<string, string> | undefined): Record<strin
|
|
|
504
604
|
return normalized;
|
|
505
605
|
}
|
|
506
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Return true only for a direct, shell-syntax-free `gjc sdk spawn` command.
|
|
609
|
+
*
|
|
610
|
+
* Master authority is process-local and must not become ambient state for an
|
|
611
|
+
* arbitrary shell pipeline. Keep a tiny lexer here instead of a prefix check:
|
|
612
|
+
* shell quoting is allowed for ordinary argument values, while operators,
|
|
613
|
+
* expansions, escapes, globbing, and line breaks all refuse the privileged
|
|
614
|
+
* environment injection path.
|
|
615
|
+
*/
|
|
616
|
+
function strictDirectSdkSpawnTokens(command: string): string[] | undefined {
|
|
617
|
+
if (command.length === 0) return undefined;
|
|
618
|
+
const tokens: string[] = [];
|
|
619
|
+
let token = "";
|
|
620
|
+
let tokenStarted = false;
|
|
621
|
+
let quote: "'" | '"' | undefined;
|
|
622
|
+
for (const character of command) {
|
|
623
|
+
if (quote !== undefined) {
|
|
624
|
+
if (character === quote) {
|
|
625
|
+
quote = undefined;
|
|
626
|
+
tokenStarted = true;
|
|
627
|
+
} else {
|
|
628
|
+
token += character;
|
|
629
|
+
tokenStarted = true;
|
|
630
|
+
}
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (";&|<>()$`\\*?[]{}#!\n\r".includes(character)) return undefined;
|
|
634
|
+
if (character === "'" || character === '"') {
|
|
635
|
+
quote = character;
|
|
636
|
+
tokenStarted = true;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (/\s/u.test(character)) {
|
|
640
|
+
if (tokenStarted) {
|
|
641
|
+
tokens.push(token);
|
|
642
|
+
token = "";
|
|
643
|
+
tokenStarted = false;
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
token += character;
|
|
648
|
+
tokenStarted = true;
|
|
649
|
+
}
|
|
650
|
+
if (quote !== undefined) return undefined;
|
|
651
|
+
if (tokenStarted) tokens.push(token);
|
|
652
|
+
return tokens.length >= 3 && tokens[0] === "gjc" && tokens[1] === "sdk" && tokens[2] === "spawn"
|
|
653
|
+
? tokens
|
|
654
|
+
: undefined;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function isStrictDirectSdkSpawnCommand(command: string): boolean {
|
|
658
|
+
return strictDirectSdkSpawnTokens(command) !== undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function parseDirectSdkSpawnArgs(command: string): SdkSpawnArgs {
|
|
662
|
+
const tokens = strictDirectSdkSpawnTokens(command);
|
|
663
|
+
if (tokens === undefined) throw new ToolError("Master spawn command is not a direct shell-safe invocation.");
|
|
664
|
+
const args: SdkSpawnArgs = {};
|
|
665
|
+
for (let index = 3; index < tokens.length; index++) {
|
|
666
|
+
const token = tokens[index]!;
|
|
667
|
+
if (token === "--json") {
|
|
668
|
+
args.json = true;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
const equals = token.indexOf("=");
|
|
672
|
+
const name = equals < 0 ? token : token.slice(0, equals);
|
|
673
|
+
const value = equals < 0 ? tokens[++index] : token.slice(equals + 1);
|
|
674
|
+
if (value === undefined || value.length === 0) throw new ToolError(`Master spawn flag ${name} requires a value.`);
|
|
675
|
+
switch (name) {
|
|
676
|
+
case "--cwd":
|
|
677
|
+
args.cwd = value;
|
|
678
|
+
break;
|
|
679
|
+
case "--prompt":
|
|
680
|
+
args.prompt = value;
|
|
681
|
+
break;
|
|
682
|
+
case "--model":
|
|
683
|
+
args.model = value;
|
|
684
|
+
break;
|
|
685
|
+
case "--profile":
|
|
686
|
+
args.profile = value;
|
|
687
|
+
break;
|
|
688
|
+
case "--idempotency-key":
|
|
689
|
+
args.idempotencyKey = value;
|
|
690
|
+
break;
|
|
691
|
+
case "--agent-dir":
|
|
692
|
+
throw new ToolError("Master spawn cannot override the session agent directory.");
|
|
693
|
+
default:
|
|
694
|
+
throw new ToolError(`Unsupported master spawn flag: ${name}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return args;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function masterCommandEnvOverrides(
|
|
701
|
+
env: Record<string, string> | undefined,
|
|
702
|
+
directMasterSpawn: boolean,
|
|
703
|
+
): Record<string, string> {
|
|
704
|
+
if (directMasterSpawn) return {};
|
|
705
|
+
return Object.fromEntries(Object.entries(env ?? {}).filter(([key]) => key !== MASTER_CAPABILITY_ENV));
|
|
706
|
+
}
|
|
707
|
+
|
|
507
708
|
function escapeBashEnvValueForDisplay(value: string): string {
|
|
508
709
|
return value
|
|
509
710
|
.replaceAll("\\", "\\\\")
|
|
@@ -648,6 +849,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
648
849
|
this.description = prompt.render(bashDescription, {
|
|
649
850
|
asyncEnabled: this.#asyncEnabled,
|
|
650
851
|
autoBackgroundEnabled: this.#autoBackgroundEnabled,
|
|
852
|
+
foregroundFoldEnabled: this.session.registerForegroundFoldParticipant !== undefined,
|
|
651
853
|
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
|
|
652
854
|
hasAstGrep: this.session.settings.get("astGrep.enabled"),
|
|
653
855
|
hasAstEdit: this.session.settings.get("astEdit.enabled"),
|
|
@@ -732,12 +934,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
732
934
|
label: string,
|
|
733
935
|
previewText: string,
|
|
734
936
|
timeoutSec: number,
|
|
735
|
-
options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
|
|
937
|
+
options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
|
|
736
938
|
): AgentToolResult<BashToolDetails> {
|
|
737
939
|
const details: BashToolDetails = {
|
|
738
940
|
timeoutSeconds: timeoutSec,
|
|
739
941
|
async: { state: "running", jobId, type: "bash" },
|
|
740
942
|
};
|
|
943
|
+
// A folded client terminal keeps its remote handle, so the editor's live
|
|
944
|
+
// terminal card stays bound to the same id after the fold.
|
|
945
|
+
if (options.terminalId !== undefined) details.terminalId = options.terminalId;
|
|
741
946
|
if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
|
|
742
947
|
details.requestedTimeoutSeconds = options.requestedTimeoutSec;
|
|
743
948
|
}
|
|
@@ -773,8 +978,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
773
978
|
notices?: readonly string[];
|
|
774
979
|
|
|
775
980
|
resolvedEnv?: Record<string, string>;
|
|
981
|
+
directMasterSpawn: boolean;
|
|
776
982
|
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
|
|
777
983
|
startBackgrounded: boolean;
|
|
984
|
+
onCompletion?: () => void;
|
|
778
985
|
/** Immutable attempt-scoped tool call id, when executed via a tool call. */
|
|
779
986
|
toolCallId?: string;
|
|
780
987
|
}): ManagedBashJobHandle {
|
|
@@ -783,7 +990,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
783
990
|
throw new ToolError("Background job manager unavailable for this session.");
|
|
784
991
|
}
|
|
785
992
|
|
|
786
|
-
const label = options.
|
|
993
|
+
const label = options.directMasterSpawn
|
|
994
|
+
? "gjc sdk spawn"
|
|
995
|
+
: options.command.length > 120
|
|
996
|
+
? `${options.command.slice(0, 117)}...`
|
|
997
|
+
: options.command;
|
|
787
998
|
let latestText = "";
|
|
788
999
|
let backgrounded = options.startBackgrounded;
|
|
789
1000
|
const runningDetails = (jobId: string): Record<string, unknown> | undefined =>
|
|
@@ -806,37 +1017,37 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
806
1017
|
|
|
807
1018
|
let executionResult: BashResult | BashInteractiveResult | undefined;
|
|
808
1019
|
try {
|
|
809
|
-
const result =
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1020
|
+
const result = options.directMasterSpawn
|
|
1021
|
+
? await this.#runDirectMasterSpawn(
|
|
1022
|
+
options.command,
|
|
1023
|
+
options.commandCwd,
|
|
1024
|
+
options.timeoutMs,
|
|
1025
|
+
runSignal,
|
|
1026
|
+
options.resolvedEnv,
|
|
1027
|
+
)
|
|
1028
|
+
: await executeBash(options.command, {
|
|
1029
|
+
cwd: options.commandCwd,
|
|
1030
|
+
settings: this.session.settings,
|
|
1031
|
+
sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
|
|
1032
|
+
timeout: options.timeoutMs,
|
|
1033
|
+
signal: runSignal,
|
|
1034
|
+
env: options.resolvedEnv,
|
|
1035
|
+
artifactPath,
|
|
1036
|
+
artifactId,
|
|
1037
|
+
artifactPublisher,
|
|
1038
|
+
spillThreshold,
|
|
1039
|
+
headBytes,
|
|
1040
|
+
oneShot: true,
|
|
1041
|
+
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
1042
|
+
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
1043
|
+
onChunk: chunk => {
|
|
1044
|
+
tailBuffer.append(chunk);
|
|
1045
|
+
latestText = tailBuffer.text();
|
|
1046
|
+
void reportProgress(latestText, runningDetails(jobId));
|
|
1047
|
+
},
|
|
1048
|
+
onRawChunk: chunk => manager.appendOutput(jobId, chunk),
|
|
1049
|
+
onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
|
|
1050
|
+
});
|
|
840
1051
|
executionResult = result;
|
|
841
1052
|
const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
|
|
842
1053
|
requestedTimeoutSec: options.requestedTimeoutSec,
|
|
@@ -845,12 +1056,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
845
1056
|
});
|
|
846
1057
|
const finalText = this.#extractTextResult(finalResult);
|
|
847
1058
|
latestText = finalText;
|
|
1059
|
+
options.onCompletion?.();
|
|
848
1060
|
completion.resolve({ kind: "completed", result: finalResult });
|
|
849
1061
|
await reportProgress(finalText, completedDetails(jobId));
|
|
850
1062
|
return finalText;
|
|
851
1063
|
} catch (error) {
|
|
852
1064
|
const message = error instanceof Error ? error.message : String(error);
|
|
853
1065
|
latestText = message;
|
|
1066
|
+
options.onCompletion?.();
|
|
854
1067
|
completion.resolve({ kind: "failed", error, result: executionResult });
|
|
855
1068
|
await reportProgress(message, failedDetails(jobId));
|
|
856
1069
|
throw error;
|
|
@@ -867,6 +1080,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
867
1080
|
},
|
|
868
1081
|
},
|
|
869
1082
|
);
|
|
1083
|
+
const jobGeneration = manager.getJob(jobId)?.generation ?? jobId;
|
|
1084
|
+
const unregisterOwnerCleanup = manager.registerOwnerCleanup(this.session.getAgentId?.() ?? "0-Main", () => {
|
|
1085
|
+
manager.failNow(jobId, jobGeneration, "Bash job owner was torn down.");
|
|
1086
|
+
});
|
|
1087
|
+
void completion.promise.finally(unregisterOwnerCleanup);
|
|
870
1088
|
registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
|
|
871
1089
|
|
|
872
1090
|
return {
|
|
@@ -893,9 +1111,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
893
1111
|
*/
|
|
894
1112
|
#resolveOwnedJobManager(): AsyncJobManager | undefined {
|
|
895
1113
|
const endpointId = this.session.getSessionId?.() ?? undefined;
|
|
896
|
-
return (
|
|
897
|
-
|
|
898
|
-
);
|
|
1114
|
+
if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
|
|
1115
|
+
return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
|
|
899
1116
|
}
|
|
900
1117
|
|
|
901
1118
|
async #waitForManagedBashJob(
|
|
@@ -953,6 +1170,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
953
1170
|
command: string;
|
|
954
1171
|
commandCwd: string;
|
|
955
1172
|
resolvedEnv: Record<string, string>;
|
|
1173
|
+
directMasterSpawn: boolean;
|
|
956
1174
|
requestedTimeoutSec: number;
|
|
957
1175
|
timeoutSec: number;
|
|
958
1176
|
timeoutMs: number;
|
|
@@ -1098,14 +1316,26 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1098
1316
|
// silently ignore the caller's override.
|
|
1099
1317
|
const explicitAgentDirOverride =
|
|
1100
1318
|
expandedEnv?.GJC_CODING_AGENT_DIR !== undefined || expandedEnv?.PI_CODING_AGENT_DIR !== undefined;
|
|
1319
|
+
// The master capability is a one-shot authority for the direct spawn CLI,
|
|
1320
|
+
// never ambient state for ordinary Bash or a chained/pipelined descendant.
|
|
1321
|
+
// Check both spellings so cwd normalization or URL expansion cannot turn a
|
|
1322
|
+
// command that contained shell syntax into a privileged one.
|
|
1323
|
+
const directMasterSpawn = isStrictDirectSdkSpawnCommand(rawCommand) && isStrictDirectSdkSpawnCommand(command);
|
|
1324
|
+
const masterCapability = directMasterSpawn ? this.session.getMasterBashCapability?.() : undefined;
|
|
1325
|
+
const masterOwnerSessionId = directMasterSpawn ? this.session.getMasterOwnerSessionId?.() : undefined;
|
|
1326
|
+
const commandEnvOverrides = masterCommandEnvOverrides(expandedEnv, directMasterSpawn);
|
|
1101
1327
|
const resolvedEnv = {
|
|
1102
1328
|
...buildGjcRuntimeSessionEnv({
|
|
1103
1329
|
sessionFile: null,
|
|
1104
1330
|
sessionId: this.session.getSessionId?.(),
|
|
1105
1331
|
cwd: this.session.cwd,
|
|
1106
1332
|
}),
|
|
1107
|
-
...(sessionAgentDir && !explicitAgentDirOverride
|
|
1108
|
-
|
|
1333
|
+
...(sessionAgentDir && (!explicitAgentDirOverride || directMasterSpawn)
|
|
1334
|
+
? { GJC_CODING_AGENT_DIR: sessionAgentDir }
|
|
1335
|
+
: {}),
|
|
1336
|
+
...commandEnvOverrides,
|
|
1337
|
+
...(masterCapability ? { [MASTER_CAPABILITY_ENV]: masterCapability } : {}),
|
|
1338
|
+
...(masterOwnerSessionId ? { GJC_MASTER_OWNER_SESSION_ID: masterOwnerSessionId } : {}),
|
|
1109
1339
|
...(this.session.bashRestrictionProfile === "read-only" ? READ_ONLY_BASH_ENV : {}),
|
|
1110
1340
|
...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),
|
|
1111
1341
|
};
|
|
@@ -1137,7 +1367,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1137
1367
|
const sleepAdvisory = longSleepAdvisory(command, timeoutSec);
|
|
1138
1368
|
if (sleepAdvisory) notices.push(sleepAdvisory);
|
|
1139
1369
|
|
|
1140
|
-
return {
|
|
1370
|
+
return {
|
|
1371
|
+
command,
|
|
1372
|
+
commandCwd,
|
|
1373
|
+
resolvedEnv,
|
|
1374
|
+
directMasterSpawn,
|
|
1375
|
+
requestedTimeoutSec,
|
|
1376
|
+
timeoutSec,
|
|
1377
|
+
timeoutMs,
|
|
1378
|
+
notices,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async #runDirectMasterSpawn(
|
|
1383
|
+
command: string,
|
|
1384
|
+
commandCwd: string,
|
|
1385
|
+
timeoutMs: number,
|
|
1386
|
+
signal: AbortSignal | undefined,
|
|
1387
|
+
resolvedEnv: Record<string, string> | undefined,
|
|
1388
|
+
): Promise<BashResult> {
|
|
1389
|
+
const masterCapability = resolvedEnv?.[MASTER_CAPABILITY_ENV];
|
|
1390
|
+
const sessionAgentDir = resolvedEnv?.GJC_CODING_AGENT_DIR;
|
|
1391
|
+
if (masterCapability === undefined || sessionAgentDir === undefined)
|
|
1392
|
+
throw new ToolError("Master spawn authority context is unavailable.");
|
|
1393
|
+
const args = parseDirectSdkSpawnArgs(command);
|
|
1394
|
+
args.agentDir = sessionAgentDir;
|
|
1395
|
+
if (args.cwd !== undefined && !path.isAbsolute(args.cwd)) args.cwd = path.resolve(commandCwd, args.cwd);
|
|
1396
|
+
const request = runSdkSpawn(args, {
|
|
1397
|
+
env: {
|
|
1398
|
+
...process.env,
|
|
1399
|
+
GJC_MASTER_CAPABILITY: masterCapability,
|
|
1400
|
+
GJC_SESSION_ID: resolvedEnv?.GJC_MASTER_OWNER_SESSION_ID ?? this.session.getSessionId?.() ?? undefined,
|
|
1401
|
+
GJC_CODING_AGENT_DIR: sessionAgentDir,
|
|
1402
|
+
},
|
|
1403
|
+
});
|
|
1404
|
+
const timeout = Bun.sleep(timeoutMs).then(() => {
|
|
1405
|
+
throw new ToolError(`Command timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
|
|
1406
|
+
});
|
|
1407
|
+
const aborted = Promise.withResolvers<never>();
|
|
1408
|
+
const onAbort = (): void => aborted.reject(new ToolAbortError("Command aborted"));
|
|
1409
|
+
if (signal?.aborted) onAbort();
|
|
1410
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
1411
|
+
let result: Awaited<typeof request>;
|
|
1412
|
+
try {
|
|
1413
|
+
result = await Promise.race([request, timeout, aborted.promise]);
|
|
1414
|
+
} finally {
|
|
1415
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1416
|
+
}
|
|
1417
|
+
const output = args.json ? JSON.stringify(result.rendered) : renderSpawnTable(result.rendered);
|
|
1418
|
+
const outputBytes = Buffer.byteLength(output, "utf8");
|
|
1419
|
+
const outputLines = output.split("\n").length;
|
|
1420
|
+
return {
|
|
1421
|
+
output,
|
|
1422
|
+
exitCode: result.exitCode,
|
|
1423
|
+
cancelled: false,
|
|
1424
|
+
truncated: false,
|
|
1425
|
+
totalLines: outputLines,
|
|
1426
|
+
totalBytes: outputBytes,
|
|
1427
|
+
outputLines,
|
|
1428
|
+
outputBytes,
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async #executeDirectMasterSpawn(
|
|
1433
|
+
command: string,
|
|
1434
|
+
commandCwd: string,
|
|
1435
|
+
timeoutSec: number,
|
|
1436
|
+
timeoutMs: number,
|
|
1437
|
+
requestedTimeoutSec: number,
|
|
1438
|
+
notices: readonly string[],
|
|
1439
|
+
signal: AbortSignal | undefined,
|
|
1440
|
+
resolvedEnv: Record<string, string>,
|
|
1441
|
+
): Promise<AgentToolResult<BashToolDetails>> {
|
|
1442
|
+
const result = await this.#runDirectMasterSpawn(command, commandCwd, timeoutMs, signal, resolvedEnv);
|
|
1443
|
+
return this.#buildCompletedResult(result, timeoutSec, { requestedTimeoutSec, notices });
|
|
1141
1444
|
}
|
|
1142
1445
|
|
|
1143
1446
|
/**
|
|
@@ -1165,6 +1468,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1165
1468
|
if (!manager) {
|
|
1166
1469
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
1167
1470
|
}
|
|
1471
|
+
if (!manager.hasCapacity()) {
|
|
1472
|
+
throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
|
|
1473
|
+
}
|
|
1168
1474
|
const prepared = await this.#prepareBashExecution(input, opts.ctx);
|
|
1169
1475
|
const label =
|
|
1170
1476
|
opts.label ?? (prepared.command.length > 120 ? `${prepared.command.slice(0, 117)}...` : prepared.command);
|
|
@@ -1232,8 +1538,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1232
1538
|
spillThreshold,
|
|
1233
1539
|
headBytes,
|
|
1234
1540
|
oneShot: true,
|
|
1235
|
-
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
1236
|
-
disableShellSnapshot:
|
|
1541
|
+
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
|
|
1542
|
+
disableShellSnapshot:
|
|
1543
|
+
this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
|
|
1237
1544
|
onChunk: chunk => {
|
|
1238
1545
|
tailBuffer.append(chunk);
|
|
1239
1546
|
void reportProgress(tailBuffer.text(), {
|
|
@@ -1289,6 +1596,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1289
1596
|
if (this.session.bashRestrictionProfile === "read-only" && pty) {
|
|
1290
1597
|
throw new ToolError("Read-only bash does not allow PTY mode.");
|
|
1291
1598
|
}
|
|
1599
|
+
const admissionManager = this.#resolveOwnedJobManager();
|
|
1600
|
+
if (asyncRequested && !admissionManager) {
|
|
1601
|
+
throw new ToolError("Async job manager unavailable for this session.");
|
|
1602
|
+
}
|
|
1603
|
+
if (admissionManager && !admissionManager.hasCapacity()) {
|
|
1604
|
+
throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
|
|
1605
|
+
}
|
|
1292
1606
|
|
|
1293
1607
|
const prepared = await this.#prepareBashExecution(
|
|
1294
1608
|
{ command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
|
|
@@ -1298,18 +1612,33 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1298
1612
|
command,
|
|
1299
1613
|
commandCwd,
|
|
1300
1614
|
resolvedEnv,
|
|
1615
|
+
directMasterSpawn,
|
|
1301
1616
|
requestedTimeoutSec,
|
|
1302
1617
|
timeoutSec,
|
|
1303
1618
|
timeoutMs,
|
|
1304
1619
|
notices: pendingNotices,
|
|
1305
1620
|
} = prepared;
|
|
1621
|
+
const masterCapability = directMasterSpawn ? resolvedEnv[MASTER_CAPABILITY_ENV] : undefined;
|
|
1622
|
+
if (directMasterSpawn && masterCapability !== undefined && !asyncRequested) {
|
|
1623
|
+
return await this.#executeDirectMasterSpawn(
|
|
1624
|
+
command,
|
|
1625
|
+
commandCwd,
|
|
1626
|
+
timeoutSec,
|
|
1627
|
+
timeoutMs,
|
|
1628
|
+
requestedTimeoutSec,
|
|
1629
|
+
pendingNotices,
|
|
1630
|
+
signal,
|
|
1631
|
+
resolvedEnv,
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1306
1634
|
|
|
1307
1635
|
if (asyncRequested) {
|
|
1308
1636
|
// Availability is endpoint-first: a concurrent top-level session
|
|
1309
1637
|
// that was the process-global instance may have been disposed,
|
|
1310
1638
|
// clearing instance() while THIS session's manager stays
|
|
1311
1639
|
// registered by endpoint (review thread P1).
|
|
1312
|
-
|
|
1640
|
+
const asyncManager = admissionManager ?? this.#resolveOwnedJobManager();
|
|
1641
|
+
if (!asyncManager) {
|
|
1313
1642
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
1314
1643
|
}
|
|
1315
1644
|
const job = this.#startManagedBashJob({
|
|
@@ -1321,10 +1650,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1321
1650
|
notices: pendingNotices,
|
|
1322
1651
|
|
|
1323
1652
|
resolvedEnv,
|
|
1653
|
+
directMasterSpawn,
|
|
1324
1654
|
onUpdate,
|
|
1325
1655
|
startBackgrounded: true,
|
|
1326
1656
|
toolCallId,
|
|
1327
1657
|
});
|
|
1658
|
+
const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
|
|
1659
|
+
job.setBackgrounded(true);
|
|
1660
|
+
asyncManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1328
1661
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
|
|
1329
1662
|
requestedTimeoutSec,
|
|
1330
1663
|
notices: pendingNotices,
|
|
@@ -1335,7 +1668,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1335
1668
|
// Skip when pty=true (PTY needs the local terminal UI).
|
|
1336
1669
|
const clientBridge =
|
|
1337
1670
|
this.session.bashRestrictionProfile === "read-only" ? undefined : this.session.getClientBridge?.();
|
|
1338
|
-
const clientTerminalActive = Boolean(
|
|
1671
|
+
const clientTerminalActive = Boolean(
|
|
1672
|
+
!directMasterSpawn && clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty,
|
|
1673
|
+
);
|
|
1339
1674
|
|
|
1340
1675
|
// Run non-PTY bash through the managed job path so Ctrl+B-twice fold-on-demand works
|
|
1341
1676
|
// even when auto-background is disabled. When a client terminal will handle the
|
|
@@ -1345,13 +1680,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1345
1680
|
// and unregistering — the process-global instance may belong to a different
|
|
1346
1681
|
// concurrent top-level session (review thread P1).
|
|
1347
1682
|
const ownedManager = this.#resolveOwnedJobManager();
|
|
1348
|
-
|
|
1683
|
+
// The client-terminal path is now itself manager-backed and foldable, so it no
|
|
1684
|
+
// longer has to be bypassed to make folding work. A capable ACP session keeps
|
|
1685
|
+
// its terminal contract and still folds.
|
|
1686
|
+
if (!pty && ownedManager && !clientTerminalActive) {
|
|
1349
1687
|
// With auto-background off, wait past the command's own timeout so the job only
|
|
1350
1688
|
// leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
|
|
1351
1689
|
const autoBackgroundWaitMs = this.#autoBackgroundEnabled
|
|
1352
1690
|
? this.#resolveAutoBackgroundWaitMs(timeoutMs)
|
|
1353
1691
|
: timeoutMs + 1_000;
|
|
1354
1692
|
const startBackgrounded = autoBackgroundWaitMs === 0;
|
|
1693
|
+
let managedForegroundSettled = false;
|
|
1355
1694
|
const job = this.#startManagedBashJob({
|
|
1356
1695
|
command,
|
|
1357
1696
|
commandCwd,
|
|
@@ -1361,20 +1700,62 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1361
1700
|
notices: pendingNotices,
|
|
1362
1701
|
|
|
1363
1702
|
resolvedEnv,
|
|
1703
|
+
directMasterSpawn,
|
|
1364
1704
|
onUpdate,
|
|
1365
1705
|
startBackgrounded,
|
|
1706
|
+
onCompletion: () => {
|
|
1707
|
+
managedForegroundSettled = true;
|
|
1708
|
+
},
|
|
1366
1709
|
toolCallId,
|
|
1367
1710
|
});
|
|
1711
|
+
const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
|
|
1368
1712
|
if (startBackgrounded) {
|
|
1713
|
+
job.setBackgrounded(true);
|
|
1714
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1369
1715
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
|
|
1370
1716
|
requestedTimeoutSec,
|
|
1371
1717
|
notices: pendingNotices,
|
|
1372
1718
|
});
|
|
1373
1719
|
}
|
|
1374
1720
|
const backgroundRequest = Promise.withResolvers<void>();
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1721
|
+
// Exactly one party may settle the foreground caller. detachObserver (the
|
|
1722
|
+
// fold won) and resolveForegroundObserver (a completion was handed back
|
|
1723
|
+
// after a failed fold) share this flag, so a race cannot double-settle.
|
|
1724
|
+
let foregroundSettled = false;
|
|
1725
|
+
const settleForeground = (): "resolved" | "already-settled" => {
|
|
1726
|
+
if (foregroundSettled || managedForegroundSettled) return "already-settled";
|
|
1727
|
+
foregroundSettled = true;
|
|
1728
|
+
return "resolved";
|
|
1729
|
+
};
|
|
1730
|
+
const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
|
|
1731
|
+
kind: "bash-managed",
|
|
1732
|
+
jobId: job.jobId,
|
|
1733
|
+
jobGeneration,
|
|
1734
|
+
label: job.label,
|
|
1735
|
+
cwdSensitive: true,
|
|
1736
|
+
signal,
|
|
1737
|
+
originatingTurn: ctx?.attemptScope !== undefined,
|
|
1738
|
+
outputRef: {
|
|
1739
|
+
jobId: job.jobId,
|
|
1740
|
+
generation: jobGeneration,
|
|
1741
|
+
instruction: `Use the job tool's tail operation for ${job.jobId} to read this command's output.`,
|
|
1742
|
+
},
|
|
1743
|
+
// Bound at registration over the manager that registered THIS job, so
|
|
1744
|
+
// identity can never resolve to another manager's identically-named job.
|
|
1745
|
+
getJob: () => {
|
|
1746
|
+
const current = ownedManager.getJob(job.jobId);
|
|
1747
|
+
return current?.generation === jobGeneration ? current : undefined;
|
|
1748
|
+
},
|
|
1749
|
+
detachObserver: () => {
|
|
1750
|
+
const outcome = settleForeground();
|
|
1751
|
+
if (outcome === "resolved") {
|
|
1752
|
+
job.setBackgrounded(true);
|
|
1753
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1754
|
+
backgroundRequest.resolve();
|
|
1755
|
+
}
|
|
1756
|
+
return outcome;
|
|
1757
|
+
},
|
|
1758
|
+
resolveForegroundObserver: () => settleForeground(),
|
|
1378
1759
|
});
|
|
1379
1760
|
let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
|
|
1380
1761
|
try {
|
|
@@ -1410,6 +1791,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1410
1791
|
throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
|
|
1411
1792
|
}
|
|
1412
1793
|
job.setBackgrounded(true);
|
|
1794
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1413
1795
|
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
|
|
1414
1796
|
requestedTimeoutSec,
|
|
1415
1797
|
notices: pendingNotices,
|
|
@@ -1417,63 +1799,200 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1417
1799
|
}
|
|
1418
1800
|
|
|
1419
1801
|
if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
|
|
1802
|
+
const clientAdmission = ownedManager?.reserveCapacity();
|
|
1420
1803
|
const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
|
|
1421
1804
|
const clientTailBytes = resolveBashOutputSinkTailBytes(this.session.settings);
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1805
|
+
const runBoundedCleanup = async (
|
|
1806
|
+
terminal: ClientBridgeTerminalHandle,
|
|
1807
|
+
operation: () => Promise<void>,
|
|
1808
|
+
label: "kill" | "release",
|
|
1809
|
+
): Promise<void> => {
|
|
1810
|
+
const attempt = Promise.resolve()
|
|
1811
|
+
.then(operation)
|
|
1812
|
+
.catch((error: unknown) => {
|
|
1813
|
+
logger.warn(`ACP terminal ${label} failed`, { terminalId: terminal.terminalId, error });
|
|
1814
|
+
});
|
|
1815
|
+
const completed = await Promise.race([
|
|
1816
|
+
attempt.then(() => true),
|
|
1817
|
+
Bun.sleep(ACP_RELEASE_TIMEOUT_MS).then(() => false),
|
|
1818
|
+
]);
|
|
1819
|
+
if (!completed) logger.warn(`ACP terminal ${label} timed out`, { terminalId: terminal.terminalId });
|
|
1820
|
+
};
|
|
1821
|
+
const createPromise = Promise.resolve().then(() =>
|
|
1822
|
+
clientBridge.createTerminal!({
|
|
1823
|
+
command,
|
|
1824
|
+
cwd: commandCwd,
|
|
1825
|
+
env: resolvedEnv
|
|
1826
|
+
? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
|
|
1827
|
+
: undefined,
|
|
1828
|
+
outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
|
|
1829
|
+
}),
|
|
1830
|
+
);
|
|
1831
|
+
const timeout = Promise.withResolvers<{ kind: "timeout" }>();
|
|
1832
|
+
const abort = Promise.withResolvers<{ kind: "aborted" }>();
|
|
1833
|
+
const onCreateAbort = (): void => abort.resolve({ kind: "aborted" });
|
|
1834
|
+
if (signal?.aborted) onCreateAbort();
|
|
1835
|
+
else signal?.addEventListener("abort", onCreateAbort, { once: true });
|
|
1836
|
+
const createTimer = setTimeout(() => timeout.resolve({ kind: "timeout" }), timeoutMs);
|
|
1837
|
+
let createOutcome:
|
|
1838
|
+
| { kind: "created"; handle: ClientBridgeTerminalHandle }
|
|
1440
1839
|
| { kind: "timeout" }
|
|
1441
1840
|
| { kind: "aborted" };
|
|
1841
|
+
try {
|
|
1842
|
+
createOutcome = await Promise.race([
|
|
1843
|
+
createPromise.then(handle => ({ kind: "created" as const, handle })),
|
|
1844
|
+
timeout.promise,
|
|
1845
|
+
abort.promise,
|
|
1846
|
+
]);
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
1849
|
+
throw error;
|
|
1850
|
+
} finally {
|
|
1851
|
+
clearTimeout(createTimer);
|
|
1852
|
+
signal?.removeEventListener("abort", onCreateAbort);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (createOutcome.kind !== "created") {
|
|
1856
|
+
void createPromise
|
|
1857
|
+
.then(async lateHandle => {
|
|
1858
|
+
await runBoundedCleanup(lateHandle, () => lateHandle.kill(), "kill");
|
|
1859
|
+
await runBoundedCleanup(lateHandle, () => lateHandle.release(), "release");
|
|
1860
|
+
})
|
|
1861
|
+
.catch((error: unknown) => {
|
|
1862
|
+
logger.warn("ACP terminal late create cleanup failed", { error });
|
|
1863
|
+
});
|
|
1864
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
1865
|
+
if (createOutcome.kind === "aborted") throw new ToolAbortError("Command aborted");
|
|
1866
|
+
throw new ToolError(`Command timed out after ${timeoutSec} seconds`);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
let handle: ClientBridgeTerminalHandle;
|
|
1870
|
+
handle = createOutcome.handle;
|
|
1442
1871
|
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1872
|
+
// The remote terminal is released exactly once, by whichever path settles
|
|
1873
|
+
// it: a foreground return, a folded background completion, or a failure.
|
|
1874
|
+
let released = false;
|
|
1875
|
+
const releaseTerminalOnce = async (): Promise<void> => {
|
|
1876
|
+
if (released) return;
|
|
1877
|
+
released = true;
|
|
1878
|
+
await runBoundedCleanup(handle, () => handle.release(), "release");
|
|
1879
|
+
};
|
|
1448
1880
|
let killPromise: Promise<void> | undefined;
|
|
1449
1881
|
const fireKill = (): Promise<void> => {
|
|
1450
1882
|
if (killPromise) return killPromise;
|
|
1451
|
-
|
|
1883
|
+
try {
|
|
1884
|
+
killPromise = Promise.resolve(handle.kill()).catch((error: unknown) => {
|
|
1885
|
+
logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
|
|
1886
|
+
});
|
|
1887
|
+
} catch (error) {
|
|
1452
1888
|
logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
|
|
1453
|
-
|
|
1889
|
+
killPromise = Promise.resolve();
|
|
1890
|
+
}
|
|
1454
1891
|
return killPromise;
|
|
1455
1892
|
};
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
void fireKill();
|
|
1893
|
+
const boundedKill = async (): Promise<void> => {
|
|
1894
|
+
await runBoundedCleanup(handle, fireKill, "kill");
|
|
1459
1895
|
};
|
|
1460
|
-
signal?.addEventListener("abort", onAbortSignal, { once: true });
|
|
1461
1896
|
|
|
1897
|
+
// Emit partial update so the editor can embed the live terminal card.
|
|
1462
1898
|
try {
|
|
1899
|
+
onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
await boundedKill();
|
|
1902
|
+
await releaseTerminalOnce();
|
|
1903
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
1904
|
+
throw error;
|
|
1905
|
+
}
|
|
1906
|
+
let latestText = "";
|
|
1907
|
+
let bridgeJobId!: string;
|
|
1908
|
+
let bridgeBackgrounded = false;
|
|
1909
|
+
let retainedAcpSnapshot = "";
|
|
1910
|
+
let retainedAcpTruncated = false;
|
|
1911
|
+
const ACP_RAW_OVERLAP_BYTES = 512 * 1024;
|
|
1912
|
+
const appendAcpSnapshot = (snapshot: string, upstreamTruncated = false): void => {
|
|
1913
|
+
retainedAcpTruncated ||= upstreamTruncated;
|
|
1914
|
+
if (!snapshot) return;
|
|
1915
|
+
const snapshotBytes = Buffer.byteLength(snapshot, "utf8");
|
|
1916
|
+
const retainedBytes = Buffer.byteLength(retainedAcpSnapshot, "utf8");
|
|
1917
|
+
const boundedSnapshot =
|
|
1918
|
+
snapshotBytes > ACP_RAW_OVERLAP_BYTES
|
|
1919
|
+
? sliceTextAfterUtf8ByteOffset(snapshot, snapshotBytes - ACP_RAW_OVERLAP_BYTES)
|
|
1920
|
+
: snapshot;
|
|
1921
|
+
const boundedRetained =
|
|
1922
|
+
retainedBytes > ACP_RAW_OVERLAP_BYTES
|
|
1923
|
+
? sliceTextAfterUtf8ByteOffset(retainedAcpSnapshot, retainedBytes - ACP_RAW_OVERLAP_BYTES)
|
|
1924
|
+
: retainedAcpSnapshot;
|
|
1925
|
+
const overlap = suffixPrefixOverlap(boundedRetained, boundedSnapshot);
|
|
1926
|
+
const delta = boundedSnapshot.slice(overlap);
|
|
1927
|
+
if (bridgeJobId && delta) ownedManager?.appendOutput(bridgeJobId, delta);
|
|
1928
|
+
retainedAcpSnapshot = boundedSnapshot;
|
|
1929
|
+
retainedAcpTruncated ||= snapshotBytes > ACP_RAW_OVERLAP_BYTES;
|
|
1930
|
+
};
|
|
1931
|
+
const retainedAcpOutput = (): ClientBridgeTerminalOutput => ({
|
|
1932
|
+
output: retainedAcpSnapshot,
|
|
1933
|
+
truncated: retainedAcpTruncated,
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
const runToCompletion = async (
|
|
1937
|
+
runSignal: AbortSignal | undefined,
|
|
1938
|
+
): Promise<AgentToolResult<BashToolDetails>> => {
|
|
1939
|
+
type BridgeRaceResult =
|
|
1940
|
+
| { kind: "exit"; status: ClientBridgeTerminalExitStatus }
|
|
1941
|
+
| { kind: "wait-failed"; error: unknown }
|
|
1942
|
+
| { kind: "poll" }
|
|
1943
|
+
| { kind: "timeout" }
|
|
1944
|
+
| { kind: "aborted" };
|
|
1945
|
+
const exitPromise = Promise.resolve().then(() => handle.waitForExit());
|
|
1946
|
+
const exitRacePromise: Promise<BridgeRaceResult> = exitPromise.then(
|
|
1947
|
+
status => ({ kind: "exit" as const, status }),
|
|
1948
|
+
error => ({ kind: "wait-failed" as const, error }),
|
|
1949
|
+
);
|
|
1950
|
+
let exitStatus!: ClientBridgeTerminalExitStatus;
|
|
1951
|
+
|
|
1952
|
+
// Set up abort listener before entering the poll loop. The listener
|
|
1953
|
+
// kicks off `handle.kill()` synchronously so a `session/cancel`
|
|
1954
|
+
// arriving mid-poll terminates the remote command immediately,
|
|
1955
|
+
// instead of waiting for the next `currentOutput()` to return.
|
|
1956
|
+
const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
|
|
1957
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
1958
|
+
const readOutput = async (
|
|
1959
|
+
limitMs: number,
|
|
1960
|
+
includeAbort = true,
|
|
1961
|
+
includeExit = false,
|
|
1962
|
+
): Promise<ClientBridgeTerminalOutput | undefined> => {
|
|
1963
|
+
const timeout = Promise.withResolvers<undefined>();
|
|
1964
|
+
const timer = setTimeout(() => timeout.resolve(undefined), Math.max(1, limitMs));
|
|
1965
|
+
return Promise.race([
|
|
1966
|
+
handle.currentOutput(),
|
|
1967
|
+
...(includeAbort ? [abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined)] : []),
|
|
1968
|
+
...(includeExit
|
|
1969
|
+
? [exitRacePromise.then(() => undefined as ClientBridgeTerminalOutput | undefined)]
|
|
1970
|
+
: []),
|
|
1971
|
+
timeout.promise,
|
|
1972
|
+
]).finally(() => clearTimeout(timer));
|
|
1973
|
+
};
|
|
1974
|
+
const onAbortSignal = () => {
|
|
1975
|
+
resolveAborted();
|
|
1976
|
+
void fireKill();
|
|
1977
|
+
};
|
|
1978
|
+
runSignal?.addEventListener("abort", onAbortSignal, { once: true });
|
|
1979
|
+
|
|
1463
1980
|
try {
|
|
1464
|
-
if (
|
|
1465
|
-
await
|
|
1981
|
+
if (runSignal?.aborted) {
|
|
1982
|
+
await boundedKill();
|
|
1466
1983
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1467
1984
|
let readDiagnostic: string | undefined;
|
|
1468
1985
|
try {
|
|
1469
|
-
current = await
|
|
1986
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1470
1987
|
} catch (error) {
|
|
1988
|
+
current = retainedAcpOutput();
|
|
1471
1989
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1472
1990
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1473
1991
|
terminalId: handle.terminalId,
|
|
1474
1992
|
error,
|
|
1475
1993
|
});
|
|
1476
1994
|
}
|
|
1995
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1477
1996
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1478
1997
|
throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
|
|
1479
1998
|
}
|
|
@@ -1482,51 +2001,81 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1482
2001
|
// Poll until the process exits, times out, or the caller aborts.
|
|
1483
2002
|
for (;;) {
|
|
1484
2003
|
const racers: Array<Promise<BridgeRaceResult>> = [
|
|
1485
|
-
|
|
2004
|
+
exitRacePromise,
|
|
1486
2005
|
timeoutPromise,
|
|
1487
2006
|
Bun.sleep(250).then(() => ({ kind: "poll" as const })),
|
|
1488
2007
|
];
|
|
1489
|
-
if (
|
|
2008
|
+
if (runSignal) {
|
|
1490
2009
|
racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
|
|
1491
2010
|
}
|
|
1492
2011
|
const raced = await Promise.race(racers);
|
|
1493
2012
|
|
|
1494
|
-
if (raced.kind === "aborted" ||
|
|
1495
|
-
await
|
|
2013
|
+
if (raced.kind === "aborted" || runSignal?.aborted) {
|
|
2014
|
+
await boundedKill();
|
|
1496
2015
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1497
2016
|
let readDiagnostic: string | undefined;
|
|
1498
2017
|
try {
|
|
1499
|
-
current = await
|
|
2018
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1500
2019
|
} catch (error) {
|
|
2020
|
+
current = retainedAcpOutput();
|
|
1501
2021
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1502
2022
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1503
2023
|
terminalId: handle.terminalId,
|
|
1504
2024
|
error,
|
|
1505
2025
|
});
|
|
1506
2026
|
}
|
|
2027
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1507
2028
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1508
2029
|
throw new ToolAbortError(
|
|
1509
2030
|
formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
|
|
1510
2031
|
);
|
|
1511
2032
|
}
|
|
1512
2033
|
|
|
2034
|
+
if (raced.kind === "wait-failed") {
|
|
2035
|
+
await boundedKill();
|
|
2036
|
+
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
2037
|
+
let readDiagnostic: string | undefined;
|
|
2038
|
+
try {
|
|
2039
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
2040
|
+
} catch (error) {
|
|
2041
|
+
current = retainedAcpOutput();
|
|
2042
|
+
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
2043
|
+
logger.warn("ACP terminal output recovery after wait failure failed", {
|
|
2044
|
+
terminalId: handle.terminalId,
|
|
2045
|
+
error,
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
2049
|
+
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
2050
|
+
throw new ToolError(
|
|
2051
|
+
formatClientTerminalWaitFailure(
|
|
2052
|
+
prepared,
|
|
2053
|
+
boundArtifactSaveDiagnostic(raced.error),
|
|
2054
|
+
readDiagnostic,
|
|
2055
|
+
pendingNotices,
|
|
2056
|
+
),
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1513
2060
|
if (raced.kind === "timeout") {
|
|
1514
2061
|
// Kill before reading final output so a slow `terminal/output`
|
|
1515
2062
|
// RPC cannot let a timed-out command keep running past the
|
|
1516
2063
|
// enforced timeout. The handle stays valid post-kill so the
|
|
1517
2064
|
// buffered output is still readable.
|
|
1518
|
-
await
|
|
2065
|
+
await boundedKill();
|
|
1519
2066
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1520
2067
|
let readDiagnostic: string | undefined;
|
|
1521
2068
|
try {
|
|
1522
|
-
current = await
|
|
2069
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1523
2070
|
} catch (error) {
|
|
2071
|
+
current = retainedAcpOutput();
|
|
1524
2072
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1525
2073
|
logger.warn("ACP terminal final output read failed", {
|
|
1526
2074
|
terminalId: handle.terminalId,
|
|
1527
2075
|
error,
|
|
1528
2076
|
});
|
|
1529
2077
|
}
|
|
2078
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1530
2079
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1531
2080
|
const timeoutNotices = [
|
|
1532
2081
|
...pendingNotices,
|
|
@@ -1555,13 +2104,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1555
2104
|
// Poll tick: push current output so agent-loop transcript stays consistent.
|
|
1556
2105
|
// Race the read against abort so a stuck `terminal/output` RPC does not
|
|
1557
2106
|
// delay cancellation.
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2107
|
+
let pollOutput: ClientBridgeTerminalOutput | undefined;
|
|
2108
|
+
try {
|
|
2109
|
+
pollOutput = await readOutput(Math.max(1, deadlineAt - Date.now()), true, true);
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
await boundedKill();
|
|
2112
|
+
const diagnostic = boundArtifactSaveDiagnostic(error);
|
|
2113
|
+
const recoveredOutput = retainedAcpOutput();
|
|
2114
|
+
appendAcpSnapshot(recoveredOutput.output, recoveredOutput.truncated);
|
|
2115
|
+
const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
|
|
2116
|
+
logger.warn("ACP terminal poll output read failed", {
|
|
2117
|
+
terminalId: handle.terminalId,
|
|
2118
|
+
error,
|
|
2119
|
+
});
|
|
2120
|
+
throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
|
|
2121
|
+
}
|
|
1562
2122
|
if (pollOutput === undefined) {
|
|
1563
2123
|
// Abort fired during the poll-tick read; let the next loop iteration
|
|
1564
|
-
// observe `
|
|
2124
|
+
// observe `runSignal?.aborted` and exit via the abort branch.
|
|
1565
2125
|
continue;
|
|
1566
2126
|
}
|
|
1567
2127
|
const { summary, locallyTruncated } = await boundClientTerminalOutput(
|
|
@@ -1573,40 +2133,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1573
2133
|
pollOutput.truncated || locallyTruncated
|
|
1574
2134
|
? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
|
|
1575
2135
|
: summary.output;
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
2136
|
+
latestText = pollText;
|
|
2137
|
+
appendAcpSnapshot(pollOutput.output, pollOutput.truncated);
|
|
2138
|
+
if (!bridgeBackgrounded) {
|
|
2139
|
+
try {
|
|
2140
|
+
onUpdate?.({
|
|
2141
|
+
content: [{ type: "text", text: pollText }],
|
|
2142
|
+
details: { terminalId: handle.terminalId },
|
|
2143
|
+
});
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
await boundedKill();
|
|
2146
|
+
const diagnostic = boundArtifactSaveDiagnostic(error);
|
|
2147
|
+
const recoveredOutput = retainedAcpOutput();
|
|
2148
|
+
const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
|
|
2149
|
+
throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
1580
2152
|
}
|
|
1581
2153
|
} finally {
|
|
1582
|
-
|
|
2154
|
+
runSignal?.removeEventListener("abort", onAbortSignal);
|
|
1583
2155
|
}
|
|
1584
2156
|
|
|
1585
|
-
if (
|
|
1586
|
-
await
|
|
2157
|
+
if (runSignal?.aborted) {
|
|
2158
|
+
await boundedKill();
|
|
1587
2159
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1588
2160
|
let readDiagnostic: string | undefined;
|
|
1589
2161
|
try {
|
|
1590
|
-
current = await
|
|
2162
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1591
2163
|
} catch (error) {
|
|
2164
|
+
current = retainedAcpOutput();
|
|
1592
2165
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1593
2166
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1594
2167
|
terminalId: handle.terminalId,
|
|
1595
2168
|
error,
|
|
1596
2169
|
});
|
|
1597
2170
|
}
|
|
2171
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1598
2172
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1599
2173
|
throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
|
|
1600
2174
|
}
|
|
1601
2175
|
|
|
1602
2176
|
// Fetch final output; the terminal is released in the outer finally.
|
|
1603
|
-
|
|
2177
|
+
let finalReadDiagnostic: string | undefined;
|
|
2178
|
+
let finalOutput: ClientBridgeTerminalOutput;
|
|
2179
|
+
try {
|
|
2180
|
+
const recovered = await readOutput(1_000, false);
|
|
2181
|
+
if (recovered === undefined) {
|
|
2182
|
+
finalOutput = retainedAcpOutput();
|
|
2183
|
+
finalReadDiagnostic = "terminal/output read timed out";
|
|
2184
|
+
} else {
|
|
2185
|
+
finalOutput = recovered;
|
|
2186
|
+
}
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
finalOutput = retainedAcpOutput();
|
|
2189
|
+
finalReadDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
2190
|
+
logger.warn("ACP terminal final output read failed", {
|
|
2191
|
+
terminalId: handle.terminalId,
|
|
2192
|
+
error,
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
if (runSignal?.aborted) {
|
|
2196
|
+
await boundedKill();
|
|
2197
|
+
appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
|
|
2198
|
+
const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
|
|
2199
|
+
throw new ToolAbortError(
|
|
2200
|
+
formatClientTerminalAbortFailure(prepared, finalReadDiagnostic, pendingNotices),
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
1604
2203
|
|
|
1605
2204
|
// Map exit status: null exitCode with a signal → treat as signal kill (137).
|
|
1606
2205
|
const rawExitCode = exitStatus.exitCode;
|
|
1607
2206
|
const exitCode: number | undefined =
|
|
1608
2207
|
rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
|
|
1609
2208
|
|
|
2209
|
+
appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
|
|
1610
2210
|
const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
|
|
1611
2211
|
|
|
1612
2212
|
const bridgeResult: BashResult = {
|
|
@@ -1619,19 +2219,188 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1619
2219
|
if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
|
|
1620
2220
|
for (const notice of pendingNotices) bridgeNotices.push(notice);
|
|
1621
2221
|
if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
|
|
2222
|
+
if (finalReadDiagnostic) bridgeNotices.push(`Terminal output recovery failed: ${finalReadDiagnostic}`);
|
|
1622
2223
|
|
|
1623
2224
|
return this.#buildCompletedResult(bridgeResult, timeoutSec, {
|
|
1624
2225
|
requestedTimeoutSec,
|
|
1625
2226
|
notices: bridgeNotices,
|
|
1626
2227
|
terminalId: handle.terminalId,
|
|
1627
2228
|
});
|
|
1628
|
-
}
|
|
2229
|
+
};
|
|
2230
|
+
|
|
2231
|
+
// Without a job manager nothing can fold this, so ownership never leaves the
|
|
2232
|
+
// foreground call.
|
|
2233
|
+
if (!ownedManager) {
|
|
1629
2234
|
try {
|
|
1630
|
-
await
|
|
1631
|
-
}
|
|
1632
|
-
|
|
2235
|
+
return await runToCompletion(signal);
|
|
2236
|
+
} finally {
|
|
2237
|
+
await releaseTerminalOnce();
|
|
1633
2238
|
}
|
|
1634
2239
|
}
|
|
2240
|
+
|
|
2241
|
+
// Manager-backed runner: the remote command keeps running after a fold and
|
|
2242
|
+
// its completion is delivered like any other background job. createTerminal
|
|
2243
|
+
// already ran above, so exactly ONE remote handle exists and is retained
|
|
2244
|
+
// across the fold.
|
|
2245
|
+
const bridgeManager = ownedManager;
|
|
2246
|
+
const bridgeEndpointId = this.session.getSessionId?.() ?? "local";
|
|
2247
|
+
const bridgeLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
|
|
2248
|
+
const bridgeCompletion = Promise.withResolvers<ManagedBashJobCompletion>();
|
|
2249
|
+
let bridgeForegroundSettled = false;
|
|
2250
|
+
const settleBridgeForeground = (): "resolved" | "already-settled" => {
|
|
2251
|
+
if (bridgeForegroundSettled) return "already-settled";
|
|
2252
|
+
bridgeForegroundSettled = true;
|
|
2253
|
+
return "resolved";
|
|
2254
|
+
};
|
|
2255
|
+
|
|
2256
|
+
try {
|
|
2257
|
+
bridgeJobId = bridgeManager.register(
|
|
2258
|
+
"bash",
|
|
2259
|
+
bridgeLabel,
|
|
2260
|
+
async ({ jobId, signal: runSignal, reportProgress }) => {
|
|
2261
|
+
try {
|
|
2262
|
+
const result = await runToCompletion(runSignal);
|
|
2263
|
+
const finalText = this.#extractTextResult(result);
|
|
2264
|
+
latestText = finalText;
|
|
2265
|
+
if (!bridgeBackgrounded) settleBridgeForeground();
|
|
2266
|
+
bridgeCompletion.resolve({ kind: "completed", result });
|
|
2267
|
+
await reportProgress(
|
|
2268
|
+
finalText,
|
|
2269
|
+
bridgeBackgrounded ? { async: { state: "completed", jobId, type: "bash" } } : undefined,
|
|
2270
|
+
);
|
|
2271
|
+
return finalText;
|
|
2272
|
+
} catch (error) {
|
|
2273
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2274
|
+
latestText = message;
|
|
2275
|
+
if (!bridgeBackgrounded) settleBridgeForeground();
|
|
2276
|
+
bridgeCompletion.resolve({ kind: "failed", error });
|
|
2277
|
+
await reportProgress(
|
|
2278
|
+
message,
|
|
2279
|
+
bridgeBackgrounded ? { async: { state: "failed", jobId, type: "bash" } } : undefined,
|
|
2280
|
+
);
|
|
2281
|
+
throw error;
|
|
2282
|
+
} finally {
|
|
2283
|
+
await releaseTerminalOnce();
|
|
2284
|
+
}
|
|
2285
|
+
},
|
|
2286
|
+
{
|
|
2287
|
+
ownerId: this.session.getAgentId?.() ?? undefined,
|
|
2288
|
+
admissionToken: clientAdmission,
|
|
2289
|
+
onProgress: async (text: string) => {
|
|
2290
|
+
latestText = text;
|
|
2291
|
+
},
|
|
2292
|
+
},
|
|
2293
|
+
);
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
await boundedKill();
|
|
2296
|
+
await releaseTerminalOnce();
|
|
2297
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
2298
|
+
throw error;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
const bridgeGeneration = bridgeManager.getJob(bridgeJobId)?.generation ?? bridgeJobId;
|
|
2302
|
+
// Owner teardown must not swallow a live remote command. failNow is the one
|
|
2303
|
+
// transition that both fails the job AND enqueues its delivery, so the
|
|
2304
|
+
// failure stays visible instead of becoming a cancelled job that delivers
|
|
2305
|
+
// nothing.
|
|
2306
|
+
const unregisterOwnerCleanup = bridgeManager.registerOwnerCleanup(
|
|
2307
|
+
this.session.getAgentId?.() ?? "0-Main",
|
|
2308
|
+
() => {
|
|
2309
|
+
void boundedKill().then(() => releaseTerminalOnce());
|
|
2310
|
+
bridgeManager.failNow(bridgeJobId, bridgeGeneration, "Client terminal owner was torn down.");
|
|
2311
|
+
},
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
const bridgeHandle: ManagedBashJobHandle = {
|
|
2315
|
+
jobId: bridgeJobId,
|
|
2316
|
+
label: bridgeLabel,
|
|
2317
|
+
completion: bridgeCompletion.promise,
|
|
2318
|
+
getLatestText: () => latestText,
|
|
2319
|
+
setBackgrounded: (next: boolean) => {
|
|
2320
|
+
bridgeBackgrounded = next;
|
|
2321
|
+
},
|
|
2322
|
+
};
|
|
2323
|
+
void bridgeHandle.completion.finally(unregisterOwnerCleanup);
|
|
2324
|
+
registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
|
|
2325
|
+
|
|
2326
|
+
const bridgeFoldRequest = Promise.withResolvers<void>();
|
|
2327
|
+
const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
|
|
2328
|
+
const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
|
|
2329
|
+
kind: "client-terminal",
|
|
2330
|
+
jobId: bridgeJobId,
|
|
2331
|
+
jobGeneration: bridgeGeneration,
|
|
2332
|
+
label: bridgeLabel,
|
|
2333
|
+
cwdSensitive: true,
|
|
2334
|
+
signal,
|
|
2335
|
+
originatingTurn: bridgeOriginatingTurn,
|
|
2336
|
+
outputRef: {
|
|
2337
|
+
jobId: bridgeJobId,
|
|
2338
|
+
generation: bridgeGeneration,
|
|
2339
|
+
instruction: `Use the job tool's tail operation for ${bridgeJobId} to read this command's output.`,
|
|
2340
|
+
},
|
|
2341
|
+
getJob: () => {
|
|
2342
|
+
const current = bridgeManager.getJob(bridgeJobId);
|
|
2343
|
+
return current?.generation === bridgeGeneration ? current : undefined;
|
|
2344
|
+
},
|
|
2345
|
+
detachObserver: () => {
|
|
2346
|
+
const outcome = settleBridgeForeground();
|
|
2347
|
+
if (outcome === "resolved") {
|
|
2348
|
+
bridgeHandle.setBackgrounded(true);
|
|
2349
|
+
bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
|
|
2350
|
+
bridgeFoldRequest.resolve();
|
|
2351
|
+
}
|
|
2352
|
+
return outcome;
|
|
2353
|
+
},
|
|
2354
|
+
resolveForegroundObserver: () => settleBridgeForeground(),
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
|
|
2358
|
+
try {
|
|
2359
|
+
bridgeWait = await this.#waitForManagedBashJob(
|
|
2360
|
+
bridgeHandle,
|
|
2361
|
+
this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
|
|
2362
|
+
signal,
|
|
2363
|
+
bridgeFoldRequest.promise,
|
|
2364
|
+
);
|
|
2365
|
+
} finally {
|
|
2366
|
+
unregisterBridgeFold?.();
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
if (bridgeWait.kind === "completed") {
|
|
2370
|
+
unregisterOwnerCleanup();
|
|
2371
|
+
bridgeManager.acknowledgeDeliveries([bridgeJobId]);
|
|
2372
|
+
unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
|
|
2373
|
+
return bridgeWait.result;
|
|
2374
|
+
}
|
|
2375
|
+
if (bridgeWait.kind === "failed") {
|
|
2376
|
+
unregisterOwnerCleanup();
|
|
2377
|
+
bridgeManager.acknowledgeDeliveries([bridgeJobId]);
|
|
2378
|
+
unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
|
|
2379
|
+
throw bridgeWait.error;
|
|
2380
|
+
}
|
|
2381
|
+
if (bridgeWait.kind === "aborted") {
|
|
2382
|
+
unregisterOwnerCleanup();
|
|
2383
|
+
bridgeManager.cancel(bridgeJobId);
|
|
2384
|
+
const terminal = await bridgeHandle.completion;
|
|
2385
|
+
bridgeManager.acknowledgeDeliveries([bridgeJobId]);
|
|
2386
|
+
unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
|
|
2387
|
+
if (terminal.kind === "failed") {
|
|
2388
|
+
throw new ToolAbortError(
|
|
2389
|
+
formatManagedAbortFailure(terminal.error, undefined, bridgeHandle.getLatestText()),
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, bridgeHandle.getLatestText()));
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Folded (or auto-backgrounded): the remote terminal keeps running and its
|
|
2396
|
+
// result arrives as a background completion.
|
|
2397
|
+
bridgeHandle.setBackgrounded(true);
|
|
2398
|
+
bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
|
|
2399
|
+
return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
|
|
2400
|
+
requestedTimeoutSec,
|
|
2401
|
+
notices: pendingNotices,
|
|
2402
|
+
terminalId: handle.terminalId,
|
|
2403
|
+
});
|
|
1635
2404
|
}
|
|
1636
2405
|
|
|
1637
2406
|
const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
|
|
@@ -1645,11 +2414,22 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1645
2414
|
const artifactPublisher = createBashArtifactPublisher(this.session);
|
|
1646
2415
|
|
|
1647
2416
|
const interactiveUi =
|
|
1648
|
-
this.session.bashRestrictionProfile === "read-only"
|
|
2417
|
+
this.session.bashRestrictionProfile === "read-only" || directMasterSpawn
|
|
1649
2418
|
? undefined
|
|
1650
2419
|
: canUseInteractiveBashPty(pty, ctx)
|
|
1651
2420
|
? ctx?.ui
|
|
1652
2421
|
: undefined;
|
|
2422
|
+
const ptyAdmission = interactiveUi && ownedManager ? ownedManager.reserveCapacity() : undefined;
|
|
2423
|
+
// A PTY command is foldable too: the runner owns the session, so the fold
|
|
2424
|
+
// registers a job that delivers the run's real outcome after the foreground
|
|
2425
|
+
// has already returned.
|
|
2426
|
+
let ptyFoldUnregister: (() => void) | undefined;
|
|
2427
|
+
let ptyFoldResult: AgentToolResult<BashToolDetails> | undefined;
|
|
2428
|
+
let ptyBackgrounded = false;
|
|
2429
|
+
let lastPtyFoldKeyTime = 0;
|
|
2430
|
+
let ptyJobId!: string;
|
|
2431
|
+
let ptyOutputSeen = false;
|
|
2432
|
+
let ptyOutcome: BashInteractiveResult | undefined;
|
|
1653
2433
|
const result: BashResult | BashInteractiveResult = interactiveUi
|
|
1654
2434
|
? await runInteractiveBashPty(interactiveUi, {
|
|
1655
2435
|
command,
|
|
@@ -1663,12 +2443,146 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1663
2443
|
artifactPublisher,
|
|
1664
2444
|
spillThreshold,
|
|
1665
2445
|
headBytes,
|
|
2446
|
+
onControls: controls => {
|
|
2447
|
+
if (!ownedManager) return;
|
|
2448
|
+
const ptyManager = ownedManager;
|
|
2449
|
+
const ptyLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
|
|
2450
|
+
const ptyOwnerId = this.session.getAgentId?.() ?? "0-Main";
|
|
2451
|
+
let ptyKillStarted = false;
|
|
2452
|
+
const killPtyOnce = (): void => {
|
|
2453
|
+
if (ptyKillStarted) return;
|
|
2454
|
+
ptyKillStarted = true;
|
|
2455
|
+
try {
|
|
2456
|
+
controls.kill();
|
|
2457
|
+
} catch (error) {
|
|
2458
|
+
logger.warn("PTY kill failed", {
|
|
2459
|
+
jobId: ptyJobId,
|
|
2460
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
};
|
|
2464
|
+
const ownerTeardownText = (): string => {
|
|
2465
|
+
const retained = ptyManager.readOutputSince(ptyJobId, 0, { ownerId: ptyOwnerId });
|
|
2466
|
+
const output = ptyOutcome?.output || retained?.text || "";
|
|
2467
|
+
if (!output && !ptyOutcome) return "PTY job owner was torn down.";
|
|
2468
|
+
const summary = ptyOutcome ? { ...ptyOutcome, output } : interactiveResultFromText(output);
|
|
2469
|
+
const body = [
|
|
2470
|
+
output || "(no output)",
|
|
2471
|
+
...(retained?.truncated && !ptyOutcome?.truncated ? ["(output truncated)"] : []),
|
|
2472
|
+
].join("\n");
|
|
2473
|
+
return formatBashFailureMessage(summary, body, "PTY job owner was torn down.");
|
|
2474
|
+
};
|
|
2475
|
+
// The job's runner simply awaits the run that is ALREADY executing,
|
|
2476
|
+
// so registering never re-executes the command.
|
|
2477
|
+
try {
|
|
2478
|
+
ptyJobId = ptyManager.register(
|
|
2479
|
+
"bash",
|
|
2480
|
+
ptyLabel,
|
|
2481
|
+
async () => {
|
|
2482
|
+
let outcome: BashInteractiveResult | undefined;
|
|
2483
|
+
try {
|
|
2484
|
+
outcome = await controls.terminalCompletion;
|
|
2485
|
+
const completed = this.#buildCompletedResult(outcome, timeoutSec, {
|
|
2486
|
+
requestedTimeoutSec,
|
|
2487
|
+
});
|
|
2488
|
+
return this.#extractTextResult(completed);
|
|
2489
|
+
} finally {
|
|
2490
|
+
if (!ptyOutputSeen && outcome)
|
|
2491
|
+
ptyManager.appendOutput(ptyJobId, this.#formatResultOutput(outcome));
|
|
2492
|
+
if (!ptyBackgrounded) ptyManager.cancel(ptyJobId);
|
|
2493
|
+
}
|
|
2494
|
+
},
|
|
2495
|
+
{
|
|
2496
|
+
ownerId: this.session.getAgentId?.() ?? undefined,
|
|
2497
|
+
admissionToken: ptyAdmission,
|
|
2498
|
+
lifecycle: { onCancel: killPtyOnce },
|
|
2499
|
+
},
|
|
2500
|
+
);
|
|
2501
|
+
} catch (error) {
|
|
2502
|
+
killPtyOnce();
|
|
2503
|
+
if (ptyAdmission) ptyManager.releaseCapacity(ptyAdmission);
|
|
2504
|
+
throw error;
|
|
2505
|
+
}
|
|
2506
|
+
const ptyGeneration = ptyManager.getJob(ptyJobId)?.generation ?? ptyJobId;
|
|
2507
|
+
registerOwnedIfLineaged(ptyManager, toolCallId, ptyJobId, this.session.getSessionId?.() ?? undefined);
|
|
2508
|
+
const unregisterPtyOwnerCleanup = ptyManager.registerOwnerCleanup(ptyOwnerId, () => {
|
|
2509
|
+
killPtyOnce();
|
|
2510
|
+
ptyManager.failNow(ptyJobId, ptyGeneration, ownerTeardownText());
|
|
2511
|
+
});
|
|
2512
|
+
void controls.terminalCompletion.then(
|
|
2513
|
+
outcome => {
|
|
2514
|
+
ptyOutcome = outcome;
|
|
2515
|
+
},
|
|
2516
|
+
(error: unknown) => {
|
|
2517
|
+
logger.warn("PTY terminal completion failed", {
|
|
2518
|
+
jobId: ptyJobId,
|
|
2519
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2520
|
+
});
|
|
2521
|
+
},
|
|
2522
|
+
);
|
|
2523
|
+
void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
|
|
2524
|
+
ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
|
|
2525
|
+
kind: "bash-pty",
|
|
2526
|
+
jobId: ptyJobId,
|
|
2527
|
+
jobGeneration: ptyGeneration,
|
|
2528
|
+
label: ptyLabel,
|
|
2529
|
+
cwdSensitive: true,
|
|
2530
|
+
signal,
|
|
2531
|
+
originatingTurn: ctx?.attemptScope !== undefined,
|
|
2532
|
+
outputRef: {
|
|
2533
|
+
jobId: ptyJobId,
|
|
2534
|
+
generation: ptyGeneration,
|
|
2535
|
+
instruction: `Use the job tool's tail operation for ${ptyJobId} to read this command's output.`,
|
|
2536
|
+
},
|
|
2537
|
+
getJob: () => {
|
|
2538
|
+
const current = ptyManager.getJob(ptyJobId);
|
|
2539
|
+
return current?.generation === ptyGeneration ? current : undefined;
|
|
2540
|
+
},
|
|
2541
|
+
detachObserver: () => {
|
|
2542
|
+
// Output-only continuation: stdin forwarding ends here and the
|
|
2543
|
+
// process is never killed or restarted by folding.
|
|
2544
|
+
const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
|
|
2545
|
+
requestedTimeoutSec,
|
|
2546
|
+
notices: pendingNotices,
|
|
2547
|
+
});
|
|
2548
|
+
const outcome = controls.detachObserver(
|
|
2549
|
+
interactiveResultFromText(this.#extractTextResult(started)),
|
|
2550
|
+
);
|
|
2551
|
+
if (outcome === "resolved") {
|
|
2552
|
+
controls.detachForegroundCancellation();
|
|
2553
|
+
ptyBackgrounded = true;
|
|
2554
|
+
ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
|
|
2555
|
+
ptyFoldResult = started;
|
|
2556
|
+
}
|
|
2557
|
+
return outcome;
|
|
2558
|
+
},
|
|
2559
|
+
resolveForegroundObserver: () => "already-settled",
|
|
2560
|
+
});
|
|
2561
|
+
},
|
|
2562
|
+
onFoldKey: () => {
|
|
2563
|
+
const now = Date.now();
|
|
2564
|
+
if (now - lastPtyFoldKeyTime > 750) {
|
|
2565
|
+
lastPtyFoldKeyTime = now;
|
|
2566
|
+
return true;
|
|
2567
|
+
}
|
|
2568
|
+
lastPtyFoldKeyTime = 0;
|
|
2569
|
+
if (!this.session.hasForegroundBashBackgroundRequestHandler?.()) return false;
|
|
2570
|
+
void this.session.requestForegroundBashBackground?.();
|
|
2571
|
+
return true;
|
|
2572
|
+
},
|
|
2573
|
+
onOutput: chunk => {
|
|
2574
|
+
ptyOutputSeen = true;
|
|
2575
|
+
if (ptyJobId) ownedManager?.appendOutput(ptyJobId, chunk);
|
|
2576
|
+
},
|
|
2577
|
+
}).catch(error => {
|
|
2578
|
+
if (ptyAdmission) ownedManager?.releaseCapacity(ptyAdmission);
|
|
2579
|
+
throw error;
|
|
1666
2580
|
})
|
|
1667
2581
|
: await executeBash(command, {
|
|
1668
2582
|
cwd: commandCwd,
|
|
1669
2583
|
settings: this.session.settings,
|
|
1670
2584
|
sessionKey: this.session.getSessionId?.() ?? undefined,
|
|
1671
|
-
oneShot: this.session.bashRestrictionProfile === "read-only",
|
|
2585
|
+
oneShot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
|
|
1672
2586
|
timeout: timeoutMs,
|
|
1673
2587
|
signal,
|
|
1674
2588
|
env: resolvedEnv,
|
|
@@ -1679,9 +2593,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1679
2593
|
headBytes,
|
|
1680
2594
|
onChunk: streamTailUpdates(tailBuffer, onUpdate),
|
|
1681
2595
|
onMinimizedSave: async originalText => saveBashOriginalArtifact(this.session, originalText),
|
|
1682
|
-
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
1683
|
-
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
2596
|
+
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
|
|
2597
|
+
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
|
|
1684
2598
|
});
|
|
2599
|
+
ptyFoldUnregister?.();
|
|
2600
|
+
// Folded: the foreground was settled by the fold, so return the background
|
|
2601
|
+
// -start result verbatim instead of presenting a half-finished command.
|
|
2602
|
+
if (ptyFoldResult) return ptyFoldResult;
|
|
2603
|
+
|
|
1685
2604
|
if (result.cancelled) {
|
|
1686
2605
|
const noticeSuffix = pendingNotices.length > 0 ? `\n\n${pendingNotices.join("\n")}` : "";
|
|
1687
2606
|
const baseCancelledText = normalizeResultOutput(result) || "Command aborted";
|