@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,255 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type * as acp from '@agentclientprotocol/sdk';
8
+ import {
9
+ SESSION_TITLE_MAX_LENGTH,
10
+ type IContent,
11
+ } from '@vybestack/llxprt-code-core';
12
+
13
+ /**
14
+ * Derives a human-readable title from the first user prompt by concatenating
15
+ * its text blocks (ignoring media/resources) and truncating to the bounded
16
+ * length the session listing uses. Returns null when the prompt carries no
17
+ * text. No LLM call — consistent with the durable listing convention.
18
+ *
19
+ * Normalization intentionally matches the durable path
20
+ * (SessionDiscovery.readFirstUserMessage → extractUserMessageText): text blocks
21
+ * are joined with an empty separator and truncated — NO trimming or newline
22
+ * collapsing — so the live title is byte-identical to the on-disk listing
23
+ * title for the same first user message.
24
+ */
25
+ export function deriveSessionTitle(
26
+ prompt: readonly acp.ContentBlock[],
27
+ ): string | null {
28
+ return extractTitleText(prompt);
29
+ }
30
+
31
+ function extractTitleText(
32
+ blocks: ReadonlyArray<{ readonly type: string; readonly text?: unknown }>,
33
+ ): string | null {
34
+ const text = blocks
35
+ .filter(
36
+ (block): block is { readonly type: 'text'; readonly text: string } =>
37
+ block.type === 'text' && typeof block.text === 'string',
38
+ )
39
+ .map((block) => block.text)
40
+ .join('');
41
+ if (text.length === 0) {
42
+ return null;
43
+ }
44
+ return text.length > SESSION_TITLE_MAX_LENGTH
45
+ ? text.slice(0, SESSION_TITLE_MAX_LENGTH)
46
+ : text;
47
+ }
48
+
49
+ /**
50
+ * Extracts the first human-speaker text from a resumed history, using the SAME
51
+ * join + truncate normalization as {@link deriveSessionTitle} and the durable
52
+ * SessionDiscovery.readFirstUserMessage. Returns null when the history has no
53
+ * human text block, so a restored session with only ai/tool entries is not
54
+ * titled (matching the durable listing, which shows no title for such sessions).
55
+ */
56
+ export function deriveTitleFromHistory(
57
+ history: readonly IContent[],
58
+ ): string | null {
59
+ for (const item of history) {
60
+ if (item.speaker !== 'human') {
61
+ continue;
62
+ }
63
+ const title = extractTitleText(item.blocks);
64
+ if (title !== null) {
65
+ return title;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Builds an ACP `session_info_update` {@link acp.SessionUpdate} using the SDK's
73
+ * discriminated-union variant directly (no casts). Only the supplied fields are
74
+ * carried so partial title/updatedAt updates are emitted without nulling the
75
+ * other.
76
+ */
77
+ export function buildSessionInfoUpdate(fields: {
78
+ readonly title?: string;
79
+ readonly updatedAt?: string;
80
+ }): acp.SessionInfoUpdate & { sessionUpdate: 'session_info_update' } {
81
+ const update: acp.SessionInfoUpdate & {
82
+ sessionUpdate: 'session_info_update';
83
+ } = {
84
+ sessionUpdate: 'session_info_update',
85
+ };
86
+ if (fields.title !== undefined) {
87
+ update.title = fields.title;
88
+ }
89
+ if (fields.updatedAt !== undefined) {
90
+ update.updatedAt = fields.updatedAt;
91
+ }
92
+ return update;
93
+ }
94
+
95
+ export interface TitleEligibilityResult {
96
+ /**
97
+ * True when THIS call won the title — i.e. the derived title was freshly set.
98
+ * The caller emits a session_info_update carrying the title exactly once.
99
+ */
100
+ readonly wonTitle: boolean;
101
+ /**
102
+ * The current title (undefined until the first text-bearing prompt wins or
103
+ * hydration sets it from history).
104
+ */
105
+ readonly title: string | undefined;
106
+ }
107
+
108
+ export interface SessionInfoRecordResult {
109
+ /**
110
+ * The session_info_update notifications to emit for this turn: always an
111
+ * updatedAt update, optionally preceded by a pending title retry.
112
+ */
113
+ readonly updates: ReadonlyArray<
114
+ acp.SessionInfoUpdate & { sessionUpdate: 'session_info_update' }
115
+ >;
116
+ /**
117
+ * The current title (undefined until the first text-bearing prompt wins or
118
+ * hydration sets it from history).
119
+ */
120
+ readonly title: string | undefined;
121
+ }
122
+
123
+ /**
124
+ * Tracks per-session title (derived once from the first text-bearing prompt)
125
+ * and updatedAt (refreshed every turn). Keeping this state in a cohesive
126
+ * helper lets zedIntegration.ts stay within its max-lines budget and makes the
127
+ * externally observable session_info_update semantics unit-testable in
128
+ * isolation.
129
+ *
130
+ * Title eligibility is consumed SYNCHRONOUSLY at prompt-acceptance time via
131
+ * {@link consumeTitleEligibility}, not deferred to turn completion, so
132
+ * overlapping prompts cannot both claim the title (race-safety, issue #1611
133
+ * finding 1). Restored sessions hydrate the title from history via
134
+ * {@link hydrateFromHistory} so later prompts never retitle them (finding 2).
135
+ */
136
+ export class SessionTitleTracker {
137
+ private title: string | undefined;
138
+ private updatedAt: string | undefined;
139
+ /**
140
+ * Once consumed, no future call can win the title — even if the winning
141
+ * prompt had no text (so a no-text first prompt still suppresses a later
142
+ * retitle).
143
+ */
144
+ private titleEligibilityConsumed = false;
145
+ /**
146
+ * A title that won eligibility but whose session_info_update notification
147
+ * failed transport. Retried (prepended) on the next turn's metadata emission
148
+ * so a transient transport error doesn't permanently lose the title.
149
+ * Issue #1611: pending title notification retry after transport failure.
150
+ */
151
+ private pendingTitle: string | undefined;
152
+
153
+ /**
154
+ * Synchronously consumes title eligibility for a prompt: on the FIRST call
155
+ * (whether or not the prompt has text), eligibility is permanently consumed.
156
+ * If the prompt has text and no title is set yet, the title is derived and
157
+ * set. Returns whether THIS call won the title (so the caller emits the
158
+ * session_info_update exactly once) and the current title.
159
+ */
160
+ consumeTitleEligibility(
161
+ prompt: readonly acp.ContentBlock[],
162
+ ): TitleEligibilityResult {
163
+ if (this.titleEligibilityConsumed) {
164
+ return { wonTitle: false, title: this.title };
165
+ }
166
+ this.titleEligibilityConsumed = true;
167
+ const derived = deriveSessionTitle(prompt);
168
+ if (derived === null) {
169
+ return { wonTitle: false, title: undefined };
170
+ }
171
+ this.title = derived;
172
+ return { wonTitle: true, title: this.title };
173
+ }
174
+
175
+ hydrateFromMetadata(title: string | null | undefined): string | undefined {
176
+ if (this.titleEligibilityConsumed) {
177
+ return this.title;
178
+ }
179
+ this.titleEligibilityConsumed = true;
180
+ if (typeof title === 'string' && title.length > 0) {
181
+ this.title = title;
182
+ }
183
+ return this.title;
184
+ }
185
+
186
+ /**
187
+ * Hydrates the title from a resumed history (issue #1611 finding 2). Called
188
+ * after a load/resume replays the conversation, BEFORE any new prompt. If the
189
+ * history's first human text yields a title, it is set AND eligibility is
190
+ * consumed, so the next live prompt can never retitle the session. Idempotent:
191
+ * a no-op if a title was already set or eligibility already consumed.
192
+ */
193
+ hydrateFromHistory(history: readonly IContent[]): string | undefined {
194
+ if (this.title !== undefined || this.titleEligibilityConsumed) {
195
+ return this.title;
196
+ }
197
+ const derived = deriveTitleFromHistory(history);
198
+ this.titleEligibilityConsumed = true;
199
+ if (derived === null) {
200
+ return undefined;
201
+ }
202
+ this.title = derived;
203
+ return this.title;
204
+ }
205
+
206
+ /**
207
+ * Records a completed turn: advances updatedAt and returns the
208
+ * session_info_update notification the caller should push. Title is handled
209
+ * separately via {@link consumeTitleEligibility} at acceptance time.
210
+ *
211
+ * If a pending title exists (from a previous transport failure), it is
212
+ * prepended to the updates so the caller retries it this turn.
213
+ */
214
+ recordTurn(updatedAt: string): SessionInfoRecordResult {
215
+ this.updatedAt = updatedAt;
216
+ const updates: Array<
217
+ acp.SessionInfoUpdate & { sessionUpdate: 'session_info_update' }
218
+ > = [];
219
+ if (this.pendingTitle !== undefined) {
220
+ updates.push(
221
+ buildSessionInfoUpdate({
222
+ title: this.pendingTitle,
223
+ updatedAt,
224
+ }),
225
+ );
226
+ this.pendingTitle = undefined;
227
+ }
228
+ updates.push(buildSessionInfoUpdate({ updatedAt }));
229
+ return { updates, title: this.title };
230
+ }
231
+
232
+ /**
233
+ * Marks a title as pending for retry (issue #1611). Called by the caller
234
+ * when the title-bearing session_info_update notification fails transport.
235
+ * The next {@link recordTurn} will prepend the title to its updates.
236
+ */
237
+ markPendingTitle(title: string): void {
238
+ this.pendingTitle = title;
239
+ }
240
+
241
+ /**
242
+ * Returns the pending title (for testing) or undefined when none is pending.
243
+ */
244
+ getPendingTitle(): string | undefined {
245
+ return this.pendingTitle;
246
+ }
247
+
248
+ getTitle(): string | undefined {
249
+ return this.title;
250
+ }
251
+
252
+ getUpdatedAt(): string | undefined {
253
+ return this.updatedAt;
254
+ }
255
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as acp from '@agentclientprotocol/sdk';
8
+ import {
9
+ deleteSessionById,
10
+ getProjectHash,
11
+ SESSION_NOT_FOUND_PREFIX,
12
+ SessionDiscovery,
13
+ type ApprovalMode,
14
+ type Config,
15
+ } from '@vybestack/llxprt-code-core';
16
+ import { buildSessionModes } from './zed-helpers.js';
17
+ import { listRecordedSessions } from './zed-session-listing.js';
18
+ import type { LifecycleSession } from './zed-session-pagination.js';
19
+ import type {
20
+ CloseSessionRequest,
21
+ CloseSessionResponse,
22
+ DeleteSessionRequest,
23
+ DeleteSessionResponse,
24
+ } from './acp-types.js';
25
+
26
+ export interface LifecycleSessionHandle {
27
+ getApprovalMode(): ApprovalMode;
28
+ getLifecycleInfo(): LifecycleSession;
29
+ dispose(): Promise<void>;
30
+ sendAvailableCommands(): Promise<void>;
31
+ getConfigOptions(): Promise<acp.SessionConfigOption[]>;
32
+ }
33
+
34
+ interface RestoredSession {
35
+ readonly session: LifecycleSessionHandle;
36
+ }
37
+
38
+ export class SessionLifecycle {
39
+ private readonly queues = new Map<string, Promise<unknown>>();
40
+
41
+ constructor(
42
+ private readonly config: Config,
43
+ private readonly sessions: Map<string, LifecycleSessionHandle>,
44
+ private readonly restore: (
45
+ sessionId: string,
46
+ cwd: string | undefined,
47
+ ) => Promise<RestoredSession>,
48
+ private readonly configOptions: (
49
+ session: LifecycleSessionHandle,
50
+ ) => Promise<
51
+ Pick<acp.ResumeSessionResponse, 'configOptions'>
52
+ > = async () => ({}),
53
+ ) {}
54
+
55
+ list(params: acp.ListSessionsRequest): Promise<acp.ListSessionsResponse> {
56
+ const projectRoot = this.config.getProjectRoot();
57
+ return listRecordedSessions(
58
+ this.config.storage.getProjectChatsDir(),
59
+ getProjectHash(projectRoot),
60
+ projectRoot,
61
+ params,
62
+ [...this.sessions.values()].map((session) => session.getLifecycleInfo()),
63
+ );
64
+ }
65
+
66
+ resume(params: acp.ResumeSessionRequest): Promise<acp.ResumeSessionResponse> {
67
+ return this.runSerialized(params.sessionId, () =>
68
+ this.performResume(params),
69
+ );
70
+ }
71
+
72
+ close(params: CloseSessionRequest): Promise<CloseSessionResponse> {
73
+ return this.runSerialized(params.sessionId, async () => {
74
+ await this.disposeLive(params.sessionId);
75
+ return {};
76
+ });
77
+ }
78
+
79
+ delete(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
80
+ return this.runSerialized(params.sessionId, () =>
81
+ this.performDelete(params),
82
+ );
83
+ }
84
+
85
+ runSerialized<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
86
+ const prior = this.queues.get(sessionId);
87
+ // A failed operation must not poison the session queue; the next operation
88
+ // starts after either settlement while each caller still receives its own
89
+ // operation's rejection.
90
+ const run = (prior ?? Promise.resolve()).then(operation, operation);
91
+ this.queues.set(sessionId, run);
92
+ return run.finally(() => {
93
+ if (this.queues.get(sessionId) === run) {
94
+ this.queues.delete(sessionId);
95
+ }
96
+ });
97
+ }
98
+
99
+ private async performResume(
100
+ params: acp.ResumeSessionRequest,
101
+ ): Promise<acp.ResumeSessionResponse> {
102
+ const live = this.sessions.get(params.sessionId);
103
+ if (live !== undefined) {
104
+ if (live.getLifecycleInfo().cwd !== params.cwd) {
105
+ throw acp.RequestError.resourceNotFound(params.sessionId);
106
+ }
107
+ await live.sendAvailableCommands();
108
+ return {
109
+ modes: buildSessionModes(live.getApprovalMode()),
110
+ ...(await this.configOptions(live)),
111
+ };
112
+ }
113
+ const projectRoot = this.config.getProjectRoot();
114
+ const summaries = await SessionDiscovery.listSessions(
115
+ this.config.storage.getProjectChatsDir(),
116
+ getProjectHash(projectRoot),
117
+ );
118
+ const target = summaries.find(
119
+ (summary) => summary.sessionId === params.sessionId,
120
+ );
121
+ if (target === undefined || (target.cwd ?? projectRoot) !== params.cwd) {
122
+ throw acp.RequestError.resourceNotFound(params.sessionId);
123
+ }
124
+ const { session } = await this.restore(params.sessionId, params.cwd);
125
+ this.sessions.set(params.sessionId, session);
126
+ try {
127
+ await session.sendAvailableCommands();
128
+ return {
129
+ modes: buildSessionModes(session.getApprovalMode()),
130
+ ...(await this.configOptions(session)),
131
+ };
132
+ } catch (error) {
133
+ this.sessions.delete(params.sessionId);
134
+ await session.dispose().catch(() => undefined);
135
+ throw error;
136
+ }
137
+ }
138
+
139
+ private async performDelete(
140
+ params: DeleteSessionRequest,
141
+ ): Promise<DeleteSessionResponse> {
142
+ const hadLiveSession = await this.disposeLive(params.sessionId);
143
+ const projectRoot = this.config.getProjectRoot();
144
+ let result: Awaited<ReturnType<typeof deleteSessionById>>;
145
+ try {
146
+ result = await deleteSessionById(
147
+ params.sessionId,
148
+ this.config.storage.getProjectChatsDir(),
149
+ getProjectHash(projectRoot),
150
+ );
151
+ } catch (error) {
152
+ throw acp.RequestError.internalError({
153
+ sessionId: params.sessionId,
154
+ reason: error instanceof Error ? error.message : String(error),
155
+ });
156
+ }
157
+ if (result.ok) {
158
+ return {};
159
+ }
160
+ if (result.error.startsWith(SESSION_NOT_FOUND_PREFIX)) {
161
+ if (hadLiveSession) {
162
+ return {};
163
+ }
164
+ throw acp.RequestError.resourceNotFound(params.sessionId);
165
+ }
166
+ throw acp.RequestError.internalError({
167
+ sessionId: params.sessionId,
168
+ reason: result.error,
169
+ });
170
+ }
171
+
172
+ private async disposeLive(sessionId: string): Promise<boolean> {
173
+ const live = this.sessions.get(sessionId);
174
+ if (live === undefined) {
175
+ return false;
176
+ }
177
+ try {
178
+ await live.dispose();
179
+ } catch {
180
+ // Persisted deletion must still proceed after best-effort live cleanup.
181
+ } finally {
182
+ this.sessions.delete(sessionId);
183
+ }
184
+ return true;
185
+ }
186
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { SessionSummary } from '@vybestack/llxprt-code-core';
8
+ import { SessionDiscovery } from '@vybestack/llxprt-code-core';
9
+ import type * as acp from '@agentclientprotocol/sdk';
10
+ import {
11
+ paginateSessions,
12
+ type LifecycleSession,
13
+ } from './zed-session-pagination.js';
14
+
15
+ // Keep responses responsive without returning an unwieldy session list.
16
+ const SESSION_PAGE_SIZE = 50;
17
+ // Bound concurrent metadata reads to avoid I/O spikes on large histories.
18
+ const LIST_CONCURRENCY_LIMIT = 8;
19
+
20
+ async function mapWithConcurrencyLimit<T, R>(
21
+ items: readonly T[],
22
+ limit: number,
23
+ fn: (item: T) => Promise<R>,
24
+ ): Promise<R[]> {
25
+ if (!Number.isInteger(limit) || limit <= 0) {
26
+ throw new RangeError('Concurrency limit must be a positive integer');
27
+ }
28
+ const results: R[] = [];
29
+ let index = 0;
30
+ let aborted = false;
31
+ const workers = Array.from(
32
+ { length: Math.min(limit, items.length) },
33
+ async () => {
34
+ while (!aborted && index < items.length) {
35
+ const current = index++;
36
+ results[current] = await fn(items[current]);
37
+ }
38
+ },
39
+ );
40
+ try {
41
+ await Promise.all(workers);
42
+ } catch (error) {
43
+ aborted = true;
44
+ await Promise.allSettled(workers);
45
+ throw error;
46
+ }
47
+ return results;
48
+ }
49
+
50
+ export async function listRecordedSessions(
51
+ chatsDir: string,
52
+ projectHash: string,
53
+ fallbackCwd: string,
54
+ request: acp.ListSessionsRequest,
55
+ liveSessions: readonly LifecycleSession[] = [],
56
+ ): Promise<acp.ListSessionsResponse> {
57
+ const summaries = await SessionDiscovery.listSessions(chatsDir, projectHash);
58
+ const records = await mapWithConcurrencyLimit(
59
+ summaries,
60
+ LIST_CONCURRENCY_LIMIT,
61
+ (summary) => toLifecycleSession(summary, fallbackCwd),
62
+ );
63
+ const merged = new Map(
64
+ records.map((session) => [session.sessionId, session] as const),
65
+ );
66
+ for (const session of liveSessions) {
67
+ const durable = merged.get(session.sessionId);
68
+ merged.set(session.sessionId, mergeLifecycleSession(durable, session));
69
+ }
70
+ const page = paginateSessions(
71
+ [...merged.values()],
72
+ { cwd: request.cwd ?? null, cursor: request.cursor ?? null },
73
+ SESSION_PAGE_SIZE,
74
+ );
75
+ return {
76
+ sessions: page.sessions.map(toSessionInfo),
77
+ ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
78
+ };
79
+
80
+ function mergeLifecycleSession(
81
+ durable: LifecycleSession | undefined,
82
+ live: LifecycleSession,
83
+ ): LifecycleSession {
84
+ if (durable === undefined) {
85
+ return live;
86
+ }
87
+ return {
88
+ ...live,
89
+ // createdAt is immutable — durable recording wins (live sessions may not
90
+ // have it until the session_start is persisted). Issue #1611.
91
+ ...(durable.createdAt !== undefined
92
+ ? { createdAt: durable.createdAt }
93
+ : {}),
94
+ updatedAt: resolveUpdatedAt(durable.updatedAt, live.updatedAt),
95
+ ...(durable.title === undefined ? {} : { title: durable.title }),
96
+ };
97
+ }
98
+ }
99
+
100
+ function resolveUpdatedAt(
101
+ durableUpdatedAt: string,
102
+ liveUpdatedAt: string,
103
+ ): string {
104
+ const durableTimestamp = Date.parse(durableUpdatedAt);
105
+ const liveTimestamp = Date.parse(liveUpdatedAt);
106
+ if (Number.isNaN(liveTimestamp)) {
107
+ return durableUpdatedAt;
108
+ }
109
+ if (Number.isNaN(durableTimestamp)) {
110
+ return liveUpdatedAt;
111
+ }
112
+ return durableTimestamp >= liveTimestamp ? durableUpdatedAt : liveUpdatedAt;
113
+ }
114
+
115
+ /**
116
+ * Resolves the tri-state title for a durable session.
117
+ *
118
+ * Issue #1611: a persisted `session_metadata` event is the source of truth.
119
+ * For legacy files without one (title === undefined), fall back to the first
120
+ * human message (which returns null for no-human-text sessions, omitted from
121
+ * the output). A null metadata title remains explicitly untitled by suppressing
122
+ * that legacy fallback and omitting the ACP title property.
123
+ */
124
+ async function toLifecycleSession(
125
+ summary: SessionSummary,
126
+ fallbackCwd: string,
127
+ ): Promise<LifecycleSession> {
128
+ const metadataTitle = await SessionDiscovery.readSessionMetadataTitle(
129
+ summary.filePath,
130
+ );
131
+ const displayTitle =
132
+ metadataTitle === undefined
133
+ ? await SessionDiscovery.readFirstUserMessage(summary.filePath)
134
+ : metadataTitle;
135
+ return {
136
+ sessionId: summary.sessionId,
137
+ cwd: summary.cwd ?? fallbackCwd,
138
+ updatedAt: summary.lastModified.toISOString(),
139
+ ...(summary.createdAt !== undefined
140
+ ? { createdAt: summary.createdAt }
141
+ : {}),
142
+ ...(typeof displayTitle === 'string' ? { title: displayTitle } : {}),
143
+ };
144
+ }
145
+
146
+ function toSessionInfo(session: LifecycleSession): acp.SessionInfo {
147
+ return {
148
+ sessionId: session.sessionId,
149
+ cwd: session.cwd,
150
+ updatedAt: session.updatedAt,
151
+ ...(session.title === undefined ? {} : { title: session.title }),
152
+ };
153
+ }