@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.
Files changed (126) hide show
  1. package/package.json +13 -13
  2. package/src/commands/mcp/add.ts +8 -4
  3. package/src/config/cliArgParser.ts +2 -0
  4. package/src/config/config.ts +2 -0
  5. package/src/config/configBuilder.ts +3 -0
  6. package/src/config/configError.ts +23 -0
  7. package/src/config/extensions/extensionEnablement.ts +62 -1
  8. package/src/config/interactiveContext.ts +2 -1
  9. package/src/config/intermediateConfig.ts +4 -0
  10. package/src/config/keyBindings.ts +29 -3
  11. package/src/config/policy.ts +7 -1
  12. package/src/config/postConfigRuntime.ts +20 -14
  13. package/src/config/settings-schema/schema-ui.ts +9 -0
  14. package/src/config/settingsLoader.ts +21 -17
  15. package/src/config/settingsSchema.ts +2 -2
  16. package/src/config/trustedFolders.ts +275 -58
  17. package/src/config/yargsOptions.ts +7 -0
  18. package/src/generated/git-commit.json +1 -1
  19. package/src/nonInteractiveCli.ts +1 -0
  20. package/src/nonInteractiveCliSupport.ts +171 -51
  21. package/src/runtime/interactiveToolScheduler.ts +6 -6
  22. package/src/services/BuiltinCommandLoader.ts +33 -25
  23. package/src/services/FileCommandLoader.ts +34 -7
  24. package/src/test-utils/async.ts +20 -0
  25. package/src/test-utils/render.tsx +10 -1
  26. package/src/ui/App.tsx +17 -10
  27. package/src/ui/AppContainerRuntime.tsx +7 -9
  28. package/src/ui/cliUiRuntime.ts +28 -0
  29. package/src/ui/commands/authCommand.ts +240 -3
  30. package/src/ui/commands/compressCommand.ts +9 -1
  31. package/src/ui/commands/directoryCommand.tsx +1 -1
  32. package/src/ui/commands/logoutCommand.ts +2 -2
  33. package/src/ui/commands/permissionsCommand.ts +77 -20
  34. package/src/ui/commands/policiesCommand.ts +6 -1
  35. package/src/ui/commands/quotaCommand.ts +501 -0
  36. package/src/ui/commands/statsCommand.ts +2 -2
  37. package/src/ui/commands/statsQuota.ts +1 -1
  38. package/src/ui/commands/subagentCommand.ts +59 -27
  39. package/src/ui/components/AuthDialog.tsx +3 -3
  40. package/src/ui/components/Composer.tsx +1 -0
  41. package/src/ui/components/DialogManager.tsx +9 -2
  42. package/src/ui/components/FolderTrustDialog.tsx +71 -60
  43. package/src/ui/components/PermissionsModifyTrustDialog.tsx +211 -146
  44. package/src/ui/components/ProfileCreateWizard/constants.ts +13 -1
  45. package/src/ui/components/QueuedMessagesPanel.tsx +388 -0
  46. package/src/ui/components/inputPromptHooks.ts +2 -0
  47. package/src/ui/components/inputPromptKeyHandlers.ts +14 -0
  48. package/src/ui/components/inputPromptTypes.ts +6 -0
  49. package/src/ui/components/messages/AiMessage.tsx +24 -12
  50. package/src/ui/components/messages/ToolMessage.tsx +26 -40
  51. package/src/ui/containers/AppContainer/builders/buildUIActions.ts +3 -1
  52. package/src/ui/containers/AppContainer/builders/buildUIState.ts +5 -4
  53. package/src/ui/containers/AppContainer/hooks/useAppBootstrap.ts +3 -33
  54. package/src/ui/containers/AppContainer/hooks/useAppDialogs.ts +2 -14
  55. package/src/ui/containers/AppContainer/hooks/useAppInput.ts +31 -25
  56. package/src/ui/containers/AppContainer/hooks/useAppLayout.ts +90 -60
  57. package/src/ui/containers/AppContainer/hooks/useDisplayPreferences.ts +10 -0
  58. package/src/ui/containers/AppContainer/hooks/useInputHandling.ts +3 -17
  59. package/src/ui/containers/AppContainer/hooks/useKeybindings.ts +51 -38
  60. package/src/ui/containers/AppContainer/hooks/useSteer.ts +51 -0
  61. package/src/ui/contexts/KeypressContext.tsx +13 -2
  62. package/src/ui/contexts/ShellCommandDisplayContext.tsx +103 -0
  63. package/src/ui/contexts/TodoContext.tsx +0 -13
  64. package/src/ui/contexts/TodoProvider.tsx +1 -34
  65. package/src/ui/contexts/UIActionsContext.tsx +2 -1
  66. package/src/ui/contexts/UIStateContext.tsx +3 -2
  67. package/src/ui/hooks/agentStream/streamUtils.ts +0 -24
  68. package/src/ui/hooks/agentStream/thoughtState.ts +110 -30
  69. package/src/ui/hooks/agentStream/types.ts +1 -0
  70. package/src/ui/hooks/agentStream/useAgentEventStream.ts +8 -17
  71. package/src/ui/hooks/agentStream/useAgentStream.ts +2 -2
  72. package/src/ui/hooks/agentStream/useAgentStreamLifecycle.ts +4 -5
  73. package/src/ui/hooks/agentStream/useAgentStreamOrchestration.ts +12 -5
  74. package/src/ui/hooks/agentStream/useQueuedSubmissions.ts +99 -0
  75. package/src/ui/hooks/agentStream/useStreamEventHandlers.ts +4 -13
  76. package/src/ui/hooks/agentStream/useStreamState.ts +60 -41
  77. package/src/ui/hooks/agentStream/useSubmitQuery.ts +284 -59
  78. package/src/ui/hooks/slashCommandProcessorSupport.ts +4 -0
  79. package/src/ui/hooks/useAgentStream-test-helpers.ts +1 -0
  80. package/src/ui/hooks/useAutoAcceptIndicator.ts +13 -0
  81. package/src/ui/hooks/useFolderTrust.ts +118 -81
  82. package/src/ui/hooks/useGitBranchName.ts +79 -33
  83. package/src/ui/hooks/useIdeTrustListener.ts +5 -19
  84. package/src/ui/hooks/usePermissionsModifyTrust.ts +223 -85
  85. package/src/ui/hooks/useReactToolScheduler.ts +13 -17
  86. package/src/ui/hooks/useTodoPausePreserver.ts +0 -55
  87. package/src/ui/layouts/DefaultAppLayout.tsx +2 -0
  88. package/src/ui/layouts/DefaultAppLayoutHelpers.tsx +37 -162
  89. package/src/ui/layouts/InlineContent.tsx +198 -0
  90. package/src/ui/trustDialogHelpers.ts +190 -0
  91. package/src/utils/cleanup-state.ts +115 -0
  92. package/src/utils/cleanup.ts +32 -34
  93. package/src/utils/sandbox-containers.ts +183 -53
  94. package/src/utils/sandbox-exec.ts +9 -3
  95. package/src/zed-integration/acp-terminal-shell-host.ts +112 -0
  96. package/src/zed-integration/acp-types.ts +35 -0
  97. package/src/zed-integration/fileSystemService.ts +1 -1
  98. package/src/zed-integration/runZedIntegration.ts +142 -0
  99. package/src/zed-integration/zed-agent-event-handler.ts +179 -0
  100. package/src/zed-integration/zed-agent-setup.ts +43 -0
  101. package/src/zed-integration/zed-command-registry.ts +167 -0
  102. package/src/zed-integration/zed-config-options.ts +280 -0
  103. package/src/zed-integration/zed-content-utils.ts +2 -2
  104. package/src/zed-integration/zed-helpers.ts +132 -0
  105. package/src/zed-integration/zed-initialize.ts +64 -0
  106. package/src/zed-integration/zed-plan-update.ts +20 -0
  107. package/src/zed-integration/zed-prompt-command.ts +77 -0
  108. package/src/zed-integration/zed-session-config.ts +119 -0
  109. package/src/zed-integration/zed-session-errors.ts +337 -0
  110. package/src/zed-integration/zed-session-events.ts +216 -0
  111. package/src/zed-integration/zed-session-info.ts +255 -0
  112. package/src/zed-integration/zed-session-lifecycle.ts +186 -0
  113. package/src/zed-integration/zed-session-listing.ts +153 -0
  114. package/src/zed-integration/zed-session-loader.ts +193 -0
  115. package/src/zed-integration/zed-session-pagination.ts +240 -0
  116. package/src/zed-integration/zed-session-replay.ts +564 -0
  117. package/src/zed-integration/zed-stream-batcher.ts +236 -0
  118. package/src/zed-integration/zed-terminal-manager.ts +416 -0
  119. package/src/zed-integration/zed-terminal-setup.ts +72 -0
  120. package/src/zed-integration/zed-test-helpers.ts +349 -4
  121. package/src/zed-integration/zed-tool-handler.ts +47 -5
  122. package/src/zed-integration/zedIntegration.ts +538 -587
  123. package/src/services/todo-continuation/todoContinuationService.ts +0 -610
  124. package/src/ui/containers/AppContainer/hooks/useIdeRestartHotkey.ts +0 -33
  125. package/src/ui/containers/AppContainer/hooks/useTodoContinuationFlow.ts +0 -160
  126. 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
+ }