@gajae-code/coding-agent 0.15.4 → 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 +46 -2
- 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 +27 -5
- 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 +99 -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 +4 -4
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- 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/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/cli/session-cli.d.ts +42 -1
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +2 -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 +19 -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 -5
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
- 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/tools/path-utils.d.ts +5 -6
- package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
- 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 +142 -6
- 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/commands/sdk.ts +5 -0
- package/src/config/autorouting-tier-map.ts +33 -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 +56 -14
- package/src/config/model-registry.ts +1028 -123
- 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/policy.ts +1 -1
- 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 +4 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/extensibility/shared-events.ts +2 -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.ts +44 -7
- package/src/gjc-runtime/ralplan-runtime.ts +14 -1
- package/src/gjc-runtime/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/gjc-runtime/tmux-sessions.ts +145 -48
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- 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/prompt-action-autocomplete.ts +26 -47
- 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 +46 -8
- package/src/sdk/bus/prompt-reconciliation.ts +33 -6
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/cli/session-cli.ts +161 -5
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- package/src/sdk/host/host.ts +7 -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 +1240 -107
- package/src/sdk/host/session-runtime.ts +1029 -115
- 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 +297 -111
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +887 -95
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +64 -26
- 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 +29 -10
- 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 +786 -67
- package/src/tools/index.ts +7 -5
- package/src/tools/path-utils.ts +77 -29
- package/src/tools/tmux-self-injection-guard.ts +359 -0
- 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,
|
|
@@ -59,8 +63,23 @@ import {
|
|
|
59
63
|
} from "./output-meta";
|
|
60
64
|
import { resolveToCwd } from "./path-utils";
|
|
61
65
|
import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
|
|
66
|
+
import { checkTmuxSelfInjection } from "./tmux-self-injection-guard";
|
|
62
67
|
import { ToolAbortError, ToolError } from "./tool-errors";
|
|
63
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
|
+
|
|
64
83
|
import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
|
|
65
84
|
|
|
66
85
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
@@ -69,6 +88,7 @@ const BASH_ERROR_MAX_BYTES = 4096;
|
|
|
69
88
|
const ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
|
|
70
89
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
71
90
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
91
|
+
const ACP_RELEASE_TIMEOUT_MS = 1_000;
|
|
72
92
|
const READ_ONLY_BASH_ENV: Record<string, string> = {
|
|
73
93
|
GREP_OPTIONS: "",
|
|
74
94
|
GREP_COLOR: "",
|
|
@@ -150,6 +170,27 @@ function artifactReferenceIsReachable(text: string, result: BashResult | BashInt
|
|
|
150
170
|
);
|
|
151
171
|
}
|
|
152
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
|
+
|
|
153
194
|
function artifactFailureDiagnosticForResult(result: BashResult | BashInteractiveResult): string | undefined {
|
|
154
195
|
const diagnostic = (result as OutputSummary).artifactFailureDiagnostic;
|
|
155
196
|
return typeof diagnostic === "string" && diagnostic.length > 0 ? diagnostic : undefined;
|
|
@@ -290,6 +331,48 @@ function formatClientTerminalAbortFailure(
|
|
|
290
331
|
return formatBashFailureMessage(result, outputText, "Command aborted");
|
|
291
332
|
}
|
|
292
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
|
+
|
|
293
376
|
function appendArtifactDetails(text: string, result: BashResult | BashInteractiveResult): string {
|
|
294
377
|
const suffixParts: string[] = [];
|
|
295
378
|
const reference = artifactReferenceForResult(result);
|
|
@@ -467,6 +550,21 @@ interface ManagedBashJobHandle {
|
|
|
467
550
|
setBackgrounded: (backgrounded: boolean) => void;
|
|
468
551
|
}
|
|
469
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
|
+
|
|
470
568
|
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
|
|
471
569
|
return result.output || "";
|
|
472
570
|
}
|
|
@@ -647,6 +745,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
647
745
|
this.description = prompt.render(bashDescription, {
|
|
648
746
|
asyncEnabled: this.#asyncEnabled,
|
|
649
747
|
autoBackgroundEnabled: this.#autoBackgroundEnabled,
|
|
748
|
+
foregroundFoldEnabled: this.session.registerForegroundFoldParticipant !== undefined,
|
|
650
749
|
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
|
|
651
750
|
hasAstGrep: this.session.settings.get("astGrep.enabled"),
|
|
652
751
|
hasAstEdit: this.session.settings.get("astEdit.enabled"),
|
|
@@ -731,12 +830,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
731
830
|
label: string,
|
|
732
831
|
previewText: string,
|
|
733
832
|
timeoutSec: number,
|
|
734
|
-
options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
|
|
833
|
+
options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
|
|
735
834
|
): AgentToolResult<BashToolDetails> {
|
|
736
835
|
const details: BashToolDetails = {
|
|
737
836
|
timeoutSeconds: timeoutSec,
|
|
738
837
|
async: { state: "running", jobId, type: "bash" },
|
|
739
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;
|
|
740
842
|
if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
|
|
741
843
|
details.requestedTimeoutSeconds = options.requestedTimeoutSec;
|
|
742
844
|
}
|
|
@@ -774,6 +876,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
774
876
|
resolvedEnv?: Record<string, string>;
|
|
775
877
|
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
|
|
776
878
|
startBackgrounded: boolean;
|
|
879
|
+
onCompletion?: () => void;
|
|
777
880
|
/** Immutable attempt-scoped tool call id, when executed via a tool call. */
|
|
778
881
|
toolCallId?: string;
|
|
779
882
|
}): ManagedBashJobHandle {
|
|
@@ -844,12 +947,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
844
947
|
});
|
|
845
948
|
const finalText = this.#extractTextResult(finalResult);
|
|
846
949
|
latestText = finalText;
|
|
950
|
+
options.onCompletion?.();
|
|
847
951
|
completion.resolve({ kind: "completed", result: finalResult });
|
|
848
952
|
await reportProgress(finalText, completedDetails(jobId));
|
|
849
953
|
return finalText;
|
|
850
954
|
} catch (error) {
|
|
851
955
|
const message = error instanceof Error ? error.message : String(error);
|
|
852
956
|
latestText = message;
|
|
957
|
+
options.onCompletion?.();
|
|
853
958
|
completion.resolve({ kind: "failed", error, result: executionResult });
|
|
854
959
|
await reportProgress(message, failedDetails(jobId));
|
|
855
960
|
throw error;
|
|
@@ -866,6 +971,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
866
971
|
},
|
|
867
972
|
},
|
|
868
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);
|
|
869
979
|
registerOwnedIfLineaged(manager, options.toolCallId, jobId, this.session.getSessionId?.() ?? undefined);
|
|
870
980
|
|
|
871
981
|
return {
|
|
@@ -892,9 +1002,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
892
1002
|
*/
|
|
893
1003
|
#resolveOwnedJobManager(): AsyncJobManager | undefined {
|
|
894
1004
|
const endpointId = this.session.getSessionId?.() ?? undefined;
|
|
895
|
-
return (
|
|
896
|
-
|
|
897
|
-
);
|
|
1005
|
+
if (this.session.getAsyncJobManager) return this.session.getAsyncJobManager();
|
|
1006
|
+
return AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance();
|
|
898
1007
|
}
|
|
899
1008
|
|
|
900
1009
|
async #waitForManagedBashJob(
|
|
@@ -1053,6 +1162,19 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1053
1162
|
...internalUrlOptions,
|
|
1054
1163
|
ensureLocalParentDirs: this.session.bashRestrictionProfile !== "read-only",
|
|
1055
1164
|
});
|
|
1165
|
+
// Issue #5039: refuse tmux write verbs whose target resolves to this
|
|
1166
|
+
// agent's own tmux pane — such bytes forge a human `user` turn in the
|
|
1167
|
+
// transcript. This is after command expansion but before any spawn, so
|
|
1168
|
+
// blocked attempts inject no bytes and create no transcript turn.
|
|
1169
|
+
if (this.session.bashRestrictionProfile !== "read-only") {
|
|
1170
|
+
const injection = await checkTmuxSelfInjection(command, {
|
|
1171
|
+
env: process.env,
|
|
1172
|
+
cwd: this.session.cwd,
|
|
1173
|
+
});
|
|
1174
|
+
if (injection.block) {
|
|
1175
|
+
throw new ToolError(injection.reason ?? "Blocked: tmux self-pane injection.");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1056
1178
|
const expandedEnv = env
|
|
1057
1179
|
? Object.fromEntries(
|
|
1058
1180
|
await Promise.all(
|
|
@@ -1151,6 +1273,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1151
1273
|
if (!manager) {
|
|
1152
1274
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
1153
1275
|
}
|
|
1276
|
+
if (!manager.hasCapacity()) {
|
|
1277
|
+
throw new ToolError("Background job limit reached. Wait for running jobs to finish or cancel one.");
|
|
1278
|
+
}
|
|
1154
1279
|
const prepared = await this.#prepareBashExecution(input, opts.ctx);
|
|
1155
1280
|
const label =
|
|
1156
1281
|
opts.label ?? (prepared.command.length > 120 ? `${prepared.command.slice(0, 117)}...` : prepared.command);
|
|
@@ -1275,6 +1400,13 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1275
1400
|
if (this.session.bashRestrictionProfile === "read-only" && pty) {
|
|
1276
1401
|
throw new ToolError("Read-only bash does not allow PTY mode.");
|
|
1277
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
|
+
}
|
|
1278
1410
|
|
|
1279
1411
|
const prepared = await this.#prepareBashExecution(
|
|
1280
1412
|
{ command: rawCommand, env: rawEnv, timeout: rawTimeout, cwd },
|
|
@@ -1295,7 +1427,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1295
1427
|
// that was the process-global instance may have been disposed,
|
|
1296
1428
|
// clearing instance() while THIS session's manager stays
|
|
1297
1429
|
// registered by endpoint (review thread P1).
|
|
1298
|
-
|
|
1430
|
+
const asyncManager = admissionManager ?? this.#resolveOwnedJobManager();
|
|
1431
|
+
if (!asyncManager) {
|
|
1299
1432
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
1300
1433
|
}
|
|
1301
1434
|
const job = this.#startManagedBashJob({
|
|
@@ -1311,6 +1444,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1311
1444
|
startBackgrounded: true,
|
|
1312
1445
|
toolCallId,
|
|
1313
1446
|
});
|
|
1447
|
+
const jobGeneration = asyncManager.getJob(job.jobId)?.generation ?? job.jobId;
|
|
1448
|
+
job.setBackgrounded(true);
|
|
1449
|
+
asyncManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1314
1450
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
|
|
1315
1451
|
requestedTimeoutSec,
|
|
1316
1452
|
notices: pendingNotices,
|
|
@@ -1331,13 +1467,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1331
1467
|
// and unregistering — the process-global instance may belong to a different
|
|
1332
1468
|
// concurrent top-level session (review thread P1).
|
|
1333
1469
|
const ownedManager = this.#resolveOwnedJobManager();
|
|
1334
|
-
|
|
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) {
|
|
1335
1474
|
// With auto-background off, wait past the command's own timeout so the job only
|
|
1336
1475
|
// leaves the foreground on an explicit Ctrl+B fold, never on an auto-background timer.
|
|
1337
1476
|
const autoBackgroundWaitMs = this.#autoBackgroundEnabled
|
|
1338
1477
|
? this.#resolveAutoBackgroundWaitMs(timeoutMs)
|
|
1339
1478
|
: timeoutMs + 1_000;
|
|
1340
1479
|
const startBackgrounded = autoBackgroundWaitMs === 0;
|
|
1480
|
+
let managedForegroundSettled = false;
|
|
1341
1481
|
const job = this.#startManagedBashJob({
|
|
1342
1482
|
command,
|
|
1343
1483
|
commandCwd,
|
|
@@ -1349,18 +1489,59 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1349
1489
|
resolvedEnv,
|
|
1350
1490
|
onUpdate,
|
|
1351
1491
|
startBackgrounded,
|
|
1492
|
+
onCompletion: () => {
|
|
1493
|
+
managedForegroundSettled = true;
|
|
1494
|
+
},
|
|
1352
1495
|
toolCallId,
|
|
1353
1496
|
});
|
|
1497
|
+
const jobGeneration = ownedManager.getJob(job.jobId)?.generation ?? job.jobId;
|
|
1354
1498
|
if (startBackgrounded) {
|
|
1499
|
+
job.setBackgrounded(true);
|
|
1500
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1355
1501
|
return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
|
|
1356
1502
|
requestedTimeoutSec,
|
|
1357
1503
|
notices: pendingNotices,
|
|
1358
1504
|
});
|
|
1359
1505
|
}
|
|
1360
1506
|
const backgroundRequest = Promise.withResolvers<void>();
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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(),
|
|
1364
1545
|
});
|
|
1365
1546
|
let waitResult: ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" };
|
|
1366
1547
|
try {
|
|
@@ -1396,6 +1577,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1396
1577
|
throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, job.getLatestText()));
|
|
1397
1578
|
}
|
|
1398
1579
|
job.setBackgrounded(true);
|
|
1580
|
+
ownedManager.markBackgrounded(job.jobId, jobGeneration);
|
|
1399
1581
|
return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
|
|
1400
1582
|
requestedTimeoutSec,
|
|
1401
1583
|
notices: pendingNotices,
|
|
@@ -1403,63 +1585,200 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1403
1585
|
}
|
|
1404
1586
|
|
|
1405
1587
|
if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
|
|
1588
|
+
const clientAdmission = ownedManager?.reserveCapacity();
|
|
1406
1589
|
const clientHeadBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
|
|
1407
1590
|
const clientTailBytes = resolveBashOutputSinkTailBytes(this.session.settings);
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
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 }
|
|
1426
1625
|
| { kind: "timeout" }
|
|
1427
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;
|
|
1428
1657
|
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
+
};
|
|
1434
1666
|
let killPromise: Promise<void> | undefined;
|
|
1435
1667
|
const fireKill = (): Promise<void> => {
|
|
1436
1668
|
if (killPromise) return killPromise;
|
|
1437
|
-
|
|
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) {
|
|
1438
1674
|
logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
|
|
1439
|
-
|
|
1675
|
+
killPromise = Promise.resolve();
|
|
1676
|
+
}
|
|
1440
1677
|
return killPromise;
|
|
1441
1678
|
};
|
|
1442
|
-
const
|
|
1443
|
-
|
|
1444
|
-
void fireKill();
|
|
1679
|
+
const boundedKill = async (): Promise<void> => {
|
|
1680
|
+
await runBoundedCleanup(handle, fireKill, "kill");
|
|
1445
1681
|
};
|
|
1446
|
-
signal?.addEventListener("abort", onAbortSignal, { once: true });
|
|
1447
1682
|
|
|
1683
|
+
// Emit partial update so the editor can embed the live terminal card.
|
|
1448
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
|
+
|
|
1449
1766
|
try {
|
|
1450
|
-
if (
|
|
1451
|
-
await
|
|
1767
|
+
if (runSignal?.aborted) {
|
|
1768
|
+
await boundedKill();
|
|
1452
1769
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1453
1770
|
let readDiagnostic: string | undefined;
|
|
1454
1771
|
try {
|
|
1455
|
-
current = await
|
|
1772
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1456
1773
|
} catch (error) {
|
|
1774
|
+
current = retainedAcpOutput();
|
|
1457
1775
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1458
1776
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1459
1777
|
terminalId: handle.terminalId,
|
|
1460
1778
|
error,
|
|
1461
1779
|
});
|
|
1462
1780
|
}
|
|
1781
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1463
1782
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1464
1783
|
throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
|
|
1465
1784
|
}
|
|
@@ -1468,51 +1787,81 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1468
1787
|
// Poll until the process exits, times out, or the caller aborts.
|
|
1469
1788
|
for (;;) {
|
|
1470
1789
|
const racers: Array<Promise<BridgeRaceResult>> = [
|
|
1471
|
-
|
|
1790
|
+
exitRacePromise,
|
|
1472
1791
|
timeoutPromise,
|
|
1473
1792
|
Bun.sleep(250).then(() => ({ kind: "poll" as const })),
|
|
1474
1793
|
];
|
|
1475
|
-
if (
|
|
1794
|
+
if (runSignal) {
|
|
1476
1795
|
racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
|
|
1477
1796
|
}
|
|
1478
1797
|
const raced = await Promise.race(racers);
|
|
1479
1798
|
|
|
1480
|
-
if (raced.kind === "aborted" ||
|
|
1481
|
-
await
|
|
1799
|
+
if (raced.kind === "aborted" || runSignal?.aborted) {
|
|
1800
|
+
await boundedKill();
|
|
1482
1801
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1483
1802
|
let readDiagnostic: string | undefined;
|
|
1484
1803
|
try {
|
|
1485
|
-
current = await
|
|
1804
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1486
1805
|
} catch (error) {
|
|
1806
|
+
current = retainedAcpOutput();
|
|
1487
1807
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1488
1808
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1489
1809
|
terminalId: handle.terminalId,
|
|
1490
1810
|
error,
|
|
1491
1811
|
});
|
|
1492
1812
|
}
|
|
1813
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1493
1814
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1494
1815
|
throw new ToolAbortError(
|
|
1495
1816
|
formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices),
|
|
1496
1817
|
);
|
|
1497
1818
|
}
|
|
1498
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
|
+
|
|
1499
1846
|
if (raced.kind === "timeout") {
|
|
1500
1847
|
// Kill before reading final output so a slow `terminal/output`
|
|
1501
1848
|
// RPC cannot let a timed-out command keep running past the
|
|
1502
1849
|
// enforced timeout. The handle stays valid post-kill so the
|
|
1503
1850
|
// buffered output is still readable.
|
|
1504
|
-
await
|
|
1851
|
+
await boundedKill();
|
|
1505
1852
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1506
1853
|
let readDiagnostic: string | undefined;
|
|
1507
1854
|
try {
|
|
1508
|
-
current = await
|
|
1855
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1509
1856
|
} catch (error) {
|
|
1857
|
+
current = retainedAcpOutput();
|
|
1510
1858
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1511
1859
|
logger.warn("ACP terminal final output read failed", {
|
|
1512
1860
|
terminalId: handle.terminalId,
|
|
1513
1861
|
error,
|
|
1514
1862
|
});
|
|
1515
1863
|
}
|
|
1864
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1516
1865
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1517
1866
|
const timeoutNotices = [
|
|
1518
1867
|
...pendingNotices,
|
|
@@ -1541,13 +1890,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1541
1890
|
// Poll tick: push current output so agent-loop transcript stays consistent.
|
|
1542
1891
|
// Race the read against abort so a stuck `terminal/output` RPC does not
|
|
1543
1892
|
// delay cancellation.
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
+
}
|
|
1548
1908
|
if (pollOutput === undefined) {
|
|
1549
1909
|
// Abort fired during the poll-tick read; let the next loop iteration
|
|
1550
|
-
// observe `
|
|
1910
|
+
// observe `runSignal?.aborted` and exit via the abort branch.
|
|
1551
1911
|
continue;
|
|
1552
1912
|
}
|
|
1553
1913
|
const { summary, locallyTruncated } = await boundClientTerminalOutput(
|
|
@@ -1559,40 +1919,80 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1559
1919
|
pollOutput.truncated || locallyTruncated
|
|
1560
1920
|
? `${summary.output}${summary.output.endsWith("\n") ? "" : "\n"}(output truncated)`
|
|
1561
1921
|
: summary.output;
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
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
|
+
}
|
|
1566
1938
|
}
|
|
1567
1939
|
} finally {
|
|
1568
|
-
|
|
1940
|
+
runSignal?.removeEventListener("abort", onAbortSignal);
|
|
1569
1941
|
}
|
|
1570
1942
|
|
|
1571
|
-
if (
|
|
1572
|
-
await
|
|
1943
|
+
if (runSignal?.aborted) {
|
|
1944
|
+
await boundedKill();
|
|
1573
1945
|
let current: ClientBridgeTerminalOutput = { output: "", truncated: false };
|
|
1574
1946
|
let readDiagnostic: string | undefined;
|
|
1575
1947
|
try {
|
|
1576
|
-
current = await
|
|
1948
|
+
current = (await readOutput(1_000, false)) ?? retainedAcpOutput();
|
|
1577
1949
|
} catch (error) {
|
|
1950
|
+
current = retainedAcpOutput();
|
|
1578
1951
|
readDiagnostic = boundArtifactSaveDiagnostic(error);
|
|
1579
1952
|
logger.warn("ACP terminal aborted output read failed", {
|
|
1580
1953
|
terminalId: handle.terminalId,
|
|
1581
1954
|
error,
|
|
1582
1955
|
});
|
|
1583
1956
|
}
|
|
1957
|
+
appendAcpSnapshot(current.output, current.truncated);
|
|
1584
1958
|
const prepared = await prepareClientTerminalOutput(this.session, current);
|
|
1585
1959
|
throw new ToolAbortError(formatClientTerminalAbortFailure(prepared, readDiagnostic, pendingNotices));
|
|
1586
1960
|
}
|
|
1587
1961
|
|
|
1588
1962
|
// Fetch final output; the terminal is released in the outer finally.
|
|
1589
|
-
|
|
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
|
+
}
|
|
1590
1989
|
|
|
1591
1990
|
// Map exit status: null exitCode with a signal → treat as signal kill (137).
|
|
1592
1991
|
const rawExitCode = exitStatus.exitCode;
|
|
1593
1992
|
const exitCode: number | undefined =
|
|
1594
1993
|
rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
|
|
1595
1994
|
|
|
1995
|
+
appendAcpSnapshot(finalOutput.output, finalOutput.truncated);
|
|
1596
1996
|
const prepared = await prepareClientTerminalOutput(this.session, finalOutput);
|
|
1597
1997
|
|
|
1598
1998
|
const bridgeResult: BashResult = {
|
|
@@ -1605,19 +2005,188 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1605
2005
|
if (finalOutput.truncated || prepared.locallyTruncated) bridgeNotices.push("(output truncated)");
|
|
1606
2006
|
for (const notice of pendingNotices) bridgeNotices.push(notice);
|
|
1607
2007
|
if (prepared.artifactSaveNotice) bridgeNotices.push(prepared.artifactSaveNotice);
|
|
2008
|
+
if (finalReadDiagnostic) bridgeNotices.push(`Terminal output recovery failed: ${finalReadDiagnostic}`);
|
|
1608
2009
|
|
|
1609
2010
|
return this.#buildCompletedResult(bridgeResult, timeoutSec, {
|
|
1610
2011
|
requestedTimeoutSec,
|
|
1611
2012
|
notices: bridgeNotices,
|
|
1612
2013
|
terminalId: handle.terminalId,
|
|
1613
2014
|
});
|
|
1614
|
-
}
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
// Without a job manager nothing can fold this, so ownership never leaves the
|
|
2018
|
+
// foreground call.
|
|
2019
|
+
if (!ownedManager) {
|
|
1615
2020
|
try {
|
|
1616
|
-
await
|
|
1617
|
-
}
|
|
1618
|
-
|
|
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
|
+
);
|
|
1619
2177
|
}
|
|
2178
|
+
throw new ToolAbortError(formatManagedAbortFailure(undefined, undefined, bridgeHandle.getLatestText()));
|
|
1620
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
|
+
});
|
|
1621
2190
|
}
|
|
1622
2191
|
|
|
1623
2192
|
const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
|
|
@@ -1636,6 +2205,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1636
2205
|
: canUseInteractiveBashPty(pty, ctx)
|
|
1637
2206
|
? ctx?.ui
|
|
1638
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;
|
|
1639
2219
|
const result: BashResult | BashInteractiveResult = interactiveUi
|
|
1640
2220
|
? await runInteractiveBashPty(interactiveUi, {
|
|
1641
2221
|
command,
|
|
@@ -1649,6 +2229,140 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1649
2229
|
artifactPublisher,
|
|
1650
2230
|
spillThreshold,
|
|
1651
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;
|
|
1652
2366
|
})
|
|
1653
2367
|
: await executeBash(command, {
|
|
1654
2368
|
cwd: commandCwd,
|
|
@@ -1668,6 +2382,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
1668
2382
|
ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
|
|
1669
2383
|
disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
|
|
1670
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
|
+
|
|
1671
2390
|
if (result.cancelled) {
|
|
1672
2391
|
const noticeSuffix = pendingNotices.length > 0 ? `\n\n${pendingNotices.join("\n")}` : "";
|
|
1673
2392
|
const baseCancelledText = normalizeResultOutput(result) || "Command aborted";
|