@vybestack/llxprt-code 0.10.0 → 0.11.0-nightly.260728.ffaa8d5d7
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/package.json +13 -13
- package/src/commands/mcp/add.ts +8 -4
- package/src/config/cliArgParser.ts +2 -0
- package/src/config/config.ts +2 -0
- package/src/config/configBuilder.ts +3 -0
- package/src/config/configError.ts +23 -0
- package/src/config/extensions/extensionEnablement.ts +62 -1
- package/src/config/interactiveContext.ts +2 -1
- package/src/config/intermediateConfig.ts +4 -0
- package/src/config/keyBindings.ts +29 -3
- package/src/config/policy.ts +7 -1
- package/src/config/postConfigRuntime.ts +20 -14
- package/src/config/settings-schema/schema-ui.ts +9 -0
- package/src/config/settingsLoader.ts +21 -17
- package/src/config/settingsSchema.ts +2 -2
- package/src/config/trustedFolders.ts +275 -58
- package/src/config/yargsOptions.ts +7 -0
- package/src/generated/git-commit.json +1 -1
- package/src/nonInteractiveCli.ts +1 -0
- package/src/nonInteractiveCliSupport.ts +171 -51
- package/src/runtime/interactiveToolScheduler.ts +6 -6
- package/src/services/BuiltinCommandLoader.ts +33 -25
- package/src/services/FileCommandLoader.ts +34 -7
- package/src/test-utils/async.ts +20 -0
- package/src/test-utils/render.tsx +10 -1
- package/src/ui/App.tsx +17 -10
- package/src/ui/AppContainerRuntime.tsx +7 -9
- package/src/ui/cliUiRuntime.ts +28 -0
- package/src/ui/commands/authCommand.ts +240 -3
- package/src/ui/commands/compressCommand.ts +9 -1
- package/src/ui/commands/directoryCommand.tsx +1 -1
- package/src/ui/commands/logoutCommand.ts +2 -2
- package/src/ui/commands/permissionsCommand.ts +77 -20
- package/src/ui/commands/policiesCommand.ts +6 -1
- package/src/ui/commands/quotaCommand.ts +501 -0
- package/src/ui/commands/statsCommand.ts +2 -2
- package/src/ui/commands/statsQuota.ts +1 -1
- package/src/ui/commands/subagentCommand.ts +59 -27
- package/src/ui/components/AuthDialog.tsx +3 -3
- package/src/ui/components/Composer.tsx +1 -0
- package/src/ui/components/DialogManager.tsx +9 -2
- package/src/ui/components/FolderTrustDialog.tsx +71 -60
- package/src/ui/components/PermissionsModifyTrustDialog.tsx +211 -146
- package/src/ui/components/ProfileCreateWizard/constants.ts +13 -1
- package/src/ui/components/QueuedMessagesPanel.tsx +388 -0
- package/src/ui/components/inputPromptHooks.ts +2 -0
- package/src/ui/components/inputPromptKeyHandlers.ts +14 -0
- package/src/ui/components/inputPromptTypes.ts +6 -0
- package/src/ui/components/messages/AiMessage.tsx +24 -12
- package/src/ui/components/messages/ToolMessage.tsx +26 -40
- package/src/ui/containers/AppContainer/builders/buildUIActions.ts +3 -1
- package/src/ui/containers/AppContainer/builders/buildUIState.ts +5 -4
- package/src/ui/containers/AppContainer/hooks/useAppBootstrap.ts +3 -33
- package/src/ui/containers/AppContainer/hooks/useAppDialogs.ts +2 -14
- package/src/ui/containers/AppContainer/hooks/useAppInput.ts +31 -25
- package/src/ui/containers/AppContainer/hooks/useAppLayout.ts +90 -60
- package/src/ui/containers/AppContainer/hooks/useDisplayPreferences.ts +10 -0
- package/src/ui/containers/AppContainer/hooks/useInputHandling.ts +3 -17
- package/src/ui/containers/AppContainer/hooks/useKeybindings.ts +51 -38
- package/src/ui/containers/AppContainer/hooks/useSteer.ts +51 -0
- package/src/ui/contexts/KeypressContext.tsx +13 -2
- package/src/ui/contexts/ShellCommandDisplayContext.tsx +103 -0
- package/src/ui/contexts/TodoContext.tsx +0 -13
- package/src/ui/contexts/TodoProvider.tsx +1 -34
- package/src/ui/contexts/UIActionsContext.tsx +2 -1
- package/src/ui/contexts/UIStateContext.tsx +3 -2
- package/src/ui/hooks/agentStream/streamUtils.ts +0 -24
- package/src/ui/hooks/agentStream/thoughtState.ts +110 -30
- package/src/ui/hooks/agentStream/types.ts +1 -0
- package/src/ui/hooks/agentStream/useAgentEventStream.ts +8 -17
- package/src/ui/hooks/agentStream/useAgentStream.ts +2 -2
- package/src/ui/hooks/agentStream/useAgentStreamLifecycle.ts +4 -5
- package/src/ui/hooks/agentStream/useAgentStreamOrchestration.ts +12 -5
- package/src/ui/hooks/agentStream/useQueuedSubmissions.ts +99 -0
- package/src/ui/hooks/agentStream/useStreamEventHandlers.ts +4 -13
- package/src/ui/hooks/agentStream/useStreamState.ts +60 -41
- package/src/ui/hooks/agentStream/useSubmitQuery.ts +284 -59
- package/src/ui/hooks/slashCommandProcessorSupport.ts +4 -0
- package/src/ui/hooks/useAgentStream-test-helpers.ts +1 -0
- package/src/ui/hooks/useAutoAcceptIndicator.ts +13 -0
- package/src/ui/hooks/useFolderTrust.ts +118 -81
- package/src/ui/hooks/useGitBranchName.ts +79 -33
- package/src/ui/hooks/useIdeTrustListener.ts +5 -19
- package/src/ui/hooks/usePermissionsModifyTrust.ts +223 -85
- package/src/ui/hooks/useReactToolScheduler.ts +13 -17
- package/src/ui/hooks/useTodoPausePreserver.ts +0 -55
- package/src/ui/layouts/DefaultAppLayout.tsx +2 -0
- package/src/ui/layouts/DefaultAppLayoutHelpers.tsx +37 -162
- package/src/ui/layouts/InlineContent.tsx +198 -0
- package/src/ui/trustDialogHelpers.ts +190 -0
- package/src/utils/cleanup-state.ts +115 -0
- package/src/utils/cleanup.ts +32 -34
- package/src/utils/sandbox-containers.ts +183 -53
- package/src/utils/sandbox-exec.ts +9 -3
- package/src/zed-integration/acp-terminal-shell-host.ts +112 -0
- package/src/zed-integration/acp-types.ts +35 -0
- package/src/zed-integration/fileSystemService.ts +1 -1
- package/src/zed-integration/runZedIntegration.ts +142 -0
- package/src/zed-integration/zed-agent-event-handler.ts +179 -0
- package/src/zed-integration/zed-agent-setup.ts +43 -0
- package/src/zed-integration/zed-command-registry.ts +167 -0
- package/src/zed-integration/zed-config-options.ts +280 -0
- package/src/zed-integration/zed-content-utils.ts +2 -2
- package/src/zed-integration/zed-helpers.ts +132 -0
- package/src/zed-integration/zed-initialize.ts +64 -0
- package/src/zed-integration/zed-plan-update.ts +20 -0
- package/src/zed-integration/zed-prompt-command.ts +77 -0
- package/src/zed-integration/zed-session-config.ts +119 -0
- package/src/zed-integration/zed-session-errors.ts +337 -0
- package/src/zed-integration/zed-session-events.ts +216 -0
- package/src/zed-integration/zed-session-info.ts +255 -0
- package/src/zed-integration/zed-session-lifecycle.ts +186 -0
- package/src/zed-integration/zed-session-listing.ts +153 -0
- package/src/zed-integration/zed-session-loader.ts +193 -0
- package/src/zed-integration/zed-session-pagination.ts +240 -0
- package/src/zed-integration/zed-session-replay.ts +564 -0
- package/src/zed-integration/zed-stream-batcher.ts +236 -0
- package/src/zed-integration/zed-terminal-manager.ts +416 -0
- package/src/zed-integration/zed-terminal-setup.ts +72 -0
- package/src/zed-integration/zed-test-helpers.ts +349 -4
- package/src/zed-integration/zed-tool-handler.ts +47 -5
- package/src/zed-integration/zedIntegration.ts +538 -587
- package/src/services/todo-continuation/todoContinuationService.ts +0 -610
- package/src/ui/containers/AppContainer/hooks/useIdeRestartHotkey.ts +0 -33
- package/src/ui/containers/AppContainer/hooks/useTodoContinuationFlow.ts +0 -160
- package/src/ui/hooks/useTodoContinuation.ts +0 -299
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Vybestack LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* StreamBatcher batches live streaming text/thought chunks from the agent turn
|
|
9
|
+
* and flushes them to the ACP client as agent_message_chunk / agent_thought_chunk
|
|
10
|
+
* session/update notifications on a short interval, routing every chunk through
|
|
11
|
+
* the session's EmojiFilter (with blocked-response handling and a trailing
|
|
12
|
+
* buffer flush).
|
|
13
|
+
*
|
|
14
|
+
* Extracted from zedIntegration.ts into its own module so the integration file
|
|
15
|
+
* stays within the max-lines complexity budget; focused behavior is exercised by
|
|
16
|
+
* zed-stream-batcher.test.ts and prompt integration coverage.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
20
|
+
import { type EmojiFilter, DebugLogger } from '@vybestack/llxprt-code-core';
|
|
21
|
+
|
|
22
|
+
// 100ms balances streaming latency (users see text quickly) with throughput
|
|
23
|
+
// (fewer, larger ACP notifications). Tuned for local stdio transport; not
|
|
24
|
+
// configurable because the ACP protocol expects near-real-time streaming.
|
|
25
|
+
const BATCH_INTERVAL_MS = 100;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The message emitted (as an agent_message_chunk) when the EmojiFilter blocks a
|
|
29
|
+
* streamed response in error mode (FINDING E2). Exported so tests reference this
|
|
30
|
+
* single source of truth instead of duplicating the literal, keeping the wire
|
|
31
|
+
* text and its assertions in lockstep. Intentionally hardcoded — not i18n'd
|
|
32
|
+
* because the ACP protocol requires deterministic, locale-independent status.
|
|
33
|
+
*/
|
|
34
|
+
export const STREAM_BLOCKED_MESSAGE =
|
|
35
|
+
'[Error: Response blocked due to emoji detection]';
|
|
36
|
+
|
|
37
|
+
export class StreamBatcher {
|
|
38
|
+
private pendingChunks: Array<{ kind: 'text' | 'thought'; text: string }> = [];
|
|
39
|
+
private batchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
41
|
+
private disposed = false;
|
|
42
|
+
private readonly logger: DebugLogger;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly emojiFilter: EmojiFilter,
|
|
46
|
+
private readonly sendUpdate: (update: acp.SessionUpdate) => Promise<void>,
|
|
47
|
+
logger?: DebugLogger,
|
|
48
|
+
) {
|
|
49
|
+
// Injectable for testability; defaults to the shared zed-integration
|
|
50
|
+
// namespace so the otherwise-silent flush-chain / per-chunk failures
|
|
51
|
+
// (FINDING F4/F17) are diagnosable.
|
|
52
|
+
this.logger =
|
|
53
|
+
logger ?? new DebugLogger('llxprt:zed-integration:stream-batcher');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
append(text: string, isThought: boolean): void {
|
|
57
|
+
if (this.disposed) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const filterResult = isThought
|
|
61
|
+
? this.emojiFilter.filterText(text)
|
|
62
|
+
: this.emojiFilter.filterStreamChunk(text);
|
|
63
|
+
if (filterResult.blocked) {
|
|
64
|
+
// filterText() is stateless, so a blocked thought must not clear partial
|
|
65
|
+
// text held by filterStreamChunk() for a later streaming boundary.
|
|
66
|
+
if (!isThought) {
|
|
67
|
+
const residual = this.emojiFilter.flushBuffer();
|
|
68
|
+
if (residual.length > 0) {
|
|
69
|
+
this.appendPendingChunk('text', residual);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// FINDING E1: clear any pending batch timer BEFORE building the blocked
|
|
73
|
+
// chain (exactly as flush() does). Otherwise a timer armed by a prior
|
|
74
|
+
// normal chunk survives and later fires its own flush() — appending a
|
|
75
|
+
// SECOND doFlush()+flushEmojiBuffer chain that races the blocked-path
|
|
76
|
+
// chain, re-flushing already-flushed content after the error message. The
|
|
77
|
+
// blocked path here already flushes the residual + queued chunks, so the
|
|
78
|
+
// timer has nothing left to do.
|
|
79
|
+
if (this.batchTimer !== null) {
|
|
80
|
+
clearTimeout(this.batchTimer);
|
|
81
|
+
this.batchTimer = null;
|
|
82
|
+
}
|
|
83
|
+
const pending = this.flushChain
|
|
84
|
+
.then(() => this.doFlush())
|
|
85
|
+
.then(() =>
|
|
86
|
+
this.sendUpdate({
|
|
87
|
+
sessionUpdate: isThought
|
|
88
|
+
? 'agent_thought_chunk'
|
|
89
|
+
: 'agent_message_chunk',
|
|
90
|
+
content: {
|
|
91
|
+
type: 'text',
|
|
92
|
+
text: STREAM_BLOCKED_MESSAGE,
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
this.flushChain = this.settleChainLink(pending);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const filteredText =
|
|
100
|
+
typeof filterResult.filtered === 'string' ? filterResult.filtered : '';
|
|
101
|
+
if (filteredText.length === 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.appendPendingChunk(isThought ? 'thought' : 'text', filteredText);
|
|
105
|
+
this.batchTimer ??= setTimeout(() => {
|
|
106
|
+
this.batchTimer = null;
|
|
107
|
+
void this.flush().catch(() => undefined);
|
|
108
|
+
}, BATCH_INTERVAL_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async flush(): Promise<void> {
|
|
112
|
+
if (this.batchTimer !== null) {
|
|
113
|
+
clearTimeout(this.batchTimer);
|
|
114
|
+
this.batchTimer = null;
|
|
115
|
+
}
|
|
116
|
+
// try/finally so the trailing emoji-buffer flush runs even if a future
|
|
117
|
+
// doFlush change introduces a rejection path — the residual filter content
|
|
118
|
+
// must never be stranded behind a failed chunk send.
|
|
119
|
+
const pending = this.flushChain.then(async () => {
|
|
120
|
+
try {
|
|
121
|
+
await this.doFlush();
|
|
122
|
+
} finally {
|
|
123
|
+
await this.flushEmojiBuffer();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
this.flushChain = this.settleChainLink(pending);
|
|
127
|
+
await pending;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Clears any pending batch timer and drops buffered chunks so no timer fires
|
|
132
|
+
* after the owning prompt completes/aborts (FINDING F9), and marks the batcher
|
|
133
|
+
* disposed so a late {@link append} after the turn ends is a silent no-op
|
|
134
|
+
* (nothing new can enter the flush chain). Idempotent and safe to call after
|
|
135
|
+
* {@link flush}; it does NOT emit, so any pending chunks must be flushed first
|
|
136
|
+
* (the prompt path flushes then disposes). An in-flight flush is not aborted:
|
|
137
|
+
* chunks it already detached may finish sending while the chain settles.
|
|
138
|
+
*/
|
|
139
|
+
dispose(): void {
|
|
140
|
+
this.disposed = true;
|
|
141
|
+
if (this.batchTimer !== null) {
|
|
142
|
+
clearTimeout(this.batchTimer);
|
|
143
|
+
this.batchTimer = null;
|
|
144
|
+
}
|
|
145
|
+
this.pendingChunks = [];
|
|
146
|
+
// Drop residual streaming state without emitting after the prompt boundary.
|
|
147
|
+
// The batcher/filter are per-prompt, but draining here also makes disposal
|
|
148
|
+
// complete if that ownership model changes later.
|
|
149
|
+
this.emojiFilter.flushBuffer();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async flushEmojiBuffer(): Promise<void> {
|
|
153
|
+
// A dispose() that raced an in-flight chain link means the prompt is over:
|
|
154
|
+
// do not re-queue residual filter content past the turn boundary. (The
|
|
155
|
+
// standard prompt pattern — await flush() in try, dispose() in finally —
|
|
156
|
+
// never hits this: dispose only runs after the flush chain link settled.)
|
|
157
|
+
if (this.disposed) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const remaining = this.emojiFilter.flushBuffer();
|
|
161
|
+
if (remaining.length === 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.appendPendingChunk('text', remaining);
|
|
165
|
+
await this.doFlush();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private appendPendingChunk(kind: 'text' | 'thought', text: string): void {
|
|
169
|
+
const lastChunk = this.pendingChunks.at(-1);
|
|
170
|
+
if (lastChunk?.kind === kind) {
|
|
171
|
+
lastChunk.text += text;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.pendingChunks.push({ kind, text });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async doFlush(): Promise<void> {
|
|
178
|
+
const chunks = this.pendingChunks;
|
|
179
|
+
this.pendingChunks = [];
|
|
180
|
+
let firstError: unknown = null;
|
|
181
|
+
let lastError: unknown = null;
|
|
182
|
+
let failureCount = 0;
|
|
183
|
+
// FINDING F4: iterate a detached local copy and CONTINUE past a per-chunk
|
|
184
|
+
// send failure so a single rejecting sendUpdate does not drop every later
|
|
185
|
+
// chunk. The injected sendUpdate is best-effort today (Session.sendUpdate
|
|
186
|
+
// swallows), so this cannot reject in practice — but the injected contract
|
|
187
|
+
// is not guaranteed, so no chunk is silently lost if it ever does. The first
|
|
188
|
+
// and last distinct errors are collected + logged (not rethrown) to keep
|
|
189
|
+
// the flushChain intact.
|
|
190
|
+
for (const chunk of chunks) {
|
|
191
|
+
try {
|
|
192
|
+
await this.sendUpdate({
|
|
193
|
+
sessionUpdate:
|
|
194
|
+
chunk.kind === 'thought'
|
|
195
|
+
? 'agent_thought_chunk'
|
|
196
|
+
: 'agent_message_chunk',
|
|
197
|
+
content: { type: 'text', text: chunk.text },
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
failureCount += 1;
|
|
201
|
+
if (firstError === null) {
|
|
202
|
+
firstError = error;
|
|
203
|
+
}
|
|
204
|
+
lastError = error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (firstError !== null) {
|
|
208
|
+
const firstMsg =
|
|
209
|
+
firstError instanceof Error ? firstError.message : String(firstError);
|
|
210
|
+
const lastMsg =
|
|
211
|
+
lastError instanceof Error ? lastError.message : String(lastError);
|
|
212
|
+
this.logger.debug(
|
|
213
|
+
() =>
|
|
214
|
+
`doFlush: ${failureCount} chunk update(s) failed to send; the remaining chunks were still attempted (no chunk dropped). First error: ${firstMsg}${
|
|
215
|
+
lastMsg !== firstMsg ? `. Last error: ${lastMsg}` : ''
|
|
216
|
+
}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Terminates a flush-chain link so a rejection cannot poison the serialized
|
|
223
|
+
* chain for subsequent appends, while still surfacing the swallowed error via
|
|
224
|
+
* the logger (FINDING F17) instead of discarding it silently.
|
|
225
|
+
*/
|
|
226
|
+
private settleChainLink(pending: Promise<void>): Promise<void> {
|
|
227
|
+
return pending.catch((error: unknown) => {
|
|
228
|
+
this.logger.debug(
|
|
229
|
+
() =>
|
|
230
|
+
`flush chain link failed (swallowed to keep the batch chain alive): ${
|
|
231
|
+
error instanceof Error ? error.message : String(error)
|
|
232
|
+
}`,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Vybestack LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
9
|
+
import type { DebugLogger } from '@vybestack/llxprt-code-core';
|
|
10
|
+
import {
|
|
11
|
+
getShellConfiguration,
|
|
12
|
+
isWithinRoot,
|
|
13
|
+
} from '@vybestack/llxprt-code-core';
|
|
14
|
+
import type { AgentToolCall } from '@vybestack/llxprt-code-agents';
|
|
15
|
+
import type {
|
|
16
|
+
ShellExecutionResult,
|
|
17
|
+
ShellOutputEvent,
|
|
18
|
+
} from '@vybestack/llxprt-code-tools';
|
|
19
|
+
|
|
20
|
+
const OUTPUT_POLL_INTERVAL_MS = 100;
|
|
21
|
+
const APPROXIMATE_BYTES_PER_TOKEN = 4;
|
|
22
|
+
const KILL_GRACE_PERIOD_MS = 5000;
|
|
23
|
+
|
|
24
|
+
type SendUpdateFn = (update: acp.SessionUpdate) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
interface PendingShellCall {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly command: string;
|
|
29
|
+
readonly cwd: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ActiveTerminal {
|
|
33
|
+
readonly handle: acp.TerminalHandle;
|
|
34
|
+
readonly command: string;
|
|
35
|
+
readonly cwd: string;
|
|
36
|
+
toolCallId: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function errorMessage(error: unknown): string {
|
|
40
|
+
return error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TerminalManager {
|
|
44
|
+
private readonly pendingCalls: PendingShellCall[] = [];
|
|
45
|
+
private readonly activeTerminals = new Set<ActiveTerminal>();
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly sessionId: string,
|
|
49
|
+
private readonly connection: acp.AgentSideConnection,
|
|
50
|
+
private readonly targetDir: string,
|
|
51
|
+
private readonly sendUpdate: SendUpdateFn,
|
|
52
|
+
private readonly logger: DebugLogger,
|
|
53
|
+
private readonly maxOutputTokens?: number,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
static isShellToolCall(
|
|
57
|
+
call: AgentToolCall,
|
|
58
|
+
kind: string | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
return kind === 'execute' && typeof call.args['command'] === 'string';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async observeToolCall(call: AgentToolCall): Promise<void> {
|
|
64
|
+
const command = readCommand(call);
|
|
65
|
+
if (command === null) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const pending: PendingShellCall = {
|
|
69
|
+
id: call.id,
|
|
70
|
+
command,
|
|
71
|
+
cwd: resolveCallCwd(call, this.targetDir),
|
|
72
|
+
};
|
|
73
|
+
const active = [...this.activeTerminals].find(
|
|
74
|
+
(terminal) =>
|
|
75
|
+
terminal.toolCallId === undefined &&
|
|
76
|
+
terminal.cwd === pending.cwd &&
|
|
77
|
+
commandsMatch(terminal.command, pending.command),
|
|
78
|
+
);
|
|
79
|
+
if (active === undefined) {
|
|
80
|
+
this.pendingCalls.push(pending);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
active.toolCallId = pending.id;
|
|
84
|
+
try {
|
|
85
|
+
await this.sendTerminalUpdate(active);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
active.toolCallId = undefined;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
completeToolCall(toolCallId: string): void {
|
|
93
|
+
const pendingIndex = this.pendingCalls.findIndex(
|
|
94
|
+
(pending) => pending.id === toolCallId,
|
|
95
|
+
);
|
|
96
|
+
if (pendingIndex >= 0) {
|
|
97
|
+
this.pendingCalls.splice(pendingIndex, 1);
|
|
98
|
+
}
|
|
99
|
+
for (const terminal of this.activeTerminals) {
|
|
100
|
+
if (terminal.toolCallId === toolCallId) {
|
|
101
|
+
terminal.toolCallId = undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async executeShellCommand(
|
|
107
|
+
command: string,
|
|
108
|
+
cwd: string,
|
|
109
|
+
onOutput: (event: ShellOutputEvent) => void,
|
|
110
|
+
signal: AbortSignal,
|
|
111
|
+
): Promise<ShellExecutionResult> {
|
|
112
|
+
if (signal.aborted) {
|
|
113
|
+
return abortedResult();
|
|
114
|
+
}
|
|
115
|
+
if (command.trim().length === 0) {
|
|
116
|
+
throw new Error('Shell command must not be empty');
|
|
117
|
+
}
|
|
118
|
+
const shell = getShellConfiguration();
|
|
119
|
+
const handle = await this.connection.createTerminal({
|
|
120
|
+
command: shell.executable,
|
|
121
|
+
args: [...shell.argsPrefix, command],
|
|
122
|
+
cwd,
|
|
123
|
+
sessionId: this.sessionId,
|
|
124
|
+
...(this.maxOutputTokens === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: {
|
|
127
|
+
outputByteLimit: this.maxOutputTokens * APPROXIMATE_BYTES_PER_TOKEN,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
const active: ActiveTerminal = {
|
|
131
|
+
handle,
|
|
132
|
+
command,
|
|
133
|
+
cwd,
|
|
134
|
+
toolCallId: undefined,
|
|
135
|
+
};
|
|
136
|
+
let registered = false;
|
|
137
|
+
try {
|
|
138
|
+
active.toolCallId = this.claimPendingCall(command, cwd);
|
|
139
|
+
this.activeTerminals.add(active);
|
|
140
|
+
registered = true;
|
|
141
|
+
if (active.toolCallId !== undefined) {
|
|
142
|
+
await this.sendTerminalUpdate(active);
|
|
143
|
+
}
|
|
144
|
+
return await this.waitForTerminal(active, onOutput, signal);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (this.activeTerminals.delete(active) || !registered) {
|
|
147
|
+
await this.killAndRelease(active);
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
} finally {
|
|
151
|
+
if (this.activeTerminals.delete(active)) {
|
|
152
|
+
await this.release(active);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async settleAll(): Promise<void> {
|
|
158
|
+
this.pendingCalls.splice(0);
|
|
159
|
+
const terminals = [...this.activeTerminals];
|
|
160
|
+
this.activeTerminals.clear();
|
|
161
|
+
await Promise.allSettled(terminals.map((t) => this.killAndRelease(t)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private claimPendingCall(command: string, cwd: string): string | undefined {
|
|
165
|
+
const index = this.pendingCalls.findIndex(
|
|
166
|
+
(pending) =>
|
|
167
|
+
pending.cwd === cwd && commandsMatch(command, pending.command),
|
|
168
|
+
);
|
|
169
|
+
if (index < 0) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
return this.pendingCalls.splice(index, 1)[0]?.id;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async sendTerminalUpdate(active: ActiveTerminal): Promise<void> {
|
|
176
|
+
if (active.toolCallId === undefined) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await this.sendUpdate({
|
|
180
|
+
sessionUpdate: 'tool_call_update',
|
|
181
|
+
toolCallId: active.toolCallId,
|
|
182
|
+
status: 'in_progress',
|
|
183
|
+
content: [{ type: 'terminal', terminalId: active.handle.id }],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async waitForTerminal(
|
|
188
|
+
active: ActiveTerminal,
|
|
189
|
+
onOutput: (event: ShellOutputEvent) => void,
|
|
190
|
+
signal: AbortSignal,
|
|
191
|
+
): Promise<ShellExecutionResult> {
|
|
192
|
+
let exit: acp.WaitForTerminalExitResponse | undefined;
|
|
193
|
+
let exitError: unknown;
|
|
194
|
+
const exitPromise = active.handle
|
|
195
|
+
.waitForExit()
|
|
196
|
+
.then((result) => {
|
|
197
|
+
exit = result;
|
|
198
|
+
})
|
|
199
|
+
.catch((error: unknown) => {
|
|
200
|
+
exitError = error;
|
|
201
|
+
});
|
|
202
|
+
let previousOutput = '';
|
|
203
|
+
let killed = false;
|
|
204
|
+
let killDeadline = 0;
|
|
205
|
+
let done = false;
|
|
206
|
+
while (!done) {
|
|
207
|
+
if (signal.aborted && !killed) {
|
|
208
|
+
killed = true;
|
|
209
|
+
killDeadline = Date.now() + KILL_GRACE_PERIOD_MS;
|
|
210
|
+
await this.kill(active);
|
|
211
|
+
}
|
|
212
|
+
done =
|
|
213
|
+
exit !== undefined ||
|
|
214
|
+
exitError !== undefined ||
|
|
215
|
+
(killed && Date.now() >= killDeadline);
|
|
216
|
+
if (!done) {
|
|
217
|
+
const pollDelay = delay(OUTPUT_POLL_INTERVAL_MS);
|
|
218
|
+
try {
|
|
219
|
+
await Promise.race([exitPromise, pollDelay.promise]);
|
|
220
|
+
} finally {
|
|
221
|
+
pollDelay.cancel();
|
|
222
|
+
}
|
|
223
|
+
let current: acp.TerminalOutputResponse;
|
|
224
|
+
try {
|
|
225
|
+
current = await active.handle.currentOutput();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.logger.debug(
|
|
228
|
+
() => `Terminal output poll failed: ${errorMessage(error)}`,
|
|
229
|
+
);
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
const chunk = outputDelta(
|
|
233
|
+
previousOutput,
|
|
234
|
+
current.output,
|
|
235
|
+
current.truncated,
|
|
236
|
+
);
|
|
237
|
+
if (chunk !== '') {
|
|
238
|
+
onOutput({ type: 'data', chunk });
|
|
239
|
+
}
|
|
240
|
+
previousOutput = current.output;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (exitError === undefined) {
|
|
244
|
+
previousOutput = await this.pollFinalOutput(
|
|
245
|
+
active,
|
|
246
|
+
previousOutput,
|
|
247
|
+
onOutput,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (exitError !== undefined) {
|
|
251
|
+
throw exitError;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
output: previousOutput,
|
|
255
|
+
exitCode: exit?.exitCode ?? null,
|
|
256
|
+
signal: exit?.signal ?? null,
|
|
257
|
+
error: null,
|
|
258
|
+
aborted: signal.aborted,
|
|
259
|
+
pid: undefined,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async pollFinalOutput(
|
|
264
|
+
active: ActiveTerminal,
|
|
265
|
+
previousOutput: string,
|
|
266
|
+
onOutput: (event: ShellOutputEvent) => void,
|
|
267
|
+
): Promise<string> {
|
|
268
|
+
try {
|
|
269
|
+
const final = await withTimeout(
|
|
270
|
+
active.handle.currentOutput(),
|
|
271
|
+
OUTPUT_POLL_INTERVAL_MS * 5,
|
|
272
|
+
);
|
|
273
|
+
if (final === undefined) {
|
|
274
|
+
return previousOutput;
|
|
275
|
+
}
|
|
276
|
+
const chunk = outputDelta(previousOutput, final.output, final.truncated);
|
|
277
|
+
if (chunk !== '') {
|
|
278
|
+
onOutput({ type: 'data', chunk });
|
|
279
|
+
}
|
|
280
|
+
return final.output;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
this.logger.debug(
|
|
283
|
+
() => `Terminal post-exit output poll failed: ${errorMessage(error)}`,
|
|
284
|
+
);
|
|
285
|
+
return previousOutput;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async killAndRelease(active: ActiveTerminal): Promise<void> {
|
|
290
|
+
await this.kill(active);
|
|
291
|
+
await this.release(active);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async kill(active: ActiveTerminal): Promise<void> {
|
|
295
|
+
try {
|
|
296
|
+
await active.handle.kill();
|
|
297
|
+
} catch (error) {
|
|
298
|
+
this.logger.debug(() => `Terminal kill failed: ${errorMessage(error)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async release(active: ActiveTerminal): Promise<void> {
|
|
303
|
+
try {
|
|
304
|
+
await active.handle.release();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
this.logger.debug(
|
|
307
|
+
() => `Terminal release failed: ${errorMessage(error)}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function readCommand(call: AgentToolCall): string | null {
|
|
314
|
+
const command = call.args['command'];
|
|
315
|
+
return typeof command === 'string' && command.trim() !== '' ? command : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveCallCwd(call: AgentToolCall, targetDir: string): string {
|
|
319
|
+
const dirPath = call.args['dir_path'];
|
|
320
|
+
const directory = call.args['directory'];
|
|
321
|
+
let value = '';
|
|
322
|
+
if (typeof dirPath === 'string') {
|
|
323
|
+
value = dirPath;
|
|
324
|
+
} else if (typeof directory === 'string') {
|
|
325
|
+
value = directory;
|
|
326
|
+
}
|
|
327
|
+
if (value === '') {
|
|
328
|
+
return targetDir;
|
|
329
|
+
}
|
|
330
|
+
const resolved = path.isAbsolute(value)
|
|
331
|
+
? path.resolve(value)
|
|
332
|
+
: path.resolve(targetDir, value);
|
|
333
|
+
if (!isWithinRoot(resolved, targetDir)) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Shell tool cwd resolves outside the session root: ${value}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return resolved;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function commandsMatch(preparedCommand: string, rawCommand: string): boolean {
|
|
342
|
+
// ShellExecutionService wraps commands to capture their exit code. Match only
|
|
343
|
+
// that exact prefix so terminal updates attach to the originating raw command.
|
|
344
|
+
const trimmed = rawCommand.trim();
|
|
345
|
+
if (preparedCommand === trimmed) {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
const terminated =
|
|
349
|
+
trimmed.endsWith('&') || trimmed.endsWith(';') ? trimmed : `${trimmed};`;
|
|
350
|
+
return preparedCommand.startsWith(`{ ${terminated} }; __code=$?;`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function abortedResult(): ShellExecutionResult {
|
|
354
|
+
return {
|
|
355
|
+
output: '',
|
|
356
|
+
exitCode: null,
|
|
357
|
+
signal: null,
|
|
358
|
+
error: null,
|
|
359
|
+
aborted: true,
|
|
360
|
+
pid: undefined,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function outputDelta(
|
|
365
|
+
previous: string,
|
|
366
|
+
current: string,
|
|
367
|
+
truncated: boolean,
|
|
368
|
+
): string {
|
|
369
|
+
if (current.startsWith(previous)) {
|
|
370
|
+
return current.slice(previous.length);
|
|
371
|
+
}
|
|
372
|
+
if (!truncated) {
|
|
373
|
+
return current;
|
|
374
|
+
}
|
|
375
|
+
for (
|
|
376
|
+
let overlap = Math.min(previous.length, current.length);
|
|
377
|
+
overlap > 0;
|
|
378
|
+
overlap--
|
|
379
|
+
) {
|
|
380
|
+
if (previous.endsWith(current.slice(0, overlap))) {
|
|
381
|
+
return current.slice(overlap);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return current;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function withTimeout<T>(
|
|
388
|
+
promise: Promise<T>,
|
|
389
|
+
milliseconds: number,
|
|
390
|
+
): Promise<T | undefined> {
|
|
391
|
+
const timeout = delay(milliseconds);
|
|
392
|
+
try {
|
|
393
|
+
return await Promise.race([promise, timeout.promise.then(() => undefined)]);
|
|
394
|
+
} finally {
|
|
395
|
+
timeout.cancel();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function delay(milliseconds: number): {
|
|
400
|
+
promise: Promise<void>;
|
|
401
|
+
cancel: () => void;
|
|
402
|
+
} {
|
|
403
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
404
|
+
const promise = new Promise<void>((resolve) => {
|
|
405
|
+
timer = setTimeout(resolve, milliseconds);
|
|
406
|
+
});
|
|
407
|
+
return {
|
|
408
|
+
promise,
|
|
409
|
+
cancel: () => {
|
|
410
|
+
if (timer !== undefined) {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
timer = undefined;
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|