@gajae-code/coding-agent 0.15.5 → 0.15.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 +26 -1
- package/dist/types/async/job-manager.d.ts +83 -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/update-cli.d.ts +2 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +96 -0
- package/dist/types/config/file-lock.d.ts +49 -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 +11 -5
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -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/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/sdk/broker/broker.d.ts +3 -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/client/discovery.d.ts +1 -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 +15 -1
- 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 +9 -0
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +19 -7
- 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/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/index.d.ts +6 -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 +3 -3
- package/src/async/job-manager.ts +671 -39
- 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 +3 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/update-cli.ts +40 -5
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/model-presets.ts +24 -0
- package/src/config/autorouting-tier-map.ts +32 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +775 -59
- 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 +2799 -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 +51 -9
- package/src/config/model-registry.ts +1021 -122
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +6 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +571 -0
- package/src/coordinator-mcp/server.ts +19 -24
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- 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/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +9 -2
- package/src/modes/acp/acp-agent.ts +11 -5
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +23 -6
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/utils/injected-user-submission.ts +15 -1
- 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 +48 -21
- package/src/sdk/broker/lifecycle.ts +32 -6
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
- 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/cli/session-cli.ts +12 -1
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- 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 +371 -37
- package/src/sdk/host/session-runtime.ts +364 -52
- 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 +2 -2
- package/src/sdk/protocol/operation-registry.ts +4 -1
- package/src/sdk/router/session-router.ts +228 -96
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +678 -103
- package/src/session/fold-coordinator.ts +433 -0
- 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 +5 -4
- 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 +142 -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 +772 -67
- package/src/tools/index.ts +7 -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
|
@@ -18,7 +18,11 @@ 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
|
+
ClientBridgeTerminalExitStatus,
|
|
23
|
+
ClientBridgeTerminalHandle,
|
|
24
|
+
ClientBridgeTerminalOutput,
|
|
25
|
+
} from "../session/client-bridge";
|
|
22
26
|
import {
|
|
23
27
|
DEFAULT_ARTIFACT_MAX_BYTES,
|
|
24
28
|
OutputSink,
|
|
@@ -62,6 +66,20 @@ import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
|
|
|
62
66
|
import { checkTmuxSelfInjection } from "./tmux-self-injection-guard";
|
|
63
67
|
import { ToolAbortError, ToolError } from "./tool-errors";
|
|
64
68
|
import { toolResult } from "./tool-result";
|
|
69
|
+
|
|
70
|
+
function sliceTextAfterUtf8ByteOffset(text: string, offsetBytes: number): string {
|
|
71
|
+
if (offsetBytes <= 0) return text;
|
|
72
|
+
let consumed = 0;
|
|
73
|
+
let index = 0;
|
|
74
|
+
while (index < text.length && consumed < offsetBytes) {
|
|
75
|
+
const codePoint = text.codePointAt(index);
|
|
76
|
+
if (codePoint === undefined) break;
|
|
77
|
+
consumed += Buffer.byteLength(String.fromCodePoint(codePoint), "utf8");
|
|
78
|
+
index += codePoint > 0xffff ? 2 : 1;
|
|
79
|
+
}
|
|
80
|
+
return text.slice(index);
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
|
|
66
84
|
|
|
67
85
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
@@ -70,6 +88,7 @@ const BASH_ERROR_MAX_BYTES = 4096;
|
|
|
70
88
|
const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
|
|
71
89
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
72
90
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
91
|
+
const ACP_RELEASE_TIMEOUT_MS = 1_000;
|
|
73
92
|
const READ_ONLY_BASH_ENV: Record<string, string> = {
|
|
74
93
|
GREP_OPTIONS: "",
|
|
75
94
|
GREP_COLOR: "",
|
|
@@ -151,6 +170,27 @@ function artifactReferenceIsReachable(text: string, result: BashResult | BashInt
|
|
|
151
170
|
);
|
|
152
171
|
}
|
|
153
172
|
|
|
173
|
+
function suffixPrefixOverlap(source: string, target: string): number {
|
|
174
|
+
if (source.length === 0 || target.length === 0) return 0;
|
|
175
|
+
const prefix = new Uint32Array(target.length);
|
|
176
|
+
for (let index = 1; index < target.length; index += 1) {
|
|
177
|
+
let length = prefix[index - 1] ?? 0;
|
|
178
|
+
const code = target.charCodeAt(index);
|
|
179
|
+
while (length > 0 && code !== target.charCodeAt(length)) length = prefix[length - 1] ?? 0;
|
|
180
|
+
if (code === target.charCodeAt(length)) length += 1;
|
|
181
|
+
prefix[index] = length;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let overlap = 0;
|
|
185
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
186
|
+
const code = source.charCodeAt(index);
|
|
187
|
+
while (overlap > 0 && code !== target.charCodeAt(overlap)) overlap = prefix[overlap - 1] ?? 0;
|
|
188
|
+
if (code === target.charCodeAt(overlap)) overlap += 1;
|
|
189
|
+
if (overlap === target.length && index < source.length - 1) overlap = prefix[overlap - 1] ?? 0;
|
|
190
|
+
}
|
|
191
|
+
return overlap;
|
|
192
|
+
}
|
|
193
|
+
|
|
154
194
|
function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
|
|
155
195
|
const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
|
|
156
196
|
return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
|
|
@@ -291,6 +331,48 @@ function formatClientTerminalAbortFailure(
|
|
|
291
331
|
return formatBashFailureMessage(result, outputText, "Command aborted");
|
|
292
332
|
}
|
|
293
333
|
|
|
334
|
+
function formatClientTerminalReadFailure(
|
|
335
|
+
prepared: PreparedClientTerminalOutput,
|
|
336
|
+
readDiagnostic: string,
|
|
337
|
+
pendingNotices: readonly string[] = [],
|
|
338
|
+
): string {
|
|
339
|
+
const notices = [
|
|
340
|
+
...pendingNotices,
|
|
341
|
+
...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
|
|
342
|
+
...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
|
|
343
|
+
`Terminal output recovery failed: ${readDiagnostic}`,
|
|
344
|
+
];
|
|
345
|
+
const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
|
|
346
|
+
const result: BashResult = {
|
|
347
|
+
...prepared.summary,
|
|
348
|
+
exitCode: undefined,
|
|
349
|
+
cancelled: false,
|
|
350
|
+
};
|
|
351
|
+
return formatBashFailureMessage(result, outputText, "Terminal output read failed");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function formatClientTerminalWaitFailure(
|
|
355
|
+
prepared: PreparedClientTerminalOutput,
|
|
356
|
+
waitDiagnostic: string,
|
|
357
|
+
readDiagnostic?: string,
|
|
358
|
+
pendingNotices: readonly string[] = [],
|
|
359
|
+
): string {
|
|
360
|
+
const notices = [
|
|
361
|
+
...pendingNotices,
|
|
362
|
+
...(prepared.current.truncated || prepared.locallyTruncated ? ["(output truncated)"] : []),
|
|
363
|
+
...(prepared.artifactSaveNotice ? [prepared.artifactSaveNotice] : []),
|
|
364
|
+
`Terminal wait failed: ${waitDiagnostic}`,
|
|
365
|
+
...(readDiagnostic ? [`Terminal output recovery failed: ${readDiagnostic}`] : []),
|
|
366
|
+
];
|
|
367
|
+
const outputText = [prepared.summary.output || "(no output)", ...notices].filter(Boolean).join("\n");
|
|
368
|
+
const result: BashResult = {
|
|
369
|
+
...prepared.summary,
|
|
370
|
+
exitCode: undefined,
|
|
371
|
+
cancelled: false,
|
|
372
|
+
};
|
|
373
|
+
return formatBashFailureMessage(result, outputText, "Terminal wait failed");
|
|
374
|
+
}
|
|
375
|
+
|
|
294
376
|
function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
|
|
295
377
|
const suffixParts: string[] = [];
|
|
296
378
|
const reference = artifactReferenceForResult(result);
|
|
@@ -468,6 +550,21 @@ interface ManagedBashJobHandle {
|
|
|
468
550
|
setBackgrounded: (backgrounded: boolean) => void;
|
|
469
551
|
}
|
|
470
552
|
|
|
553
|
+
/** The placeholder a folded PTY hands back to its (now finished) foreground call. */
|
|
554
|
+
function interactiveResultFromText(text: string): BashInteractiveResult {
|
|
555
|
+
return {
|
|
556
|
+
exitCode: undefined,
|
|
557
|
+
cancelled: false,
|
|
558
|
+
timedOut: false,
|
|
559
|
+
output: text,
|
|
560
|
+
outputBytes: 0,
|
|
561
|
+
outputLines: 0,
|
|
562
|
+
totalBytes: 0,
|
|
563
|
+
totalLines: 0,
|
|
564
|
+
truncated: false,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
471
568
|
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
|
|
472
569
|
return result.output || "";
|
|
473
570
|
}
|
|
@@ -648,6 +745,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
648
745
|
this.description = prompt.render(bashDescription, {
|
|
649
746
|
asyncEnabled: this.#asyncEnabled,
|
|
650
747
|
autoBackgroundEnabled: this.#autoBackgroundEnabled,
|
|
748
|
+
foregroundFoldEnabled: this.session.registerForegroundFoldParticipant !== undefined,
|
|
651
749
|
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
|
|
652
750
|
hasAstGrep: this.session.settings.get("astGrep.enabled"),
|
|
653
751
|
hasAstEdit: this.session.settings.get("astEdit.enabled"),
|
|
@@ -732,12 +830,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
732
830
|
label: string,
|
|
733
831
|
previewText: string,
|
|
734
832
|
timeoutSec: number,
|
|
735
|
-
options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
|
|
833
|
+
options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
|
|
736
834
|
): AgentToolResult<BashToolDetails> {
|
|
737
835
|
const details: BashToolDetails = {
|
|
738
836
|
timeoutSeconds: timeoutSec,
|
|
739
837
|
async: { state: "running", jobId, type: "bash" },
|
|
740
838
|
};
|
|
839
|
+
// A folded client terminal keeps its remote handle, so the editor's live
|
|
840
|
+
// terminal card stays bound to the same id after the fold.
|
|
841
|
+
if (options.terminalId !== undefined) details.terminalId = options.terminalId;
|
|
741
842
|
if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
|
|
742
843
|
details.requestedTimeoutSeconds = options.requestedTimeoutSec;
|
|
743
844
|
}
|
|
@@ -775,6 +876,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
775
876
|
resolvedEnv?: Record<string, string>;
|
|
776
877
|
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
|
|
777
878
|
startBackgrounded: boolean;
|
|
879
|
+
onCompletion?: () => void;
|
|
778
880
|
/** Immutable attempt-scoped tool call id, when executed via a tool call. */
|
|
779
881
|
toolCallId?: string;
|
|
780
882
|
}): ManagedBashJobHandle {
|
|
@@ -845,12 +947,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
845
947
|
});
|
|
846
948
|
const finalText = this.#extractTextResult(finalResult);
|
|
847
949
|
latestText = finalText;
|
|
950
|
+
options.onCompletion?.();
|
|
848
951
|
completion.resolve({ kind: "completed", result: finalResult });
|
|
849
952
|
await reportProgress(finalText, completedDetails(jobId));
|
|
850
953
|
return finalText;
|
|
851
954
|
} catch (error) {
|
|
852
955
|
const message = error instanceof Error ? error.message : String(error);
|
|
853
956
|
latestText = message;
|
|
957
|
+
options.onCompletion?.();
|
|
854
958
|
completion.resolve({ kind: "failed", error, result: executionResult });
|
|
855
959
|
await reportProgress(message, failedDetails(jobId));
|
|
856
960
|
throw error;
|
|
@@ -867,6 +971,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
867
971
|
},
|
|
868
972
|
},
|
|
869
973
|
);
|
|
974
|
+
const jobGeneration = manager.getJob(jobId)?.generation ?? jobId;
|
|
975
|
+
const unregisterOwnerCleanup = manager.registerOwnerCleanup(this.session.getAgentId?.() ?? "0-Main", () => {
|
|
976
|
+
manager.failNow(jobId, jobGeneration, "Bash job owner was torn down.");
|
|
977
|
+
});
|
|
978
|
+
void completion.promise.finally(unregisterOwnerCleanup);
|
|
870
979
|
registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
|
|
871
980
|
|
|
872
981
|
return {
|
|
@@ -893,9 +1002,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
893
1002
|
*/
|
|
894
1003
|
#resolveOwnedJobManager(): AsyncJobManager | undefined {
|
|
895
1004
|
const endpointId = this.session.getSessionId?.() ?? undefined;
|
|
896
|
-
return (
|
|
897
|
-
|
|
898
|
-
);
|
|
1005
|
+
if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
|
|
1006
|
+
return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
|
|
899
1007
|
}
|
|
900
1008
|
|
|
901
1009
|
async #waitForManagedBashJob(
|
|
@@ -1165,6 +1273,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1165
1273
|
if (!manager) {
|
|
1166
1274
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
1167
1275
|
}
|
|
1276
|
+
if (!manager.hasCapacity()) {
|
|
1277
|
+
throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
|
|
1278
|
+
}
|
|
1168
1279
|
const prepared = await this.#prepareBashExecution(input, opts.ctx);
|
|
1169
1280
|
const label =
|
|
1170
1281
|
opts.label ?? (prepared.command.length > 120 ? `${prepared.command.slice(0, 117)}...` : prepared.command);
|
|
@@ -1289,6 +1400,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1289
1400
|
if (this.session.bashRestrictionProfile === "read-only" && pty) {
|
|
1290
1401
|
throw new ToolError("Read-only bash does not allow PTY mode.");
|
|
1291
1402
|
}
|
|
1403
|
+
const admissionManager = this.#resolveOwnedJobManager();
|
|
1404
|
+
if (asyncRequested && !admissionManager) {
|
|
1405
|
+
throw new ToolError("Async job manager unavailable for this session.");
|
|
1406
|
+
}
|
|
1407
|
+
if (admissionManager && !admissionManager.hasCapacity()) {
|
|
1408
|
+
throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
|
|
1409
|
+
}
|
|
1292
1410
|
|
|
1293
1411
|
const prepared = await this.#prepareBashExecution(
|
|
1294
1412
|
{ command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
|
|
@@ -1309,7 +1427,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1309
1427
|
// that was the process-global instance may have been disposed,
|
|
1310
1428
|
// clearing instance() while THIS session's manager stays
|
|
1311
1429
|
// registered by endpoint (review thread P1).
|
|
1312
|
-
|
|
1430
|
+
const asyncManager = admissionManager ?? this.#resolveOwnedJobManager();
|
|
1431
|
+
if (!asyncManager) {
|
|
1313
1432
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
1314
1433
|
}
|
|
1315
1434
|
const job = this.#startManagedBashJob({
|
|
@@ -1325,6 +1444,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1325
1444
|
startBackgrounded: true,
|
|
1326
1445
|
toolCallId,
|
|
1327
1446
|
});
|
|
1447
|
+
const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
|
|
1448
|
+
job.setBackgrounded(true);
|
|
1449
|
+
asyncManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1328
1450
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
|
|
1329
1451
|
requestedTimeoutSec,
|
|
1330
1452
|
notices: pendingNotices,
|
|
@@ -1345,13 +1467,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1345
1467
|
// and unregistering — the process-global instance may belong to a different
|
|
1346
1468
|
// concurrent top-level session (review thread P1).
|
|
1347
1469
|
const ownedManager = this.#resolveOwnedJobManager();
|
|
1348
|
-
|
|
1470
|
+
// The client-terminal path is now itself manager-backed and foldable, so it no
|
|
1471
|
+
// longer has to be bypassed to make folding work. A capable ACP session keeps
|
|
1472
|
+
// its terminal contract and still folds.
|
|
1473
|
+
if (!pty && ownedManager && !clientTerminalActive) {
|
|
1349
1474
|
// With auto-background off, wait past the command's own timeout so the job only
|
|
1350
1475
|
// leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
|
|
1351
1476
|
const autoBackgroundWaitMs = this.#autoBackgroundEnabled
|
|
1352
1477
|
? this.#resolveAutoBackgroundWaitMs(timeoutMs)
|
|
1353
1478
|
: timeoutMs + 1_000;
|
|
1354
1479
|
const startBackgrounded = autoBackgroundWaitMs === 0;
|
|
1480
|
+
let managedForegroundSettled = false;
|
|
1355
1481
|
const job = this.#startManagedBashJob({
|
|
1356
1482
|
command,
|
|
1357
1483
|
commandCwd,
|
|
@@ -1363,18 +1489,59 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1363
1489
|
resolvedEnv,
|
|
1364
1490
|
onUpdate,
|
|
1365
1491
|
startBackgrounded,
|
|
1492
|
+
onCompletion: () => {
|
|
1493
|
+
managedForegroundSettled = true;
|
|
1494
|
+
},
|
|
1366
1495
|
toolCallId,
|
|
1367
1496
|
});
|
|
1497
|
+
const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
|
|
1368
1498
|
if (startBackgrounded) {
|
|
1499
|
+
job.setBackgrounded(true);
|
|
1500
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1369
1501
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
|
|
1370
1502
|
requestedTimeoutSec,
|
|
1371
1503
|
notices: pendingNotices,
|
|
1372
1504
|
});
|
|
1373
1505
|
}
|
|
1374
1506
|
const backgroundRequest = Promise.withResolvers<void>();
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1507
|
+
// Exactly one party may settle the foreground caller. detachObserver (the
|
|
1508
|
+
// fold won) and resolveForegroundObserver (a completion was handed back
|
|
1509
|
+
// after a failed fold) share this flag, so a race cannot double-settle.
|
|
1510
|
+
let foregroundSettled = false;
|
|
1511
|
+
const settleForeground = (): "resolved" | "already-settled" => {
|
|
1512
|
+
if (foregroundSettled || managedForegroundSettled) return "already-settled";
|
|
1513
|
+
foregroundSettled = true;
|
|
1514
|
+
return "resolved";
|
|
1515
|
+
};
|
|
1516
|
+
const unregisterBackgroundRequest = this.session.registerForegroundFoldParticipant?.({
|
|
1517
|
+
kind: "bash-managed",
|
|
1518
|
+
jobId: job.jobId,
|
|
1519
|
+
jobGeneration,
|
|
1520
|
+
label: job.label,
|
|
1521
|
+
cwdSensitive: true,
|
|
1522
|
+
signal,
|
|
1523
|
+
originatingTurn: ctx?.attemptScope !== undefined,
|
|
1524
|
+
outputRef: {
|
|
1525
|
+
jobId: job.jobId,
|
|
1526
|
+
generation: jobGeneration,
|
|
1527
|
+
instruction: `Use the job tool's tail operation for ${job.jobId} to read this command's output.`,
|
|
1528
|
+
},
|
|
1529
|
+
// Bound at registration over the manager that registered THIS job, so
|
|
1530
|
+
// identity can never resolve to another manager's identically-named job.
|
|
1531
|
+
getJob: () => {
|
|
1532
|
+
const current = ownedManager.getJob(job.jobId);
|
|
1533
|
+
return current?.generation === jobGeneration ? current : undefined;
|
|
1534
|
+
},
|
|
1535
|
+
detachObserver: () => {
|
|
1536
|
+
const outcome = settleForeground();
|
|
1537
|
+
if (outcome === "resolved") {
|
|
1538
|
+
job.setBackgrounded(true);
|
|
1539
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1540
|
+
backgroundRequest.resolve();
|
|
1541
|
+
}
|
|
1542
|
+
return outcome;
|
|
1543
|
+
},
|
|
1544
|
+
resolveForegroundObserver: () => settleForeground(),
|
|
1378
1545
|
});
|
|
1379
1546
|
let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
|
|
1380
1547
|
try {
|
|
@@ -1410,6 +1577,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1410
1577
|
throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
|
|
1411
1578
|
}
|
|
1412
1579
|
job.setBackgrounded(true);
|
|
1580
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1413
1581
|
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
|
|
1414
1582
|
requestedTimeoutSec,
|
|
1415
1583
|
notices: pendingNotices,
|
|
@@ -1417,63 +1585,200 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1417
1585
|
}
|
|
1418
1586
|
|
|
1419
1587
|
if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
|
|
1588
|
+
const clientAdmission = ownedManager?.reserveCapacity();
|
|
1420
1589
|
const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
|
|
1421
1590
|
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
|
-
|
|
1591
|
+
const runBoundedCleanup = async (
|
|
1592
|
+
terminal: ClientBridgeTerminalHandle,
|
|
1593
|
+
operation: () => Promise<void>,
|
|
1594
|
+
label: "kill" | "release",
|
|
1595
|
+
): Promise<void> => {
|
|
1596
|
+
const attempt = Promise.resolve()
|
|
1597
|
+
.then(operation)
|
|
1598
|
+
.catch((error: unknown) => {
|
|
1599
|
+
logger.warn(`ACP terminal ${label} failed`, { terminalId: terminal.terminalId, error });
|
|
1600
|
+
});
|
|
1601
|
+
const completed = await Promise.race([
|
|
1602
|
+
attempt.then(() => true),
|
|
1603
|
+
Bun.sleep(ACP_RELEASE_TIMEOUT_MS).then(() => false),
|
|
1604
|
+
]);
|
|
1605
|
+
if (!completed) logger.warn(`ACP terminal ${label} timed out`, { terminalId: terminal.terminalId });
|
|
1606
|
+
};
|
|
1607
|
+
const createPromise = Promise.resolve().then(() =>
|
|
1608
|
+
clientBridge.createTerminal!({
|
|
1609
|
+
command,
|
|
1610
|
+
cwd: commandCwd,
|
|
1611
|
+
env: resolvedEnv
|
|
1612
|
+
? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
|
|
1613
|
+
: undefined,
|
|
1614
|
+
outputByteLimit: clientHeadBytes > 0 ? undefined : clientTailBytes,
|
|
1615
|
+
}),
|
|
1616
|
+
);
|
|
1617
|
+
const timeout = Promise.withResolvers<{ kind: "timeout" }>();
|
|
1618
|
+
const abort = Promise.withResolvers<{ kind: "aborted" }>();
|
|
1619
|
+
const onCreateAbort = (): void => abort.resolve({ kind: "aborted" });
|
|
1620
|
+
if (signal?.aborted) onCreateAbort();
|
|
1621
|
+
else signal?.addEventListener("abort", onCreateAbort, { once: true });
|
|
1622
|
+
const createTimer = setTimeout(() => timeout.resolve({ kind: "timeout" }), timeoutMs);
|
|
1623
|
+
let createOutcome:
|
|
1624
|
+
| { kind: "created"; handle: ClientBridgeTerminalHandle }
|
|
1440
1625
|
| { kind: "timeout" }
|
|
1441
1626
|
| { kind: "aborted" };
|
|
1627
|
+
try {
|
|
1628
|
+
createOutcome = await Promise.race([
|
|
1629
|
+
createPromise.then(handle => ({ kind: "created" as const, handle })),
|
|
1630
|
+
timeout.promise,
|
|
1631
|
+
abort.promise,
|
|
1632
|
+
]);
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
1635
|
+
throw error;
|
|
1636
|
+
} finally {
|
|
1637
|
+
clearTimeout(createTimer);
|
|
1638
|
+
signal?.removeEventListener("abort", onCreateAbort);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (createOutcome.kind !== "created") {
|
|
1642
|
+
void createPromise
|
|
1643
|
+
.then(async lateHandle => {
|
|
1644
|
+
await runBoundedCleanup(lateHandle, () => lateHandle.kill(), "kill");
|
|
1645
|
+
await runBoundedCleanup(lateHandle, () => lateHandle.release(), "release");
|
|
1646
|
+
})
|
|
1647
|
+
.catch((error: unknown) => {
|
|
1648
|
+
logger.warn("ACP terminal late create cleanup failed", { error });
|
|
1649
|
+
});
|
|
1650
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
1651
|
+
if (createOutcome.kind === "aborted") throw new ToolAbortError("Command aborted");
|
|
1652
|
+
throw new ToolError(`Command timed out after ${timeoutSec} seconds`);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
let handle: ClientBridgeTerminalHandle;
|
|
1656
|
+
handle = createOutcome.handle;
|
|
1442
1657
|
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1658
|
+
// The remote terminal is released exactly once, by whichever path settles
|
|
1659
|
+
// it: a foreground return, a folded background completion, or a failure.
|
|
1660
|
+
let released = false;
|
|
1661
|
+
const releaseTerminalOnce = async (): Promise<void> => {
|
|
1662
|
+
if (released) return;
|
|
1663
|
+
released = true;
|
|
1664
|
+
await runBoundedCleanup(handle, () => handle.release(), "release");
|
|
1665
|
+
};
|
|
1448
1666
|
let killPromise: Promise<void> | undefined;
|
|
1449
1667
|
const fireKill = (): Promise<void> => {
|
|
1450
1668
|
if (killPromise) return killPromise;
|
|
1451
|
-
|
|
1669
|
+
try {
|
|
1670
|
+
killPromise = Promise.resolve(handle.kill()).catch((error: unknown) => {
|
|
1671
|
+
logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
|
|
1672
|
+
});
|
|
1673
|
+
} catch (error) {
|
|
1452
1674
|
logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
|
|
1453
|
-
|
|
1675
|
+
killPromise = Promise.resolve();
|
|
1676
|
+
}
|
|
1454
1677
|
return killPromise;
|
|
1455
1678
|
};
|
|
1456
|
-
const
|
|
1457
|
-
|
|
1458
|
-
void fireKill();
|
|
1679
|
+
const boundedKill = async (): Promise<void> => {
|
|
1680
|
+
await runBoundedCleanup(handle, fireKill, "kill");
|
|
1459
1681
|
};
|
|
1460
|
-
signal?.addEventListener("abort", onAbortSignal, { once: true });
|
|
1461
1682
|
|
|
1683
|
+
// Emit partial update so the editor can embed the live terminal card.
|
|
1462
1684
|
try {
|
|
1685
|
+
onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
await boundedKill();
|
|
1688
|
+
await releaseTerminalOnce();
|
|
1689
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
1690
|
+
throw error;
|
|
1691
|
+
}
|
|
1692
|
+
let latestText = "";
|
|
1693
|
+
let bridgeJobId!: string;
|
|
1694
|
+
let bridgeBackgrounded = false;
|
|
1695
|
+
let retainedAcpSnapshot = "";
|
|
1696
|
+
let retainedAcpTruncated = false;
|
|
1697
|
+
const ACP_RAW_OVERLAP_BYTES = 512 * 1024;
|
|
1698
|
+
const appendAcpSnapshot = (snapshot: string, upstreamTruncated = false): void => {
|
|
1699
|
+
retainedAcpTruncated ||= upstreamTruncated;
|
|
1700
|
+
if (!snapshot) return;
|
|
1701
|
+
const snapshotBytes = Buffer.byteLength(snapshot, "utf8");
|
|
1702
|
+
const retainedBytes = Buffer.byteLength(retainedAcpSnapshot, "utf8");
|
|
1703
|
+
const boundedSnapshot =
|
|
1704
|
+
snapshotBytes > ACP_RAW_OVERLAP_BYTES
|
|
1705
|
+
? sliceTextAfterUtf8ByteOffset(snapshot, snapshotBytes - ACP_RAW_OVERLAP_BYTES)
|
|
1706
|
+
: snapshot;
|
|
1707
|
+
const boundedRetained =
|
|
1708
|
+
retainedBytes > ACP_RAW_OVERLAP_BYTES
|
|
1709
|
+
? sliceTextAfterUtf8ByteOffset(retainedAcpSnapshot, retainedBytes - ACP_RAW_OVERLAP_BYTES)
|
|
1710
|
+
: retainedAcpSnapshot;
|
|
1711
|
+
const overlap = suffixPrefixOverlap(boundedRetained, boundedSnapshot);
|
|
1712
|
+
const delta = boundedSnapshot.slice(overlap);
|
|
1713
|
+
if (bridgeJobId && delta) ownedManager?.appendOutput(bridgeJobId, delta);
|
|
1714
|
+
retainedAcpSnapshot = boundedSnapshot;
|
|
1715
|
+
retainedAcpTruncated ||= snapshotBytes > ACP_RAW_OVERLAP_BYTES;
|
|
1716
|
+
};
|
|
1717
|
+
const retainedAcpOutput = (): ClientBridgeTerminalOutput => ({
|
|
1718
|
+
output: retainedAcpSnapshot,
|
|
1719
|
+
truncated: retainedAcpTruncated,
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
const runToCompletion = async (
|
|
1723
|
+
runSignal: AbortSignal | undefined,
|
|
1724
|
+
): Promise<AgentToolResult<BashToolDetails>> => {
|
|
1725
|
+
type BridgeRaceResult =
|
|
1726
|
+
| { kind: "exit"; status: ClientBridgeTerminalExitStatus }
|
|
1727
|
+
| { kind: "wait-failed"; error: unknown }
|
|
1728
|
+
| { kind: "poll" }
|
|
1729
|
+
| { kind: "timeout" }
|
|
1730
|
+
| { kind: "aborted" };
|
|
1731
|
+
const exitPromise = Promise.resolve().then(() => handle.waitForExit());
|
|
1732
|
+
const exitRacePromise: Promise<BridgeRaceResult> = exitPromise.then(
|
|
1733
|
+
status => ({ kind: "exit" as const, status }),
|
|
1734
|
+
error => ({ kind: "wait-failed" as const, error }),
|
|
1735
|
+
);
|
|
1736
|
+
let exitStatus!: ClientBridgeTerminalExitStatus;
|
|
1737
|
+
|
|
1738
|
+
// Set up abort listener before entering the poll loop. The listener
|
|
1739
|
+
// kicks off `handle.kill()` synchronously so a `session/cancel`
|
|
1740
|
+
// arriving mid-poll terminates the remote command immediately,
|
|
1741
|
+
// instead of waiting for the next `currentOutput()` to return.
|
|
1742
|
+
const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
|
|
1743
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
1744
|
+
const readOutput = async (
|
|
1745
|
+
limitMs: number,
|
|
1746
|
+
includeAbort = true,
|
|
1747
|
+
includeExit = false,
|
|
1748
|
+
): Promise<ClientBridgeTerminalOutput | undefined> => {
|
|
1749
|
+
const timeout = Promise.withResolvers<undefined>();
|
|
1750
|
+
const timer = setTimeout(() => timeout.resolve(undefined), Math.max(1, limitMs));
|
|
1751
|
+
return Promise.race([
|
|
1752
|
+
handle.currentOutput(),
|
|
1753
|
+
...(includeAbort ? [abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined)] : []),
|
|
1754
|
+
...(includeExit
|
|
1755
|
+
? [exitRacePromise.then(() => undefined as ClientBridgeTerminalOutput | undefined)]
|
|
1756
|
+
: []),
|
|
1757
|
+
timeout.promise,
|
|
1758
|
+
]).finally(() => clearTimeout(timer));
|
|
1759
|
+
};
|
|
1760
|
+
const onAbortSignal = () => {
|
|
1761
|
+
resolveAborted();
|
|
1762
|
+
void fireKill();
|
|
1763
|
+
};
|
|
1764
|
+
runSignal?.addEventListener("abort", onAbortSignal, { once: true });
|
|
1765
|
+
|
|
1463
1766
|
try {
|
|
1464
|
-
if (
|
|
1465
|
-
await
|
|
1767
|
+
if (runSignal?.aborted) {
|
|
1768
|
+
await boundedKill();
|
|
1466
1769
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1467
1770
|
let readDiagnostic: string | undefined;
|
|
1468
1771
|
try {
|
|
1469
|
-
current = await
|
|
1772
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1470
1773
|
} catch (error) {
|
|
1774
|
+
current = retainedAcpOutput();
|
|
1471
1775
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1472
1776
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1473
1777
|
terminalId: handle.terminalId,
|
|
1474
1778
|
error,
|
|
1475
1779
|
});
|
|
1476
1780
|
}
|
|
1781
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1477
1782
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1478
1783
|
throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
|
|
1479
1784
|
}
|
|
@@ -1482,51 +1787,81 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1482
1787
|
// Poll until the process exits, times out, or the caller aborts.
|
|
1483
1788
|
for (;;) {
|
|
1484
1789
|
const racers: Array<Promise<BridgeRaceResult>> = [
|
|
1485
|
-
|
|
1790
|
+
exitRacePromise,
|
|
1486
1791
|
timeoutPromise,
|
|
1487
1792
|
Bun.sleep(250).then(() => ({ kind: "poll" as const })),
|
|
1488
1793
|
];
|
|
1489
|
-
if (
|
|
1794
|
+
if (runSignal) {
|
|
1490
1795
|
racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
|
|
1491
1796
|
}
|
|
1492
1797
|
const raced = await Promise.race(racers);
|
|
1493
1798
|
|
|
1494
|
-
if (raced.kind === "aborted" ||
|
|
1495
|
-
await
|
|
1799
|
+
if (raced.kind === "aborted" || runSignal?.aborted) {
|
|
1800
|
+
await boundedKill();
|
|
1496
1801
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1497
1802
|
let readDiagnostic: string | undefined;
|
|
1498
1803
|
try {
|
|
1499
|
-
current = await
|
|
1804
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1500
1805
|
} catch (error) {
|
|
1806
|
+
current = retainedAcpOutput();
|
|
1501
1807
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1502
1808
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1503
1809
|
terminalId: handle.terminalId,
|
|
1504
1810
|
error,
|
|
1505
1811
|
});
|
|
1506
1812
|
}
|
|
1813
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1507
1814
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1508
1815
|
throw new ToolAbortError(
|
|
1509
1816
|
formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
|
|
1510
1817
|
);
|
|
1511
1818
|
}
|
|
1512
1819
|
|
|
1820
|
+
if (raced.kind === "wait-failed") {
|
|
1821
|
+
await boundedKill();
|
|
1822
|
+
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1823
|
+
let readDiagnostic: string | undefined;
|
|
1824
|
+
try {
|
|
1825
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
current = retainedAcpOutput();
|
|
1828
|
+
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1829
|
+
logger.warn("ACP terminal output recovery after wait failure failed", {
|
|
1830
|
+
terminalId: handle.terminalId,
|
|
1831
|
+
error,
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1835
|
+
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1836
|
+
throw new ToolError(
|
|
1837
|
+
formatClientTerminalWaitFailure(
|
|
1838
|
+
prepared,
|
|
1839
|
+
boundArtifactSaveDiagnostic(raced.error),
|
|
1840
|
+
readDiagnostic,
|
|
1841
|
+
pendingNotices,
|
|
1842
|
+
),
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1513
1846
|
if (raced.kind === "timeout") {
|
|
1514
1847
|
// Kill before reading final output so a slow `terminal/output`
|
|
1515
1848
|
// RPC cannot let a timed-out command keep running past the
|
|
1516
1849
|
// enforced timeout. The handle stays valid post-kill so the
|
|
1517
1850
|
// buffered output is still readable.
|
|
1518
|
-
await
|
|
1851
|
+
await boundedKill();
|
|
1519
1852
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1520
1853
|
let readDiagnostic: string | undefined;
|
|
1521
1854
|
try {
|
|
1522
|
-
current = await
|
|
1855
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1523
1856
|
} catch (error) {
|
|
1857
|
+
current = retainedAcpOutput();
|
|
1524
1858
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1525
1859
|
logger.warn("ACP terminal final output read failed", {
|
|
1526
1860
|
terminalId: handle.terminalId,
|
|
1527
1861
|
error,
|
|
1528
1862
|
});
|
|
1529
1863
|
}
|
|
1864
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1530
1865
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1531
1866
|
const timeoutNotices = [
|
|
1532
1867
|
...pendingNotices,
|
|
@@ -1555,13 +1890,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1555
1890
|
// Poll tick: push current output so agent-loop transcript stays consistent.
|
|
1556
1891
|
// Race the read against abort so a stuck `terminal/output` RPC does not
|
|
1557
1892
|
// delay cancellation.
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1893
|
+
let pollOutput: ClientBridgeTerminalOutput | undefined;
|
|
1894
|
+
try {
|
|
1895
|
+
pollOutput = await readOutput(Math.max(1, deadlineAt - Date.now()), true, true);
|
|
1896
|
+
} catch (error) {
|
|
1897
|
+
await boundedKill();
|
|
1898
|
+
const diagnostic = boundArtifactSaveDiagnostic(error);
|
|
1899
|
+
const recoveredOutput = retainedAcpOutput();
|
|
1900
|
+
appendAcpSnapshot(recoveredOutput.output, recoveredOutput.truncated);
|
|
1901
|
+
const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
|
|
1902
|
+
logger.warn("ACP terminal poll output read failed", {
|
|
1903
|
+
terminalId: handle.terminalId,
|
|
1904
|
+
error,
|
|
1905
|
+
});
|
|
1906
|
+
throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
|
|
1907
|
+
}
|
|
1562
1908
|
if (pollOutput === undefined) {
|
|
1563
1909
|
// Abort fired during the poll-tick read; let the next loop iteration
|
|
1564
|
-
// observe `
|
|
1910
|
+
// observe `runSignal?.aborted` and exit via the abort branch.
|
|
1565
1911
|
continue;
|
|
1566
1912
|
}
|
|
1567
1913
|
const { summary, locallyTruncated } = await boundClientTerminalOutput(
|
|
@@ -1573,40 +1919,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1573
1919
|
pollOutput.truncated || locallyTruncated
|
|
1574
1920
|
? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
|
|
1575
1921
|
: summary.output;
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1922
|
+
latestText = pollText;
|
|
1923
|
+
appendAcpSnapshot(pollOutput.output, pollOutput.truncated);
|
|
1924
|
+
if (!bridgeBackgrounded) {
|
|
1925
|
+
try {
|
|
1926
|
+
onUpdate?.({
|
|
1927
|
+
content: [{ type: "text", text: pollText }],
|
|
1928
|
+
details: { terminalId: handle.terminalId },
|
|
1929
|
+
});
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
await boundedKill();
|
|
1932
|
+
const diagnostic = boundArtifactSaveDiagnostic(error);
|
|
1933
|
+
const recoveredOutput = retainedAcpOutput();
|
|
1934
|
+
const prepared = await prepareClientTerminalOutput(this.session, recoveredOutput);
|
|
1935
|
+
throw new ToolError(formatClientTerminalReadFailure(prepared, diagnostic, pendingNotices));
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1580
1938
|
}
|
|
1581
1939
|
} finally {
|
|
1582
|
-
|
|
1940
|
+
runSignal?.removeEventListener("abort", onAbortSignal);
|
|
1583
1941
|
}
|
|
1584
1942
|
|
|
1585
|
-
if (
|
|
1586
|
-
await
|
|
1943
|
+
if (runSignal?.aborted) {
|
|
1944
|
+
await boundedKill();
|
|
1587
1945
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1588
1946
|
let readDiagnostic: string | undefined;
|
|
1589
1947
|
try {
|
|
1590
|
-
current = await
|
|
1948
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1591
1949
|
} catch (error) {
|
|
1950
|
+
current = retainedAcpOutput();
|
|
1592
1951
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1593
1952
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1594
1953
|
terminalId: handle.terminalId,
|
|
1595
1954
|
error,
|
|
1596
1955
|
});
|
|
1597
1956
|
}
|
|
1957
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1598
1958
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1599
1959
|
throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
|
|
1600
1960
|
}
|
|
1601
1961
|
|
|
1602
1962
|
// Fetch final output; the terminal is released in the outer finally.
|
|
1603
|
-
|
|
1963
|
+
let finalReadDiagnostic: string | undefined;
|
|
1964
|
+
let finalOutput: ClientBridgeTerminalOutput;
|
|
1965
|
+
try {
|
|
1966
|
+
const recovered = await readOutput(1_000, false);
|
|
1967
|
+
if (recovered === undefined) {
|
|
1968
|
+
finalOutput = retainedAcpOutput();
|
|
1969
|
+
finalReadDiagnostic = "terminal/output read timed out";
|
|
1970
|
+
} else {
|
|
1971
|
+
finalOutput = recovered;
|
|
1972
|
+
}
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
finalOutput = retainedAcpOutput();
|
|
1975
|
+
finalReadDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1976
|
+
logger.warn("ACP terminal final output read failed", {
|
|
1977
|
+
terminalId: handle.terminalId,
|
|
1978
|
+
error,
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
if (runSignal?.aborted) {
|
|
1982
|
+
await boundedKill();
|
|
1983
|
+
appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
|
|
1984
|
+
const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
|
|
1985
|
+
throw new ToolAbortError(
|
|
1986
|
+
formatClientTerminalAbortFailure(prepared, finalReadDiagnostic, pendingNotices),
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1604
1989
|
|
|
1605
1990
|
// Map exit status: null exitCode with a signal → treat as signal kill (137).
|
|
1606
1991
|
const rawExitCode = exitStatus.exitCode;
|
|
1607
1992
|
const exitCode: number | undefined =
|
|
1608
1993
|
rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
|
|
1609
1994
|
|
|
1995
|
+
appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
|
|
1610
1996
|
const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
|
|
1611
1997
|
|
|
1612
1998
|
const bridgeResult: BashResult = {
|
|
@@ -1619,19 +2005,188 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1619
2005
|
if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
|
|
1620
2006
|
for (const notice of pendingNotices) bridgeNotices.push(notice);
|
|
1621
2007
|
if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
|
|
2008
|
+
if (finalReadDiagnostic) bridgeNotices.push(`Terminal output recovery failed: ${finalReadDiagnostic}`);
|
|
1622
2009
|
|
|
1623
2010
|
return this.#buildCompletedResult(bridgeResult, timeoutSec, {
|
|
1624
2011
|
requestedTimeoutSec,
|
|
1625
2012
|
notices: bridgeNotices,
|
|
1626
2013
|
terminalId: handle.terminalId,
|
|
1627
2014
|
});
|
|
1628
|
-
}
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
// Without a job manager nothing can fold this, so ownership never leaves the
|
|
2018
|
+
// foreground call.
|
|
2019
|
+
if (!ownedManager) {
|
|
1629
2020
|
try {
|
|
1630
|
-
await
|
|
1631
|
-
}
|
|
1632
|
-
|
|
2021
|
+
return await runToCompletion(signal);
|
|
2022
|
+
} finally {
|
|
2023
|
+
await releaseTerminalOnce();
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Manager-backed runner: the remote command keeps running after a fold and
|
|
2028
|
+
// its completion is delivered like any other background job. createTerminal
|
|
2029
|
+
// already ran above, so exactly ONE remote handle exists and is retained
|
|
2030
|
+
// across the fold.
|
|
2031
|
+
const bridgeManager = ownedManager;
|
|
2032
|
+
const bridgeEndpointId = this.session.getSessionId?.() ?? "local";
|
|
2033
|
+
const bridgeLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
|
|
2034
|
+
const bridgeCompletion = Promise.withResolvers<ManagedBashJobCompletion>();
|
|
2035
|
+
let bridgeForegroundSettled = false;
|
|
2036
|
+
const settleBridgeForeground = (): "resolved" | "already-settled" => {
|
|
2037
|
+
if (bridgeForegroundSettled) return "already-settled";
|
|
2038
|
+
bridgeForegroundSettled = true;
|
|
2039
|
+
return "resolved";
|
|
2040
|
+
};
|
|
2041
|
+
|
|
2042
|
+
try {
|
|
2043
|
+
bridgeJobId = bridgeManager.register(
|
|
2044
|
+
"bash",
|
|
2045
|
+
bridgeLabel,
|
|
2046
|
+
async ({ jobId, signal: runSignal, reportProgress }) => {
|
|
2047
|
+
try {
|
|
2048
|
+
const result = await runToCompletion(runSignal);
|
|
2049
|
+
const finalText = this.#extractTextResult(result);
|
|
2050
|
+
latestText = finalText;
|
|
2051
|
+
if (!bridgeBackgrounded) settleBridgeForeground();
|
|
2052
|
+
bridgeCompletion.resolve({ kind: "completed", result });
|
|
2053
|
+
await reportProgress(
|
|
2054
|
+
finalText,
|
|
2055
|
+
bridgeBackgrounded ? { async: { state: "completed", jobId, type: "bash" } } : undefined,
|
|
2056
|
+
);
|
|
2057
|
+
return finalText;
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2060
|
+
latestText = message;
|
|
2061
|
+
if (!bridgeBackgrounded) settleBridgeForeground();
|
|
2062
|
+
bridgeCompletion.resolve({ kind: "failed", error });
|
|
2063
|
+
await reportProgress(
|
|
2064
|
+
message,
|
|
2065
|
+
bridgeBackgrounded ? { async: { state: "failed", jobId, type: "bash" } } : undefined,
|
|
2066
|
+
);
|
|
2067
|
+
throw error;
|
|
2068
|
+
} finally {
|
|
2069
|
+
await releaseTerminalOnce();
|
|
2070
|
+
}
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
ownerId: this.session.getAgentId?.() ?? undefined,
|
|
2074
|
+
admissionToken: clientAdmission,
|
|
2075
|
+
onProgress: async (text: string) => {
|
|
2076
|
+
latestText = text;
|
|
2077
|
+
},
|
|
2078
|
+
},
|
|
2079
|
+
);
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
await boundedKill();
|
|
2082
|
+
await releaseTerminalOnce();
|
|
2083
|
+
if (clientAdmission) ownedManager?.releaseCapacity(clientAdmission);
|
|
2084
|
+
throw error;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
const bridgeGeneration = bridgeManager.getJob(bridgeJobId)?.generation ?? bridgeJobId;
|
|
2088
|
+
// Owner teardown must not swallow a live remote command. failNow is the one
|
|
2089
|
+
// transition that both fails the job AND enqueues its delivery, so the
|
|
2090
|
+
// failure stays visible instead of becoming a cancelled job that delivers
|
|
2091
|
+
// nothing.
|
|
2092
|
+
const unregisterOwnerCleanup = bridgeManager.registerOwnerCleanup(
|
|
2093
|
+
this.session.getAgentId?.() ?? "0-Main",
|
|
2094
|
+
() => {
|
|
2095
|
+
void boundedKill().then(() => releaseTerminalOnce());
|
|
2096
|
+
bridgeManager.failNow(bridgeJobId, bridgeGeneration, "Client terminal owner was torn down.");
|
|
2097
|
+
},
|
|
2098
|
+
);
|
|
2099
|
+
|
|
2100
|
+
const bridgeHandle: ManagedBashJobHandle = {
|
|
2101
|
+
jobId: bridgeJobId,
|
|
2102
|
+
label: bridgeLabel,
|
|
2103
|
+
completion: bridgeCompletion.promise,
|
|
2104
|
+
getLatestText: () => latestText,
|
|
2105
|
+
setBackgrounded: (next: boolean) => {
|
|
2106
|
+
bridgeBackgrounded = next;
|
|
2107
|
+
},
|
|
2108
|
+
};
|
|
2109
|
+
void bridgeHandle.completion.finally(unregisterOwnerCleanup);
|
|
2110
|
+
registerOwnedIfLineaged(bridgeManager, toolCallId, bridgeJobId, bridgeEndpointId);
|
|
2111
|
+
|
|
2112
|
+
const bridgeFoldRequest = Promise.withResolvers<void>();
|
|
2113
|
+
const bridgeOriginatingTurn = ctx?.attemptScope !== undefined;
|
|
2114
|
+
const unregisterBridgeFold = this.session.registerForegroundFoldParticipant?.({
|
|
2115
|
+
kind: "client-terminal",
|
|
2116
|
+
jobId: bridgeJobId,
|
|
2117
|
+
jobGeneration: bridgeGeneration,
|
|
2118
|
+
label: bridgeLabel,
|
|
2119
|
+
cwdSensitive: true,
|
|
2120
|
+
signal,
|
|
2121
|
+
originatingTurn: bridgeOriginatingTurn,
|
|
2122
|
+
outputRef: {
|
|
2123
|
+
jobId: bridgeJobId,
|
|
2124
|
+
generation: bridgeGeneration,
|
|
2125
|
+
instruction: `Use the job tool's tail operation for ${bridgeJobId} to read this command's output.`,
|
|
2126
|
+
},
|
|
2127
|
+
getJob: () => {
|
|
2128
|
+
const current = bridgeManager.getJob(bridgeJobId);
|
|
2129
|
+
return current?.generation === bridgeGeneration ? current : undefined;
|
|
2130
|
+
},
|
|
2131
|
+
detachObserver: () => {
|
|
2132
|
+
const outcome = settleBridgeForeground();
|
|
2133
|
+
if (outcome === "resolved") {
|
|
2134
|
+
bridgeHandle.setBackgrounded(true);
|
|
2135
|
+
bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
|
|
2136
|
+
bridgeFoldRequest.resolve();
|
|
2137
|
+
}
|
|
2138
|
+
return outcome;
|
|
2139
|
+
},
|
|
2140
|
+
resolveForegroundObserver: () => settleBridgeForeground(),
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
let bridgeWait: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
|
|
2144
|
+
try {
|
|
2145
|
+
bridgeWait = await this.#waitForManagedBashJob(
|
|
2146
|
+
bridgeHandle,
|
|
2147
|
+
this.#autoBackgroundEnabled ? this.#resolveAutoBackgroundWaitMs(timeoutMs) : timeoutMs + 1_000,
|
|
2148
|
+
signal,
|
|
2149
|
+
bridgeFoldRequest.promise,
|
|
2150
|
+
);
|
|
2151
|
+
} finally {
|
|
2152
|
+
unregisterBridgeFold?.();
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
if (bridgeWait.kind === "completed") {
|
|
2156
|
+
unregisterOwnerCleanup();
|
|
2157
|
+
bridgeManager.acknowledgeDeliveries([bridgeJobId]);
|
|
2158
|
+
unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
|
|
2159
|
+
return bridgeWait.result;
|
|
2160
|
+
}
|
|
2161
|
+
if (bridgeWait.kind === "failed") {
|
|
2162
|
+
unregisterOwnerCleanup();
|
|
2163
|
+
bridgeManager.acknowledgeDeliveries([bridgeJobId]);
|
|
2164
|
+
unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
|
|
2165
|
+
throw bridgeWait.error;
|
|
2166
|
+
}
|
|
2167
|
+
if (bridgeWait.kind === "aborted") {
|
|
2168
|
+
unregisterOwnerCleanup();
|
|
2169
|
+
bridgeManager.cancel(bridgeJobId);
|
|
2170
|
+
const terminal = await bridgeHandle.completion;
|
|
2171
|
+
bridgeManager.acknowledgeDeliveries([bridgeJobId]);
|
|
2172
|
+
unregisterForegroundOwnedBash(bridgeManager, bridgeJobId, bridgeEndpointId);
|
|
2173
|
+
if (terminal.kind === "failed") {
|
|
2174
|
+
throw new ToolAbortError(
|
|
2175
|
+
formatManagedAbortFailure(terminal.error, undefined, bridgeHandle.getLatestText()),
|
|
2176
|
+
);
|
|
1633
2177
|
}
|
|
2178
|
+
throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, bridgeHandle.getLatestText()));
|
|
1634
2179
|
}
|
|
2180
|
+
|
|
2181
|
+
// Folded (or auto-backgrounded): the remote terminal keeps running and its
|
|
2182
|
+
// result arrives as a background completion.
|
|
2183
|
+
bridgeHandle.setBackgrounded(true);
|
|
2184
|
+
bridgeManager.markBackgrounded(bridgeJobId, bridgeGeneration);
|
|
2185
|
+
return this.#buildBackgroundStartResult(bridgeJobId, bridgeLabel, bridgeHandle.getLatestText(), timeoutSec, {
|
|
2186
|
+
requestedTimeoutSec,
|
|
2187
|
+
notices: pendingNotices,
|
|
2188
|
+
terminalId: handle.terminalId,
|
|
2189
|
+
});
|
|
1635
2190
|
}
|
|
1636
2191
|
|
|
1637
2192
|
const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
|
|
@@ -1650,6 +2205,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1650
2205
|
: canUseInteractiveBashPty(pty, ctx)
|
|
1651
2206
|
? ctx?.ui
|
|
1652
2207
|
: undefined;
|
|
2208
|
+
const ptyAdmission = interactiveUi && ownedManager ? ownedManager.reserveCapacity() : undefined;
|
|
2209
|
+
// A PTY command is foldable too: the runner owns the session, so the fold
|
|
2210
|
+
// registers a job that delivers the run's real outcome after the foreground
|
|
2211
|
+
// has already returned.
|
|
2212
|
+
let ptyFoldUnregister: (() => void) | undefined;
|
|
2213
|
+
let ptyFoldResult: AgentToolResult<BashToolDetails> | undefined;
|
|
2214
|
+
let ptyBackgrounded = false;
|
|
2215
|
+
let lastPtyFoldKeyTime = 0;
|
|
2216
|
+
let ptyJobId!: string;
|
|
2217
|
+
let ptyOutputSeen = false;
|
|
2218
|
+
let ptyOutcome: BashInteractiveResult | undefined;
|
|
1653
2219
|
const result: BashResult | BashInteractiveResult = interactiveUi
|
|
1654
2220
|
? await runInteractiveBashPty(interactiveUi, {
|
|
1655
2221
|
command,
|
|
@@ -1663,6 +2229,140 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1663
2229
|
artifactPublisher,
|
|
1664
2230
|
spillThreshold,
|
|
1665
2231
|
headBytes,
|
|
2232
|
+
onControls: controls => {
|
|
2233
|
+
if (!ownedManager) return;
|
|
2234
|
+
const ptyManager = ownedManager;
|
|
2235
|
+
const ptyLabel = command.length > 120 ? `${command.slice(0, 117)}...` : command;
|
|
2236
|
+
const ptyOwnerId = this.session.getAgentId?.() ?? "0-Main";
|
|
2237
|
+
let ptyKillStarted = false;
|
|
2238
|
+
const killPtyOnce = (): void => {
|
|
2239
|
+
if (ptyKillStarted) return;
|
|
2240
|
+
ptyKillStarted = true;
|
|
2241
|
+
try {
|
|
2242
|
+
controls.kill();
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
logger.warn("PTY kill failed", {
|
|
2245
|
+
jobId: ptyJobId,
|
|
2246
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
const ownerTeardownText = (): string => {
|
|
2251
|
+
const retained = ptyManager.readOutputSince(ptyJobId, 0, { ownerId: ptyOwnerId });
|
|
2252
|
+
const output = ptyOutcome?.output || retained?.text || "";
|
|
2253
|
+
if (!output && !ptyOutcome) return "PTY job owner was torn down.";
|
|
2254
|
+
const summary = ptyOutcome ? { ...ptyOutcome, output } : interactiveResultFromText(output);
|
|
2255
|
+
const body = [
|
|
2256
|
+
output || "(no output)",
|
|
2257
|
+
...(retained?.truncated && !ptyOutcome?.truncated ? ["(output truncated)"] : []),
|
|
2258
|
+
].join("\n");
|
|
2259
|
+
return formatBashFailureMessage(summary, body, "PTY job owner was torn down.");
|
|
2260
|
+
};
|
|
2261
|
+
// The job's runner simply awaits the run that is ALREADY executing,
|
|
2262
|
+
// so registering never re-executes the command.
|
|
2263
|
+
try {
|
|
2264
|
+
ptyJobId = ptyManager.register(
|
|
2265
|
+
"bash",
|
|
2266
|
+
ptyLabel,
|
|
2267
|
+
async () => {
|
|
2268
|
+
let outcome: BashInteractiveResult | undefined;
|
|
2269
|
+
try {
|
|
2270
|
+
outcome = await controls.terminalCompletion;
|
|
2271
|
+
const completed = this.#buildCompletedResult(outcome, timeoutSec, {
|
|
2272
|
+
requestedTimeoutSec,
|
|
2273
|
+
});
|
|
2274
|
+
return this.#extractTextResult(completed);
|
|
2275
|
+
} finally {
|
|
2276
|
+
if (!ptyOutputSeen && outcome)
|
|
2277
|
+
ptyManager.appendOutput(ptyJobId, this.#formatResultOutput(outcome));
|
|
2278
|
+
if (!ptyBackgrounded) ptyManager.cancel(ptyJobId);
|
|
2279
|
+
}
|
|
2280
|
+
},
|
|
2281
|
+
{
|
|
2282
|
+
ownerId: this.session.getAgentId?.() ?? undefined,
|
|
2283
|
+
admissionToken: ptyAdmission,
|
|
2284
|
+
lifecycle: { onCancel: killPtyOnce },
|
|
2285
|
+
},
|
|
2286
|
+
);
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
killPtyOnce();
|
|
2289
|
+
if (ptyAdmission) ptyManager.releaseCapacity(ptyAdmission);
|
|
2290
|
+
throw error;
|
|
2291
|
+
}
|
|
2292
|
+
const ptyGeneration = ptyManager.getJob(ptyJobId)?.generation ?? ptyJobId;
|
|
2293
|
+
registerOwnedIfLineaged(ptyManager, toolCallId, ptyJobId, this.session.getSessionId?.() ?? undefined);
|
|
2294
|
+
const unregisterPtyOwnerCleanup = ptyManager.registerOwnerCleanup(ptyOwnerId, () => {
|
|
2295
|
+
killPtyOnce();
|
|
2296
|
+
ptyManager.failNow(ptyJobId, ptyGeneration, ownerTeardownText());
|
|
2297
|
+
});
|
|
2298
|
+
void controls.terminalCompletion.then(
|
|
2299
|
+
outcome => {
|
|
2300
|
+
ptyOutcome = outcome;
|
|
2301
|
+
},
|
|
2302
|
+
(error: unknown) => {
|
|
2303
|
+
logger.warn("PTY terminal completion failed", {
|
|
2304
|
+
jobId: ptyJobId,
|
|
2305
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2306
|
+
});
|
|
2307
|
+
},
|
|
2308
|
+
);
|
|
2309
|
+
void controls.terminalCompletion.finally(unregisterPtyOwnerCleanup);
|
|
2310
|
+
ptyFoldUnregister = this.session.registerForegroundFoldParticipant?.({
|
|
2311
|
+
kind: "bash-pty",
|
|
2312
|
+
jobId: ptyJobId,
|
|
2313
|
+
jobGeneration: ptyGeneration,
|
|
2314
|
+
label: ptyLabel,
|
|
2315
|
+
cwdSensitive: true,
|
|
2316
|
+
signal,
|
|
2317
|
+
originatingTurn: ctx?.attemptScope !== undefined,
|
|
2318
|
+
outputRef: {
|
|
2319
|
+
jobId: ptyJobId,
|
|
2320
|
+
generation: ptyGeneration,
|
|
2321
|
+
instruction: `Use the job tool's tail operation for ${ptyJobId} to read this command's output.`,
|
|
2322
|
+
},
|
|
2323
|
+
getJob: () => {
|
|
2324
|
+
const current = ptyManager.getJob(ptyJobId);
|
|
2325
|
+
return current?.generation === ptyGeneration ? current : undefined;
|
|
2326
|
+
},
|
|
2327
|
+
detachObserver: () => {
|
|
2328
|
+
// Output-only continuation: stdin forwarding ends here and the
|
|
2329
|
+
// process is never killed or restarted by folding.
|
|
2330
|
+
const started = this.#buildBackgroundStartResult(ptyJobId, ptyLabel, "", timeoutSec, {
|
|
2331
|
+
requestedTimeoutSec,
|
|
2332
|
+
notices: pendingNotices,
|
|
2333
|
+
});
|
|
2334
|
+
const outcome = controls.detachObserver(
|
|
2335
|
+
interactiveResultFromText(this.#extractTextResult(started)),
|
|
2336
|
+
);
|
|
2337
|
+
if (outcome === "resolved") {
|
|
2338
|
+
controls.detachForegroundCancellation();
|
|
2339
|
+
ptyBackgrounded = true;
|
|
2340
|
+
ptyManager.markBackgrounded(ptyJobId, ptyGeneration);
|
|
2341
|
+
ptyFoldResult = started;
|
|
2342
|
+
}
|
|
2343
|
+
return outcome;
|
|
2344
|
+
},
|
|
2345
|
+
resolveForegroundObserver: () => "already-settled",
|
|
2346
|
+
});
|
|
2347
|
+
},
|
|
2348
|
+
onFoldKey: () => {
|
|
2349
|
+
const now = Date.now();
|
|
2350
|
+
if (now - lastPtyFoldKeyTime > 750) {
|
|
2351
|
+
lastPtyFoldKeyTime = now;
|
|
2352
|
+
return true;
|
|
2353
|
+
}
|
|
2354
|
+
lastPtyFoldKeyTime = 0;
|
|
2355
|
+
if (!this.session.hasForegroundBashBackgroundRequestHandler?.()) return false;
|
|
2356
|
+
void this.session.requestForegroundBashBackground?.();
|
|
2357
|
+
return true;
|
|
2358
|
+
},
|
|
2359
|
+
onOutput: chunk => {
|
|
2360
|
+
ptyOutputSeen = true;
|
|
2361
|
+
if (ptyJobId) ownedManager?.appendOutput(ptyJobId, chunk);
|
|
2362
|
+
},
|
|
2363
|
+
}).catch(error => {
|
|
2364
|
+
if (ptyAdmission) ownedManager?.releaseCapacity(ptyAdmission);
|
|
2365
|
+
throw error;
|
|
1666
2366
|
})
|
|
1667
2367
|
: await executeBash(command, {
|
|
1668
2368
|
cwd: commandCwd,
|
|
@@ -1682,6 +2382,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1682
2382
|
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
1683
2383
|
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
1684
2384
|
});
|
|
2385
|
+
ptyFoldUnregister?.();
|
|
2386
|
+
// Folded: the foreground was settled by the fold, so return the background
|
|
2387
|
+
// -start result verbatim instead of presenting a half-finished command.
|
|
2388
|
+
if (ptyFoldResult) return ptyFoldResult;
|
|
2389
|
+
|
|
1685
2390
|
if (result.cancelled) {
|
|
1686
2391
|
const noticeSuffix = pendingNotices.length > 0 ? `\n\n${pendingNotices.join("\n")}` : "";
|
|
1687
2392
|
const baseCancelledText = normalizeResultOutput(result) || "Command aborted";
|