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