@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
@@ -3,270 +3,423 @@
3
3
  * Copyright 2025 Vybestack LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
-
7
6
  import {
8
7
  type Config,
9
- getErrorStatus,
10
8
  todoEvents,
11
9
  DEFAULT_AGENT_ID,
12
- isWithinRoot,
13
- createInkStdio,
14
- type ContentBlock,
15
- EmojiFilter,
16
10
  type FilterConfiguration,
11
+ type IContent,
17
12
  type TodoUpdateEvent,
18
- type Todo,
19
13
  type ApprovalMode,
20
- type RuntimeProviderManager,
21
14
  } from '@vybestack/llxprt-code-core';
22
15
  import { debugLogger, DebugLogger } from '@vybestack/llxprt-code-telemetry';
23
- import * as acp from '@agentclientprotocol/sdk';
16
+ import type * as acp from '@agentclientprotocol/sdk';
24
17
  import {
25
18
  fromConfig,
19
+ getTokenLimitForConfiguredContext,
26
20
  type Agent,
27
21
  type AgentEvent,
28
- type DoneReason,
29
- type ThoughtSummary,
30
- type AgentInput,
31
22
  } from '@vybestack/llxprt-code-agents';
32
- import { Readable, Writable } from 'node:stream';
33
- import * as path from 'node:path';
34
23
  import { type LoadedSettings } from '../config/settings.js';
35
24
  import { randomUUID } from 'crypto';
36
- import {
37
- loadProfileByName,
38
- setCliRuntimeContext,
39
- } from '@vybestack/llxprt-code-providers/runtime.js';
40
- import { runExitCleanup } from '../utils/cleanup.js';
41
25
  import { AcpFileSystemService } from './fileSystemService.js';
42
- import { parseZedAuthMethodId, buildAvailableModes } from './zed-helpers.js';
26
+ import {
27
+ buildAvailableModes,
28
+ buildSessionModes,
29
+ buildUsageUpdate,
30
+ describeSessionUpdateForLog,
31
+ } from './zed-helpers.js';
43
32
  import { ZedPathResolver } from './zed-path-resolver.js';
44
33
  import { ToolConfirmationOutcome } from '@vybestack/llxprt-code-tools';
45
34
  import {
46
- emitToolCallStart,
47
- emitToolStatus,
48
- emitToolResult,
49
35
  requestToolConfirmation,
50
36
  type PermissionRoundTripResult,
51
37
  } from './zed-tool-handler.js';
52
-
38
+ import type { TerminalManager } from './zed-terminal-manager.js';
39
+ import { buildZedTerminalSetup } from './zed-terminal-setup.js';
40
+ import { mapHistoryToSessionUpdates } from './zed-session-replay.js';
41
+ import { wrapReplayFailure } from './zed-session-errors.js';
42
+ import {
43
+ resumeAgentHistory,
44
+ toLoadRequestError,
45
+ hasRecordedSessionFile,
46
+ readAgentHistoryForReplay,
47
+ nodeChatSessionFileLister,
48
+ type ChatSessionFileLister,
49
+ } from './zed-session-loader.js';
50
+ import { SessionLifecycle } from './zed-session-lifecycle.js';
51
+ import type { LifecycleSession } from './zed-session-pagination.js';
52
+ import { authenticateZedAgent, initializeZedAgent } from './zed-initialize.js';
53
+ import {
54
+ createSessionScopedConfig,
55
+ resolveSessionTargetDir,
56
+ } from './zed-session-config.js';
57
+ import { buildAvailableCommandsUpdate } from './zed-command-registry.js';
58
+ import { tryHandleZedCommand } from './zed-prompt-command.js';
59
+ import {
60
+ buildZedConfigOptions,
61
+ dispatchZedConfigOption,
62
+ observeZedConfigOptions,
63
+ setZedConfigOption,
64
+ zedConfigOptionsForClient,
65
+ zedSessionConfigOptions,
66
+ } from './zed-config-options.js';
67
+ import {
68
+ buildZedSession,
69
+ enableZedSessionRecording,
70
+ } from './zed-agent-setup.js';
71
+ import {
72
+ runPromptTurn as executePromptTurn,
73
+ type SessionStreamDeps,
74
+ } from './zed-session-events.js';
75
+ import { buildZedPlanUpdate } from './zed-plan-update.js';
76
+ import {
77
+ SessionTitleTracker,
78
+ buildSessionInfoUpdate,
79
+ } from './zed-session-info.js';
80
+ import type {
81
+ CloseSessionRequest,
82
+ CloseSessionResponse,
83
+ DeleteSessionRequest,
84
+ DeleteSessionResponse,
85
+ ClientCapabilitiesWithSession,
86
+ } from './acp-types.js';
53
87
  export { parseZedAuthMethodId } from './zed-helpers.js';
54
-
55
- export async function runZedIntegration(
56
- config: Config,
57
- settings: LoadedSettings,
58
- ): Promise<void> {
59
- const logger = new DebugLogger('llxprt:zed-integration');
60
- logger.debug(() => 'Starting Zed integration');
61
-
62
- const { stdout: workingStdout } = createInkStdio();
63
- const stdout = Writable.toWeb(workingStdout) as WritableStream;
64
- const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
65
-
66
- logger.debug(() => 'Streams created');
67
-
68
- // Deliberate foreground hand-off: the Zed integration replaces the CLI
69
- // bootstrap runtime as the process default. allowDefaultHandoff opts past the
70
- // write-once guard for this one intentional transition (issue #2300).
71
- setCliRuntimeContext(config.getSettingsService(), config, {
72
- runtimeId: 'cli.runtime.zed',
73
- metadata: { source: 'zed-integration', stage: 'bootstrap' },
74
- allowDefaultHandoff: true,
75
- });
76
-
77
- let zedAgent: ZedAgent | undefined;
78
-
79
- try {
80
- const stream = acp.ndJsonStream(stdout, stdin);
81
- const connection = new acp.AgentSideConnection((conn) => {
82
- logger.debug(() => 'Creating ZedAgent');
83
- zedAgent = new ZedAgent(config, settings, conn);
84
- return zedAgent;
85
- }, stream);
86
- logger.debug(() => 'AgentSideConnection created successfully');
87
-
88
- try {
89
- await connection.closed;
90
- } finally {
91
- await zedAgent?.disposeAll();
92
- await runExitCleanup();
93
- }
94
- } catch (e) {
95
- logger.debug(() => `ERROR: Failed to create AgentSideConnection: ${e}`);
96
- throw e;
97
- }
98
- }
99
-
100
- function resolveSessionTargetDir(
101
- config: Config,
102
- cwd: string | undefined,
103
- ): string {
104
- if (cwd === undefined || cwd.trim().length === 0) {
105
- return config.getTargetDir();
106
- }
107
- const candidate = path.isAbsolute(cwd)
108
- ? cwd
109
- : path.resolve(config.getTargetDir(), cwd);
110
- return isWithinRoot(candidate, config.getTargetDir())
111
- ? candidate
112
- : config.getTargetDir();
113
- }
114
-
115
- export function createSessionScopedConfig(
116
- config: Config,
117
- initialFileSystemService: ReturnType<Config['getFileSystemService']>,
118
- targetDir: string = config.getTargetDir(),
119
- ): Config {
120
- let fileSystemService = initialFileSystemService;
121
- let providerManager: RuntimeProviderManager | undefined =
122
- config.getProviderManager();
123
- return new Proxy(config, {
124
- get(target, property, receiver) {
125
- if (property === 'getFileSystemService') {
126
- return () => fileSystemService;
127
- }
128
- if (property === 'setFileSystemService') {
129
- return (nextFileSystemService: typeof fileSystemService) => {
130
- fileSystemService = nextFileSystemService;
131
- };
132
- }
133
- if (property === 'getProviderManager') {
134
- return () => providerManager;
135
- }
136
- if (property === 'setProviderManager') {
137
- return (nextProviderManager: RuntimeProviderManager) => {
138
- providerManager = nextProviderManager;
139
- };
140
- }
141
- if (property === 'getProjectRoot') {
142
- return () => targetDir;
143
- }
144
- if (property === 'getTargetDir') {
145
- return () => targetDir;
146
- }
147
- return Reflect.get(target, property, receiver);
148
- },
149
- });
150
- }
151
-
88
+ export { createSessionScopedConfig } from './zed-session-config.js';
89
+ export { runZedIntegration } from './runZedIntegration.js';
152
90
  export class ZedAgent {
153
91
  private sessions: Map<string, Session> = new Map();
154
- private clientCapabilities: acp.ClientCapabilities | undefined;
155
- private logger: DebugLogger;
156
-
92
+ private clientCapabilities: ClientCapabilitiesWithSession | undefined;
93
+ private readonly logger = new DebugLogger('llxprt:zed-integration');
94
+ private readonly lifecycle: SessionLifecycle;
157
95
  constructor(
158
96
  private config: Config,
159
97
  _settings: LoadedSettings,
160
98
  private connection: acp.AgentSideConnection,
99
+ private readonly sessionFileLister: ChatSessionFileLister = nodeChatSessionFileLister,
161
100
  ) {
162
- this.logger = new DebugLogger('llxprt:zed-integration');
101
+ this.lifecycle = new SessionLifecycle(
102
+ config,
103
+ this.sessions,
104
+ (sessionId, cwd) => this.buildAndResumeSession(sessionId, cwd),
105
+ (session) => zedSessionConfigOptions(this.clientCapabilities, session),
106
+ );
163
107
  }
164
-
165
108
  async initialize(
166
109
  args: acp.InitializeRequest,
167
110
  ): Promise<acp.InitializeResponse> {
168
- this.clientCapabilities = args.clientCapabilities;
169
- const profileManager = this.config.getProfileManager();
170
- const profileNames = profileManager
171
- ? await profileManager.listProfiles()
172
- : [];
173
- const authMethods = profileNames.map((name) => ({
174
- id: name,
175
- name,
176
- description: null,
177
- }));
178
-
179
- return {
180
- protocolVersion: acp.PROTOCOL_VERSION,
181
- authMethods,
182
- agentCapabilities: {
183
- loadSession: false,
184
- promptCapabilities: {
185
- image: true,
186
- audio: true,
187
- embeddedContext: true,
188
- },
189
- },
190
- };
111
+ this.clientCapabilities = args.clientCapabilities as
112
+ | ClientCapabilitiesWithSession
113
+ | undefined;
114
+ return initializeZedAgent(this.config);
191
115
  }
192
-
193
- async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
194
- const profileManager = this.config.getProfileManager();
195
- const availableProfiles = profileManager
196
- ? await profileManager.listProfiles()
197
- : [];
198
- const profileName = parseZedAuthMethodId(methodId, availableProfiles);
199
-
200
- await loadProfileByName(profileName);
116
+ listSessions(
117
+ params: acp.ListSessionsRequest,
118
+ ): Promise<acp.ListSessionsResponse> {
119
+ return this.lifecycle.list(params);
120
+ }
121
+ authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
122
+ return authenticateZedAgent(this.config, methodId);
201
123
  }
202
-
203
124
  async newSession({
204
125
  cwd,
205
126
  mcpServers: _mcpServers,
206
127
  }: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
207
128
  try {
208
129
  const sessionId = randomUUID();
209
- const baseFileSystemService = this.config.getFileSystemService();
210
- const sessionFileSystemService = this.clientCapabilities?.fs
211
- ? new AcpFileSystemService(
212
- this.connection,
213
- sessionId,
214
- this.clientCapabilities.fs,
215
- baseFileSystemService,
216
- )
217
- : baseFileSystemService;
218
- const sessionConfig = createSessionScopedConfig(
219
- this.config,
220
- sessionFileSystemService,
221
- resolveSessionTargetDir(this.config, cwd),
222
- );
223
-
224
- this.logger.debug(() => `newSession - creating session ${sessionId}`);
225
-
226
- const agent = await fromConfig({
130
+ const {
131
+ agent,
227
132
  config: sessionConfig,
133
+ terminals,
134
+ } = await this.buildSessionAgent(sessionId, cwd);
135
+ let session: Session;
136
+ try {
137
+ await enableZedSessionRecording(agent, (error) =>
138
+ this.logger.debug(
139
+ () => `Recording failed for ${sessionId}: ${error}`,
140
+ ),
141
+ );
142
+ session = await this.createSession(
143
+ sessionId,
144
+ agent,
145
+ sessionConfig,
146
+ terminals,
147
+ );
148
+ } catch (error) {
149
+ await agent.dispose().catch(() => undefined);
150
+ await terminals?.settleAll().catch(() => undefined);
151
+ throw error;
152
+ }
153
+ try {
154
+ await session.sendAvailableCommands();
155
+ } catch (error) {
156
+ await session.dispose();
157
+ throw error;
158
+ }
159
+ let configOptions: acp.NewSessionResponse['configOptions'];
160
+ try {
161
+ ({ configOptions } = await zedConfigOptionsForClient(
162
+ this.clientCapabilities,
163
+ agent,
164
+ sessionConfig,
165
+ ));
166
+ } catch (error) {
167
+ await session.dispose();
168
+ throw error;
169
+ }
170
+ this.sessions.set(sessionId, session);
171
+ return {
228
172
  sessionId,
229
- });
230
-
173
+ modes: buildSessionModes(agent.getApprovalMode()),
174
+ ...(configOptions === undefined ? {} : { configOptions }),
175
+ };
176
+ } catch (error) {
177
+ this.logger.debug(() => `ERROR in newSession: ${error}`);
178
+ throw error;
179
+ }
180
+ }
181
+ resumeSession(
182
+ params: acp.ResumeSessionRequest,
183
+ ): Promise<acp.ResumeSessionResponse> {
184
+ return this.lifecycle.resume(params);
185
+ }
186
+ private supportsConfigOptions(): boolean {
187
+ return this.clientCapabilities?.session?.configOptions === true;
188
+ }
189
+ private supportsTerminal(): boolean {
190
+ return this.clientCapabilities?.terminal === true;
191
+ }
192
+ private createSession(
193
+ id: string,
194
+ agent: Agent,
195
+ config: Config,
196
+ terminals: TerminalManager | null,
197
+ ) {
198
+ return buildZedSession(
199
+ agent,
200
+ () =>
201
+ new Session(
202
+ id,
203
+ agent,
204
+ config,
205
+ this.connection,
206
+ this.supportsConfigOptions(),
207
+ terminals,
208
+ ),
209
+ (error) => this.logger.debug(() => `Session cleanup failed: ${error}`),
210
+ );
211
+ }
212
+ async loadSession(
213
+ params: acp.LoadSessionRequest,
214
+ ): Promise<acp.LoadSessionResponse> {
215
+ return this.lifecycle.runSerialized(params.sessionId, () =>
216
+ this.performLoadSession(params),
217
+ );
218
+ }
219
+ private async performLoadSession(
220
+ params: acp.LoadSessionRequest,
221
+ ): Promise<acp.LoadSessionResponse> {
222
+ const { sessionId } = params;
223
+ const reattached = await this.tryReattachLiveSession(sessionId);
224
+ if (reattached !== null) {
225
+ try {
226
+ await reattached.sendAvailableCommands();
227
+ } catch (error) {
228
+ await this.rollbackSession(sessionId, reattached);
229
+ throw error;
230
+ }
231
+ try {
232
+ return {
233
+ modes: buildSessionModes(reattached.getApprovalMode()),
234
+ ...(await zedSessionConfigOptions(
235
+ this.clientCapabilities,
236
+ reattached,
237
+ )),
238
+ };
239
+ } catch (error) {
240
+ await this.rollbackSession(sessionId, reattached);
241
+ throw error;
242
+ }
243
+ }
244
+ await this.disposePriorSession(sessionId);
245
+ const session = await this.installResumedSession(sessionId, params.cwd);
246
+ try {
247
+ await session.sendAvailableCommands();
248
+ } catch (error) {
249
+ await this.rollbackSession(sessionId, session);
250
+ throw error;
251
+ }
252
+ return {
253
+ modes: buildSessionModes(session.getApprovalMode()),
254
+ ...(await zedSessionConfigOptions(this.clientCapabilities, session)),
255
+ };
256
+ }
257
+ private async tryReattachLiveSession(
258
+ sessionId: string,
259
+ ): Promise<Session | null> {
260
+ const existing = this.sessions.get(sessionId);
261
+ if (existing === undefined) {
262
+ return null;
263
+ }
264
+ const recordingExists = await hasRecordedSessionFile(
265
+ this.config,
266
+ sessionId,
267
+ this.sessionFileLister,
268
+ );
269
+ if (recordingExists) {
270
+ return null;
271
+ }
272
+ this.logger.debug(
273
+ () => `loadSession - re-attaching live session ${sessionId}`,
274
+ );
275
+ try {
276
+ await existing.replayLiveHistory();
277
+ return existing;
278
+ } catch (error) {
279
+ await this.rollbackSession(sessionId, existing);
280
+ throw error;
281
+ }
282
+ }
283
+ private async rollbackSession(
284
+ sessionId: string,
285
+ session: Session,
286
+ ): Promise<void> {
287
+ this.sessions.delete(sessionId);
288
+ await session.dispose().catch(() => undefined);
289
+ }
290
+ private async disposePriorSession(sessionId: string): Promise<void> {
291
+ const existing = this.sessions.get(sessionId);
292
+ if (existing !== undefined) await this.rollbackSession(sessionId, existing);
293
+ }
294
+ private async installResumedSession(
295
+ sessionId: string,
296
+ cwd: string | undefined,
297
+ ): Promise<Session> {
298
+ const { session, history } = await this.buildAndResumeSession(
299
+ sessionId,
300
+ cwd,
301
+ );
302
+ this.sessions.set(sessionId, session);
303
+ try {
304
+ await session.streamHistory(history);
305
+ } catch (error) {
306
+ await this.rollbackSession(sessionId, session);
307
+ this.logger.debug(() => `loadSession - replay failed: ${error}`);
308
+ throw error;
309
+ }
310
+ return session;
311
+ }
312
+ private async buildAndResumeSession(
313
+ sessionId: string,
314
+ cwd: string | undefined,
315
+ ): Promise<{ session: Session; history: readonly IContent[] }> {
316
+ const {
317
+ agent,
318
+ config: sessionConfig,
319
+ terminals,
320
+ } = await this.buildSessionAgent(sessionId, cwd);
321
+ try {
322
+ const history = await resumeAgentHistory(
323
+ agent,
324
+ sessionId,
325
+ sessionConfig,
326
+ this.sessionFileLister,
327
+ );
231
328
  const session = new Session(
232
329
  sessionId,
233
330
  agent,
234
331
  sessionConfig,
235
332
  this.connection,
333
+ this.supportsConfigOptions(),
334
+ terminals,
236
335
  );
237
- this.sessions.set(sessionId, session);
238
-
239
- return {
336
+ return { session, history };
337
+ } catch (error) {
338
+ await agent.dispose().catch(() => undefined);
339
+ await terminals?.settleAll().catch(() => undefined);
340
+ this.logger.debug(() => `loadSession - build/resume failed: ${error}`);
341
+ throw toLoadRequestError(sessionId, error);
342
+ }
343
+ }
344
+ private async buildSessionAgent(
345
+ sessionId: string,
346
+ cwd: string | undefined,
347
+ ): Promise<{
348
+ agent: Agent;
349
+ config: Config;
350
+ terminals: TerminalManager | null;
351
+ }> {
352
+ const baseFileSystemService = this.config.getFileSystemService();
353
+ const sessionFileSystemService = this.clientCapabilities?.fs
354
+ ? new AcpFileSystemService(
355
+ this.connection,
356
+ sessionId,
357
+ this.clientCapabilities.fs,
358
+ baseFileSystemService,
359
+ )
360
+ : baseFileSystemService;
361
+ let terminalSetup: ReturnType<typeof buildZedTerminalSetup> | undefined;
362
+ const sessionConfig = createSessionScopedConfig(
363
+ this.config,
364
+ sessionFileSystemService,
365
+ resolveSessionTargetDir(this.config, cwd),
366
+ () => terminalSetup?.registry,
367
+ );
368
+ let agent: Agent | undefined;
369
+ try {
370
+ agent = await fromConfig({
371
+ config: sessionConfig,
240
372
  sessionId,
241
- modes: {
242
- availableModes: buildAvailableModes(),
243
- currentModeId: agent.getApprovalMode(),
244
- },
245
- };
373
+ });
374
+ if (this.supportsTerminal()) {
375
+ terminalSetup = buildZedTerminalSetup(
376
+ sessionId,
377
+ sessionConfig,
378
+ this.config.getToolRegistry(),
379
+ this.connection,
380
+ this.logger,
381
+ agent.getMessageBus(),
382
+ );
383
+ }
246
384
  } catch (error) {
247
- this.logger.debug(() => `ERROR in newSession: ${error}`);
385
+ // buildZedTerminalSetup can throw after fromConfig succeeded; dispose the
386
+ // already-constructed agent (and any partially-built terminals) so it is
387
+ // not leaked when this method aborts before returning ownership to caller.
388
+ await agent?.dispose().catch(() => undefined);
389
+ await terminalSetup?.terminals.settleAll().catch(() => undefined);
248
390
  throw error;
249
391
  }
392
+ return {
393
+ agent,
394
+ config: sessionConfig,
395
+ terminals: terminalSetup?.terminals ?? null,
396
+ };
250
397
  }
251
-
252
- async setSessionMode(
398
+ deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
399
+ return this.lifecycle.delete(params);
400
+ }
401
+ closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
402
+ return this.lifecycle.close(params);
403
+ }
404
+ setSessionMode(
253
405
  params: acp.SetSessionModeRequest,
254
406
  ): Promise<acp.SetSessionModeResponse> {
255
407
  const session = this.sessions.get(params.sessionId);
256
408
  if (!session) {
257
409
  throw new Error(`Session not found: ${params.sessionId}`);
258
410
  }
259
- return session.setMode(params.modeId);
411
+ return Promise.resolve(session.setMode(params.modeId));
260
412
  }
261
-
262
- async cancel(params: acp.CancelNotification): Promise<void> {
263
- const session = this.sessions.get(params.sessionId);
264
- if (!session) {
265
- throw new Error(`Session not found: ${params.sessionId}`);
266
- }
413
+ setSessionConfigOption(params: acp.SetSessionConfigOptionRequest) {
414
+ return this.lifecycle.runSerialized(params.sessionId, () =>
415
+ dispatchZedConfigOption(this.clientCapabilities, this.sessions, params),
416
+ );
417
+ }
418
+ async cancel({ sessionId }: acp.CancelNotification): Promise<void> {
419
+ const session = this.sessions.get(sessionId);
420
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
267
421
  await session.cancelPendingPrompt();
268
422
  }
269
-
270
423
  async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
271
424
  const session = this.sessions.get(params.sessionId);
272
425
  if (!session) {
@@ -274,41 +427,15 @@ export class ZedAgent {
274
427
  }
275
428
  return session.prompt(params);
276
429
  }
277
-
278
430
  async disposeAll(): Promise<void> {
279
431
  const sessions = [...this.sessions.values()];
280
432
  this.sessions.clear();
281
433
  await Promise.allSettled(sessions.map((session) => session.dispose()));
282
434
  }
283
435
  }
284
-
285
- function mapDoneReasonToStopReason(reason: DoneReason): acp.StopReason {
286
- switch (reason) {
287
- case 'stop':
288
- case 'loop-detected':
289
- return 'end_turn';
290
- case 'aborted':
291
- return 'cancelled';
292
- case 'max-turns':
293
- return 'max_turn_requests';
294
- case 'context-overflow':
295
- return 'max_tokens';
296
- case 'refusal':
297
- return 'refusal';
298
- case 'error':
299
- case 'hook-stopped':
300
- throw new Error(`Agent stopped with terminal reason: ${reason}`);
301
- default: {
302
- const exhaustive: never = reason;
303
- return exhaustive;
304
- }
305
- }
306
- }
307
-
308
436
  export class Session {
309
437
  private pendingPrompt: AbortController | null = null;
310
- private emojiFilter: EmojiFilter;
311
- private logger: DebugLogger;
438
+ private readonly logger = new DebugLogger('llxprt:zed-integration');
312
439
  private pathResolver: ZedPathResolver;
313
440
  private activeConfirmations = new Map<
314
441
  string,
@@ -320,25 +447,29 @@ export class Session {
320
447
  >();
321
448
  private promptGeneration = 0;
322
449
  private readonly todoListener: (event: TodoUpdateEvent) => void;
450
+ private readonly stopConfigUpdates: () => void;
451
+ private readonly sessionInfo = new SessionTitleTracker();
452
+ private readonly createdAt = new Date().toISOString();
453
+ private readonly terminals: TerminalManager | null;
323
454
 
324
455
  constructor(
325
456
  private readonly id: string,
326
457
  private readonly agent: Agent,
327
458
  private readonly config: Config,
328
459
  private readonly connection: acp.AgentSideConnection,
460
+ configOptionsEnabled = false,
461
+ terminals: TerminalManager | null = null,
329
462
  ) {
330
- this.logger = new DebugLogger('llxprt:zed-integration');
331
- const configuredEmojiFilterMode = this.config.getEphemeralSetting(
332
- 'emojifilter',
333
- ) as 'allowed' | 'auto' | 'warn' | 'error' | undefined;
334
- const emojiFilterMode = configuredEmojiFilterMode ?? 'auto';
335
- const filterConfig: FilterConfiguration = { mode: emojiFilterMode };
336
- this.emojiFilter = new EmojiFilter(filterConfig);
337
-
463
+ this.terminals = terminals;
338
464
  this.pathResolver = new ZedPathResolver(this.config, (msg) =>
339
465
  this.debug(msg),
340
466
  );
341
-
467
+ const recordedTitle = config
468
+ .getSessionRecordingService()
469
+ ?.getSessionMetadataTitle();
470
+ if (recordedTitle !== undefined) {
471
+ this.sessionInfo.hydrateFromMetadata(recordedTitle);
472
+ }
342
473
  this.todoListener = (event: TodoUpdateEvent) => {
343
474
  const eventAgentId = event.agentId ?? DEFAULT_AGENT_ID;
344
475
  if (event.sessionId === this.id && eventAgentId === DEFAULT_AGENT_ID) {
@@ -348,8 +479,15 @@ export class Session {
348
479
  }
349
480
  };
350
481
  todoEvents.onTodoUpdated(this.todoListener);
482
+ this.stopConfigUpdates = configOptionsEnabled
483
+ ? observeZedConfigOptions(
484
+ this.agent,
485
+ this.config,
486
+ (update) => this.sendUpdateStrict(update),
487
+ (error) => this.logger.debug(() => `Config update failed: ${error}`),
488
+ )
489
+ : () => undefined;
351
490
  }
352
-
353
491
  setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse {
354
492
  const availableModes = buildAvailableModes();
355
493
  const mode = availableModes.find((m) => m.id === modeId);
@@ -359,46 +497,43 @@ export class Session {
359
497
  this.agent.setApprovalMode(mode.id as ApprovalMode);
360
498
  return {};
361
499
  }
362
-
500
+ getApprovalMode(): ApprovalMode {
501
+ return this.agent.getApprovalMode();
502
+ }
503
+ setConfigOption(configId: string, value: string) {
504
+ return setZedConfigOption(this.agent, this.config, configId, value);
505
+ }
506
+ getConfigOptions = () => buildZedConfigOptions(this.agent, this.config);
507
+ getLifecycleInfo(): LifecycleSession {
508
+ const title = this.sessionInfo.getTitle();
509
+ return {
510
+ sessionId: this.id,
511
+ cwd: this.config.getProjectRoot(),
512
+ updatedAt: this.sessionInfo.getUpdatedAt() ?? this.createdAt,
513
+ createdAt: this.createdAt,
514
+ ...(title === undefined ? {} : { title }),
515
+ };
516
+ }
363
517
  async cancelPendingPrompt(): Promise<void> {
364
518
  this.settleActiveConfirmation();
365
- if (!this.pendingPrompt) {
366
- return;
367
- }
368
- this.pendingPrompt.abort();
519
+ this.pendingPrompt?.abort();
369
520
  this.pendingPrompt = null;
521
+ await this.terminals
522
+ ?.settleAll()
523
+ .catch((e: unknown) =>
524
+ this.logger.debug(() => `Terminal settleAll failed: ${e}`),
525
+ );
370
526
  }
371
-
372
527
  private settleActiveConfirmation(): void {
373
- const confirmations = [...this.activeConfirmations.entries()];
374
- this.activeConfirmations.clear();
375
- for (const [confirmationId, state] of confirmations) {
376
- if (state.settled) {
377
- continue;
378
- }
379
- state.settled = true;
380
- try {
381
- this.agent.tools.respondToConfirmation(
382
- confirmationId,
383
- ToolConfirmationOutcome.Cancel,
384
- );
385
- } catch (error) {
386
- debugLogger.error('Failed to cancel active tool confirmation:', error);
387
- } finally {
388
- state.cancelWaiter();
389
- }
528
+ for (const confirmationId of [...this.activeConfirmations.keys()]) {
529
+ this.settleConfirmation(confirmationId);
390
530
  }
391
531
  }
392
-
393
532
  private settleConfirmation(confirmationId: string): void {
394
533
  const state = this.activeConfirmations.get(confirmationId);
395
- if (state === undefined) {
396
- return;
397
- }
534
+ if (state === undefined) return;
398
535
  this.activeConfirmations.delete(confirmationId);
399
- if (state.settled) {
400
- return;
401
- }
536
+ if (state.settled) return;
402
537
  state.settled = true;
403
538
  try {
404
539
  this.agent.tools.respondToConfirmation(
@@ -411,218 +546,141 @@ export class Session {
411
546
  state.cancelWaiter();
412
547
  }
413
548
  }
414
-
415
549
  async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
416
550
  await this.cancelPendingPrompt();
417
- const pendingSend = new AbortController();
418
- this.pendingPrompt = pendingSend;
419
- this.promptGeneration += 1;
420
- const promptGeneration = this.promptGeneration;
421
-
422
- const promptId = Math.random().toString(16).slice(2);
423
-
551
+ const eligibility = this.sessionInfo.consumeTitleEligibility(params.prompt);
424
552
  try {
425
- let parts: ContentBlock[];
426
- try {
427
- parts = await this.pathResolver.resolvePrompt(
428
- params.prompt,
429
- pendingSend.signal,
430
- );
431
- } catch (error) {
432
- if (
433
- pendingSend.signal.aborted ||
434
- (error instanceof Error && error.name === 'AbortError')
435
- ) {
436
- return { stopReason: 'cancelled' };
437
- }
438
- throw error;
439
- }
440
-
441
- const batcher = new StreamBatcher(this.emojiFilter, (u) =>
442
- this.sendUpdate(u),
553
+ this.recordMetadataTitle(eligibility.title);
554
+ } catch (error) {
555
+ this.logger.debug(() => `Session metadata recording failed: ${error}`);
556
+ }
557
+ try {
558
+ const commandResult = await tryHandleZedCommand(
559
+ params.prompt,
560
+ this.agent,
561
+ (update) => this.sendUpdateStrict(update),
443
562
  );
444
- let terminalStopReason: acp.StopReason | null = null;
445
-
563
+ if (commandResult !== null) {
564
+ return commandResult.response;
565
+ }
566
+ const pendingSend = new AbortController();
567
+ this.pendingPrompt = pendingSend;
568
+ this.promptGeneration += 1;
569
+ const promptGeneration = this.promptGeneration;
570
+ const promptId = randomUUID();
446
571
  try {
447
- terminalStopReason = await this.consumeAgentStream(
448
- parts,
572
+ return await this.runPromptTurn(
573
+ params,
449
574
  pendingSend,
450
575
  promptId,
451
576
  promptGeneration,
452
- batcher,
453
577
  );
454
- } catch (error) {
455
- if (getErrorStatus(error) === 429) {
456
- throw new acp.RequestError(
457
- 429,
458
- 'Rate limit exceeded. Try again later.',
459
- );
460
- }
461
- if (
462
- pendingSend.signal.aborted ||
463
- (error instanceof Error && error.name === 'AbortError')
464
- ) {
465
- return { stopReason: 'cancelled' };
466
- }
467
- throw error;
468
578
  } finally {
469
- await batcher.flush();
470
- }
471
-
472
- if (pendingSend.signal.aborted && terminalStopReason !== 'cancelled') {
473
- return { stopReason: 'cancelled' };
474
- }
475
-
476
- if (terminalStopReason !== null) {
477
- return { stopReason: terminalStopReason };
579
+ if (this.pendingPrompt === pendingSend) {
580
+ this.pendingPrompt = null;
581
+ }
478
582
  }
479
-
480
- return { stopReason: 'end_turn' };
481
583
  } finally {
482
- if (this.pendingPrompt === pendingSend) {
483
- this.pendingPrompt = null;
584
+ try {
585
+ await this.emitTurnMetadata(eligibility);
586
+ } catch (error) {
587
+ this.logger.debug(() => `emitTurnMetadata ERROR: ${String(error)}`);
484
588
  }
485
589
  }
486
590
  }
487
591
 
488
- private async consumeAgentStream(
489
- parts: ContentBlock[],
592
+ private async runPromptTurn(
593
+ params: acp.PromptRequest,
490
594
  pendingSend: AbortController,
491
595
  promptId: string,
492
596
  promptGeneration: number,
493
- batcher: StreamBatcher,
494
- ): Promise<acp.StopReason | null> {
495
- const eventStream = this.agent.stream(parts as AgentInput, {
496
- signal: pendingSend.signal,
597
+ ): Promise<acp.PromptResponse> {
598
+ return executePromptTurn(
599
+ {
600
+ pathResolver: this.pathResolver,
601
+ emojiFilterMode: (this.config.getEphemeralSetting('emojifilter') ??
602
+ 'auto') as FilterConfiguration['mode'],
603
+ streamDeps: this.buildStreamDeps(promptGeneration, pendingSend),
604
+ },
605
+ params,
606
+ pendingSend,
497
607
  promptId,
608
+ promptGeneration,
609
+ );
610
+ }
611
+
612
+ private buildStreamDeps(
613
+ promptGeneration: number,
614
+ pendingSend: AbortController,
615
+ ): SessionStreamDeps {
616
+ return {
617
+ agent: this.agent,
618
+ terminals: this.terminals,
619
+ sendUpdate: (update) => this.sendUpdate(update),
620
+ sendUsage: (usage) => this.sendUsageUpdate(usage),
621
+ handleConfirmation: (confirmation) =>
622
+ this.handleToolConfirmation(
623
+ confirmation,
624
+ promptGeneration,
625
+ pendingSend,
626
+ ),
627
+ isPromptStale: (gen, send) => this.isPromptStale(gen, send),
498
628
  maxTurns: this.config.getMaxSessionTurns(),
499
- });
500
- let terminalStopReason: acp.StopReason | null = null;
501
- for await (const event of eventStream) {
502
- if (this.isPromptStale(promptGeneration, pendingSend)) {
503
- if (event.type === 'done') {
504
- return 'cancelled';
505
- }
506
- continue;
507
- }
508
- const stopReason = await this.handleAgentEvent(
509
- event,
510
- batcher,
511
- promptGeneration,
512
- pendingSend,
629
+ logger: this.logger,
630
+ };
631
+ }
632
+
633
+ private async emitTurnMetadata(eligibility: {
634
+ readonly wonTitle: boolean;
635
+ readonly title: string | undefined;
636
+ }): Promise<void> {
637
+ const { updates } = this.sessionInfo.recordTurn(new Date().toISOString());
638
+ const updatedAt = updates[0]?.updatedAt ?? undefined;
639
+ if (eligibility.wonTitle && eligibility.title !== undefined) {
640
+ await this.sendTitleUpdate(
641
+ buildSessionInfoUpdate({ title: eligibility.title, updatedAt }),
642
+ eligibility.title,
513
643
  );
514
- if (stopReason !== null) {
515
- terminalStopReason = stopReason;
644
+ return;
645
+ }
646
+ for (const update of updates) {
647
+ if (typeof update.title === 'string') {
648
+ await this.sendTitleUpdate(update, update.title);
649
+ } else {
650
+ await this.sendUpdate(update);
516
651
  }
517
652
  }
518
- return terminalStopReason;
519
653
  }
520
654
 
521
- private async handleAgentEvent(
522
- event: AgentEvent,
523
- batcher: StreamBatcher,
524
- promptGeneration: number,
525
- pendingSend: AbortController,
526
- ): Promise<acp.StopReason | null> {
527
- switch (event.type) {
528
- case 'text':
529
- batcher.append(event.text, false);
530
- return null;
531
- case 'thinking': {
532
- const thoughtText = this.extractThoughtText(event.thought);
533
- if (thoughtText.length > 0) {
534
- batcher.append(thoughtText, true);
535
- }
536
- return null;
537
- }
538
- case 'tool-call':
539
- await batcher.flush();
540
- await emitToolCallStart(event.call, (u) => this.sendUpdate(u));
541
- return null;
542
- case 'tool-status':
543
- await batcher.flush();
544
- await emitToolStatus(event.update, (u) => this.sendUpdate(u));
545
- return null;
546
- case 'tool-result':
547
- await batcher.flush();
548
- await emitToolResult(event.result, (u) => this.sendUpdate(u));
549
- return null;
550
- case 'tool-confirmation':
551
- await batcher.flush();
552
- await this.handleToolConfirmation(event, promptGeneration, pendingSend);
553
- return null;
554
- case 'done':
555
- await batcher.flush();
556
- return mapDoneReasonToStopReason(event.reason);
557
- case 'error':
558
- await batcher.flush();
559
- throw this.translateErrorEvent(event);
560
- case 'idle-timeout':
561
- await batcher.flush();
562
- throw this.translateIdleTimeout(event);
563
- case 'invalid-stream':
564
- await batcher.flush();
565
- throw new Error(
566
- 'Agent produced an invalid stream that could not be recovered.',
567
- );
568
- case 'hook-blocked':
569
- await batcher.flush();
570
- throw new Error(
571
- event.info.systemMessage ?? 'Agent stopped by a hook blocker.',
572
- );
573
- case 'loop-detected':
574
- // The agent detects a tool-call loop. Map to end_turn so the client
575
- // sees a clean turn end rather than an indefinite hang.
576
- await batcher.flush();
577
- return 'end_turn';
578
- case 'notice':
579
- // Informational notices have no direct ACP SessionUpdate; surface as
580
- // an agent message so the user sees them.
581
- await batcher.flush();
582
- await this.sendUpdate({
583
- sessionUpdate: 'agent_message_chunk',
584
- content: { type: 'text', text: event.message },
585
- });
586
- return null;
587
- case 'usage': {
588
- await batcher.flush();
589
- await this.sendUsageUpdate(event.usage);
590
- return null;
591
- }
592
- case 'context-warning':
593
- case 'compression':
594
- case 'model-info':
595
- case 'retry':
596
- case 'citation':
597
- // These variants have no faithful ACP translation: usage is mapped
598
- // separately, compression/model-info/citation are metadata, retry is
599
- // an internal agent detail, and context-warning is advisory. They are
600
- // intentionally consumed without surfacing to ACP.
601
- return null;
602
- default: {
603
- // Exhaustiveness guard: any future AgentEvent variant added to the
604
- // union without an explicit case above will fail to type-check here.
605
- const exhaustive: never = event;
606
- throw new Error(`Unhandled agent event: ${String(exhaustive)}`);
607
- }
655
+ private async sendTitleUpdate(
656
+ update: acp.SessionInfoUpdate & { sessionUpdate: 'session_info_update' },
657
+ title: string,
658
+ ): Promise<void> {
659
+ try {
660
+ await this.sendUpdateStrict(update);
661
+ } catch (error) {
662
+ const msg = error instanceof Error ? error.message : String(error);
663
+ this.logger.debug(
664
+ () => `sendTitleUpdate ERROR (will retry next turn): ${msg}`,
665
+ );
666
+ this.sessionInfo.markPendingTitle(title);
608
667
  }
609
668
  }
610
669
 
670
+ private recordMetadataTitle(title: string | undefined): void {
671
+ const recording = this.config.getSessionRecordingService();
672
+ if (recording?.isActive() === true)
673
+ recording.recordSessionMetadata(title ?? null);
674
+ }
611
675
  private async sendUsageUpdate(
612
676
  usage: Extract<AgentEvent, { type: 'usage' }>['usage'],
613
677
  ): Promise<void> {
614
- const outputTokens = usage.candidatesTokenCount ?? 0;
615
- const size = usage.totalTokenCount ?? outputTokens;
616
- if (size === 0 && outputTokens === 0) {
617
- return;
618
- }
619
- await this.sendUpdate({
620
- sessionUpdate: 'usage_update',
621
- used: size,
622
- size,
623
- });
678
+ const update = buildUsageUpdate(
679
+ usage,
680
+ getTokenLimitForConfiguredContext(this.config.getModel(), this.config),
681
+ );
682
+ if (update !== null) await this.sendUpdate(update);
624
683
  }
625
-
626
684
  private isPromptStale(
627
685
  promptGeneration: number,
628
686
  pendingSend: AbortController,
@@ -633,7 +691,6 @@ export class Session {
633
691
  pendingSend.signal.aborted
634
692
  );
635
693
  }
636
-
637
694
  private async handleToolConfirmation(
638
695
  event: Extract<AgentEvent, { type: 'tool-confirmation' }>,
639
696
  promptGeneration: number,
@@ -647,12 +704,10 @@ export class Session {
647
704
  settled: false,
648
705
  });
649
706
  });
650
-
651
707
  if (this.isPromptStale(promptGeneration, pendingSend)) {
652
708
  this.settleConfirmation(confirmationId);
653
709
  return;
654
710
  }
655
-
656
711
  let result: PermissionRoundTripResult | null;
657
712
  try {
658
713
  result = await Promise.race([
@@ -662,6 +717,7 @@ export class Session {
662
717
  event.confirmation.name,
663
718
  event.confirmation.details,
664
719
  this.connection,
720
+ this.agent.tools.get(event.confirmation.name)?.kind,
665
721
  ),
666
722
  cancelled,
667
723
  ] as const);
@@ -669,7 +725,6 @@ export class Session {
669
725
  this.settleConfirmation(confirmationId);
670
726
  throw error;
671
727
  }
672
-
673
728
  const state = this.activeConfirmations.get(confirmationId);
674
729
  if (
675
730
  result === null ||
@@ -679,7 +734,6 @@ export class Session {
679
734
  ) {
680
735
  return;
681
736
  }
682
-
683
737
  state.settled = true;
684
738
  this.activeConfirmations.delete(confirmationId);
685
739
  try {
@@ -696,43 +750,16 @@ export class Session {
696
750
  );
697
751
  }
698
752
  }
699
-
700
- private extractThoughtText(thought: ThoughtSummary): string {
701
- const parts = [thought.subject, thought.description].filter(
702
- (v) => v.length > 0,
703
- );
704
- return parts.join(parts.length > 1 ? ' ' : '');
753
+ private async sendUpdateStrict(update: acp.SessionUpdate): Promise<void> {
754
+ this.logger.debug(() => describeSessionUpdateForLog(update));
755
+ await this.connection.sessionUpdate({ sessionId: this.id, update });
705
756
  }
706
-
707
- private translateErrorEvent(
708
- event: Extract<AgentEvent, { type: 'error' }>,
709
- ): Error {
710
- const error = new Error(event.error.message);
711
- if (event.error.status !== undefined) {
712
- Object.assign(error, { status: event.error.status });
713
- }
714
- return error;
757
+ sendAvailableCommands(): Promise<void> {
758
+ return this.sendUpdateStrict(buildAvailableCommandsUpdate());
715
759
  }
716
-
717
- private translateIdleTimeout(
718
- event: Extract<AgentEvent, { type: 'idle-timeout' }>,
719
- ): Error {
720
- return new Error(event.error.message);
721
- }
722
-
723
760
  private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
724
- const params: acp.SessionNotification = { sessionId: this.id, update };
725
- this.logger.debug(
726
- () =>
727
- `sendUpdate: ${update.sessionUpdate} ${
728
- 'content' in update && update.content && 'text' in update.content
729
- ? `(${(update.content as { text: string }).text.length} chars)`
730
- : ''
731
- }`,
732
- );
733
761
  try {
734
- await this.connection.sessionUpdate(params);
735
- this.logger.debug(() => 'sendUpdate: delivered');
762
+ await this.sendUpdateStrict(update);
736
763
  } catch (error) {
737
764
  this.logger.debug(
738
765
  () =>
@@ -740,122 +767,46 @@ export class Session {
740
767
  );
741
768
  }
742
769
  }
743
-
744
770
  debug(msg: string) {
745
- if (this.config.getDebugMode()) {
746
- debugLogger.warn(msg);
771
+ if (this.config.getDebugMode()) debugLogger.warn(msg);
772
+ }
773
+ private sendPlanUpdate(todos: TodoUpdateEvent['todos']): Promise<void> {
774
+ return this.sendUpdate(buildZedPlanUpdate(todos));
775
+ }
776
+ async streamHistory(items: readonly IContent[]): Promise<void> {
777
+ const updates = mapHistoryToSessionUpdates(items);
778
+ for (const update of updates) {
779
+ try {
780
+ await this.sendUpdateStrict(update);
781
+ } catch (error) {
782
+ throw wrapReplayFailure(this.id, error);
783
+ }
747
784
  }
785
+ this.sessionInfo.hydrateFromHistory(items);
748
786
  }
749
-
750
- private async sendPlanUpdate(todos: Todo[]): Promise<void> {
751
- const entries: acp.PlanEntry[] = todos.map((todo) => ({
752
- content: todo.content,
753
- status: todo.status,
754
- priority: 'medium' as const,
755
- }));
756
- await this.sendUpdate({ sessionUpdate: 'plan', entries });
787
+ async replayLiveHistory(): Promise<void> {
788
+ await this.streamHistory(
789
+ await readAgentHistoryForReplay(this.agent, this.id),
790
+ );
757
791
  }
758
-
759
792
  async dispose(): Promise<void> {
760
793
  try {
761
794
  todoEvents.offTodoUpdated(this.todoListener);
795
+ this.stopConfigUpdates();
762
796
  this.settleActiveConfirmation();
797
+ await this.terminals
798
+ ?.settleAll()
799
+ .catch((error) =>
800
+ this.logger.debug(() => `Terminal cleanup failed: ${error}`),
801
+ );
763
802
  this.pendingPrompt?.abort();
764
803
  this.pendingPrompt = null;
765
804
  } finally {
766
- try {
767
- await this.agent.dispose();
768
- } catch (error) {
769
- debugLogger.error('Failed to dispose Zed session agent:', error);
770
- }
771
- }
772
- }
773
- }
774
-
775
- const BATCH_INTERVAL_MS = 100;
776
-
777
- class StreamBatcher {
778
- private pendingChunks: Array<{ kind: 'text' | 'thought'; text: string }> = [];
779
- private batchTimer: ReturnType<typeof setTimeout> | null = null;
780
- private flushChain: Promise<void> = Promise.resolve();
781
-
782
- constructor(
783
- private readonly emojiFilter: EmojiFilter,
784
- private readonly sendUpdate: (update: acp.SessionUpdate) => Promise<void>,
785
- ) {}
786
-
787
- append(text: string, isThought: boolean): void {
788
- const filterResult = isThought
789
- ? this.emojiFilter.filterText(text)
790
- : this.emojiFilter.filterStreamChunk(text);
791
- if (filterResult.blocked) {
792
- const pending = this.flushChain
793
- .then(() => this.doFlush())
794
- .then(() =>
795
- this.sendUpdate({
796
- sessionUpdate: 'agent_message_chunk',
797
- content: {
798
- type: 'text',
799
- text: '[Error: Response blocked due to emoji detection]',
800
- },
801
- }),
805
+ await this.agent
806
+ .dispose()
807
+ .catch((e: unknown) =>
808
+ this.logger.debug(() => `Failed to dispose Zed session agent: ${e}`),
802
809
  );
803
- this.flushChain = pending.catch(() => undefined);
804
- return;
805
- }
806
- const filteredText =
807
- typeof filterResult.filtered === 'string' ? filterResult.filtered : '';
808
- if (filteredText.length === 0) {
809
- return;
810
- }
811
- this.appendPendingChunk(isThought ? 'thought' : 'text', filteredText);
812
- this.batchTimer ??= setTimeout(() => {
813
- this.batchTimer = null;
814
- void this.flush();
815
- }, BATCH_INTERVAL_MS);
816
- }
817
-
818
- async flush(): Promise<void> {
819
- if (this.batchTimer !== null) {
820
- clearTimeout(this.batchTimer);
821
- this.batchTimer = null;
822
- }
823
- const pending = this.flushChain
824
- .then(() => this.doFlush())
825
- .then(() => this.flushEmojiBuffer());
826
- this.flushChain = pending.catch(() => undefined);
827
- await pending;
828
- }
829
-
830
- private async flushEmojiBuffer(): Promise<void> {
831
- const remaining = this.emojiFilter.flushBuffer();
832
- if (remaining.length === 0) {
833
- return;
834
- }
835
- this.appendPendingChunk('text', remaining);
836
- await this.doFlush();
837
- }
838
-
839
- private appendPendingChunk(kind: 'text' | 'thought', text: string): void {
840
- const lastChunk = this.pendingChunks.at(-1);
841
- if (lastChunk?.kind === kind) {
842
- lastChunk.text += text;
843
- return;
844
- }
845
- this.pendingChunks.push({ kind, text });
846
- }
847
-
848
- private async doFlush(): Promise<void> {
849
- const chunks = this.pendingChunks;
850
- this.pendingChunks = [];
851
- for (const chunk of chunks) {
852
- await this.sendUpdate({
853
- sessionUpdate:
854
- chunk.kind === 'thought'
855
- ? 'agent_thought_chunk'
856
- : 'agent_message_chunk',
857
- content: { type: 'text', text: chunk.text },
858
- });
859
810
  }
860
811
  }
861
812
  }