@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,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
+ }