@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,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Vybestack LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resume + error-classification helpers for ACP session/load (loadSession)
|
|
9
|
+
* (issue #1604). These own the I/O-touching parts of a load (the recorded-history
|
|
10
|
+
* resume, the corrupt-vs-missing session-file probe, the live-session re-attach
|
|
11
|
+
* probe, and the in-memory history bridge) plus the final RequestError
|
|
12
|
+
* normalization, kept OUT of zedIntegration.ts so that near-cap file stays within
|
|
13
|
+
* its max-lines budget and the load orchestration is individually testable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdir } from 'node:fs/promises';
|
|
17
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
18
|
+
import type { Config, IContent } from '@vybestack/llxprt-code-core';
|
|
19
|
+
import { DebugLogger } from '@vybestack/llxprt-code-core';
|
|
20
|
+
import type { Agent } from '@vybestack/llxprt-code-agents';
|
|
21
|
+
import {
|
|
22
|
+
classifyResumeFailure,
|
|
23
|
+
findMatchingSessionFile,
|
|
24
|
+
isEnoent,
|
|
25
|
+
wrapReplayFailure,
|
|
26
|
+
} from './zed-session-errors.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A readdir-like directory lister injected for testability (FINDING B / #1604
|
|
30
|
+
* re-attach). Production passes {@link nodeChatSessionFileLister} (a thin wrapper
|
|
31
|
+
* over `node:fs/promises` readdir); tests pass an honest fake that returns the
|
|
32
|
+
* directory entry names, NOT a result-shaped mock of our matching logic, so the
|
|
33
|
+
* real chats-dir derivation + filename matching are exercised.
|
|
34
|
+
*/
|
|
35
|
+
export type ChatSessionFileLister = (
|
|
36
|
+
chatsDir: string,
|
|
37
|
+
) => Promise<readonly string[]>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default {@link ChatSessionFileLister}: the real `node:fs/promises` readdir,
|
|
41
|
+
* wrapped so it satisfies the lister signature regardless of readdir's overloads.
|
|
42
|
+
*/
|
|
43
|
+
export const nodeChatSessionFileLister: ChatSessionFileLister = (chatsDir) =>
|
|
44
|
+
readdir(chatsDir);
|
|
45
|
+
|
|
46
|
+
const logger = new DebugLogger('llxprt:zed-integration:session-loader');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resumes the agent's recorded history, mapping a resume rejection to a precise
|
|
50
|
+
* ACP RequestError via {@link classifyResumeFailure}. When the plain
|
|
51
|
+
* classification would be "not found", the on-disk session-file namespace (the
|
|
52
|
+
* same chats dir the recording writes to) is probed so a corrupt-but-present
|
|
53
|
+
* session is reported as internalError ("file exists but could not be
|
|
54
|
+
* read/replayed") rather than being misreported as resourceNotFound (FINDING B).
|
|
55
|
+
*/
|
|
56
|
+
export async function resumeAgentHistory(
|
|
57
|
+
agent: Agent,
|
|
58
|
+
sessionId: string,
|
|
59
|
+
sessionConfig: Config,
|
|
60
|
+
listFiles: ChatSessionFileLister = nodeChatSessionFileLister,
|
|
61
|
+
): Promise<readonly IContent[]> {
|
|
62
|
+
try {
|
|
63
|
+
return await agent.session.resume(sessionId);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw await classifyResumeFailure(sessionId, error, () =>
|
|
66
|
+
listSessionFileNames(sessionConfig, listFiles),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lists the chats-dir entry names for the corrupt-vs-missing probe (FINDING B),
|
|
73
|
+
* deriving the directory the SAME way the recording layer does
|
|
74
|
+
* (via Storage.getProjectChatsDir()) and delegating the actual read
|
|
75
|
+
* to the injected {@link ChatSessionFileLister} (FINDING C1) so the resume probe
|
|
76
|
+
* and the re-attach probe ({@link hasRecordedSessionFile}) share ONE injected
|
|
77
|
+
* lister rather than one hardcoding readdir. A read failure propagates to
|
|
78
|
+
* {@link classifyResumeFailure}, which classifies it (ENOENT → genuinely
|
|
79
|
+
* missing; other → indeterminate/internalError) so the original resume failure
|
|
80
|
+
* is never silently masked.
|
|
81
|
+
*/
|
|
82
|
+
async function listSessionFileNames(
|
|
83
|
+
sessionConfig: Config,
|
|
84
|
+
listFiles: ChatSessionFileLister,
|
|
85
|
+
): Promise<readonly string[]> {
|
|
86
|
+
const chatsDir = chatsDirFor(sessionConfig);
|
|
87
|
+
return listFiles(chatsDir);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Re-attach decision probe (#1604): true when a recorded session file for
|
|
92
|
+
* `sessionId` already exists on disk under the ZedAgent config's chats dir.
|
|
93
|
+
*
|
|
94
|
+
* A freshly created but UNPROMPTED session has no recording — SessionRecordingService
|
|
95
|
+
* only materializes the JSONL file on the FIRST content event — so this returns
|
|
96
|
+
* false for it, signalling loadSession to RE-ATTACH the live in-memory session
|
|
97
|
+
* (replaying its in-memory history) instead of destroying it and failing a disk
|
|
98
|
+
* resume that would find no file. Once the session has been prompted (a file
|
|
99
|
+
* exists), this returns true and loadSession takes the destroy-prior + disk-resume
|
|
100
|
+
* path. An ENOENT probe failure is treated as "no recording present" (returns
|
|
101
|
+
* false, logged at debug) so a missing directory safely routes to re-attach;
|
|
102
|
+
* other probe failures propagate so permission and I/O errors remain visible.
|
|
103
|
+
*
|
|
104
|
+
* The directory is derived the SAME way the recording layer does
|
|
105
|
+
* (via Storage.getProjectChatsDir()) and matched with the SAME
|
|
106
|
+
* filename rule the corrupt-vs-missing resume probe uses
|
|
107
|
+
* ({@link findMatchingSessionFile}), keeping the two in lockstep.
|
|
108
|
+
*/
|
|
109
|
+
export async function hasRecordedSessionFile(
|
|
110
|
+
config: Config,
|
|
111
|
+
sessionId: string,
|
|
112
|
+
listFiles: ChatSessionFileLister = nodeChatSessionFileLister,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
try {
|
|
115
|
+
const chatsDir = chatsDirFor(config);
|
|
116
|
+
const entries = await listFiles(chatsDir);
|
|
117
|
+
return findMatchingSessionFile(sessionId, entries) !== null;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (isEnoent(error)) {
|
|
120
|
+
logger.debug(
|
|
121
|
+
() =>
|
|
122
|
+
`hasRecordedSessionFile: chats dir absent (ENOENT) for ${sessionId}; ` +
|
|
123
|
+
`treating as no on-disk recording (re-attach): ${String(error)}`,
|
|
124
|
+
);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reads a live agent's in-memory conversation as neutral IContent[] for the
|
|
133
|
+
* re-attach replay (#1604). `agent.getHistory()` already returns neutral
|
|
134
|
+
* AgentMessage/IContent values, so preserving them directly keeps every block
|
|
135
|
+
* intact and maps identically to a disk resume. A fresh unprompted session has
|
|
136
|
+
* empty history, yielding an empty array (zero replay updates).
|
|
137
|
+
*/
|
|
138
|
+
export async function readAgentHistoryAsIContent(
|
|
139
|
+
agent: Agent,
|
|
140
|
+
): Promise<readonly IContent[]> {
|
|
141
|
+
return [...(await agent.getHistory())];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* {@link readAgentHistoryAsIContent} with replay-failure normalization: a
|
|
146
|
+
* getHistory()/conversion rejection is wrapped exactly like a delivery failure
|
|
147
|
+
* ({@link wrapReplayFailure} -> internalError, phase:'replay') so a re-attach
|
|
148
|
+
* load always rejects with a well-formed RequestError — consistent with the
|
|
149
|
+
* disk-resume path's error semantics.
|
|
150
|
+
*/
|
|
151
|
+
export async function readAgentHistoryForReplay(
|
|
152
|
+
agent: Agent,
|
|
153
|
+
sessionId: string,
|
|
154
|
+
): Promise<readonly IContent[]> {
|
|
155
|
+
try {
|
|
156
|
+
return await readAgentHistoryAsIContent(agent);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw wrapReplayFailure(sessionId, error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The chats directory (where session recordings live), delegated to
|
|
164
|
+
* Storage.getProjectChatsDir() — the single source of truth shared with
|
|
165
|
+
* SessionControl.chatsDir() (the recording/resume path that WRITES the files),
|
|
166
|
+
* so the probe↔recording filename match can never drift on the location
|
|
167
|
+
* (FINDING C3).
|
|
168
|
+
*/
|
|
169
|
+
function chatsDirFor(config: Config): string {
|
|
170
|
+
return config.storage.getProjectChatsDir();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Normalizes a post-fromConfig load failure into an ACP RequestError (FINDING E).
|
|
175
|
+
* An error that is ALREADY a RequestError (e.g. the precise error returned by
|
|
176
|
+
* {@link resumeAgentHistory}) passes through unchanged; any other throw (such as
|
|
177
|
+
* one while constructing the Session AFTER resume already adopted the recording +
|
|
178
|
+
* lock) is wrapped as internalError carrying the detail, so the caller can always
|
|
179
|
+
* dispose the fresh agent and rethrow a single, well-formed RequestError.
|
|
180
|
+
*/
|
|
181
|
+
export function toLoadRequestError(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
error: unknown,
|
|
184
|
+
): acp.RequestError {
|
|
185
|
+
if (error instanceof acp.RequestError) {
|
|
186
|
+
return error;
|
|
187
|
+
}
|
|
188
|
+
const rawDetail = error instanceof Error ? error.message : String(error);
|
|
189
|
+
const detail =
|
|
190
|
+
rawDetail ||
|
|
191
|
+
(error instanceof Error ? error.constructor.name : 'unknown error');
|
|
192
|
+
return acp.RequestError.internalError({ sessionId, reason: detail }, detail);
|
|
193
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
|
|
9
|
+
export interface LifecycleSession {
|
|
10
|
+
readonly sessionId: string;
|
|
11
|
+
readonly cwd: string;
|
|
12
|
+
readonly updatedAt: string;
|
|
13
|
+
readonly title?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Immutable creation timestamp used for stable ordering (issue #1611).
|
|
16
|
+
* Falls back to updatedAt for legacy sessions without a persisted createdAt.
|
|
17
|
+
*/
|
|
18
|
+
readonly createdAt?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Self-contained, process-independent snapshot cursor (issue #1611).
|
|
23
|
+
*
|
|
24
|
+
* The cursor encodes the full (createdAt, sessionId) tuple of the last item on
|
|
25
|
+
* the previous page so the next page can be computed from the cursor alone,
|
|
26
|
+
* without any shared in-process state. The `v` field distinguishes v1 (legacy
|
|
27
|
+
* updatedAt-based, unstable) from v2 (createdAt-based, immutable).
|
|
28
|
+
*/
|
|
29
|
+
interface CursorPayload {
|
|
30
|
+
readonly v: 2;
|
|
31
|
+
readonly cwd: string | null;
|
|
32
|
+
readonly createdAt: string;
|
|
33
|
+
readonly sessionId: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LegacyCursorPayload {
|
|
37
|
+
readonly version: 1;
|
|
38
|
+
readonly cwd: string | null;
|
|
39
|
+
readonly updatedAt: string;
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PaginationRequest {
|
|
44
|
+
readonly cwd: string | null;
|
|
45
|
+
readonly cursor: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PaginationResult {
|
|
49
|
+
readonly sessions: readonly LifecycleSession[];
|
|
50
|
+
readonly nextCursor?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function paginateSessions(
|
|
54
|
+
sessions: readonly LifecycleSession[],
|
|
55
|
+
request: PaginationRequest,
|
|
56
|
+
pageSize: number,
|
|
57
|
+
): PaginationResult {
|
|
58
|
+
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
|
59
|
+
throw acp.RequestError.invalidParams(
|
|
60
|
+
{ pageSize },
|
|
61
|
+
'pageSize must be a positive integer',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const cursor = decodeCursor(request.cursor, request.cwd);
|
|
65
|
+
const ordered = [...sessions]
|
|
66
|
+
.filter((session) => request.cwd === null || session.cwd === request.cwd)
|
|
67
|
+
.sort(compareSessions);
|
|
68
|
+
const remaining =
|
|
69
|
+
cursor === null
|
|
70
|
+
? ordered
|
|
71
|
+
: ordered.filter((session) => compareWithCursor(session, cursor) > 0);
|
|
72
|
+
const page = remaining.slice(0, pageSize);
|
|
73
|
+
if (remaining.length <= pageSize) {
|
|
74
|
+
return { sessions: page };
|
|
75
|
+
}
|
|
76
|
+
const last = page[page.length - 1];
|
|
77
|
+
return { sessions: page, nextCursor: encodeCursor(last, request.cwd) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Orders sessions by createdAt DESC then sessionId DESC.
|
|
82
|
+
*
|
|
83
|
+
* createdAt is the immutable session_start timestamp — it never changes when
|
|
84
|
+
* a session is updated (issue #1611). Falls back to updatedAt when createdAt
|
|
85
|
+
* is absent (legacy sessions recorded before this change) so they still sort
|
|
86
|
+
* deterministically. Using updatedAt as a fallback is acceptable because legacy
|
|
87
|
+
* sessions are static (no new writes), so their updatedAt is effectively
|
|
88
|
+
* immutable too.
|
|
89
|
+
*/
|
|
90
|
+
function compareSessions(a: LifecycleSession, b: LifecycleSession): number {
|
|
91
|
+
return compareByCreatedAtAndId(
|
|
92
|
+
getCreatedAt(a),
|
|
93
|
+
a.sessionId,
|
|
94
|
+
getCreatedAt(b),
|
|
95
|
+
b.sessionId,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareWithCursor(
|
|
100
|
+
session: LifecycleSession,
|
|
101
|
+
cursor: CursorPayload,
|
|
102
|
+
): number {
|
|
103
|
+
return compareByCreatedAtAndId(
|
|
104
|
+
getCreatedAt(session),
|
|
105
|
+
session.sessionId,
|
|
106
|
+
cursor.createdAt,
|
|
107
|
+
cursor.sessionId,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function compareByCreatedAtAndId(
|
|
112
|
+
aCreatedAt: string,
|
|
113
|
+
aId: string,
|
|
114
|
+
bCreatedAt: string,
|
|
115
|
+
bId: string,
|
|
116
|
+
): number {
|
|
117
|
+
const timestamp = compareDescending(aCreatedAt, bCreatedAt);
|
|
118
|
+
return timestamp === 0 ? compareDescending(aId, bId) : timestamp;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compareDescending(a: string, b: string): number {
|
|
122
|
+
if (a === b) {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
return a > b ? -1 : 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCreatedAt(session: LifecycleSession): string {
|
|
129
|
+
const createdAt =
|
|
130
|
+
session.createdAt === undefined
|
|
131
|
+
? Number.NaN
|
|
132
|
+
: Date.parse(session.createdAt);
|
|
133
|
+
if (Number.isFinite(createdAt)) {
|
|
134
|
+
return new Date(createdAt).toISOString();
|
|
135
|
+
}
|
|
136
|
+
const updatedAt = Date.parse(session.updatedAt);
|
|
137
|
+
if (Number.isFinite(updatedAt)) {
|
|
138
|
+
return new Date(updatedAt).toISOString();
|
|
139
|
+
}
|
|
140
|
+
// Invalid or non-standard timestamps (corrupted/legacy data). Fall back to
|
|
141
|
+
// the epoch sentinel so sort order is deterministic and cursor encoding
|
|
142
|
+
// never embeds an invalid value that would break decode (issue #1611).
|
|
143
|
+
return new Date(0).toISOString();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function encodeCursor(session: LifecycleSession, cwd: string | null): string {
|
|
147
|
+
const payload: CursorPayload = {
|
|
148
|
+
v: 2,
|
|
149
|
+
cwd,
|
|
150
|
+
createdAt: getCreatedAt(session),
|
|
151
|
+
sessionId: session.sessionId,
|
|
152
|
+
};
|
|
153
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function decodeCursor(
|
|
157
|
+
cursor: string | null,
|
|
158
|
+
cwd: string | null,
|
|
159
|
+
): CursorPayload | null {
|
|
160
|
+
if (cursor === null) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
let detail: string;
|
|
164
|
+
try {
|
|
165
|
+
const value: unknown = JSON.parse(
|
|
166
|
+
Buffer.from(cursor, 'base64url').toString('utf8'),
|
|
167
|
+
);
|
|
168
|
+
const decoded = normalizeCursorPayload(value);
|
|
169
|
+
if (decoded === null) {
|
|
170
|
+
throw new Error('malformed cursor payload');
|
|
171
|
+
}
|
|
172
|
+
if (decoded.cwd !== cwd) {
|
|
173
|
+
throw new Error('cursor cwd does not match request cwd');
|
|
174
|
+
}
|
|
175
|
+
return decoded;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
detail = error instanceof Error ? error.message : 'invalid encoding';
|
|
178
|
+
}
|
|
179
|
+
throw acp.RequestError.invalidParams(
|
|
180
|
+
{ cursor },
|
|
181
|
+
`Invalid session cursor: ${detail}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeCursorPayload(value: unknown): CursorPayload | null {
|
|
186
|
+
if (isCursorPayload(value)) {
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
if (!isLegacyCursorPayload(value)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
v: 2,
|
|
194
|
+
cwd: value.cwd,
|
|
195
|
+
createdAt: new Date(Date.parse(value.updatedAt)).toISOString(),
|
|
196
|
+
sessionId: value.sessionId,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isCursorPayload(value: unknown): value is CursorPayload {
|
|
201
|
+
if (value === null || typeof value !== 'object') {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const record = value as Record<string, unknown>;
|
|
205
|
+
return (
|
|
206
|
+
record.v === 2 &&
|
|
207
|
+
hasValidCursorIdentity(record) &&
|
|
208
|
+
isCanonicalTimestamp(record.createdAt)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isLegacyCursorPayload(value: unknown): value is LegacyCursorPayload {
|
|
213
|
+
if (value === null || typeof value !== 'object') {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const record = value as Record<string, unknown>;
|
|
217
|
+
return (
|
|
218
|
+
record.version === 1 &&
|
|
219
|
+
hasValidCursorIdentity(record) &&
|
|
220
|
+
typeof record.updatedAt === 'string' &&
|
|
221
|
+
Number.isFinite(Date.parse(record.updatedAt))
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasValidCursorIdentity(record: Record<string, unknown>): boolean {
|
|
226
|
+
const hasValidCwd = record.cwd === null || typeof record.cwd === 'string';
|
|
227
|
+
const hasValidSessionId =
|
|
228
|
+
typeof record.sessionId === 'string' && record.sessionId.length > 0;
|
|
229
|
+
return hasValidCwd && hasValidSessionId;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isCanonicalTimestamp(value: unknown): value is string {
|
|
233
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const timestamp = Date.parse(value);
|
|
237
|
+
return (
|
|
238
|
+
Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value
|
|
239
|
+
);
|
|
240
|
+
}
|