@vybestack/llxprt-code 0.10.0 → 0.11.0-nightly.260728.ffaa8d5d7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/commands/mcp/add.ts +8 -4
- package/src/config/cliArgParser.ts +2 -0
- package/src/config/config.ts +2 -0
- package/src/config/configBuilder.ts +3 -0
- package/src/config/configError.ts +23 -0
- package/src/config/extensions/extensionEnablement.ts +62 -1
- package/src/config/interactiveContext.ts +2 -1
- package/src/config/intermediateConfig.ts +4 -0
- package/src/config/keyBindings.ts +29 -3
- package/src/config/policy.ts +7 -1
- package/src/config/postConfigRuntime.ts +20 -14
- package/src/config/settings-schema/schema-ui.ts +9 -0
- package/src/config/settingsLoader.ts +21 -17
- package/src/config/settingsSchema.ts +2 -2
- package/src/config/trustedFolders.ts +275 -58
- package/src/config/yargsOptions.ts +7 -0
- package/src/generated/git-commit.json +1 -1
- package/src/nonInteractiveCli.ts +1 -0
- package/src/nonInteractiveCliSupport.ts +171 -51
- package/src/runtime/interactiveToolScheduler.ts +6 -6
- package/src/services/BuiltinCommandLoader.ts +33 -25
- package/src/services/FileCommandLoader.ts +34 -7
- package/src/test-utils/async.ts +20 -0
- package/src/test-utils/render.tsx +10 -1
- package/src/ui/App.tsx +17 -10
- package/src/ui/AppContainerRuntime.tsx +7 -9
- package/src/ui/cliUiRuntime.ts +28 -0
- package/src/ui/commands/authCommand.ts +240 -3
- package/src/ui/commands/compressCommand.ts +9 -1
- package/src/ui/commands/directoryCommand.tsx +1 -1
- package/src/ui/commands/logoutCommand.ts +2 -2
- package/src/ui/commands/permissionsCommand.ts +77 -20
- package/src/ui/commands/policiesCommand.ts +6 -1
- package/src/ui/commands/quotaCommand.ts +501 -0
- package/src/ui/commands/statsCommand.ts +2 -2
- package/src/ui/commands/statsQuota.ts +1 -1
- package/src/ui/commands/subagentCommand.ts +59 -27
- package/src/ui/components/AuthDialog.tsx +3 -3
- package/src/ui/components/Composer.tsx +1 -0
- package/src/ui/components/DialogManager.tsx +9 -2
- package/src/ui/components/FolderTrustDialog.tsx +71 -60
- package/src/ui/components/PermissionsModifyTrustDialog.tsx +211 -146
- package/src/ui/components/ProfileCreateWizard/constants.ts +13 -1
- package/src/ui/components/QueuedMessagesPanel.tsx +388 -0
- package/src/ui/components/inputPromptHooks.ts +2 -0
- package/src/ui/components/inputPromptKeyHandlers.ts +14 -0
- package/src/ui/components/inputPromptTypes.ts +6 -0
- package/src/ui/components/messages/AiMessage.tsx +24 -12
- package/src/ui/components/messages/ToolMessage.tsx +26 -40
- package/src/ui/containers/AppContainer/builders/buildUIActions.ts +3 -1
- package/src/ui/containers/AppContainer/builders/buildUIState.ts +5 -4
- package/src/ui/containers/AppContainer/hooks/useAppBootstrap.ts +3 -33
- package/src/ui/containers/AppContainer/hooks/useAppDialogs.ts +2 -14
- package/src/ui/containers/AppContainer/hooks/useAppInput.ts +31 -25
- package/src/ui/containers/AppContainer/hooks/useAppLayout.ts +90 -60
- package/src/ui/containers/AppContainer/hooks/useDisplayPreferences.ts +10 -0
- package/src/ui/containers/AppContainer/hooks/useInputHandling.ts +3 -17
- package/src/ui/containers/AppContainer/hooks/useKeybindings.ts +51 -38
- package/src/ui/containers/AppContainer/hooks/useSteer.ts +51 -0
- package/src/ui/contexts/KeypressContext.tsx +13 -2
- package/src/ui/contexts/ShellCommandDisplayContext.tsx +103 -0
- package/src/ui/contexts/TodoContext.tsx +0 -13
- package/src/ui/contexts/TodoProvider.tsx +1 -34
- package/src/ui/contexts/UIActionsContext.tsx +2 -1
- package/src/ui/contexts/UIStateContext.tsx +3 -2
- package/src/ui/hooks/agentStream/streamUtils.ts +0 -24
- package/src/ui/hooks/agentStream/thoughtState.ts +110 -30
- package/src/ui/hooks/agentStream/types.ts +1 -0
- package/src/ui/hooks/agentStream/useAgentEventStream.ts +8 -17
- package/src/ui/hooks/agentStream/useAgentStream.ts +2 -2
- package/src/ui/hooks/agentStream/useAgentStreamLifecycle.ts +4 -5
- package/src/ui/hooks/agentStream/useAgentStreamOrchestration.ts +12 -5
- package/src/ui/hooks/agentStream/useQueuedSubmissions.ts +99 -0
- package/src/ui/hooks/agentStream/useStreamEventHandlers.ts +4 -13
- package/src/ui/hooks/agentStream/useStreamState.ts +60 -41
- package/src/ui/hooks/agentStream/useSubmitQuery.ts +284 -59
- package/src/ui/hooks/slashCommandProcessorSupport.ts +4 -0
- package/src/ui/hooks/useAgentStream-test-helpers.ts +1 -0
- package/src/ui/hooks/useAutoAcceptIndicator.ts +13 -0
- package/src/ui/hooks/useFolderTrust.ts +118 -81
- package/src/ui/hooks/useGitBranchName.ts +79 -33
- package/src/ui/hooks/useIdeTrustListener.ts +5 -19
- package/src/ui/hooks/usePermissionsModifyTrust.ts +223 -85
- package/src/ui/hooks/useReactToolScheduler.ts +13 -17
- package/src/ui/hooks/useTodoPausePreserver.ts +0 -55
- package/src/ui/layouts/DefaultAppLayout.tsx +2 -0
- package/src/ui/layouts/DefaultAppLayoutHelpers.tsx +37 -162
- package/src/ui/layouts/InlineContent.tsx +198 -0
- package/src/ui/trustDialogHelpers.ts +190 -0
- package/src/utils/cleanup-state.ts +115 -0
- package/src/utils/cleanup.ts +32 -34
- package/src/utils/sandbox-containers.ts +183 -53
- package/src/utils/sandbox-exec.ts +9 -3
- package/src/zed-integration/acp-terminal-shell-host.ts +112 -0
- package/src/zed-integration/acp-types.ts +35 -0
- package/src/zed-integration/fileSystemService.ts +1 -1
- package/src/zed-integration/runZedIntegration.ts +142 -0
- package/src/zed-integration/zed-agent-event-handler.ts +179 -0
- package/src/zed-integration/zed-agent-setup.ts +43 -0
- package/src/zed-integration/zed-command-registry.ts +167 -0
- package/src/zed-integration/zed-config-options.ts +280 -0
- package/src/zed-integration/zed-content-utils.ts +2 -2
- package/src/zed-integration/zed-helpers.ts +132 -0
- package/src/zed-integration/zed-initialize.ts +64 -0
- package/src/zed-integration/zed-plan-update.ts +20 -0
- package/src/zed-integration/zed-prompt-command.ts +77 -0
- package/src/zed-integration/zed-session-config.ts +119 -0
- package/src/zed-integration/zed-session-errors.ts +337 -0
- package/src/zed-integration/zed-session-events.ts +216 -0
- package/src/zed-integration/zed-session-info.ts +255 -0
- package/src/zed-integration/zed-session-lifecycle.ts +186 -0
- package/src/zed-integration/zed-session-listing.ts +153 -0
- package/src/zed-integration/zed-session-loader.ts +193 -0
- package/src/zed-integration/zed-session-pagination.ts +240 -0
- package/src/zed-integration/zed-session-replay.ts +564 -0
- package/src/zed-integration/zed-stream-batcher.ts +236 -0
- package/src/zed-integration/zed-terminal-manager.ts +416 -0
- package/src/zed-integration/zed-terminal-setup.ts +72 -0
- package/src/zed-integration/zed-test-helpers.ts +349 -4
- package/src/zed-integration/zed-tool-handler.ts +47 -5
- package/src/zed-integration/zedIntegration.ts +538 -587
- package/src/services/todo-continuation/todoContinuationService.ts +0 -610
- package/src/ui/containers/AppContainer/hooks/useIdeRestartHotkey.ts +0 -33
- package/src/ui/containers/AppContainer/hooks/useTodoContinuationFlow.ts +0 -160
- package/src/ui/hooks/useTodoContinuation.ts +0 -299
|
@@ -0,0 +1,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
|
+
}
|