@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,119 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * Session-scoped Config construction for the Zed integration (issue #1604).
9
+ * Extracted from zedIntegration.ts so that near-cap file stays within its
10
+ * max-lines budget; the behavior is unchanged and exercised by
11
+ * zedIntegration.test.ts (createSessionScopedConfig) and the loadSession/prompt
12
+ * suites.
13
+ */
14
+
15
+ import * as path from 'node:path';
16
+ import {
17
+ type Config,
18
+ isWithinRoot,
19
+ type RuntimeProviderManager,
20
+ } from '@vybestack/llxprt-code-core';
21
+ import type { FileSystemService } from '@vybestack/llxprt-code-storage';
22
+ import type { ToolRegistry } from '@vybestack/llxprt-code-tools';
23
+
24
+ /**
25
+ * Resolves the effective target directory for a session from an optional
26
+ * client-supplied cwd: a missing/non-string/empty cwd falls back to the config
27
+ * target dir; an absolute or config-relative cwd is accepted only when it
28
+ * resolves WITHIN the config target dir (otherwise the target dir is used), so
29
+ * a session can never escape the project root via lexical traversal (`..`
30
+ * segments are resolved before the containment check).
31
+ *
32
+ * Trust model: the cwd comes from the ACP client (the user's own editor over
33
+ * stdio), which shares the local user's privileges — this guard protects
34
+ * against accidental misconfiguration, not a hostile client. Symlinks inside
35
+ * the project are deliberately NOT realpath-resolved here; a client that can
36
+ * plant symlinks already has direct filesystem access.
37
+ */
38
+ export function resolveSessionTargetDir(
39
+ config: Config,
40
+ cwd: string | undefined,
41
+ ): string {
42
+ if (typeof cwd !== 'string' || cwd.trim().length === 0) {
43
+ return config.getTargetDir();
44
+ }
45
+ const candidate = path.isAbsolute(cwd)
46
+ ? cwd
47
+ : path.resolve(config.getTargetDir(), cwd);
48
+ return isWithinRoot(candidate, config.getTargetDir())
49
+ ? candidate
50
+ : config.getTargetDir();
51
+ }
52
+
53
+ /**
54
+ * Builds a per-session Config proxy that overrides the file-system service,
55
+ * provider manager, and project/target dir for a single session WITHOUT
56
+ * mutating the shared base Config. getFileSystemService/getProviderManager
57
+ * return the session-scoped instances (swappable via their setters), and
58
+ * getProjectRoot/getTargetDir return the resolved session target dir; every
59
+ * other access falls through to the base Config.
60
+ */
61
+ export function createSessionScopedConfig(
62
+ config: Config,
63
+ initialFileSystemService: FileSystemService,
64
+ targetDir: string = config.getTargetDir(),
65
+ resolveToolRegistry?: () => ToolRegistry | undefined,
66
+ ): Config {
67
+ let fileSystemService = initialFileSystemService;
68
+ let providerManager: RuntimeProviderManager | undefined =
69
+ config.getProviderManager();
70
+ const propertyOverrides = new Map<PropertyKey, unknown>();
71
+ return new Proxy(config, {
72
+ get(target, property, receiver) {
73
+ if (property === 'getToolRegistry' && resolveToolRegistry !== undefined) {
74
+ return () => resolveToolRegistry() ?? config.getToolRegistry();
75
+ }
76
+ if (property === 'getFileSystemService') {
77
+ return () => fileSystemService;
78
+ }
79
+ if (property === 'setFileSystemService') {
80
+ return (nextFileSystemService: FileSystemService) => {
81
+ fileSystemService = nextFileSystemService;
82
+ };
83
+ }
84
+ if (property === 'getProviderManager') {
85
+ return () => providerManager;
86
+ }
87
+ if (property === 'setProviderManager') {
88
+ return (nextProviderManager: RuntimeProviderManager) => {
89
+ providerManager = nextProviderManager;
90
+ };
91
+ }
92
+ if (property === 'getProjectRoot') {
93
+ return () => targetDir;
94
+ }
95
+ if (property === 'getTargetDir') {
96
+ return () => targetDir;
97
+ }
98
+ if (propertyOverrides.has(property)) {
99
+ return propertyOverrides.get(property);
100
+ }
101
+ return Reflect.get(target, property, receiver);
102
+ },
103
+ set(target, property, value) {
104
+ // Direct property assignment must update the SAME backing store the
105
+ // getter closures read from, so `config.fileSystemService = X` and
106
+ // `config.setFileSystemService(X)` stay reconciled (previously these
107
+ // diverged: set wrote to propertyOverrides while get read the closure).
108
+ if (property === 'fileSystemService') {
109
+ fileSystemService = value as FileSystemService;
110
+ return true;
111
+ }
112
+ if (property === 'providerManager') {
113
+ providerManager = value as RuntimeProviderManager;
114
+ return true;
115
+ }
116
+ return Reflect.set(target, property, value);
117
+ },
118
+ });
119
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * Resume-failure -> ACP RequestError mapping for ACP session/load (loadSession)
9
+ * (issue #1604).
10
+ *
11
+ * `agent.session.resume` (SessionControl.resume) rejects with a single
12
+ * `Error('Failed to resume session: <detail>')` whose <detail> is the core
13
+ * resume flow's distinguishable reason (see resumeSession.ts / SessionDiscovery
14
+ * / ReplayEngine): a missing session ("No sessions found for this project",
15
+ * "Session not found for this project: <ref>", "... out of range ...") versus an
16
+ * in-use lock, a project-hash mismatch, an empty/corrupt file, or a replay
17
+ * error. Collapsing every one of these into resourceNotFound (as the first cut
18
+ * did) misleads the client into thinking the session does not exist when it may
19
+ * simply be locked or corrupt. This pure mapper distinguishes the not-found
20
+ * family (-> resourceNotFound(sessionId)) from everything else (-> internalError
21
+ * carrying the underlying detail in both the message and structured data) so the
22
+ * Zed client receives an actionable error.
23
+ *
24
+ * Corrupt-vs-missing disambiguation (FINDING B): the core discovery layer
25
+ * silently SKIPS a session file whose header/first line is unreadable, so a
26
+ * corrupt-but-present session surfaces the SAME "not found" reason as a session
27
+ * that genuinely does not exist. `classifyResumeFailure` closes that gap in the
28
+ * zed layer WITHOUT touching core discovery: when the plain mapping would be
29
+ * resourceNotFound, it probes the on-disk session-file namespace (a readdir of
30
+ * the chats dir, injected for testability) and — if a file matching this
31
+ * session's `session-*-<first-12-of-id>.jsonl` name EXISTS — reports
32
+ * internalError ("session file exists but could not be read/replayed") instead,
33
+ * so a corrupt session is never misreported as missing.
34
+ */
35
+
36
+ import * as acp from '@agentclientprotocol/sdk';
37
+ import {
38
+ DebugLogger,
39
+ RESUME_NO_SESSIONS_FOUND,
40
+ RESUME_SESSION_NOT_FOUND_PREFIX,
41
+ RESUME_SESSION_INDEX_OUT_OF_RANGE_RE,
42
+ SESSION_FILE_ID_PREFIX_LENGTH,
43
+ } from '@vybestack/llxprt-code-core';
44
+
45
+ /** JSON-RPC code ACP assigns to resourceNotFound. */
46
+ const RESOURCE_NOT_FOUND_CODE = -32002;
47
+
48
+ /**
49
+ * Module logger (mirroring the zedIntegration.ts / sessionControl.ts precedent
50
+ * of a namespaced core DebugLogger) used to surface the otherwise-silent
51
+ * corrupt-vs-missing probe failure (FINDING F6) so a swallowed readdir error is
52
+ * diagnosable. Debug-level only: it never changes the fallback behavior.
53
+ */
54
+ const logger = new DebugLogger('llxprt:zed-integration:session-errors');
55
+
56
+ // The EXACT not-found sentences the core resume flow emits, matched verbatim so
57
+ // an unrelated message that merely CONTAINS the words "not found" (e.g. "Replay
58
+ // failed: session content not found in cache") is NOT misclassified as a missing
59
+ // session (FINDING F1). Imported from core's resumeNotFoundMessages.ts — the
60
+ // same constants the emitting sites (resumeSession.ts / SessionDiscovery.ts)
61
+ // use — so a core rewording can never silently desynchronize this classifier.
62
+ // Each sentence is carried inside the `Failed to resume session: <detail>`
63
+ // envelope SessionControl.resume adds, so substring matching is used.
64
+
65
+ /**
66
+ * Maps a resume rejection without probing durable state. Resume-flow callers
67
+ * must use {@link classifyResumeFailure}; this pure mapper remains exported for
68
+ * focused contract tests only.
69
+ *
70
+ * Maps a resume rejection to the closest ACP {@link acp.RequestError}. A
71
+ * not-found-style reason yields {@link acp.RequestError.resourceNotFound}
72
+ * (JSON-RPC -32002) carrying the session id; every other reason (locked,
73
+ * corrupt, replay/hash failure, or an unrecognized message) yields
74
+ * {@link acp.RequestError.internalError} (JSON-RPC -32603) carrying the
75
+ * underlying detail as both the appended message and structured `data`, so the
76
+ * client can surface why the load actually failed.
77
+ *
78
+ * Passthrough guard: an `error` that is ALREADY an {@link acp.RequestError}
79
+ * (e.g. a precise error thrown by a lower layer) is returned unchanged so it is
80
+ * never double-wrapped into a generic internalError.
81
+ */
82
+ export function mapResumeError(
83
+ sessionId: string,
84
+ error: unknown,
85
+ ): acp.RequestError {
86
+ if (error instanceof acp.RequestError) {
87
+ return error;
88
+ }
89
+ const detail = errorMessage(error);
90
+ if (isNotFoundResumeReason(detail)) {
91
+ return acp.RequestError.resourceNotFound(sessionId);
92
+ }
93
+ return acp.RequestError.internalError({ sessionId, reason: detail }, detail);
94
+ }
95
+
96
+ /**
97
+ * Like {@link mapResumeError}, but disambiguates a not-found classification
98
+ * against the on-disk session-file namespace (FINDING B). When the plain mapping
99
+ * is resourceNotFound, `probe` is invoked to list the chats-dir entries; if any
100
+ * entry matches this session's recorded filename
101
+ * (`session-<timestamp>-<first-12-of-id>.jsonl`), the session exists but could
102
+ * not be read/replayed (corrupt or incompatible) and an internalError carrying
103
+ * the filename is returned instead of the misleading resourceNotFound. When no
104
+ * entry matches (including an absent chats directory), the genuine
105
+ * resourceNotFound is returned. A non-ENOENT probe failure makes existence
106
+ * indeterminate and therefore becomes internalError with the probe detail.
107
+ * Non-not-found mappings and already-constructed RequestErrors are returned
108
+ * without probing.
109
+ */
110
+ export async function classifyResumeFailure(
111
+ sessionId: string,
112
+ error: unknown,
113
+ probe: () => Promise<readonly string[]>,
114
+ ): Promise<acp.RequestError> {
115
+ // An already-constructed RequestError from a lower layer is authoritative:
116
+ // pass it through unchanged WITHOUT probing/re-wrapping (FINDING E guard), even
117
+ // when it is a resourceNotFound — re-classifying it would double-wrap a precise
118
+ // error and could spuriously flip it based on unrelated on-disk files.
119
+ if (error instanceof acp.RequestError) {
120
+ return error;
121
+ }
122
+ const mapped = mapResumeError(sessionId, error);
123
+ if (mapped.code !== RESOURCE_NOT_FOUND_CODE) {
124
+ return mapped;
125
+ }
126
+ const outcome = await probeMatchingSessionFile(sessionId, probe);
127
+ if (outcome.kind === 'match') {
128
+ const detail = `session file exists but could not be read/replayed (corrupt or incompatible): ${outcome.file}`;
129
+ return acp.RequestError.internalError(
130
+ { sessionId, reason: detail, file: outcome.file },
131
+ detail,
132
+ );
133
+ }
134
+ if (outcome.kind === 'probe-error') {
135
+ // FINDING B2: a NON-ENOENT probe failure (EACCES, disk error, ...) means we
136
+ // genuinely could not determine whether the session exists on disk, so
137
+ // reporting resourceNotFound would be MISLEADING (the session may well
138
+ // exist). Surface an internalError carrying the probe failure as cause
139
+ // detail so the client sees the real, actionable reason instead of a
140
+ // spurious "not found". ENOENT (chats dir absent) is NOT a probe-error — it
141
+ // means genuinely missing and keeps the resourceNotFound path below.
142
+ const detail = `could not determine whether the session exists on disk (session-file probe failed): ${outcome.message}`;
143
+ return acp.RequestError.internalError(
144
+ { sessionId, reason: detail, probeError: outcome.message },
145
+ detail,
146
+ );
147
+ }
148
+ // outcome.kind === 'no-match' (including ENOENT chats dir): genuinely missing.
149
+ return mapped;
150
+ }
151
+
152
+ /**
153
+ * The three distinguishable outcomes of the corrupt-vs-missing session-file
154
+ * probe (FINDING B2): a matching file was found (corrupt-but-present), no file
155
+ * matched (genuinely missing — includes an ENOENT chats dir), or the probe
156
+ * itself failed for a NON-ENOENT reason (EACCES/disk error) so existence is
157
+ * indeterminate and must surface as an internalError rather than a misleading
158
+ * resourceNotFound.
159
+ */
160
+ type ProbeOutcome =
161
+ | { readonly kind: 'match'; readonly file: string }
162
+ | { readonly kind: 'no-match' }
163
+ | { readonly kind: 'probe-error'; readonly message: string };
164
+
165
+ /**
166
+ * Probes the on-disk chats-dir namespace for a recorded file matching
167
+ * `sessionId`, returning a {@link ProbeOutcome}. A successful listing yields
168
+ * `match` (with the filename) or `no-match`. A probe rejection is classified by
169
+ * errno: an ENOENT (the chats dir does not exist yet) is treated as `no-match`
170
+ * (genuinely missing, keep the resourceNotFound path); any other rejection
171
+ * (EACCES, EIO, ...) yields `probe-error` (existence indeterminate → the caller
172
+ * surfaces internalError, FINDING B2) and is logged at debug (FINDING F6) so the
173
+ * underlying directory-read error stays diagnosable.
174
+ */
175
+ async function probeMatchingSessionFile(
176
+ sessionId: string,
177
+ probe: () => Promise<readonly string[]>,
178
+ ): Promise<ProbeOutcome> {
179
+ let entries: readonly string[];
180
+ try {
181
+ entries = await probe();
182
+ } catch (error) {
183
+ const message = errorMessage(error);
184
+ if (isEnoent(error)) {
185
+ // The chats dir does not exist yet: the session is genuinely missing, not
186
+ // a probe failure. Keep the plain not-found mapping.
187
+ logger.debug(
188
+ () =>
189
+ `probeMatchingSessionFile: chats dir absent (ENOENT) for ${sessionId}; ` +
190
+ `treating as genuinely missing: ${message}`,
191
+ );
192
+ return { kind: 'no-match' };
193
+ }
194
+ // A NON-ENOENT probe failure (EACCES/disk error) means existence is
195
+ // indeterminate; the caller surfaces internalError instead of a misleading
196
+ // resourceNotFound (FINDING B2). Log at debug (FINDING F6) so it stays
197
+ // diagnosable.
198
+ logger.debug(
199
+ () =>
200
+ `probeMatchingSessionFile: session-file probe failed for ${sessionId}; ` +
201
+ `reporting indeterminate existence: ${message}`,
202
+ );
203
+ return { kind: 'probe-error', message };
204
+ }
205
+ if (
206
+ !Array.isArray(entries) ||
207
+ entries.some((entry) => typeof entry !== 'string')
208
+ ) {
209
+ return {
210
+ kind: 'probe-error',
211
+ message: `session-file probe returned a non-string-array result: ${summarizeProbeValue(entries)}`,
212
+ };
213
+ }
214
+ const file = findMatchingSessionFile(sessionId, entries);
215
+ return file === null ? { kind: 'no-match' } : { kind: 'match', file };
216
+ }
217
+
218
+ /** Summarizes an unexpected probe value without risking circular JSON. */
219
+ function summarizeProbeValue(value: unknown): string {
220
+ try {
221
+ return JSON.stringify(value).slice(0, 200);
222
+ } catch {
223
+ return String(value).slice(0, 200);
224
+ }
225
+ }
226
+
227
+ /** True when `error` is a Node ENOENT rejection. */
228
+ export function isEnoent(error: unknown): boolean {
229
+ return (
230
+ typeof error === 'object' &&
231
+ error !== null &&
232
+ (error as NodeJS.ErrnoException).code === 'ENOENT'
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Pure matcher: returns the first entry naming a recorded session file for
238
+ * `sessionId`, or null when none match. SessionRecordingService.materialize
239
+ * names files `session-<timestamp>-<first-12-of-id>.jsonl`, so a matching file
240
+ * both starts with `session-` and ends with `-<first-12-of-id>.jsonl`. Exported
241
+ * so the loadSession re-attach probe (zed-session-loader.ts) decides "does an
242
+ * on-disk recording exist for this id?" against the EXACT same naming rule the
243
+ * corrupt-vs-missing resume probe uses, keeping the two in lockstep.
244
+ *
245
+ * Known limitation: because the file name embeds ONLY the first 12 characters
246
+ * of the id, two ids sharing that 12-char prefix are indistinguishable at the
247
+ * file-name level — the matcher inherits the recording service's naming
248
+ * granularity and cannot be stricter than it. Session ids are UUIDs, so a
249
+ * 12-hex-char prefix collision is not a practical concern; resolving it would
250
+ * require the recording service to embed the full id in the file name.
251
+ */
252
+ export function findMatchingSessionFile(
253
+ sessionId: string,
254
+ entries: readonly string[],
255
+ ): string | null {
256
+ const suffix = sessionFileSuffix(sessionId);
257
+ return (
258
+ entries.find(
259
+ (name) => name.startsWith('session-') && name.endsWith(suffix),
260
+ ) ?? null
261
+ );
262
+ }
263
+
264
+ /**
265
+ * The trailing `-<first-12-of-id>.jsonl` a recorded session file for `sessionId`
266
+ * ends with. SessionRecordingService.materialize names files
267
+ * `session-<timestamp>-<first-12-of-id>.jsonl`, so a matching file both starts
268
+ * with `session-` and ends with this suffix.
269
+ */
270
+ function sessionFileSuffix(sessionId: string): string {
271
+ return `-${sessionId.substring(0, SESSION_FILE_ID_PREFIX_LENGTH)}.jsonl`;
272
+ }
273
+
274
+ /**
275
+ * Wraps a history-REPLAY failure (a rejected `session/update` during
276
+ * streamHistory, FINDING A) into an ACP {@link acp.RequestError}. A dead/failing
277
+ * transport that loses the transcript must surface as an error rather than a
278
+ * silent success, so this yields {@link acp.RequestError.internalError} carrying
279
+ * the underlying detail (and a `phase: 'replay'` marker) in both the message and
280
+ * structured `data`. An `error` that is ALREADY a RequestError is returned
281
+ * unchanged (passthrough guard) so it is never double-wrapped.
282
+ */
283
+ export function wrapReplayFailure(
284
+ sessionId: string,
285
+ error: unknown,
286
+ ): acp.RequestError {
287
+ if (error instanceof acp.RequestError) {
288
+ return error;
289
+ }
290
+ const detail = errorMessage(error);
291
+ return acp.RequestError.internalError(
292
+ { sessionId, reason: detail, phase: 'replay' },
293
+ `Failed to replay session history: ${detail}`,
294
+ );
295
+ }
296
+
297
+ /**
298
+ * Extracts a human-readable message from an unknown thrown value (Error.message
299
+ * when available, else its String() form) so the mapper can classify it.
300
+ */
301
+ function errorMessage(error: unknown): string {
302
+ if (error instanceof Error) return error.message;
303
+ if (
304
+ typeof error === 'object' &&
305
+ error !== null &&
306
+ typeof (error as Record<string, unknown>).message === 'string'
307
+ ) {
308
+ return (error as { message: string }).message;
309
+ }
310
+ return String(error);
311
+ }
312
+
313
+ /**
314
+ * True when the resume detail indicates the session could not be found (as
315
+ * opposed to being locked, corrupt, or otherwise unreadable). Matches ONLY the
316
+ * EXACT core not-found vocabulary (FINDING F1) — the three sentences the core
317
+ * discovery/resume layer emits — rather than any message that merely contains
318
+ * the substring "not found" or "out of range":
319
+ * - "No sessions found for this project" (resumeSession.ts)
320
+ * - "Session not found for this project: <ref>" (SessionDiscovery)
321
+ * - "Session index <n> out of range (1-<m>)" (SessionDiscovery)
322
+ * A corrupt/replay message such as "Failed to replay session: ... content not
323
+ * found in cache" therefore falls through to internalError instead of being
324
+ * misreported as a missing session. Lock/corrupt/replay/hash reasons all fall
325
+ * through to internalError.
326
+ */
327
+ function isNotFoundResumeReason(detail: string): boolean {
328
+ const envelope = 'Failed to resume session: ';
329
+ const reason = detail.startsWith(envelope)
330
+ ? detail.slice(envelope.length)
331
+ : detail;
332
+ return (
333
+ reason === RESUME_NO_SESSIONS_FOUND ||
334
+ reason.startsWith(RESUME_SESSION_NOT_FOUND_PREFIX) ||
335
+ RESUME_SESSION_INDEX_OUT_OF_RANGE_RE.test(reason)
336
+ );
337
+ }
@@ -0,0 +1,216 @@
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 { type Agent, type AgentEvent } from '@vybestack/llxprt-code-agents';
9
+ import {
10
+ EmojiFilter,
11
+ type ContentBlock,
12
+ type DebugLogger,
13
+ type FilterConfiguration,
14
+ getErrorStatus,
15
+ } from '@vybestack/llxprt-code-core';
16
+ import { handleZedAgentEvent } from './zed-agent-event-handler.js';
17
+ import { StreamBatcher } from './zed-stream-batcher.js';
18
+ import type { ZedPathResolver } from './zed-path-resolver.js';
19
+ import { TerminalManager } from './zed-terminal-manager.js';
20
+
21
+ type SendUpdateFn = (update: acp.SessionUpdate) => Promise<void>;
22
+ type SendUsageFn = (
23
+ usage: Extract<AgentEvent, { type: 'usage' }>['usage'],
24
+ ) => Promise<void>;
25
+ type HandleConfirmationFn = (
26
+ event: Extract<AgentEvent, { type: 'tool-confirmation' }>,
27
+ ) => Promise<void>;
28
+
29
+ export interface SessionStreamDeps {
30
+ readonly agent: Agent;
31
+ readonly terminals: TerminalManager | null;
32
+ readonly sendUpdate: SendUpdateFn;
33
+ readonly sendUsage: SendUsageFn;
34
+ readonly handleConfirmation: HandleConfirmationFn;
35
+ readonly isPromptStale: (
36
+ promptGeneration: number,
37
+ pendingSend: AbortController,
38
+ ) => boolean;
39
+ readonly maxTurns: number;
40
+ readonly logger: Pick<DebugLogger, 'debug'>;
41
+ }
42
+
43
+ /**
44
+ * Consumes the agent event stream, forwarding each event to the Zed handler
45
+ * and managing terminal lifecycle for shell tool calls.
46
+ */
47
+ export async function consumeAgentStream(
48
+ deps: SessionStreamDeps,
49
+ parts: readonly ContentBlock[],
50
+ pendingSend: AbortController,
51
+ promptId: string,
52
+ promptGeneration: number,
53
+ batcher: StreamBatcher,
54
+ ): Promise<acp.StopReason | null> {
55
+ const eventStream = deps.agent.stream(parts, {
56
+ signal: pendingSend.signal,
57
+ promptId,
58
+ maxTurns: deps.maxTurns,
59
+ });
60
+ let terminalStopReason: acp.StopReason | null = null;
61
+ try {
62
+ for await (const event of eventStream) {
63
+ const result = await processStreamEvent(
64
+ event,
65
+ deps,
66
+ batcher,
67
+ promptGeneration,
68
+ pendingSend,
69
+ );
70
+ if (result === 'cancelled') return 'cancelled';
71
+ if (result !== null) terminalStopReason = result;
72
+ }
73
+ return terminalStopReason;
74
+ } finally {
75
+ if (deps.terminals !== null) {
76
+ try {
77
+ await deps.terminals.settleAll();
78
+ } catch (error) {
79
+ deps.logger.debug(() => `Terminal cleanup failed: ${String(error)}`);
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ async function processStreamEvent(
86
+ event: AgentEvent,
87
+ deps: SessionStreamDeps,
88
+ batcher: StreamBatcher,
89
+ promptGeneration: number,
90
+ pendingSend: AbortController,
91
+ ): Promise<acp.StopReason | null | 'cancelled'> {
92
+ if (deps.isPromptStale(promptGeneration, pendingSend)) {
93
+ return 'cancelled';
94
+ }
95
+ const stopReason = await handleZedAgentEvent(event, batcher, {
96
+ sendUpdate: deps.sendUpdate,
97
+ sendUsage: deps.sendUsage,
98
+ handleConfirmation: deps.handleConfirmation,
99
+ resolveToolKind: (toolName) => deps.agent.tools.get(toolName)?.kind,
100
+ });
101
+ await trackTerminalEvent(event, deps);
102
+ return stopReason;
103
+ }
104
+
105
+ /**
106
+ * Best-effort terminal bookkeeping after each event. Extracted so terminal
107
+ * tracking errors never mask the stop reason from `handleZedAgentEvent`.
108
+ * The single `deps.terminals !== null` check addresses the repeated null guard.
109
+ */
110
+ async function trackTerminalEvent(
111
+ event: AgentEvent,
112
+ deps: SessionStreamDeps,
113
+ ): Promise<void> {
114
+ if (deps.terminals === null) return;
115
+ try {
116
+ if (
117
+ event.type === 'tool-call' &&
118
+ TerminalManager.isShellToolCall(
119
+ event.call,
120
+ deps.agent.tools.get(event.call.name)?.kind,
121
+ )
122
+ ) {
123
+ await deps.terminals.observeToolCall(event.call);
124
+ } else if (event.type === 'tool-result') {
125
+ deps.terminals.completeToolCall(event.result.id);
126
+ }
127
+ } catch (error) {
128
+ deps.logger.debug(() => `Terminal tracking failed: ${String(error)}`);
129
+ }
130
+ }
131
+
132
+ export interface PromptTurnDeps {
133
+ readonly pathResolver: ZedPathResolver;
134
+ readonly emojiFilterMode: FilterConfiguration['mode'];
135
+ readonly streamDeps: SessionStreamDeps;
136
+ }
137
+
138
+ export async function runPromptTurn(
139
+ deps: PromptTurnDeps,
140
+ params: acp.PromptRequest,
141
+ pendingSend: AbortController,
142
+ promptId: string,
143
+ promptGeneration: number,
144
+ ): Promise<acp.PromptResponse> {
145
+ let parts: ContentBlock[];
146
+ try {
147
+ parts = await deps.pathResolver.resolvePrompt(
148
+ params.prompt,
149
+ pendingSend.signal,
150
+ );
151
+ } catch (error) {
152
+ if (
153
+ pendingSend.signal.aborted ||
154
+ (error instanceof Error && error.name === 'AbortError')
155
+ ) {
156
+ return { stopReason: 'cancelled' };
157
+ }
158
+ throw error;
159
+ }
160
+ const batcher = new StreamBatcher(
161
+ new EmojiFilter({ mode: deps.emojiFilterMode }),
162
+ (u) => deps.streamDeps.sendUpdate(u),
163
+ );
164
+ let terminalStopReason: acp.StopReason | null = null;
165
+ let thrownError: unknown = null;
166
+ try {
167
+ terminalStopReason = await consumeAgentStream(
168
+ deps.streamDeps,
169
+ parts,
170
+ pendingSend,
171
+ promptId,
172
+ promptGeneration,
173
+ batcher,
174
+ );
175
+ } catch (error) {
176
+ thrownError = error;
177
+ }
178
+ await safeFlush(batcher, deps.streamDeps.logger);
179
+ batcher.dispose();
180
+ if (thrownError !== null) {
181
+ if (
182
+ pendingSend.signal.aborted ||
183
+ (thrownError instanceof Error && thrownError.name === 'AbortError')
184
+ ) {
185
+ return { stopReason: 'cancelled' };
186
+ }
187
+ if (getErrorStatus(thrownError) === 429) {
188
+ const reason =
189
+ thrownError instanceof Error
190
+ ? thrownError.message
191
+ : String(thrownError);
192
+ throw new acp.RequestError(429, 'Rate limit exceeded. Try again later.', {
193
+ reason,
194
+ });
195
+ }
196
+ throw thrownError;
197
+ }
198
+ if (terminalStopReason !== null) {
199
+ return { stopReason: terminalStopReason };
200
+ }
201
+ if (pendingSend.signal.aborted) {
202
+ return { stopReason: 'cancelled' };
203
+ }
204
+ return { stopReason: 'end_turn' };
205
+ }
206
+
207
+ async function safeFlush(
208
+ batcher: StreamBatcher,
209
+ logger: Pick<DebugLogger, 'debug'>,
210
+ ): Promise<void> {
211
+ try {
212
+ await batcher.flush();
213
+ } catch (error) {
214
+ logger.debug(() => `Stream flush failed: ${String(error)}`);
215
+ }
216
+ }