@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3
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/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +13 -13
- package/docs/sdk/openapi.yaml +8 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1442 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2392 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- package/src/sdk-wave-e.ts +1 -1
|
@@ -1,1250 +1,1250 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const { writeJson } = require('../json-file');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const crypto = require('crypto');
|
|
8
|
-
const { spawnSync } = require('child_process');
|
|
9
|
-
const { createAppServerBridge } = require('../bridges/app-server-bridge');
|
|
10
|
-
const {
|
|
11
|
-
appendTextAttachments, normalizeAttachments, resolveOpenAIBaseUrl, resolveOpenAIModel,
|
|
12
|
-
} = require('../../../contracts');
|
|
13
|
-
const { stampEvent, resequenceRestoredEvents } = require('../event-stream');
|
|
14
|
-
|
|
15
|
-
// ZCode driver — speaks the "ZCode Protocol" app-server bundled with the
|
|
16
|
-
// ZCode desktop install (`resources/glm/zcode.cjs`, `app-server --stdio`).
|
|
17
|
-
// Protocol verified live against zcode CLI 0.16.5 / desktop 3.11.2:
|
|
18
|
-
//
|
|
19
|
-
// - Line-delimited JSON-RPC over stdio WITHOUT the `jsonrpc` envelope field
|
|
20
|
-
// (the same dialect as `codex app-server`, so the shared app-server bridge
|
|
21
|
-
// drives it as-is). Server-initiated request ids are strings ("server-N").
|
|
22
|
-
// - No initialize handshake. The first request is usually `session/create`:
|
|
23
|
-
// { workspace: { workspacePath, workspaceKey }, mode, runtimeModel? }
|
|
24
|
-
// and it BLOCKS on a server-initiated `session/requestRuntimePreferences`
|
|
25
|
-
// request that must be answered before the create response arrives.
|
|
26
|
-
// - Sessions, not threads: `session/create` -> `session/resume` ->
|
|
27
|
-
// `session/subscribe` { deliveryKind: 'desktop-continuous' } ->
|
|
28
|
-
// `session/send` { content } -> `session/stop` to abort. Turn lifecycle
|
|
29
|
-
// and token deltas ride `session/event` notifications (types: turn.started,
|
|
30
|
-
// model.streaming, tool.updated, permission.requested/resolved,
|
|
31
|
-
// turn.completed, session.updated, session.titleUpdated, ...).
|
|
32
|
-
// - Approvals are server-initiated `interaction/requestPermission` requests
|
|
33
|
-
// whose params carry an `options[]` array; the response must echo one of
|
|
34
|
-
// `options[].response` (allow_once / allow_project / deny).
|
|
35
|
-
// - Models come from `runtimeModel` on create/resume/send (provider kind
|
|
36
|
-
// anthropic | openai | openai-compatible, inline api keys supported).
|
|
37
|
-
// Without one the app-server requires ~/.zcode/cli/config.json to define a
|
|
38
|
-
// provider; agent-owned turns bridge the desktop's ~/.zcode/v2/config.json
|
|
39
|
-
// providers instead (see desktopRuntimeModelFor).
|
|
40
|
-
//
|
|
41
|
-
// The driver deliberately has NO CLI one-shot fallback: the bundled CLI's
|
|
42
|
-
// headless `--prompt` mode has no verified JSON event stream, so an unusable
|
|
43
|
-
// app-server surfaces honest errors instead of a fake fallback transport.
|
|
44
|
-
|
|
45
|
-
const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
|
|
46
|
-
// Workbench permission modes -> ZCode session modes
|
|
47
|
-
// (default/plan/acceptEdits/bypassPermissions -> build/plan/edit/yolo).
|
|
48
|
-
const MODE_FOR_PERMISSION = {
|
|
49
|
-
default: 'build',
|
|
50
|
-
plan: 'plan',
|
|
51
|
-
acceptEdits: 'edit',
|
|
52
|
-
bypassPermissions: 'yolo',
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const RUNTIME_PREFERENCES = Object.freeze({
|
|
56
|
-
nativeSearchEnhancementsEnabled: false,
|
|
57
|
-
memoryEnabled: false,
|
|
58
|
-
askUserQuestionAutoResolutionEnabled: false,
|
|
59
|
-
modelContextBudgetStrategy: 'preflight-v1',
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const APP_SERVER_PERSIST_SCHEMA = 'zcode-appserver-session/v1';
|
|
63
|
-
const MAX_PERSISTED_EVENTS = 5000;
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// CLI discovery
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
function defaultRegQuery(key) {
|
|
70
|
-
try {
|
|
71
|
-
const result = spawnSync('reg', ['query', key], { windowsHide: true, encoding: 'utf8', timeout: 5000 });
|
|
72
|
-
if (result.error || result.status !== 0) return null;
|
|
73
|
-
return String(result.stdout || '');
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function safeExists(target) {
|
|
80
|
-
try { return fs.existsSync(target); } catch { return false; }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function safeReaddir(dir) {
|
|
84
|
-
try { return fs.readdirSync(dir); } catch { return []; }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function providerRuntimeEnvironment(cliPath, { env = process.env, homedir = os.homedir(), existsSync = safeExists } = {}) {
|
|
88
|
-
const output = {};
|
|
89
|
-
if (!env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE?.trim()) {
|
|
90
|
-
// Desktop bundles keep config beside glm, not inside it. Recent native
|
|
91
|
-
// CLIs otherwise resolve a development-tree fallback outside the install.
|
|
92
|
-
const dir = path.dirname(path.resolve(cliPath));
|
|
93
|
-
const bundled = [path.join(dir, 'provider/zcode-builtin.json'), path.resolve(dir, '../config/provider/zcode-builtin.json')].find(existsSync);
|
|
94
|
-
if (!bundled) return output;
|
|
95
|
-
output.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = bundled;
|
|
96
|
-
}
|
|
97
|
-
if (!env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim()) output.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE = path.join(env.ZCODE_DATA_BASE_DIR?.trim() || homedir, '.zcode/v2/provider_config.json');
|
|
98
|
-
return output;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Discovery chain for the bundled zcode CLI:
|
|
102
|
-
// 1. ZCODE_CLI_PATH env override (the .cjs itself or the install root);
|
|
103
|
-
// 2. common Windows install roots (%LOCALAPPDATA%\Programs, Program Files,
|
|
104
|
-
// Program Files (x86) on every configured drive) scanning for
|
|
105
|
-
// <root>/*[Zz][Cc]ode*/resources/glm/zcode.cjs;
|
|
106
|
-
// 3. registry App Paths (HKCU/HKLM ZCode.exe) via `reg query`;
|
|
107
|
-
// 4. ~/.zcode neighbors (last resort).
|
|
108
|
-
// Purely filesystem-based (no subprocess) so driver construction stays cheap;
|
|
109
|
-
// everything is injectable for tests (env/existsSync/readdirSync/regQuery).
|
|
110
|
-
function discoverZCodeCli(options = {}) {
|
|
111
|
-
const env = options.env || process.env;
|
|
112
|
-
const exists = options.existsSync || safeExists;
|
|
113
|
-
const readdir = options.readdirSync || safeReaddir;
|
|
114
|
-
const regQuery = options.regQuery || defaultRegQuery;
|
|
115
|
-
const home = options.homedir || os.homedir();
|
|
116
|
-
|
|
117
|
-
const candidates = [];
|
|
118
|
-
const envPath = String(env.ZCODE_CLI_PATH || '').trim();
|
|
119
|
-
if (envPath) {
|
|
120
|
-
candidates.push(envPath, path.join(envPath, 'resources', 'glm', 'zcode.cjs'));
|
|
121
|
-
}
|
|
122
|
-
const rootExprs = [
|
|
123
|
-
env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, 'Programs'),
|
|
124
|
-
env.ProgramFiles,
|
|
125
|
-
env['ProgramFiles(x86)'],
|
|
126
|
-
'C:\\Program Files',
|
|
127
|
-
'C:\\Program Files (x86)',
|
|
128
|
-
'D:\\Program Files',
|
|
129
|
-
'D:\\Program Files (x86)',
|
|
130
|
-
].filter(Boolean);
|
|
131
|
-
for (const root of rootExprs) {
|
|
132
|
-
candidates.push(path.join(root, 'ZCode', 'resources', 'glm', 'zcode.cjs'));
|
|
133
|
-
for (const name of readdir(root)) {
|
|
134
|
-
if (/^zcode/i.test(name) || /zcode$/i.test(name)) {
|
|
135
|
-
candidates.push(path.join(root, name, 'resources', 'glm', 'zcode.cjs'));
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// macOS desktop layout (cheap extra candidate, harmless elsewhere).
|
|
140
|
-
candidates.push('/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs');
|
|
141
|
-
// Registry App Paths: the desktop registers ZCode.exe there on install.
|
|
142
|
-
for (const hive of ['HKCU', 'HKLM']) {
|
|
143
|
-
const output = regQuery(`${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\ZCode.exe`);
|
|
144
|
-
const match = output && output.match(/REG_SZ\s+(\S*ZCode\.exe)/i);
|
|
145
|
-
if (match) candidates.push(path.join(path.dirname(match[1]), 'resources', 'glm', 'zcode.cjs'));
|
|
146
|
-
}
|
|
147
|
-
candidates.push(
|
|
148
|
-
path.join(home, '.zcode', 'cli', 'zcode.cjs'),
|
|
149
|
-
path.join(home, '.zcode', 'zcode.cjs'),
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
const seen = new Set();
|
|
153
|
-
for (const candidate of candidates) {
|
|
154
|
-
if (!candidate || seen.has(candidate)) continue;
|
|
155
|
-
seen.add(candidate);
|
|
156
|
-
if (exists(candidate)) return { path: candidate, source: 'discovered' };
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Version probe for /agents: spawns `node <zcode.cjs> --version`. Skipped under
|
|
162
|
-
// the node:test runner so unit/integration suites never spawn a real 12MB CLI
|
|
163
|
-
// bundle; production gets a real version string (cached by agent-discovery).
|
|
164
|
-
function probeZCodeVersion(cliPath) {
|
|
165
|
-
if (process.env.NODE_TEST_CONTEXT) return null;
|
|
166
|
-
try {
|
|
167
|
-
const result = spawnSync(process.execPath, [cliPath, '--version'], {
|
|
168
|
-
timeout: 10000, windowsHide: true, encoding: 'utf8',
|
|
169
|
-
});
|
|
170
|
-
if (result.error || result.status !== 0) return null;
|
|
171
|
-
const match = String(result.stdout || result.stderr || '').match(/(\d+\.\d+[\w.-]*)/);
|
|
172
|
-
return match ? match[1] : null;
|
|
173
|
-
} catch {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
// Driver factory
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
|
|
182
|
-
function createZCodeDriver(options = {}) {
|
|
183
|
-
const injected = Boolean(options.appServerSpawn);
|
|
184
|
-
// Under the node:test runner an un-injected driver must stay a deterministic
|
|
185
|
-
// 501 placeholder (agent-drivers.test.js): discovery on a dev machine with
|
|
186
|
-
// ZCode installed would otherwise spawn real app-server children in tests.
|
|
187
|
-
const testContext = Boolean(process.env.NODE_TEST_CONTEXT);
|
|
188
|
-
let cli = null;
|
|
189
|
-
if (injected) {
|
|
190
|
-
cli = { path: options.cliPath || '<injected app-server>', source: 'injected' };
|
|
191
|
-
} else if (!testContext) {
|
|
192
|
-
cli = options.cli || discoverZCodeCli({ env: options.env });
|
|
193
|
-
}
|
|
194
|
-
if (!cli) {
|
|
195
|
-
const reason = testContext
|
|
196
|
-
? 'ZCode app-server transport is not scripted in this test context; inject an appServerSpawn double to drive it'
|
|
197
|
-
: 'ZCode desktop was not found on this machine. Install the ZCode desktop app (it bundles the zcode CLI used by the app-server transport), or set ZCODE_CLI_PATH to the installed resources/glm/zcode.cjs';
|
|
198
|
-
return createUnavailableZCodeDriver(reason);
|
|
199
|
-
}
|
|
200
|
-
return createAppServerZCodeDriver(options, cli);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Honest 501 shell (the pre-driver behavior, kept for missing installs and
|
|
204
|
-
// unscripted test contexts).
|
|
205
|
-
function createUnavailableZCodeDriver(reason) {
|
|
206
|
-
return {
|
|
207
|
-
agentId: 'zcode',
|
|
208
|
-
unavailableReason: reason,
|
|
209
|
-
startChatSession() {
|
|
210
|
-
throw Object.assign(new Error(this.unavailableReason), { status: 501 });
|
|
211
|
-
},
|
|
212
|
-
listSessions() { return []; },
|
|
213
|
-
getState() { return null; },
|
|
214
|
-
getSession() { return null; },
|
|
215
|
-
getSessionTokenUsage() { return { used: 0, hasUsage: false }; },
|
|
216
|
-
subscribe() { throw Object.assign(new Error('session not found'), { status: 404 }); },
|
|
217
|
-
async sendInput() { throw Object.assign(new Error(this.unavailableReason), { status: 501 }); },
|
|
218
|
-
resolvePermission() { throw Object.assign(new Error(this.unavailableReason), { status: 501 }); },
|
|
219
|
-
abort() {},
|
|
220
|
-
deleteSession() { return false; },
|
|
221
|
-
async listSupportedCommands() { return []; },
|
|
222
|
-
findClaudeExecutableForSdk() { return null; },
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
// app-server transport
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
function createAppServerZCodeDriver(options, cli) {
|
|
231
|
-
const log = typeof options.log === 'function' ? options.log : () => {};
|
|
232
|
-
const sessions = new Map();
|
|
233
|
-
const zcodeSessions = new Map(); // zcode sessionId (sess_...) -> driver sessionId
|
|
234
|
-
const persist = resolvePersistence(options);
|
|
235
|
-
let diskIndex = null;
|
|
236
|
-
let nextId = 1;
|
|
237
|
-
let nextPermissionId = 1;
|
|
238
|
-
let bridge = null;
|
|
239
|
-
let protocolMode = null;
|
|
240
|
-
let profileRuntime = null;
|
|
241
|
-
let profileRuntimeSignature = null;
|
|
242
|
-
let bridgeSetup = Promise.resolve();
|
|
243
|
-
let destroyed = false;
|
|
244
|
-
let desktopProviderCache; // lazily-read ~/.zcode/v2 providers (agent-owned auth)
|
|
245
|
-
|
|
246
|
-
function summary(session) {
|
|
247
|
-
return {
|
|
248
|
-
sessionId: session.sessionId,
|
|
249
|
-
projectSlug: session.projectSlug,
|
|
250
|
-
state: session.state,
|
|
251
|
-
model: session.model,
|
|
252
|
-
selectedModel: session.selectedModel,
|
|
253
|
-
aiProfileId: session.aiProfileId,
|
|
254
|
-
permissionMode: session.permissionMode,
|
|
255
|
-
turns: session.turns,
|
|
256
|
-
startedAt: session.startedAt,
|
|
257
|
-
// Live activity marker: turn end refreshes it (see turns += 1 site).
|
|
258
|
-
updatedAt: session.updatedAt || session.startedAt,
|
|
259
|
-
endedAt: session.endedAt,
|
|
260
|
-
pendingPermission: session.pendingPermission,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function emit(session, event) {
|
|
265
|
-
stampEvent(session, event);
|
|
266
|
-
session.outputBuffer.push(event);
|
|
267
|
-
if (session.outputBuffer.length > MAX_PERSISTED_EVENTS) session.outputBuffer.shift();
|
|
268
|
-
for (const listener of session.listeners) {
|
|
269
|
-
try { listener(event); } catch { /* listener errors never break the turn */ }
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function setState(session, state) {
|
|
274
|
-
session.state = state;
|
|
275
|
-
emit(session, { type: 'claude/state', state, turn: session.turns });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ---- persistence (records share ~/.agent-terminal/cli-sessions with the
|
|
279
|
-
// other CLI drivers but carry their own schema; only this driver claims
|
|
280
|
-
// them). The zcode session id (sess_...) is stored as threadId so a restart
|
|
281
|
-
// resumes the server-side conversation instead of starting a new one. ----
|
|
282
|
-
|
|
283
|
-
function recordFile(sessionId) {
|
|
284
|
-
return path.join(persist.dir, `${safeFileStem(sessionId)}.json`);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function readRecord(file) {
|
|
288
|
-
try {
|
|
289
|
-
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
290
|
-
if (parsed && parsed.schema === APP_SERVER_PERSIST_SCHEMA && parsed.agentId === 'zcode'
|
|
291
|
-
&& parsed.transport === 'app-server' && typeof parsed.sessionId === 'string' && parsed.sessionId) return parsed;
|
|
292
|
-
} catch { /* corrupt or foreign records are skipped, never fatal */ }
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function syncDiskIndex() {
|
|
297
|
-
if (!persist.enabled) return null;
|
|
298
|
-
if (!diskIndex) diskIndex = new Map();
|
|
299
|
-
else diskIndex.clear();
|
|
300
|
-
let names = [];
|
|
301
|
-
try { names = fs.readdirSync(persist.dir).filter(name => name.endsWith('.json')); } catch { return diskIndex; }
|
|
302
|
-
for (const name of names) {
|
|
303
|
-
const file = path.join(persist.dir, name);
|
|
304
|
-
const record = readRecord(file);
|
|
305
|
-
if (record) diskIndex.set(record.sessionId, { file, summary: persistedSummary(record) });
|
|
306
|
-
}
|
|
307
|
-
return diskIndex;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function persistedSummary(record) {
|
|
311
|
-
return {
|
|
312
|
-
sessionId: record.sessionId,
|
|
313
|
-
projectSlug: record.projectSlug,
|
|
314
|
-
state: 'idle', // a restart killed any live turn
|
|
315
|
-
model: record.model || null,
|
|
316
|
-
selectedModel: record.selectedModel || null,
|
|
317
|
-
aiProfileId: record.aiProfileId || null,
|
|
318
|
-
permissionMode: record.permissionMode || 'default',
|
|
319
|
-
turns: Number(record.turns) || 0,
|
|
320
|
-
startedAt: record.startedAt || '',
|
|
321
|
-
updatedAt: record.updatedAt || record.startedAt || '',
|
|
322
|
-
endedAt: record.endedAt || null,
|
|
323
|
-
pendingPermission: null,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function persistSession(session) {
|
|
328
|
-
if (!persist.enabled) return;
|
|
329
|
-
try {
|
|
330
|
-
const file = recordFile(session.sessionId);
|
|
331
|
-
writeJson(file, {
|
|
332
|
-
schema: APP_SERVER_PERSIST_SCHEMA,
|
|
333
|
-
agentId: 'zcode',
|
|
334
|
-
transport: 'app-server',
|
|
335
|
-
sessionId: session.sessionId,
|
|
336
|
-
projectSlug: session.projectSlug,
|
|
337
|
-
projectPath: session.projectPath,
|
|
338
|
-
state: session.state,
|
|
339
|
-
model: session.model,
|
|
340
|
-
selectedModel: session.selectedModel,
|
|
341
|
-
aiProfileId: session.aiProfileId,
|
|
342
|
-
permissionMode: session.permissionMode,
|
|
343
|
-
title: session.title || null,
|
|
344
|
-
turns: session.turns,
|
|
345
|
-
startedAt: session.startedAt,
|
|
346
|
-
endedAt: session.endedAt,
|
|
347
|
-
// The ZCode Protocol session id: session/resume needs it after a restart.
|
|
348
|
-
threadId: session.zcodeSessionId || null,
|
|
349
|
-
lastResult: session.lastResult || '',
|
|
350
|
-
usage: { ...session.usage },
|
|
351
|
-
peakTurnTokens: session.peakTurnTokens || 0,
|
|
352
|
-
eventSequence: Number(session.eventSequence) || 0,
|
|
353
|
-
// Old numeric cursors predate this session's numbering era; the
|
|
354
|
-
// boundary must survive restarts, not just the first restore.
|
|
355
|
-
legacySequenceReset: session.legacySequenceReset === true,
|
|
356
|
-
events: session.outputBuffer.slice(-MAX_PERSISTED_EVENTS),
|
|
357
|
-
updatedAt: new Date().toISOString(),
|
|
358
|
-
});
|
|
359
|
-
if (diskIndex) diskIndex.set(session.sessionId, { file: recordFile(session.sessionId), summary: summary(session) });
|
|
360
|
-
} catch {
|
|
361
|
-
// Read-only home or full disk degrades to memory-only behavior.
|
|
362
|
-
persist.enabled = false;
|
|
363
|
-
diskIndex = null;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function removeRecord(sessionId) {
|
|
368
|
-
if (diskIndex) diskIndex.delete(sessionId);
|
|
369
|
-
if (!persist.enabled) return;
|
|
370
|
-
try {
|
|
371
|
-
const file = recordFile(sessionId);
|
|
372
|
-
if (fs.existsSync(file) && readRecord(file)) fs.rmSync(file, { force: true });
|
|
373
|
-
} catch { /* already gone */ }
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function restoreSession(id) {
|
|
377
|
-
if (!persist.enabled) return null;
|
|
378
|
-
const index = syncDiskIndex();
|
|
379
|
-
const entry = index && index.get(id);
|
|
380
|
-
if (!entry) return null;
|
|
381
|
-
const record = readRecord(entry.file);
|
|
382
|
-
if (!record || record.sessionId !== id) { index.delete(id); return null; }
|
|
383
|
-
// Legacy records (written before stable event identity) carry unstamped
|
|
384
|
-
// events: renumber the retained tail explicitly and continue from there.
|
|
385
|
-
const restored = resequenceRestoredEvents(
|
|
386
|
-
Array.isArray(record.events) ? record.events.slice(-MAX_PERSISTED_EVENTS) : [],
|
|
387
|
-
record.eventSequence,
|
|
388
|
-
);
|
|
389
|
-
const session = {
|
|
390
|
-
sessionId: record.sessionId,
|
|
391
|
-
projectSlug: record.projectSlug,
|
|
392
|
-
projectPath: record.projectPath,
|
|
393
|
-
state: 'idle',
|
|
394
|
-
model: record.model || null,
|
|
395
|
-
selectedModel: record.selectedModel || null,
|
|
396
|
-
aiProfileId: record.aiProfileId || null,
|
|
397
|
-
permissionMode: record.permissionMode || 'default',
|
|
398
|
-
effort: null,
|
|
399
|
-
title: record.title || null,
|
|
400
|
-
turns: Number(record.turns) || 0,
|
|
401
|
-
startedAt: record.startedAt || new Date().toISOString(),
|
|
402
|
-
updatedAt: record.updatedAt || record.startedAt || new Date().toISOString(),
|
|
403
|
-
endedAt: record.endedAt || null,
|
|
404
|
-
pendingPermission: null,
|
|
405
|
-
pendingApproval: null,
|
|
406
|
-
outputBuffer: restored.events,
|
|
407
|
-
eventSequence: restored.eventSequence,
|
|
408
|
-
legacySequenceReset: restored.legacyRenumbered === true || record.legacySequenceReset === true,
|
|
409
|
-
listeners: new Set(),
|
|
410
|
-
zcodeSessionId: record.threadId || null,
|
|
411
|
-
currentTurn: null,
|
|
412
|
-
lastResult: record.lastResult || '',
|
|
413
|
-
lastDiag: '',
|
|
414
|
-
usage: {
|
|
415
|
-
inputTokens: Number(record.usage && record.usage.inputTokens) || 0,
|
|
416
|
-
outputTokens: Number(record.usage && record.usage.outputTokens) || 0,
|
|
417
|
-
cachedTokens: Number(record.usage && record.usage.cachedTokens) || 0,
|
|
418
|
-
},
|
|
419
|
-
peakTurnTokens: Number(record.peakTurnTokens) || 0,
|
|
420
|
-
turnTokenUsage: null,
|
|
421
|
-
turnPromise: Promise.resolve(),
|
|
422
|
-
};
|
|
423
|
-
sessions.set(id, session);
|
|
424
|
-
return session;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const runner = {
|
|
428
|
-
agentId: 'zcode',
|
|
429
|
-
|
|
430
|
-
startChatSession({ slug, projectPath, aiProfile, permissionMode, effort, title }) {
|
|
431
|
-
if (!slug) throw new Error('slug required');
|
|
432
|
-
if (!projectPath) throw new Error('projectPath required');
|
|
433
|
-
const sessionId = nextDriverSessionId();
|
|
434
|
-
const session = {
|
|
435
|
-
sessionId,
|
|
436
|
-
projectSlug: slug,
|
|
437
|
-
projectPath,
|
|
438
|
-
state: 'idle',
|
|
439
|
-
model: modelForProfile(aiProfile),
|
|
440
|
-
selectedModel: aiProfile && (aiProfile.mainModel || aiProfile.models && aiProfile.models.default) || '',
|
|
441
|
-
aiProfileId: aiProfile && aiProfile.id,
|
|
442
|
-
permissionMode: permissionMode || 'default',
|
|
443
|
-
effort: effort || null,
|
|
444
|
-
title: title || null,
|
|
445
|
-
turns: 0,
|
|
446
|
-
startedAt: new Date().toISOString(),
|
|
447
|
-
endedAt: null,
|
|
448
|
-
pendingPermission: null,
|
|
449
|
-
pendingApproval: null,
|
|
450
|
-
outputBuffer: [],
|
|
451
|
-
eventSequence: 0,
|
|
452
|
-
listeners: new Set(),
|
|
453
|
-
zcodeSessionId: null,
|
|
454
|
-
currentTurn: null,
|
|
455
|
-
lastResult: '',
|
|
456
|
-
lastDiag: '',
|
|
457
|
-
usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
|
|
458
|
-
peakTurnTokens: 0,
|
|
459
|
-
turnTokenUsage: null,
|
|
460
|
-
turnPromise: Promise.resolve(),
|
|
461
|
-
};
|
|
462
|
-
sessions.set(sessionId, session);
|
|
463
|
-
emit(session, {
|
|
464
|
-
type: 'claude/session-ready',
|
|
465
|
-
model: session.model,
|
|
466
|
-
aiProfileId: session.aiProfileId,
|
|
467
|
-
permissionMode: session.permissionMode,
|
|
468
|
-
message: 'zcode session is ready. Send a message to start the first turn.',
|
|
469
|
-
});
|
|
470
|
-
// Write the record immediately so a session with no turns yet still
|
|
471
|
-
// shows up in the sidebar after a restart.
|
|
472
|
-
persistSession(session);
|
|
473
|
-
return { sessionId };
|
|
474
|
-
},
|
|
475
|
-
|
|
476
|
-
listSessions(filter = {}) {
|
|
477
|
-
const byId = new Map();
|
|
478
|
-
const index = syncDiskIndex();
|
|
479
|
-
if (index) {
|
|
480
|
-
for (const entry of index.values()) {
|
|
481
|
-
if (filter.projectSlug && entry.summary.projectSlug !== filter.projectSlug) continue;
|
|
482
|
-
byId.set(entry.summary.sessionId, entry.summary);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
for (const session of sessions.values()) {
|
|
486
|
-
if (filter.projectSlug && session.projectSlug !== filter.projectSlug) continue;
|
|
487
|
-
byId.set(session.sessionId, summary(session));
|
|
488
|
-
}
|
|
489
|
-
return [...byId.values()];
|
|
490
|
-
},
|
|
491
|
-
|
|
492
|
-
getState(id) {
|
|
493
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
494
|
-
if (!session) return null;
|
|
495
|
-
return {
|
|
496
|
-
...summary(session),
|
|
497
|
-
listenerCount: session.listeners.size,
|
|
498
|
-
bufferedEvents: session.outputBuffer.length,
|
|
499
|
-
};
|
|
500
|
-
},
|
|
501
|
-
|
|
502
|
-
getSession(id) { return sessions.get(id) || restoreSession(id); },
|
|
503
|
-
|
|
504
|
-
getSessionTokenUsage(id) {
|
|
505
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
506
|
-
if (!session) return { used: 0, hasUsage: false };
|
|
507
|
-
const { inputTokens, outputTokens } = session.usage;
|
|
508
|
-
const used = session.peakTurnTokens || 0;
|
|
509
|
-
return {
|
|
510
|
-
used,
|
|
511
|
-
inputTokens,
|
|
512
|
-
outputTokens,
|
|
513
|
-
hasUsage: Boolean(used || inputTokens || outputTokens),
|
|
514
|
-
};
|
|
515
|
-
},
|
|
516
|
-
|
|
517
|
-
subscribe(id, listener) {
|
|
518
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
519
|
-
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
520
|
-
for (const event of session.outputBuffer) listener(event);
|
|
521
|
-
session.listeners.add(listener);
|
|
522
|
-
return () => session.listeners.delete(listener);
|
|
523
|
-
},
|
|
524
|
-
|
|
525
|
-
async sendInput(id, text, profile, sendOptions = {}) {
|
|
526
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
527
|
-
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
528
|
-
if (ACTIVE_STATES.has(session.state)) {
|
|
529
|
-
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
530
|
-
}
|
|
531
|
-
const value = String(text || '').trim();
|
|
532
|
-
const attachments = normalizeAttachments(sendOptions.attachments || []);
|
|
533
|
-
if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
|
|
534
|
-
if (profile) {
|
|
535
|
-
session.model = modelForProfile(profile);
|
|
536
|
-
session.selectedModel = profile.mainModel || profile.models && profile.models.default || '';
|
|
537
|
-
}
|
|
538
|
-
if (sendOptions.permissionMode) session.permissionMode = sendOptions.permissionMode;
|
|
539
|
-
session.lastPromptText = value;
|
|
540
|
-
|
|
541
|
-
let runtimeModel = null;
|
|
542
|
-
try {
|
|
543
|
-
// Invalid injected profiles (no base URL) throw here and surface as a
|
|
544
|
-
// turn error, mirroring the codex driver's bridgeSpecFor contract.
|
|
545
|
-
runtimeModel = runtimeModelFor(profile);
|
|
546
|
-
session.preparing = true;
|
|
547
|
-
session.runtimeSignature = profile?.apiKey ? runtimeModel.revision : 'agent-owned';
|
|
548
|
-
await ensureBridge(profile, session);
|
|
549
|
-
await ensureZcodeSession(session, runtimeModel);
|
|
550
|
-
} catch (error) {
|
|
551
|
-
emit(session, { type: 'claude/error', message: `zcode app-server unavailable: ${error.message}`.slice(0, 500) });
|
|
552
|
-
setState(session, 'idle');
|
|
553
|
-
return { started: false };
|
|
554
|
-
} finally { session.preparing = false; }
|
|
555
|
-
|
|
556
|
-
emit(session, {
|
|
557
|
-
type: 'claude/user-prompt',
|
|
558
|
-
text: value,
|
|
559
|
-
attachments: (sendOptions.displayAttachments || []).map(({ id: attachmentId, name, mediaType, size, width, height, url }) => ({ id: attachmentId, name, mediaType, size, width, height, url })),
|
|
560
|
-
});
|
|
561
|
-
setState(session, 'running');
|
|
562
|
-
// Until the first session/event lands the UI would show dead air
|
|
563
|
-
// (reasoning models think for tens of seconds first) — announce it.
|
|
564
|
-
setState(session, 'thinking');
|
|
565
|
-
try {
|
|
566
|
-
// Image attachments have no verified ZCode Protocol wire shape yet
|
|
567
|
-
// (session/send attachments are an opaque record); text attachments
|
|
568
|
-
// ride the prompt text, images are refused with an honest notice.
|
|
569
|
-
const images = attachments.filter(item => item.mediaType.startsWith('image/'));
|
|
570
|
-
if (images.length) {
|
|
571
|
-
emitDiag(session, { type: 'claude/stderr', text: 'the zcode app-server driver does not support image attachments yet' });
|
|
572
|
-
}
|
|
573
|
-
session.currentTurn = {
|
|
574
|
-
turnId: null,
|
|
575
|
-
announcedText: new Set(),
|
|
576
|
-
streamedText: new Set(),
|
|
577
|
-
announcedThinking: new Set(),
|
|
578
|
-
streamedThinking: new Set(),
|
|
579
|
-
sawEvent: false,
|
|
580
|
-
};
|
|
581
|
-
session.turnPromise = new Promise(resolve => { session.currentTurn.resolve = resolve; });
|
|
582
|
-
await bridge.request('session/send', {
|
|
583
|
-
sessionId: session.zcodeSessionId,
|
|
584
|
-
content: appendTextAttachments(value, attachments) + (sendOptions.knowledgeContext || ''),
|
|
585
|
-
...(protocolMode === 'selection' ? (session.nativeModel ? { modelSelection: session.nativeModel } : {}) : runtimeModel ? { runtimeModel } : {}),
|
|
586
|
-
}, { timeoutMs: 30000 });
|
|
587
|
-
return { started: true };
|
|
588
|
-
} catch (error) {
|
|
589
|
-
finishTurn(session, { resultType: 'error', response: '' }, `zcode turn failed to start: ${error.message}`);
|
|
590
|
-
return { started: false };
|
|
591
|
-
}
|
|
592
|
-
},
|
|
593
|
-
|
|
594
|
-
resolvePermission(id, requestId, decision) {
|
|
595
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
596
|
-
if (!session || !session.pendingApproval || session.pendingApproval.requestId !== requestId) {
|
|
597
|
-
throw Object.assign(new Error('permission request not found or already resolved'), { status: 409 });
|
|
598
|
-
}
|
|
599
|
-
const allow = decision && decision.allow === true;
|
|
600
|
-
const approval = session.pendingApproval;
|
|
601
|
-
session.pendingApproval = null;
|
|
602
|
-
session.pendingPermission = null;
|
|
603
|
-
const response = allow ? approval.allowResponse : approval.denyResponse;
|
|
604
|
-
emit(session, {
|
|
605
|
-
type: 'claude/permission-resolved',
|
|
606
|
-
requestId,
|
|
607
|
-
allow,
|
|
608
|
-
decision: response && response.decision,
|
|
609
|
-
resolvedAt: new Date().toISOString(),
|
|
610
|
-
});
|
|
611
|
-
setState(session, 'running');
|
|
612
|
-
approval.resolve(response);
|
|
613
|
-
return { ok: true, started: true, decision: response && response.decision };
|
|
614
|
-
},
|
|
615
|
-
|
|
616
|
-
updateSelection(id, profile, selectedModel) {
|
|
617
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
618
|
-
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
619
|
-
if (ACTIVE_STATES.has(session.state)) {
|
|
620
|
-
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
621
|
-
}
|
|
622
|
-
const fromAiProfileId = session.aiProfileId || null;
|
|
623
|
-
const fromModel = session.selectedModel || session.model || null;
|
|
624
|
-
session.aiProfileId = profile && profile.id || null;
|
|
625
|
-
session.selectedModel = selectedModel || (profile && (profile.mainModel || profile.models && profile.models.default)) || null;
|
|
626
|
-
session.model = modelForProfile(profile);
|
|
627
|
-
emit(session, {
|
|
628
|
-
type: 'claude/selection-changed',
|
|
629
|
-
fromAiProfileId,
|
|
630
|
-
toAiProfileId: session.aiProfileId,
|
|
631
|
-
fromModel,
|
|
632
|
-
toModel: session.selectedModel,
|
|
633
|
-
});
|
|
634
|
-
persistSession(session);
|
|
635
|
-
return summary(session);
|
|
636
|
-
},
|
|
637
|
-
|
|
638
|
-
abort(id, reason) {
|
|
639
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
640
|
-
if (!session) return;
|
|
641
|
-
session.abortedByUser = true;
|
|
642
|
-
denyPendingApproval(session, 'aborted');
|
|
643
|
-
const currentTurn = session.currentTurn;
|
|
644
|
-
if (currentTurn && bridge && bridge.state === 'ready' && session.zcodeSessionId) {
|
|
645
|
-
// Ask the app-server to interrupt the live turn; a failure falls back
|
|
646
|
-
// to the local turn teardown below (the bridge recycles lazily).
|
|
647
|
-
bridge.request('session/stop', { sessionId: session.zcodeSessionId }, { timeoutMs: 5000 })
|
|
648
|
-
.catch(() => recycleBridge('session/stop failed'));
|
|
649
|
-
}
|
|
650
|
-
if (!['idle', 'ended'].includes(session.state)) {
|
|
651
|
-
session.state = 'aborted';
|
|
652
|
-
emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
|
|
653
|
-
finishTurn(session, { resultType: 'cancelled', response: '' });
|
|
654
|
-
}
|
|
655
|
-
persistSession(session);
|
|
656
|
-
},
|
|
657
|
-
|
|
658
|
-
deleteSession(id) {
|
|
659
|
-
const session = sessions.get(id) || restoreSession(id);
|
|
660
|
-
if (!session) {
|
|
661
|
-
const index = syncDiskIndex();
|
|
662
|
-
if (index && index.has(id)) { removeRecord(id); return true; }
|
|
663
|
-
return false;
|
|
664
|
-
}
|
|
665
|
-
if (ACTIVE_STATES.has(session.state)) runner.abort(id);
|
|
666
|
-
denyPendingApproval(session, 'deleted');
|
|
667
|
-
if (session.zcodeSessionId) zcodeSessions.delete(session.zcodeSessionId);
|
|
668
|
-
const deleted = sessions.delete(id);
|
|
669
|
-
removeRecord(id);
|
|
670
|
-
return deleted;
|
|
671
|
-
},
|
|
672
|
-
|
|
673
|
-
async listSupportedCommands() { return []; },
|
|
674
|
-
|
|
675
|
-
findClaudeExecutableForSdk() { return null; },
|
|
676
|
-
|
|
677
|
-
async destroy() {
|
|
678
|
-
destroyed = true;
|
|
679
|
-
for (const session of sessions.values()) if (ACTIVE_STATES.has(session.state)) runner.abort(session.sessionId);
|
|
680
|
-
await bridge?.close(); bridge = null;
|
|
681
|
-
await bridgeSetup.catch(() => {});
|
|
682
|
-
await bridge?.close(); bridge = null;
|
|
683
|
-
await profileRuntime?.close(); profileRuntime = null;
|
|
684
|
-
},
|
|
685
|
-
|
|
686
|
-
// Test seams.
|
|
687
|
-
get transportMode() { return 'app-server'; },
|
|
688
|
-
get bridgeState() { return bridge ? bridge.state : 'uncreated'; },
|
|
689
|
-
get cliPath() { return cli.path; },
|
|
690
|
-
};
|
|
691
|
-
|
|
692
|
-
// -- bridge lifecycle ----------------------------------------------------
|
|
693
|
-
|
|
694
|
-
function ensureBridge(profile, session) {
|
|
695
|
-
const preparing = bridgeSetup.then(() => prepareBridge(profile, session));
|
|
696
|
-
bridgeSetup = preparing.catch(() => {});
|
|
697
|
-
return preparing;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
async function prepareBridge(profile, session) {
|
|
701
|
-
if (destroyed) throw new Error('ZCode 工作台正在关闭。');
|
|
702
|
-
if (protocolMode === 'selection' && profileRuntimeSignature !== session.runtimeSignature) {
|
|
703
|
-
if ([...sessions.values()].some(other => other !== session && (other.preparing || ACTIVE_STATES.has(other.state)) && other.runtimeSignature !== session.runtimeSignature)) throw new Error('另一个 ZCode 任务正在使用不同的模型配置,请等待任务结束。');
|
|
704
|
-
if (profileRuntimeSignature !== null || profile?.apiKey) { await bridge?.close(); bridge = null; }
|
|
705
|
-
await profileRuntime?.close(); profileRuntime = null;
|
|
706
|
-
if (profile?.apiKey) profileRuntime = await require('./zcode-profile-runtime').createZCodeProfileRuntime(injectedRuntimeModel(profile));
|
|
707
|
-
profileRuntimeSignature = session.runtimeSignature;
|
|
708
|
-
}
|
|
709
|
-
if (destroyed) throw new Error('ZCode 工作台正在关闭。');
|
|
710
|
-
if (bridge && bridge.state === 'closed') { bridge = null; }
|
|
711
|
-
if (bridge && bridge.state === 'crashed') { recycleBridge('bridge exhausted its restart budget'); }
|
|
712
|
-
if (!bridge) {
|
|
713
|
-
bridge = createAppServerBridge({
|
|
714
|
-
// The bundled CLI is a node bundle: spawn it through the running node
|
|
715
|
-
// runtime with the app-server stdio subcommand.
|
|
716
|
-
command: options.nodePath || process.execPath,
|
|
717
|
-
args: [cli.path, 'app-server', '--stdio'],
|
|
718
|
-
env: { ...providerRuntimeEnvironment(cli.path, { env: options.env || process.env }), ...(profileRuntime ? { ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: profileRuntime.file } : {}), ...(options.knowledgeContextProvided ? { KB_KNOWLEDGE_CONTEXT: '0' } : {}), ...(options.conversationCaptureProvided ? { AI_CODING_EVENT_BRIDGE_CAPTURE: '0' } : {}) },
|
|
719
|
-
spawn: options.appServerSpawn,
|
|
720
|
-
onEvent: handleNotification,
|
|
721
|
-
onServerRequest: handleServerRequest,
|
|
722
|
-
onUnexpectedExit: handleBridgeCrash,
|
|
723
|
-
idleTimeoutMs: options.idleTimeoutMs !== undefined ? options.idleTimeoutMs : 10 * 60 * 1000,
|
|
724
|
-
requestTimeoutMs: options.requestTimeoutMs !== undefined ? options.requestTimeoutMs : 120000,
|
|
725
|
-
restartBackoffMs: options.restartBackoffMs,
|
|
726
|
-
log,
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
await bridge.start();
|
|
730
|
-
if (protocolMode === null) {
|
|
731
|
-
try { const capabilities = await bridge.request('runtime/capabilities', {}, { timeoutMs: 10000 }); protocolMode = capabilities?.independentPlanState === true ? 'selection' : 'runtime-model'; }
|
|
732
|
-
catch (error) { if (error.rpcCode !== -32601) throw error; protocolMode = 'runtime-model'; }
|
|
733
|
-
if (protocolMode === 'selection') return prepareBridge(profile, session);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
function recycleBridge(reason) {
|
|
738
|
-
log(`recycling zcode app-server bridge: ${reason}`);
|
|
739
|
-
if (bridge) bridge.close().catch(() => {});
|
|
740
|
-
bridge = null;
|
|
741
|
-
failoverSessions(`zcode app-server exited: ${reason}`);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// The app-server died mid-flight: turn.completed will never arrive, so
|
|
745
|
-
// every live turn is finished with an error here (the bridge restarts
|
|
746
|
-
// itself and the next turn retries with a fresh session/resume).
|
|
747
|
-
function handleBridgeCrash(error) {
|
|
748
|
-
failoverSessions(`zcode app-server exited unexpectedly: ${error && error.message || 'crashed'}`);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function failoverSessions(reason) {
|
|
752
|
-
for (const session of sessions.values()) {
|
|
753
|
-
denyPendingApproval(session, reason);
|
|
754
|
-
if (!session.currentTurn) continue;
|
|
755
|
-
finishTurn(session, { resultType: 'error', response: '' }, String(reason).slice(0, 500));
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// -- zcode session lifecycle ----------------------------------------------
|
|
760
|
-
|
|
761
|
-
async function ensureZcodeSession(session, runtimeModel) {
|
|
762
|
-
if (session.zcodeSessionId) {
|
|
763
|
-
try {
|
|
764
|
-
const resumed = await bridge.request('session/resume', {
|
|
765
|
-
sessionId: session.zcodeSessionId,
|
|
766
|
-
...(protocolMode !== 'selection' && runtimeModel ? { runtimeModel } : {}),
|
|
767
|
-
}, { timeoutMs: 30000 });
|
|
768
|
-
if (protocolMode === 'selection') { session.nativeModel = resumed.settings?.model?.current || session.nativeModel || null; await selectNativeModel(session); }
|
|
769
|
-
zcodeSessions.set(session.zcodeSessionId, session.sessionId);
|
|
770
|
-
await subscribeZcodeSession(session);
|
|
771
|
-
return;
|
|
772
|
-
} catch (error) {
|
|
773
|
-
// The app-server child restarted or rolled the session away; fall
|
|
774
|
-
// through to a fresh one (the client-side history stays).
|
|
775
|
-
zcodeSessions.delete(session.zcodeSessionId);
|
|
776
|
-
session.zcodeSessionId = null;
|
|
777
|
-
log(`zcode session resume failed, creating a new one: ${error.message}`);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
const result = await bridge.request('session/create', {
|
|
781
|
-
workspace: { workspacePath: session.projectPath, workspaceKey: session.projectPath },
|
|
782
|
-
mode: MODE_FOR_PERMISSION[session.permissionMode] || 'build',
|
|
783
|
-
...(protocolMode === 'selection' ? { titleGenerationEnabled: false } : runtimeModel ? { runtimeModel } : {}),
|
|
784
|
-
}, { timeoutMs: 60000 });
|
|
785
|
-
const zcodeId = result && result.session && (result.session.sessionId || result.session.id);
|
|
786
|
-
if (!zcodeId) throw new Error('zcode app-server did not return a session');
|
|
787
|
-
session.zcodeSessionId = zcodeId;
|
|
788
|
-
if (protocolMode === 'selection') { session.nativeModel = result.settings?.model?.current || null; await selectNativeModel(session); }
|
|
789
|
-
zcodeSessions.set(zcodeId, session.sessionId);
|
|
790
|
-
if (result.session && result.session.title && !session.title) session.title = result.session.title;
|
|
791
|
-
await subscribeZcodeSession(session);
|
|
792
|
-
persistSession(session);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
async function selectNativeModel(session) {
|
|
796
|
-
if (profileRuntime) session.nativeModel = profileRuntime.selection;
|
|
797
|
-
else if (session.selectedModel) {
|
|
798
|
-
const slash = session.selectedModel.indexOf('/');
|
|
799
|
-
if (slash > 0) session.nativeModel = { providerId: session.selectedModel.slice(0, slash), modelId: session.selectedModel.slice(slash + 1), options: { reasoningLevel: 'enabled' } };
|
|
800
|
-
else if (session.nativeModel) session.nativeModel = { ...session.nativeModel, modelId: session.selectedModel };
|
|
801
|
-
}
|
|
802
|
-
await bridge.request('session/setMode', { sessionId: session.zcodeSessionId, mode: MODE_FOR_PERMISSION[session.permissionMode] || 'build' }, { timeoutMs: 10000 });
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
async function subscribeZcodeSession(session) {
|
|
806
|
-
await bridge.request('session/subscribe', {
|
|
807
|
-
sessionId: session.zcodeSessionId,
|
|
808
|
-
deliveryKind: 'desktop-continuous',
|
|
809
|
-
}, { timeoutMs: 30000 });
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// -- notifications ---------------------------------------------------------
|
|
813
|
-
|
|
814
|
-
function handleNotification({ method, params }) {
|
|
815
|
-
if (!params) return;
|
|
816
|
-
const session = routeZcodeSession(params.sessionId);
|
|
817
|
-
if (!session) return; // notifications for unknown sessions are ignored
|
|
818
|
-
if (method === 'session/event') return onSessionEvent(session, params);
|
|
819
|
-
if (method === 'state.updated') {
|
|
820
|
-
// status patches (running/idle) only mark liveness here; turn completion
|
|
821
|
-
// is authoritative on the session/event stream.
|
|
822
|
-
markActivity(session);
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
// computer-use/operation-event, v4/telemetry/event, process/*, ... are noise.
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function routeZcodeSession(zcodeSessionId) {
|
|
829
|
-
if (!zcodeSessionId) return null;
|
|
830
|
-
const driverId = zcodeSessions.get(zcodeSessionId);
|
|
831
|
-
return driverId ? sessions.get(driverId) : null;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function markActivity(session) {
|
|
835
|
-
const turn = session.currentTurn;
|
|
836
|
-
if (!turn) return;
|
|
837
|
-
if (!turn.sawEvent) {
|
|
838
|
-
turn.sawEvent = true;
|
|
839
|
-
if (session.state === 'thinking') setState(session, 'running');
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function onSessionEvent(session, params) {
|
|
844
|
-
markActivity(session);
|
|
845
|
-
const turn = session.currentTurn;
|
|
846
|
-
if (!turn) return; // events outside a driver-tracked turn (e.g. background) are ignored
|
|
847
|
-
const { type, payload } = params;
|
|
848
|
-
if (params.turnId && !turn.turnId) turn.turnId = params.turnId;
|
|
849
|
-
if (turn.turnId && params.turnId && params.turnId !== turn.turnId) return; // stale turn
|
|
850
|
-
|
|
851
|
-
switch (type) {
|
|
852
|
-
case 'turn.started': return; // announced locally at send time
|
|
853
|
-
case 'model.streaming': return onModelStreaming(session, payload || {});
|
|
854
|
-
case 'tool.updated': return onToolUpdated(session, payload || {});
|
|
855
|
-
case 'turn.completed': return finishTurn(session, payload || {});
|
|
856
|
-
case 'turn.failed': return finishTurn(session, { resultType: 'error', response: '' }, payload?.error?.message || payload?.error?.code || 'ZCode 任务失败');
|
|
857
|
-
case 'session.titleUpdated': {
|
|
858
|
-
if (payload && payload.title) session.title = String(payload.title).slice(0, 200);
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
case 'permission.requested':
|
|
862
|
-
case 'permission.resolved':
|
|
863
|
-
return; // approvals are driven by the interaction/requestPermission RPC
|
|
864
|
-
default: return; // session.updated, checkpoint.*, streamRecovery.*, ...
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function onModelStreaming(session, payload) {
|
|
869
|
-
const turn = session.currentTurn;
|
|
870
|
-
if (!turn) return;
|
|
871
|
-
const { kind } = payload;
|
|
872
|
-
const messageId = payload.assistantMessageId || `text-${session.turns}`;
|
|
873
|
-
if (kind === 'text_start') {
|
|
874
|
-
turn.announcedText.add(messageId);
|
|
875
|
-
emit(session, { type: 'claude/text-start', id: messageId });
|
|
876
|
-
} else if (kind === 'text_delta') {
|
|
877
|
-
if (!turn.announcedText.has(messageId)) {
|
|
878
|
-
turn.announcedText.add(messageId);
|
|
879
|
-
emit(session, { type: 'claude/text-start', id: messageId });
|
|
880
|
-
}
|
|
881
|
-
turn.streamedText.add(messageId);
|
|
882
|
-
emit(session, { type: 'claude/text-delta', id: messageId, text: String(payload.delta || '') });
|
|
883
|
-
} else if (kind === 'reasoning_start') {
|
|
884
|
-
turn.announcedThinking.add(messageId);
|
|
885
|
-
emit(session, { type: 'claude/thinking-start', id: messageId });
|
|
886
|
-
} else if (kind === 'reasoning_delta') {
|
|
887
|
-
if (!turn.announcedThinking.has(messageId)) {
|
|
888
|
-
turn.announcedThinking.add(messageId);
|
|
889
|
-
emit(session, { type: 'claude/thinking-start', id: messageId });
|
|
890
|
-
}
|
|
891
|
-
turn.streamedThinking.add(messageId);
|
|
892
|
-
emit(session, { type: 'claude/thinking-delta', id: messageId, text: String(payload.delta || '') });
|
|
893
|
-
} else if (kind === 'tool_call') {
|
|
894
|
-
setState(session, 'tool-running');
|
|
895
|
-
emit(session, {
|
|
896
|
-
type: 'claude/tool-use',
|
|
897
|
-
id: payload.toolCallId || `tool-${session.turns}-${session.outputBuffer.length}`,
|
|
898
|
-
name: payload.toolName || 'Tool',
|
|
899
|
-
input: payload.input && typeof payload.input === 'object' ? payload.input : { input: payload.input },
|
|
900
|
-
});
|
|
901
|
-
} else if (kind === 'tool_error') {
|
|
902
|
-
emit(session, {
|
|
903
|
-
type: 'claude/tool-result',
|
|
904
|
-
id: payload.toolCallId || `tool-${session.turns}-${session.outputBuffer.length}`,
|
|
905
|
-
output: String(payload.delta || payload.error || 'tool failed').slice(0, 4000),
|
|
906
|
-
isError: true,
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
// text_end / reasoning_end / tool_input_* deltas need no normalized event;
|
|
910
|
-
// the tool_use input arrives complete on the tool_call kind.
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function onToolUpdated(session, payload) {
|
|
914
|
-
if (!payload.result || !payload.toolCallId) return; // scheduling metadata only
|
|
915
|
-
setState(session, 'running');
|
|
916
|
-
emit(session, {
|
|
917
|
-
type: 'claude/tool-result',
|
|
918
|
-
id: payload.toolCallId,
|
|
919
|
-
output: toolResultText(payload.result).slice(0, 4000),
|
|
920
|
-
isError: payload.result.success === false,
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
function finishTurn(session, payload, forcedError) {
|
|
925
|
-
const current = session.currentTurn;
|
|
926
|
-
if (!current) return;
|
|
927
|
-
session.currentTurn = null;
|
|
928
|
-
denyPendingApproval(session, 'turn completed'); // the server cancels unanswered approvals
|
|
929
|
-
session.turns += 1;
|
|
930
|
-
session.updatedAt = new Date().toISOString();
|
|
931
|
-
const usage = payload.usage || {};
|
|
932
|
-
const input = Number(usage.inputTokens || 0);
|
|
933
|
-
const output = Number(usage.outputTokens || 0);
|
|
934
|
-
const cached = Number(usage.cacheReadTokens || 0);
|
|
935
|
-
if (input || output || cached) {
|
|
936
|
-
session.usage.inputTokens += input;
|
|
937
|
-
session.usage.outputTokens += output;
|
|
938
|
-
session.usage.cachedTokens += cached;
|
|
939
|
-
session.peakTurnTokens = Math.max(session.peakTurnTokens || 0, input + output + cached);
|
|
940
|
-
emit(session, { type: 'claude/usage', usage: { input_tokens: input, output_tokens: output } });
|
|
941
|
-
}
|
|
942
|
-
const resultType = String(payload.resultType || 'success');
|
|
943
|
-
const response = String(payload.response != null ? payload.response : session.lastResult || '');
|
|
944
|
-
if (response) session.lastResult = response;
|
|
945
|
-
const failed = resultType === 'error' || resultType === 'failed' || Boolean(forcedError);
|
|
946
|
-
if (failed) {
|
|
947
|
-
emitDiag(session, { type: 'claude/error', message: String(forcedError || `zcode turn ${resultType}`).slice(0, 500) });
|
|
948
|
-
}
|
|
949
|
-
if (resultType === 'cancelled' && !session.abortedByUser) {
|
|
950
|
-
emit(session, { type: 'claude/aborted' });
|
|
951
|
-
}
|
|
952
|
-
emit(session, {
|
|
953
|
-
type: 'claude/result',
|
|
954
|
-
result: failed ? '' : response,
|
|
955
|
-
model: session.model,
|
|
956
|
-
isError: failed,
|
|
957
|
-
});
|
|
958
|
-
session.abortedByUser = false;
|
|
959
|
-
if (!session.endedAt) session.state = 'idle';
|
|
960
|
-
emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
|
|
961
|
-
persistSession(session);
|
|
962
|
-
if (current.resolve) current.resolve();
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
function emitDiag(session, event) {
|
|
966
|
-
const text = event.text != null ? event.text : event.message;
|
|
967
|
-
if (text === session.lastDiag) return;
|
|
968
|
-
session.lastDiag = String(text == null ? '' : text);
|
|
969
|
-
emit(session, event);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// -- server-initiated requests (preferences + approvals) ------------------
|
|
973
|
-
|
|
974
|
-
async function handleServerRequest(method, params) {
|
|
975
|
-
if (method === 'session/requestRuntimePreferences') {
|
|
976
|
-
// Defaults verified live: native search off, memory off, question
|
|
977
|
-
// auto-resolution off; answering unblocks session/create and session/send.
|
|
978
|
-
return RUNTIME_PREFERENCES;
|
|
979
|
-
}
|
|
980
|
-
if (method === 'interaction/requestPermission') {
|
|
981
|
-
const session = routeZcodeSession(params && params.sessionId);
|
|
982
|
-
if (!session) throw Object.assign(new Error('zcode approval for an unknown session'), { status: 404 });
|
|
983
|
-
return await waitForApproval(session, method, params || {});
|
|
984
|
-
}
|
|
985
|
-
// Official MCP plugin auth (image_search & friends) and user-input
|
|
986
|
-
// interactions have no terminal surface yet: rejecting them is verified
|
|
987
|
-
// harmless (the session continues; affected tools report a failure).
|
|
988
|
-
throw Object.assign(new Error(`zcode app-server request ${method} is not supported`), { status: 501 });
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function waitForApproval(session, method, params) {
|
|
992
|
-
const choices = Array.isArray(params.options) ? params.options.filter(Boolean) : [];
|
|
993
|
-
const allowChoice = choices.find(choice => choice.kind === 'allow_once' || choice.kind === 'allow_always');
|
|
994
|
-
const denyChoice = choices.find(choice => choice.kind === 'deny');
|
|
995
|
-
const described = describeApproval(params);
|
|
996
|
-
return new Promise(resolve => {
|
|
997
|
-
const requestId = params.requestId || `zcode-perm-${nextPermissionId++}`;
|
|
998
|
-
session.pendingApproval = {
|
|
999
|
-
requestId,
|
|
1000
|
-
allowResponse: allowChoice && allowChoice.response || { decision: 'allow', reason: 'Approved by the terminal user' },
|
|
1001
|
-
denyResponse: denyChoice && denyChoice.response || { decision: 'deny', reason: 'Denied' },
|
|
1002
|
-
resolve,
|
|
1003
|
-
};
|
|
1004
|
-
session.pendingPermission = {
|
|
1005
|
-
requestId,
|
|
1006
|
-
status: 'pending',
|
|
1007
|
-
createdAt: new Date().toISOString(),
|
|
1008
|
-
summary: described.summary,
|
|
1009
|
-
};
|
|
1010
|
-
setState(session, 'tool-running');
|
|
1011
|
-
emit(session, { type: 'claude/permission-request', requestId, ...described, status: 'pending', createdAt: session.pendingPermission.createdAt });
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
function describeApproval(params) {
|
|
1016
|
-
const input = params.input && typeof params.input === 'object' ? params.input : {};
|
|
1017
|
-
const commandText = String(input.command || input.path || input.file || '');
|
|
1018
|
-
const toolName = String(params.toolName || (/file|edit|patch/i.test(commandText) ? 'Edit' : 'Bash'));
|
|
1019
|
-
const title = toolName === 'Edit' ? 'ZCode wants to modify files' : 'ZCode wants to run a command';
|
|
1020
|
-
return {
|
|
1021
|
-
toolName,
|
|
1022
|
-
title,
|
|
1023
|
-
description: commandText.slice(0, 200) || String(params.reason || 'tool approval').slice(0, 200),
|
|
1024
|
-
promptPreview: JSON.stringify({ toolName, command: commandText, riskLevel: params.riskLevel || '' }).slice(0, 500),
|
|
1025
|
-
summary: {
|
|
1026
|
-
toolName,
|
|
1027
|
-
title,
|
|
1028
|
-
description: commandText.slice(0, 200) || String(params.reason || '').slice(0, 200),
|
|
1029
|
-
promptPreview: commandText.slice(0, 500),
|
|
1030
|
-
},
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
function denyPendingApproval(session, reason) {
|
|
1035
|
-
if (!session.pendingApproval) return;
|
|
1036
|
-
const approval = session.pendingApproval;
|
|
1037
|
-
session.pendingApproval = null;
|
|
1038
|
-
session.pendingPermission = null;
|
|
1039
|
-
emit(session, {
|
|
1040
|
-
type: 'claude/permission-resolved',
|
|
1041
|
-
requestId: approval.requestId,
|
|
1042
|
-
allow: false,
|
|
1043
|
-
decision: approval.denyResponse && approval.denyResponse.decision,
|
|
1044
|
-
message: reason,
|
|
1045
|
-
resolvedAt: new Date().toISOString(),
|
|
1046
|
-
});
|
|
1047
|
-
approval.resolve(approval.denyResponse);
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
// -- model runtime ----------------------------------------------------------
|
|
1051
|
-
|
|
1052
|
-
// Profile-injected providers become inline runtimeModel configs; agent-owned
|
|
1053
|
-
// auth bridges the desktop's own provider store so a logged-in ZCode desktop
|
|
1054
|
-
// works with zero terminal-side configuration. Returns null when the
|
|
1055
|
-
// app-server should use its own ~/.zcode/cli/config.json. The desktop store
|
|
1056
|
-
// is never read under the test runner without an explicit zcodeHome, so
|
|
1057
|
-
// injected-double tests stay machine-independent.
|
|
1058
|
-
function runtimeModelFor(profile) {
|
|
1059
|
-
if (profile && profile.apiKey) return injectedRuntimeModel(profile);
|
|
1060
|
-
if (desktopProviderCache === undefined) {
|
|
1061
|
-
desktopProviderCache = options.zcodeHome || !process.env.NODE_TEST_CONTEXT
|
|
1062
|
-
? readDesktopRuntimeModel(cli, options)
|
|
1063
|
-
: null;
|
|
1064
|
-
}
|
|
1065
|
-
return desktopProviderCache;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// Driver session ids must never collide with persisted records from a
|
|
1069
|
-
// previous process: a collision would shadow a restartable session behind
|
|
1070
|
-
// an empty one.
|
|
1071
|
-
function nextDriverSessionId() {
|
|
1072
|
-
const taken = new Set(sessions.keys());
|
|
1073
|
-
const index = syncDiskIndex();
|
|
1074
|
-
if (index) for (const id of index.keys()) taken.add(id);
|
|
1075
|
-
let id = `zcode-${nextId++}`;
|
|
1076
|
-
while (taken.has(id)) id = `zcode-${nextId++}`;
|
|
1077
|
-
return id;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
return runner;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// ---------------------------------------------------------------------------
|
|
1084
|
-
// module helpers
|
|
1085
|
-
// ---------------------------------------------------------------------------
|
|
1086
|
-
|
|
1087
|
-
function modelForProfile(profile) {
|
|
1088
|
-
if (!profile) return null;
|
|
1089
|
-
const model = resolveOpenAIModel(profile);
|
|
1090
|
-
return model ? String(model) : null;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
function runtimeModelRevision(spec) {
|
|
1094
|
-
return `wb-${crypto.createHash('sha1').update(JSON.stringify(spec)).digest('hex').slice(0, 16)}`;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// Workbench profile -> ZCode runtimeModel. Anthropic-compatible base URLs
|
|
1098
|
-
// (the profile default) map to the anthropic provider kind; everything else
|
|
1099
|
-
// falls back to the resolved OpenAI-compatible endpoint.
|
|
1100
|
-
function injectedRuntimeModel(profile) {
|
|
1101
|
-
const anthropicBase = String(profile.baseUrl || '').trim();
|
|
1102
|
-
const openaiBase = resolveOpenAIBaseUrl(profile);
|
|
1103
|
-
const useAnthropic = Boolean(anthropicBase) && profile.protocol !== 'openai-compatible';
|
|
1104
|
-
if (!useAnthropic && !openaiBase) {
|
|
1105
|
-
throw new Error('the selected provider needs a base URL for ZCode');
|
|
1106
|
-
}
|
|
1107
|
-
const modelId = resolveOpenAIModel(profile) || 'default';
|
|
1108
|
-
const providerId = 'agent-terminal';
|
|
1109
|
-
const spec = {
|
|
1110
|
-
providerId, modelId,
|
|
1111
|
-
kind: useAnthropic ? 'anthropic' : 'openai-compatible',
|
|
1112
|
-
baseURL: useAnthropic ? anthropicBase : openaiBase,
|
|
1113
|
-
apiKey: profile.apiKey,
|
|
1114
|
-
};
|
|
1115
|
-
return {
|
|
1116
|
-
revision: runtimeModelRevision(spec),
|
|
1117
|
-
generatedAt: Date.now(),
|
|
1118
|
-
model: { providerId, modelId },
|
|
1119
|
-
provider: {
|
|
1120
|
-
providerId,
|
|
1121
|
-
kind: spec.kind,
|
|
1122
|
-
label: 'Agent Terminal',
|
|
1123
|
-
source: 'ephemeral',
|
|
1124
|
-
baseURL: spec.baseURL,
|
|
1125
|
-
apiKey: { source: 'inline', value: spec.apiKey },
|
|
1126
|
-
models: [{
|
|
1127
|
-
modelId,
|
|
1128
|
-
...(Number(profile.contextWindow) > 0 ? { contextWindow: profile.contextWindow } : {}),
|
|
1129
|
-
supportsTools: true,
|
|
1130
|
-
}],
|
|
1131
|
-
},
|
|
1132
|
-
};
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
// Agent-owned auth: mirror what the ZCode desktop itself does — its provider
|
|
1136
|
-
// selection lives in ~/.zcode/v2/config.json (keyed like "builtin:bigmodel"),
|
|
1137
|
-
// and model catalogs ship beside the CLI in resources/model-providers. Any
|
|
1138
|
-
// read/parse failure degrades to null (the app-server then applies its own
|
|
1139
|
-
// config or reports an actionable model_config_missing error).
|
|
1140
|
-
function readDesktopRuntimeModel(cli, options) {
|
|
1141
|
-
try {
|
|
1142
|
-
const home = options.zcodeHome || path.join(os.homedir(), '.zcode');
|
|
1143
|
-
const configPath = path.join(home, 'v2', 'config.json');
|
|
1144
|
-
if (!fs.existsSync(configPath)) return null;
|
|
1145
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
1146
|
-
const providers = config && config.provider && typeof config.provider === 'object' ? config.provider : {};
|
|
1147
|
-
let chosen = null;
|
|
1148
|
-
let chosenScore = -1;
|
|
1149
|
-
for (const [key, value] of Object.entries(providers)) {
|
|
1150
|
-
if (!value || typeof value !== 'object') continue;
|
|
1151
|
-
if (value.enabled === false || value.enabled === 'false') continue;
|
|
1152
|
-
const options2 = value.options && typeof value.options === 'object' ? value.options : {};
|
|
1153
|
-
if (!options2.apiKey || !options2.baseURL) continue;
|
|
1154
|
-
const providerId = String(key).split(':').pop() || key;
|
|
1155
|
-
let score = 1;
|
|
1156
|
-
if (/coding-plan/i.test(providerId)) score += 2;
|
|
1157
|
-
if (/bigmodel|zai|glm/i.test(providerId)) score += 1;
|
|
1158
|
-
if (score > chosenScore) { chosenScore = score; chosen = { providerId, value, options: options2 }; }
|
|
1159
|
-
}
|
|
1160
|
-
if (!chosen) return null;
|
|
1161
|
-
const modelId = desktopDefaultModel(cli, chosen.providerId, chosen.value);
|
|
1162
|
-
if (!modelId) return null;
|
|
1163
|
-
const spec = { providerId: chosen.providerId, modelId, baseURL: chosen.options.baseURL };
|
|
1164
|
-
return {
|
|
1165
|
-
revision: runtimeModelRevision(spec),
|
|
1166
|
-
generatedAt: Date.now(),
|
|
1167
|
-
model: { providerId: chosen.providerId, modelId },
|
|
1168
|
-
provider: {
|
|
1169
|
-
providerId: chosen.providerId,
|
|
1170
|
-
kind: chosen.value.kind === 'openai' || chosen.value.kind === 'openai-compatible' ? chosen.value.kind : 'anthropic',
|
|
1171
|
-
label: String(chosen.value.name || chosen.providerId).slice(0, 100),
|
|
1172
|
-
source: 'ephemeral',
|
|
1173
|
-
baseURL: chosen.options.baseURL,
|
|
1174
|
-
apiKey: { source: 'inline', value: String(chosen.options.apiKey) },
|
|
1175
|
-
models: [{ modelId }],
|
|
1176
|
-
},
|
|
1177
|
-
};
|
|
1178
|
-
} catch {
|
|
1179
|
-
return null;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
// Model catalog lookup: resources/model-providers/*.json next to the CLI
|
|
1184
|
-
// bundle; falls back to the provider's own models list, then 'glm-4.7'.
|
|
1185
|
-
function desktopDefaultModel(cli, providerId, providerValue) {
|
|
1186
|
-
try {
|
|
1187
|
-
if (Array.isArray(providerValue.models) && providerValue.models.length) {
|
|
1188
|
-
const first = providerValue.models[0];
|
|
1189
|
-
return String(first.modelId || first.id || first).slice(0, 128);
|
|
1190
|
-
}
|
|
1191
|
-
const catalogDir = path.resolve(cli.path, '..', '..', 'model-providers');
|
|
1192
|
-
for (const name of fs.readdirSync(catalogDir).filter(item => item.endsWith('.json')).sort().reverse()) {
|
|
1193
|
-
const catalog = JSON.parse(fs.readFileSync(path.join(catalogDir, name), 'utf8'));
|
|
1194
|
-
const entry = Array.isArray(catalog && catalog.providers)
|
|
1195
|
-
&& catalog.providers.find(item => item && (item.id === providerId || item.id === `builtin:${providerId}`));
|
|
1196
|
-
const models = entry && Array.isArray(entry.models) ? entry.models : [];
|
|
1197
|
-
const model = models.find(item => item && item.default) || models[0];
|
|
1198
|
-
if (model && (model.id || model.modelId)) return String(model.id || model.modelId).slice(0, 128);
|
|
1199
|
-
}
|
|
1200
|
-
} catch { /* catalog is best-effort */ }
|
|
1201
|
-
return 'glm-4.7';
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// Tool results arrive as { success, content, display } where content may be a
|
|
1205
|
-
// string or an array of parts; normalize to displayable text.
|
|
1206
|
-
function toolResultText(result) {
|
|
1207
|
-
if (result == null) return '';
|
|
1208
|
-
if (typeof result.display === 'string' && result.display) return result.display;
|
|
1209
|
-
const content = result.content;
|
|
1210
|
-
if (typeof content === 'string') return content;
|
|
1211
|
-
if (Array.isArray(content)) {
|
|
1212
|
-
return content.map(part => {
|
|
1213
|
-
if (typeof part === 'string') return part;
|
|
1214
|
-
if (part && typeof part.text === 'string') return part.text;
|
|
1215
|
-
return '';
|
|
1216
|
-
}).filter(Boolean).join('\n');
|
|
1217
|
-
}
|
|
1218
|
-
if (result.success === false && result.error) return String(result.error);
|
|
1219
|
-
return '';
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
function safeFileStem(value) {
|
|
1223
|
-
return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// Mirrors the CLI runners' resolution: explicit config always wins; otherwise
|
|
1227
|
-
// persistence is suppressed under the node:test runner so test runs never
|
|
1228
|
-
// touch the real session store. An injected app-server spawn doubles the
|
|
1229
|
-
// app-server child only and does NOT suppress persistence.
|
|
1230
|
-
function resolvePersistence(options) {
|
|
1231
|
-
const configured = options.persistence && typeof options.persistence === 'object' ? options.persistence : {};
|
|
1232
|
-
const explicitlyConfigured = options.persistence != null;
|
|
1233
|
-
const ephemeralTestContext = Boolean(process.env.NODE_TEST_CONTEXT);
|
|
1234
|
-
const enabled = options.persistence !== null
|
|
1235
|
-
&& configured.enabled !== false
|
|
1236
|
-
&& (explicitlyConfigured || !ephemeralTestContext);
|
|
1237
|
-
const dir = path.resolve(configured.dir || path.join(os.homedir(), '.agent-terminal', 'cli-sessions'));
|
|
1238
|
-
return { enabled, dir };
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
module.exports = {
|
|
1242
|
-
createZCodeDriver,
|
|
1243
|
-
discoverZCodeCli,
|
|
1244
|
-
probeZCodeVersion,
|
|
1245
|
-
providerRuntimeEnvironment,
|
|
1246
|
-
runtimeModelForProfile: injectedRuntimeModel,
|
|
1247
|
-
toolResultText,
|
|
1248
|
-
MODE_FOR_PERMISSION,
|
|
1249
|
-
APP_SERVER_PERSIST_SCHEMA,
|
|
1250
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { writeJson } = require('../json-file');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const { createAppServerBridge } = require('../bridges/app-server-bridge');
|
|
10
|
+
const {
|
|
11
|
+
appendTextAttachments, normalizeAttachments, resolveOpenAIBaseUrl, resolveOpenAIModel,
|
|
12
|
+
} = require('../../../contracts');
|
|
13
|
+
const { stampEvent, resequenceRestoredEvents } = require('../event-stream');
|
|
14
|
+
|
|
15
|
+
// ZCode driver — speaks the "ZCode Protocol" app-server bundled with the
|
|
16
|
+
// ZCode desktop install (`resources/glm/zcode.cjs`, `app-server --stdio`).
|
|
17
|
+
// Protocol verified live against zcode CLI 0.16.5 / desktop 3.11.2:
|
|
18
|
+
//
|
|
19
|
+
// - Line-delimited JSON-RPC over stdio WITHOUT the `jsonrpc` envelope field
|
|
20
|
+
// (the same dialect as `codex app-server`, so the shared app-server bridge
|
|
21
|
+
// drives it as-is). Server-initiated request ids are strings ("server-N").
|
|
22
|
+
// - No initialize handshake. The first request is usually `session/create`:
|
|
23
|
+
// { workspace: { workspacePath, workspaceKey }, mode, runtimeModel? }
|
|
24
|
+
// and it BLOCKS on a server-initiated `session/requestRuntimePreferences`
|
|
25
|
+
// request that must be answered before the create response arrives.
|
|
26
|
+
// - Sessions, not threads: `session/create` -> `session/resume` ->
|
|
27
|
+
// `session/subscribe` { deliveryKind: 'desktop-continuous' } ->
|
|
28
|
+
// `session/send` { content } -> `session/stop` to abort. Turn lifecycle
|
|
29
|
+
// and token deltas ride `session/event` notifications (types: turn.started,
|
|
30
|
+
// model.streaming, tool.updated, permission.requested/resolved,
|
|
31
|
+
// turn.completed, session.updated, session.titleUpdated, ...).
|
|
32
|
+
// - Approvals are server-initiated `interaction/requestPermission` requests
|
|
33
|
+
// whose params carry an `options[]` array; the response must echo one of
|
|
34
|
+
// `options[].response` (allow_once / allow_project / deny).
|
|
35
|
+
// - Models come from `runtimeModel` on create/resume/send (provider kind
|
|
36
|
+
// anthropic | openai | openai-compatible, inline api keys supported).
|
|
37
|
+
// Without one the app-server requires ~/.zcode/cli/config.json to define a
|
|
38
|
+
// provider; agent-owned turns bridge the desktop's ~/.zcode/v2/config.json
|
|
39
|
+
// providers instead (see desktopRuntimeModelFor).
|
|
40
|
+
//
|
|
41
|
+
// The driver deliberately has NO CLI one-shot fallback: the bundled CLI's
|
|
42
|
+
// headless `--prompt` mode has no verified JSON event stream, so an unusable
|
|
43
|
+
// app-server surfaces honest errors instead of a fake fallback transport.
|
|
44
|
+
|
|
45
|
+
const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
|
|
46
|
+
// Workbench permission modes -> ZCode session modes
|
|
47
|
+
// (default/plan/acceptEdits/bypassPermissions -> build/plan/edit/yolo).
|
|
48
|
+
const MODE_FOR_PERMISSION = {
|
|
49
|
+
default: 'build',
|
|
50
|
+
plan: 'plan',
|
|
51
|
+
acceptEdits: 'edit',
|
|
52
|
+
bypassPermissions: 'yolo',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const RUNTIME_PREFERENCES = Object.freeze({
|
|
56
|
+
nativeSearchEnhancementsEnabled: false,
|
|
57
|
+
memoryEnabled: false,
|
|
58
|
+
askUserQuestionAutoResolutionEnabled: false,
|
|
59
|
+
modelContextBudgetStrategy: 'preflight-v1',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const APP_SERVER_PERSIST_SCHEMA = 'zcode-appserver-session/v1';
|
|
63
|
+
const MAX_PERSISTED_EVENTS = 5000;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// CLI discovery
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function defaultRegQuery(key) {
|
|
70
|
+
try {
|
|
71
|
+
const result = spawnSync('reg', ['query', key], { windowsHide: true, encoding: 'utf8', timeout: 5000 });
|
|
72
|
+
if (result.error || result.status !== 0) return null;
|
|
73
|
+
return String(result.stdout || '');
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeExists(target) {
|
|
80
|
+
try { return fs.existsSync(target); } catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeReaddir(dir) {
|
|
84
|
+
try { return fs.readdirSync(dir); } catch { return []; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function providerRuntimeEnvironment(cliPath, { env = process.env, homedir = os.homedir(), existsSync = safeExists } = {}) {
|
|
88
|
+
const output = {};
|
|
89
|
+
if (!env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE?.trim()) {
|
|
90
|
+
// Desktop bundles keep config beside glm, not inside it. Recent native
|
|
91
|
+
// CLIs otherwise resolve a development-tree fallback outside the install.
|
|
92
|
+
const dir = path.dirname(path.resolve(cliPath));
|
|
93
|
+
const bundled = [path.join(dir, 'provider/zcode-builtin.json'), path.resolve(dir, '../config/provider/zcode-builtin.json')].find(existsSync);
|
|
94
|
+
if (!bundled) return output;
|
|
95
|
+
output.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = bundled;
|
|
96
|
+
}
|
|
97
|
+
if (!env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim()) output.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE = path.join(env.ZCODE_DATA_BASE_DIR?.trim() || homedir, '.zcode/v2/provider_config.json');
|
|
98
|
+
return output;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Discovery chain for the bundled zcode CLI:
|
|
102
|
+
// 1. ZCODE_CLI_PATH env override (the .cjs itself or the install root);
|
|
103
|
+
// 2. common Windows install roots (%LOCALAPPDATA%\Programs, Program Files,
|
|
104
|
+
// Program Files (x86) on every configured drive) scanning for
|
|
105
|
+
// <root>/*[Zz][Cc]ode*/resources/glm/zcode.cjs;
|
|
106
|
+
// 3. registry App Paths (HKCU/HKLM ZCode.exe) via `reg query`;
|
|
107
|
+
// 4. ~/.zcode neighbors (last resort).
|
|
108
|
+
// Purely filesystem-based (no subprocess) so driver construction stays cheap;
|
|
109
|
+
// everything is injectable for tests (env/existsSync/readdirSync/regQuery).
|
|
110
|
+
function discoverZCodeCli(options = {}) {
|
|
111
|
+
const env = options.env || process.env;
|
|
112
|
+
const exists = options.existsSync || safeExists;
|
|
113
|
+
const readdir = options.readdirSync || safeReaddir;
|
|
114
|
+
const regQuery = options.regQuery || defaultRegQuery;
|
|
115
|
+
const home = options.homedir || os.homedir();
|
|
116
|
+
|
|
117
|
+
const candidates = [];
|
|
118
|
+
const envPath = String(env.ZCODE_CLI_PATH || '').trim();
|
|
119
|
+
if (envPath) {
|
|
120
|
+
candidates.push(envPath, path.join(envPath, 'resources', 'glm', 'zcode.cjs'));
|
|
121
|
+
}
|
|
122
|
+
const rootExprs = [
|
|
123
|
+
env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, 'Programs'),
|
|
124
|
+
env.ProgramFiles,
|
|
125
|
+
env['ProgramFiles(x86)'],
|
|
126
|
+
'C:\\Program Files',
|
|
127
|
+
'C:\\Program Files (x86)',
|
|
128
|
+
'D:\\Program Files',
|
|
129
|
+
'D:\\Program Files (x86)',
|
|
130
|
+
].filter(Boolean);
|
|
131
|
+
for (const root of rootExprs) {
|
|
132
|
+
candidates.push(path.join(root, 'ZCode', 'resources', 'glm', 'zcode.cjs'));
|
|
133
|
+
for (const name of readdir(root)) {
|
|
134
|
+
if (/^zcode/i.test(name) || /zcode$/i.test(name)) {
|
|
135
|
+
candidates.push(path.join(root, name, 'resources', 'glm', 'zcode.cjs'));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// macOS desktop layout (cheap extra candidate, harmless elsewhere).
|
|
140
|
+
candidates.push('/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs');
|
|
141
|
+
// Registry App Paths: the desktop registers ZCode.exe there on install.
|
|
142
|
+
for (const hive of ['HKCU', 'HKLM']) {
|
|
143
|
+
const output = regQuery(`${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\ZCode.exe`);
|
|
144
|
+
const match = output && output.match(/REG_SZ\s+(\S*ZCode\.exe)/i);
|
|
145
|
+
if (match) candidates.push(path.join(path.dirname(match[1]), 'resources', 'glm', 'zcode.cjs'));
|
|
146
|
+
}
|
|
147
|
+
candidates.push(
|
|
148
|
+
path.join(home, '.zcode', 'cli', 'zcode.cjs'),
|
|
149
|
+
path.join(home, '.zcode', 'zcode.cjs'),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
155
|
+
seen.add(candidate);
|
|
156
|
+
if (exists(candidate)) return { path: candidate, source: 'discovered' };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Version probe for /agents: spawns `node <zcode.cjs> --version`. Skipped under
|
|
162
|
+
// the node:test runner so unit/integration suites never spawn a real 12MB CLI
|
|
163
|
+
// bundle; production gets a real version string (cached by agent-discovery).
|
|
164
|
+
function probeZCodeVersion(cliPath) {
|
|
165
|
+
if (process.env.NODE_TEST_CONTEXT) return null;
|
|
166
|
+
try {
|
|
167
|
+
const result = spawnSync(process.execPath, [cliPath, '--version'], {
|
|
168
|
+
timeout: 10000, windowsHide: true, encoding: 'utf8',
|
|
169
|
+
});
|
|
170
|
+
if (result.error || result.status !== 0) return null;
|
|
171
|
+
const match = String(result.stdout || result.stderr || '').match(/(\d+\.\d+[\w.-]*)/);
|
|
172
|
+
return match ? match[1] : null;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Driver factory
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function createZCodeDriver(options = {}) {
|
|
183
|
+
const injected = Boolean(options.appServerSpawn);
|
|
184
|
+
// Under the node:test runner an un-injected driver must stay a deterministic
|
|
185
|
+
// 501 placeholder (agent-drivers.test.js): discovery on a dev machine with
|
|
186
|
+
// ZCode installed would otherwise spawn real app-server children in tests.
|
|
187
|
+
const testContext = Boolean(process.env.NODE_TEST_CONTEXT);
|
|
188
|
+
let cli = null;
|
|
189
|
+
if (injected) {
|
|
190
|
+
cli = { path: options.cliPath || '<injected app-server>', source: 'injected' };
|
|
191
|
+
} else if (!testContext) {
|
|
192
|
+
cli = options.cli || discoverZCodeCli({ env: options.env });
|
|
193
|
+
}
|
|
194
|
+
if (!cli) {
|
|
195
|
+
const reason = testContext
|
|
196
|
+
? 'ZCode app-server transport is not scripted in this test context; inject an appServerSpawn double to drive it'
|
|
197
|
+
: 'ZCode desktop was not found on this machine. Install the ZCode desktop app (it bundles the zcode CLI used by the app-server transport), or set ZCODE_CLI_PATH to the installed resources/glm/zcode.cjs';
|
|
198
|
+
return createUnavailableZCodeDriver(reason);
|
|
199
|
+
}
|
|
200
|
+
return createAppServerZCodeDriver(options, cli);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Honest 501 shell (the pre-driver behavior, kept for missing installs and
|
|
204
|
+
// unscripted test contexts).
|
|
205
|
+
function createUnavailableZCodeDriver(reason) {
|
|
206
|
+
return {
|
|
207
|
+
agentId: 'zcode',
|
|
208
|
+
unavailableReason: reason,
|
|
209
|
+
startChatSession() {
|
|
210
|
+
throw Object.assign(new Error(this.unavailableReason), { status: 501 });
|
|
211
|
+
},
|
|
212
|
+
listSessions() { return []; },
|
|
213
|
+
getState() { return null; },
|
|
214
|
+
getSession() { return null; },
|
|
215
|
+
getSessionTokenUsage() { return { used: 0, hasUsage: false }; },
|
|
216
|
+
subscribe() { throw Object.assign(new Error('session not found'), { status: 404 }); },
|
|
217
|
+
async sendInput() { throw Object.assign(new Error(this.unavailableReason), { status: 501 }); },
|
|
218
|
+
resolvePermission() { throw Object.assign(new Error(this.unavailableReason), { status: 501 }); },
|
|
219
|
+
abort() {},
|
|
220
|
+
deleteSession() { return false; },
|
|
221
|
+
async listSupportedCommands() { return []; },
|
|
222
|
+
findClaudeExecutableForSdk() { return null; },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// app-server transport
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
function createAppServerZCodeDriver(options, cli) {
|
|
231
|
+
const log = typeof options.log === 'function' ? options.log : () => {};
|
|
232
|
+
const sessions = new Map();
|
|
233
|
+
const zcodeSessions = new Map(); // zcode sessionId (sess_...) -> driver sessionId
|
|
234
|
+
const persist = resolvePersistence(options);
|
|
235
|
+
let diskIndex = null;
|
|
236
|
+
let nextId = 1;
|
|
237
|
+
let nextPermissionId = 1;
|
|
238
|
+
let bridge = null;
|
|
239
|
+
let protocolMode = null;
|
|
240
|
+
let profileRuntime = null;
|
|
241
|
+
let profileRuntimeSignature = null;
|
|
242
|
+
let bridgeSetup = Promise.resolve();
|
|
243
|
+
let destroyed = false;
|
|
244
|
+
let desktopProviderCache; // lazily-read ~/.zcode/v2 providers (agent-owned auth)
|
|
245
|
+
|
|
246
|
+
function summary(session) {
|
|
247
|
+
return {
|
|
248
|
+
sessionId: session.sessionId,
|
|
249
|
+
projectSlug: session.projectSlug,
|
|
250
|
+
state: session.state,
|
|
251
|
+
model: session.model,
|
|
252
|
+
selectedModel: session.selectedModel,
|
|
253
|
+
aiProfileId: session.aiProfileId,
|
|
254
|
+
permissionMode: session.permissionMode,
|
|
255
|
+
turns: session.turns,
|
|
256
|
+
startedAt: session.startedAt,
|
|
257
|
+
// Live activity marker: turn end refreshes it (see turns += 1 site).
|
|
258
|
+
updatedAt: session.updatedAt || session.startedAt,
|
|
259
|
+
endedAt: session.endedAt,
|
|
260
|
+
pendingPermission: session.pendingPermission,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emit(session, event) {
|
|
265
|
+
stampEvent(session, event);
|
|
266
|
+
session.outputBuffer.push(event);
|
|
267
|
+
if (session.outputBuffer.length > MAX_PERSISTED_EVENTS) session.outputBuffer.shift();
|
|
268
|
+
for (const listener of session.listeners) {
|
|
269
|
+
try { listener(event); } catch { /* listener errors never break the turn */ }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function setState(session, state) {
|
|
274
|
+
session.state = state;
|
|
275
|
+
emit(session, { type: 'claude/state', state, turn: session.turns });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- persistence (records share ~/.agent-terminal/cli-sessions with the
|
|
279
|
+
// other CLI drivers but carry their own schema; only this driver claims
|
|
280
|
+
// them). The zcode session id (sess_...) is stored as threadId so a restart
|
|
281
|
+
// resumes the server-side conversation instead of starting a new one. ----
|
|
282
|
+
|
|
283
|
+
function recordFile(sessionId) {
|
|
284
|
+
return path.join(persist.dir, `${safeFileStem(sessionId)}.json`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function readRecord(file) {
|
|
288
|
+
try {
|
|
289
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
290
|
+
if (parsed && parsed.schema === APP_SERVER_PERSIST_SCHEMA && parsed.agentId === 'zcode'
|
|
291
|
+
&& parsed.transport === 'app-server' && typeof parsed.sessionId === 'string' && parsed.sessionId) return parsed;
|
|
292
|
+
} catch { /* corrupt or foreign records are skipped, never fatal */ }
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function syncDiskIndex() {
|
|
297
|
+
if (!persist.enabled) return null;
|
|
298
|
+
if (!diskIndex) diskIndex = new Map();
|
|
299
|
+
else diskIndex.clear();
|
|
300
|
+
let names = [];
|
|
301
|
+
try { names = fs.readdirSync(persist.dir).filter(name => name.endsWith('.json')); } catch { return diskIndex; }
|
|
302
|
+
for (const name of names) {
|
|
303
|
+
const file = path.join(persist.dir, name);
|
|
304
|
+
const record = readRecord(file);
|
|
305
|
+
if (record) diskIndex.set(record.sessionId, { file, summary: persistedSummary(record) });
|
|
306
|
+
}
|
|
307
|
+
return diskIndex;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function persistedSummary(record) {
|
|
311
|
+
return {
|
|
312
|
+
sessionId: record.sessionId,
|
|
313
|
+
projectSlug: record.projectSlug,
|
|
314
|
+
state: 'idle', // a restart killed any live turn
|
|
315
|
+
model: record.model || null,
|
|
316
|
+
selectedModel: record.selectedModel || null,
|
|
317
|
+
aiProfileId: record.aiProfileId || null,
|
|
318
|
+
permissionMode: record.permissionMode || 'default',
|
|
319
|
+
turns: Number(record.turns) || 0,
|
|
320
|
+
startedAt: record.startedAt || '',
|
|
321
|
+
updatedAt: record.updatedAt || record.startedAt || '',
|
|
322
|
+
endedAt: record.endedAt || null,
|
|
323
|
+
pendingPermission: null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function persistSession(session) {
|
|
328
|
+
if (!persist.enabled) return;
|
|
329
|
+
try {
|
|
330
|
+
const file = recordFile(session.sessionId);
|
|
331
|
+
writeJson(file, {
|
|
332
|
+
schema: APP_SERVER_PERSIST_SCHEMA,
|
|
333
|
+
agentId: 'zcode',
|
|
334
|
+
transport: 'app-server',
|
|
335
|
+
sessionId: session.sessionId,
|
|
336
|
+
projectSlug: session.projectSlug,
|
|
337
|
+
projectPath: session.projectPath,
|
|
338
|
+
state: session.state,
|
|
339
|
+
model: session.model,
|
|
340
|
+
selectedModel: session.selectedModel,
|
|
341
|
+
aiProfileId: session.aiProfileId,
|
|
342
|
+
permissionMode: session.permissionMode,
|
|
343
|
+
title: session.title || null,
|
|
344
|
+
turns: session.turns,
|
|
345
|
+
startedAt: session.startedAt,
|
|
346
|
+
endedAt: session.endedAt,
|
|
347
|
+
// The ZCode Protocol session id: session/resume needs it after a restart.
|
|
348
|
+
threadId: session.zcodeSessionId || null,
|
|
349
|
+
lastResult: session.lastResult || '',
|
|
350
|
+
usage: { ...session.usage },
|
|
351
|
+
peakTurnTokens: session.peakTurnTokens || 0,
|
|
352
|
+
eventSequence: Number(session.eventSequence) || 0,
|
|
353
|
+
// Old numeric cursors predate this session's numbering era; the
|
|
354
|
+
// boundary must survive restarts, not just the first restore.
|
|
355
|
+
legacySequenceReset: session.legacySequenceReset === true,
|
|
356
|
+
events: session.outputBuffer.slice(-MAX_PERSISTED_EVENTS),
|
|
357
|
+
updatedAt: new Date().toISOString(),
|
|
358
|
+
});
|
|
359
|
+
if (diskIndex) diskIndex.set(session.sessionId, { file: recordFile(session.sessionId), summary: summary(session) });
|
|
360
|
+
} catch {
|
|
361
|
+
// Read-only home or full disk degrades to memory-only behavior.
|
|
362
|
+
persist.enabled = false;
|
|
363
|
+
diskIndex = null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function removeRecord(sessionId) {
|
|
368
|
+
if (diskIndex) diskIndex.delete(sessionId);
|
|
369
|
+
if (!persist.enabled) return;
|
|
370
|
+
try {
|
|
371
|
+
const file = recordFile(sessionId);
|
|
372
|
+
if (fs.existsSync(file) && readRecord(file)) fs.rmSync(file, { force: true });
|
|
373
|
+
} catch { /* already gone */ }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function restoreSession(id) {
|
|
377
|
+
if (!persist.enabled) return null;
|
|
378
|
+
const index = syncDiskIndex();
|
|
379
|
+
const entry = index && index.get(id);
|
|
380
|
+
if (!entry) return null;
|
|
381
|
+
const record = readRecord(entry.file);
|
|
382
|
+
if (!record || record.sessionId !== id) { index.delete(id); return null; }
|
|
383
|
+
// Legacy records (written before stable event identity) carry unstamped
|
|
384
|
+
// events: renumber the retained tail explicitly and continue from there.
|
|
385
|
+
const restored = resequenceRestoredEvents(
|
|
386
|
+
Array.isArray(record.events) ? record.events.slice(-MAX_PERSISTED_EVENTS) : [],
|
|
387
|
+
record.eventSequence,
|
|
388
|
+
);
|
|
389
|
+
const session = {
|
|
390
|
+
sessionId: record.sessionId,
|
|
391
|
+
projectSlug: record.projectSlug,
|
|
392
|
+
projectPath: record.projectPath,
|
|
393
|
+
state: 'idle',
|
|
394
|
+
model: record.model || null,
|
|
395
|
+
selectedModel: record.selectedModel || null,
|
|
396
|
+
aiProfileId: record.aiProfileId || null,
|
|
397
|
+
permissionMode: record.permissionMode || 'default',
|
|
398
|
+
effort: null,
|
|
399
|
+
title: record.title || null,
|
|
400
|
+
turns: Number(record.turns) || 0,
|
|
401
|
+
startedAt: record.startedAt || new Date().toISOString(),
|
|
402
|
+
updatedAt: record.updatedAt || record.startedAt || new Date().toISOString(),
|
|
403
|
+
endedAt: record.endedAt || null,
|
|
404
|
+
pendingPermission: null,
|
|
405
|
+
pendingApproval: null,
|
|
406
|
+
outputBuffer: restored.events,
|
|
407
|
+
eventSequence: restored.eventSequence,
|
|
408
|
+
legacySequenceReset: restored.legacyRenumbered === true || record.legacySequenceReset === true,
|
|
409
|
+
listeners: new Set(),
|
|
410
|
+
zcodeSessionId: record.threadId || null,
|
|
411
|
+
currentTurn: null,
|
|
412
|
+
lastResult: record.lastResult || '',
|
|
413
|
+
lastDiag: '',
|
|
414
|
+
usage: {
|
|
415
|
+
inputTokens: Number(record.usage && record.usage.inputTokens) || 0,
|
|
416
|
+
outputTokens: Number(record.usage && record.usage.outputTokens) || 0,
|
|
417
|
+
cachedTokens: Number(record.usage && record.usage.cachedTokens) || 0,
|
|
418
|
+
},
|
|
419
|
+
peakTurnTokens: Number(record.peakTurnTokens) || 0,
|
|
420
|
+
turnTokenUsage: null,
|
|
421
|
+
turnPromise: Promise.resolve(),
|
|
422
|
+
};
|
|
423
|
+
sessions.set(id, session);
|
|
424
|
+
return session;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const runner = {
|
|
428
|
+
agentId: 'zcode',
|
|
429
|
+
|
|
430
|
+
startChatSession({ slug, projectPath, aiProfile, permissionMode, effort, title }) {
|
|
431
|
+
if (!slug) throw new Error('slug required');
|
|
432
|
+
if (!projectPath) throw new Error('projectPath required');
|
|
433
|
+
const sessionId = nextDriverSessionId();
|
|
434
|
+
const session = {
|
|
435
|
+
sessionId,
|
|
436
|
+
projectSlug: slug,
|
|
437
|
+
projectPath,
|
|
438
|
+
state: 'idle',
|
|
439
|
+
model: modelForProfile(aiProfile),
|
|
440
|
+
selectedModel: aiProfile && (aiProfile.mainModel || aiProfile.models && aiProfile.models.default) || '',
|
|
441
|
+
aiProfileId: aiProfile && aiProfile.id,
|
|
442
|
+
permissionMode: permissionMode || 'default',
|
|
443
|
+
effort: effort || null,
|
|
444
|
+
title: title || null,
|
|
445
|
+
turns: 0,
|
|
446
|
+
startedAt: new Date().toISOString(),
|
|
447
|
+
endedAt: null,
|
|
448
|
+
pendingPermission: null,
|
|
449
|
+
pendingApproval: null,
|
|
450
|
+
outputBuffer: [],
|
|
451
|
+
eventSequence: 0,
|
|
452
|
+
listeners: new Set(),
|
|
453
|
+
zcodeSessionId: null,
|
|
454
|
+
currentTurn: null,
|
|
455
|
+
lastResult: '',
|
|
456
|
+
lastDiag: '',
|
|
457
|
+
usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
|
|
458
|
+
peakTurnTokens: 0,
|
|
459
|
+
turnTokenUsage: null,
|
|
460
|
+
turnPromise: Promise.resolve(),
|
|
461
|
+
};
|
|
462
|
+
sessions.set(sessionId, session);
|
|
463
|
+
emit(session, {
|
|
464
|
+
type: 'claude/session-ready',
|
|
465
|
+
model: session.model,
|
|
466
|
+
aiProfileId: session.aiProfileId,
|
|
467
|
+
permissionMode: session.permissionMode,
|
|
468
|
+
message: 'zcode session is ready. Send a message to start the first turn.',
|
|
469
|
+
});
|
|
470
|
+
// Write the record immediately so a session with no turns yet still
|
|
471
|
+
// shows up in the sidebar after a restart.
|
|
472
|
+
persistSession(session);
|
|
473
|
+
return { sessionId };
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
listSessions(filter = {}) {
|
|
477
|
+
const byId = new Map();
|
|
478
|
+
const index = syncDiskIndex();
|
|
479
|
+
if (index) {
|
|
480
|
+
for (const entry of index.values()) {
|
|
481
|
+
if (filter.projectSlug && entry.summary.projectSlug !== filter.projectSlug) continue;
|
|
482
|
+
byId.set(entry.summary.sessionId, entry.summary);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const session of sessions.values()) {
|
|
486
|
+
if (filter.projectSlug && session.projectSlug !== filter.projectSlug) continue;
|
|
487
|
+
byId.set(session.sessionId, summary(session));
|
|
488
|
+
}
|
|
489
|
+
return [...byId.values()];
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
getState(id) {
|
|
493
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
494
|
+
if (!session) return null;
|
|
495
|
+
return {
|
|
496
|
+
...summary(session),
|
|
497
|
+
listenerCount: session.listeners.size,
|
|
498
|
+
bufferedEvents: session.outputBuffer.length,
|
|
499
|
+
};
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
getSession(id) { return sessions.get(id) || restoreSession(id); },
|
|
503
|
+
|
|
504
|
+
getSessionTokenUsage(id) {
|
|
505
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
506
|
+
if (!session) return { used: 0, hasUsage: false };
|
|
507
|
+
const { inputTokens, outputTokens } = session.usage;
|
|
508
|
+
const used = session.peakTurnTokens || 0;
|
|
509
|
+
return {
|
|
510
|
+
used,
|
|
511
|
+
inputTokens,
|
|
512
|
+
outputTokens,
|
|
513
|
+
hasUsage: Boolean(used || inputTokens || outputTokens),
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
subscribe(id, listener) {
|
|
518
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
519
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
520
|
+
for (const event of session.outputBuffer) listener(event);
|
|
521
|
+
session.listeners.add(listener);
|
|
522
|
+
return () => session.listeners.delete(listener);
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
async sendInput(id, text, profile, sendOptions = {}) {
|
|
526
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
527
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
528
|
+
if (ACTIVE_STATES.has(session.state)) {
|
|
529
|
+
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
530
|
+
}
|
|
531
|
+
const value = String(text || '').trim();
|
|
532
|
+
const attachments = normalizeAttachments(sendOptions.attachments || []);
|
|
533
|
+
if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
|
|
534
|
+
if (profile) {
|
|
535
|
+
session.model = modelForProfile(profile);
|
|
536
|
+
session.selectedModel = profile.mainModel || profile.models && profile.models.default || '';
|
|
537
|
+
}
|
|
538
|
+
if (sendOptions.permissionMode) session.permissionMode = sendOptions.permissionMode;
|
|
539
|
+
session.lastPromptText = value;
|
|
540
|
+
|
|
541
|
+
let runtimeModel = null;
|
|
542
|
+
try {
|
|
543
|
+
// Invalid injected profiles (no base URL) throw here and surface as a
|
|
544
|
+
// turn error, mirroring the codex driver's bridgeSpecFor contract.
|
|
545
|
+
runtimeModel = runtimeModelFor(profile);
|
|
546
|
+
session.preparing = true;
|
|
547
|
+
session.runtimeSignature = profile?.apiKey ? runtimeModel.revision : 'agent-owned';
|
|
548
|
+
await ensureBridge(profile, session);
|
|
549
|
+
await ensureZcodeSession(session, runtimeModel);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
emit(session, { type: 'claude/error', message: `zcode app-server unavailable: ${error.message}`.slice(0, 500) });
|
|
552
|
+
setState(session, 'idle');
|
|
553
|
+
return { started: false };
|
|
554
|
+
} finally { session.preparing = false; }
|
|
555
|
+
|
|
556
|
+
emit(session, {
|
|
557
|
+
type: 'claude/user-prompt',
|
|
558
|
+
text: value,
|
|
559
|
+
attachments: (sendOptions.displayAttachments || []).map(({ id: attachmentId, name, mediaType, size, width, height, url }) => ({ id: attachmentId, name, mediaType, size, width, height, url })),
|
|
560
|
+
});
|
|
561
|
+
setState(session, 'running');
|
|
562
|
+
// Until the first session/event lands the UI would show dead air
|
|
563
|
+
// (reasoning models think for tens of seconds first) — announce it.
|
|
564
|
+
setState(session, 'thinking');
|
|
565
|
+
try {
|
|
566
|
+
// Image attachments have no verified ZCode Protocol wire shape yet
|
|
567
|
+
// (session/send attachments are an opaque record); text attachments
|
|
568
|
+
// ride the prompt text, images are refused with an honest notice.
|
|
569
|
+
const images = attachments.filter(item => item.mediaType.startsWith('image/'));
|
|
570
|
+
if (images.length) {
|
|
571
|
+
emitDiag(session, { type: 'claude/stderr', text: 'the zcode app-server driver does not support image attachments yet' });
|
|
572
|
+
}
|
|
573
|
+
session.currentTurn = {
|
|
574
|
+
turnId: null,
|
|
575
|
+
announcedText: new Set(),
|
|
576
|
+
streamedText: new Set(),
|
|
577
|
+
announcedThinking: new Set(),
|
|
578
|
+
streamedThinking: new Set(),
|
|
579
|
+
sawEvent: false,
|
|
580
|
+
};
|
|
581
|
+
session.turnPromise = new Promise(resolve => { session.currentTurn.resolve = resolve; });
|
|
582
|
+
await bridge.request('session/send', {
|
|
583
|
+
sessionId: session.zcodeSessionId,
|
|
584
|
+
content: appendTextAttachments(value, attachments) + (sendOptions.parentContext || '') + (sendOptions.knowledgeContext || ''),
|
|
585
|
+
...(protocolMode === 'selection' ? (session.nativeModel ? { modelSelection: session.nativeModel } : {}) : runtimeModel ? { runtimeModel } : {}),
|
|
586
|
+
}, { timeoutMs: 30000 });
|
|
587
|
+
return { started: true };
|
|
588
|
+
} catch (error) {
|
|
589
|
+
finishTurn(session, { resultType: 'error', response: '' }, `zcode turn failed to start: ${error.message}`);
|
|
590
|
+
return { started: false };
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
resolvePermission(id, requestId, decision) {
|
|
595
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
596
|
+
if (!session || !session.pendingApproval || session.pendingApproval.requestId !== requestId) {
|
|
597
|
+
throw Object.assign(new Error('permission request not found or already resolved'), { status: 409 });
|
|
598
|
+
}
|
|
599
|
+
const allow = decision && decision.allow === true;
|
|
600
|
+
const approval = session.pendingApproval;
|
|
601
|
+
session.pendingApproval = null;
|
|
602
|
+
session.pendingPermission = null;
|
|
603
|
+
const response = allow ? approval.allowResponse : approval.denyResponse;
|
|
604
|
+
emit(session, {
|
|
605
|
+
type: 'claude/permission-resolved',
|
|
606
|
+
requestId,
|
|
607
|
+
allow,
|
|
608
|
+
decision: response && response.decision,
|
|
609
|
+
resolvedAt: new Date().toISOString(),
|
|
610
|
+
});
|
|
611
|
+
setState(session, 'running');
|
|
612
|
+
approval.resolve(response);
|
|
613
|
+
return { ok: true, started: true, decision: response && response.decision };
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
updateSelection(id, profile, selectedModel) {
|
|
617
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
618
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
619
|
+
if (ACTIVE_STATES.has(session.state)) {
|
|
620
|
+
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
621
|
+
}
|
|
622
|
+
const fromAiProfileId = session.aiProfileId || null;
|
|
623
|
+
const fromModel = session.selectedModel || session.model || null;
|
|
624
|
+
session.aiProfileId = profile && profile.id || null;
|
|
625
|
+
session.selectedModel = selectedModel || (profile && (profile.mainModel || profile.models && profile.models.default)) || null;
|
|
626
|
+
session.model = modelForProfile(profile);
|
|
627
|
+
emit(session, {
|
|
628
|
+
type: 'claude/selection-changed',
|
|
629
|
+
fromAiProfileId,
|
|
630
|
+
toAiProfileId: session.aiProfileId,
|
|
631
|
+
fromModel,
|
|
632
|
+
toModel: session.selectedModel,
|
|
633
|
+
});
|
|
634
|
+
persistSession(session);
|
|
635
|
+
return summary(session);
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
abort(id, reason) {
|
|
639
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
640
|
+
if (!session) return;
|
|
641
|
+
session.abortedByUser = true;
|
|
642
|
+
denyPendingApproval(session, 'aborted');
|
|
643
|
+
const currentTurn = session.currentTurn;
|
|
644
|
+
if (currentTurn && bridge && bridge.state === 'ready' && session.zcodeSessionId) {
|
|
645
|
+
// Ask the app-server to interrupt the live turn; a failure falls back
|
|
646
|
+
// to the local turn teardown below (the bridge recycles lazily).
|
|
647
|
+
bridge.request('session/stop', { sessionId: session.zcodeSessionId }, { timeoutMs: 5000 })
|
|
648
|
+
.catch(() => recycleBridge('session/stop failed'));
|
|
649
|
+
}
|
|
650
|
+
if (!['idle', 'ended'].includes(session.state)) {
|
|
651
|
+
session.state = 'aborted';
|
|
652
|
+
emit(session, { type: 'claude/aborted', reason: reason || 'user-abort' });
|
|
653
|
+
finishTurn(session, { resultType: 'cancelled', response: '' });
|
|
654
|
+
}
|
|
655
|
+
persistSession(session);
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
deleteSession(id) {
|
|
659
|
+
const session = sessions.get(id) || restoreSession(id);
|
|
660
|
+
if (!session) {
|
|
661
|
+
const index = syncDiskIndex();
|
|
662
|
+
if (index && index.has(id)) { removeRecord(id); return true; }
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
if (ACTIVE_STATES.has(session.state)) runner.abort(id);
|
|
666
|
+
denyPendingApproval(session, 'deleted');
|
|
667
|
+
if (session.zcodeSessionId) zcodeSessions.delete(session.zcodeSessionId);
|
|
668
|
+
const deleted = sessions.delete(id);
|
|
669
|
+
removeRecord(id);
|
|
670
|
+
return deleted;
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
async listSupportedCommands() { return []; },
|
|
674
|
+
|
|
675
|
+
findClaudeExecutableForSdk() { return null; },
|
|
676
|
+
|
|
677
|
+
async destroy() {
|
|
678
|
+
destroyed = true;
|
|
679
|
+
for (const session of sessions.values()) if (ACTIVE_STATES.has(session.state)) runner.abort(session.sessionId);
|
|
680
|
+
await bridge?.close(); bridge = null;
|
|
681
|
+
await bridgeSetup.catch(() => {});
|
|
682
|
+
await bridge?.close(); bridge = null;
|
|
683
|
+
await profileRuntime?.close(); profileRuntime = null;
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
// Test seams.
|
|
687
|
+
get transportMode() { return 'app-server'; },
|
|
688
|
+
get bridgeState() { return bridge ? bridge.state : 'uncreated'; },
|
|
689
|
+
get cliPath() { return cli.path; },
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
// -- bridge lifecycle ----------------------------------------------------
|
|
693
|
+
|
|
694
|
+
function ensureBridge(profile, session) {
|
|
695
|
+
const preparing = bridgeSetup.then(() => prepareBridge(profile, session));
|
|
696
|
+
bridgeSetup = preparing.catch(() => {});
|
|
697
|
+
return preparing;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function prepareBridge(profile, session) {
|
|
701
|
+
if (destroyed) throw new Error('ZCode 工作台正在关闭。');
|
|
702
|
+
if (protocolMode === 'selection' && profileRuntimeSignature !== session.runtimeSignature) {
|
|
703
|
+
if ([...sessions.values()].some(other => other !== session && (other.preparing || ACTIVE_STATES.has(other.state)) && other.runtimeSignature !== session.runtimeSignature)) throw new Error('另一个 ZCode 任务正在使用不同的模型配置,请等待任务结束。');
|
|
704
|
+
if (profileRuntimeSignature !== null || profile?.apiKey) { await bridge?.close(); bridge = null; }
|
|
705
|
+
await profileRuntime?.close(); profileRuntime = null;
|
|
706
|
+
if (profile?.apiKey) profileRuntime = await require('./zcode-profile-runtime').createZCodeProfileRuntime(injectedRuntimeModel(profile));
|
|
707
|
+
profileRuntimeSignature = session.runtimeSignature;
|
|
708
|
+
}
|
|
709
|
+
if (destroyed) throw new Error('ZCode 工作台正在关闭。');
|
|
710
|
+
if (bridge && bridge.state === 'closed') { bridge = null; }
|
|
711
|
+
if (bridge && bridge.state === 'crashed') { recycleBridge('bridge exhausted its restart budget'); }
|
|
712
|
+
if (!bridge) {
|
|
713
|
+
bridge = createAppServerBridge({
|
|
714
|
+
// The bundled CLI is a node bundle: spawn it through the running node
|
|
715
|
+
// runtime with the app-server stdio subcommand.
|
|
716
|
+
command: options.nodePath || process.execPath,
|
|
717
|
+
args: [cli.path, 'app-server', '--stdio'],
|
|
718
|
+
env: { ...providerRuntimeEnvironment(cli.path, { env: options.env || process.env }), ...(profileRuntime ? { ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: profileRuntime.file } : {}), ...(options.knowledgeContextProvided ? { KB_KNOWLEDGE_CONTEXT: '0' } : {}), ...(options.conversationCaptureProvided ? { AI_CODING_EVENT_BRIDGE_CAPTURE: '0' } : {}) },
|
|
719
|
+
spawn: options.appServerSpawn,
|
|
720
|
+
onEvent: handleNotification,
|
|
721
|
+
onServerRequest: handleServerRequest,
|
|
722
|
+
onUnexpectedExit: handleBridgeCrash,
|
|
723
|
+
idleTimeoutMs: options.idleTimeoutMs !== undefined ? options.idleTimeoutMs : 10 * 60 * 1000,
|
|
724
|
+
requestTimeoutMs: options.requestTimeoutMs !== undefined ? options.requestTimeoutMs : 120000,
|
|
725
|
+
restartBackoffMs: options.restartBackoffMs,
|
|
726
|
+
log,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
await bridge.start();
|
|
730
|
+
if (protocolMode === null) {
|
|
731
|
+
try { const capabilities = await bridge.request('runtime/capabilities', {}, { timeoutMs: 10000 }); protocolMode = capabilities?.independentPlanState === true ? 'selection' : 'runtime-model'; }
|
|
732
|
+
catch (error) { if (error.rpcCode !== -32601) throw error; protocolMode = 'runtime-model'; }
|
|
733
|
+
if (protocolMode === 'selection') return prepareBridge(profile, session);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function recycleBridge(reason) {
|
|
738
|
+
log(`recycling zcode app-server bridge: ${reason}`);
|
|
739
|
+
if (bridge) bridge.close().catch(() => {});
|
|
740
|
+
bridge = null;
|
|
741
|
+
failoverSessions(`zcode app-server exited: ${reason}`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// The app-server died mid-flight: turn.completed will never arrive, so
|
|
745
|
+
// every live turn is finished with an error here (the bridge restarts
|
|
746
|
+
// itself and the next turn retries with a fresh session/resume).
|
|
747
|
+
function handleBridgeCrash(error) {
|
|
748
|
+
failoverSessions(`zcode app-server exited unexpectedly: ${error && error.message || 'crashed'}`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function failoverSessions(reason) {
|
|
752
|
+
for (const session of sessions.values()) {
|
|
753
|
+
denyPendingApproval(session, reason);
|
|
754
|
+
if (!session.currentTurn) continue;
|
|
755
|
+
finishTurn(session, { resultType: 'error', response: '' }, String(reason).slice(0, 500));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// -- zcode session lifecycle ----------------------------------------------
|
|
760
|
+
|
|
761
|
+
async function ensureZcodeSession(session, runtimeModel) {
|
|
762
|
+
if (session.zcodeSessionId) {
|
|
763
|
+
try {
|
|
764
|
+
const resumed = await bridge.request('session/resume', {
|
|
765
|
+
sessionId: session.zcodeSessionId,
|
|
766
|
+
...(protocolMode !== 'selection' && runtimeModel ? { runtimeModel } : {}),
|
|
767
|
+
}, { timeoutMs: 30000 });
|
|
768
|
+
if (protocolMode === 'selection') { session.nativeModel = resumed.settings?.model?.current || session.nativeModel || null; await selectNativeModel(session); }
|
|
769
|
+
zcodeSessions.set(session.zcodeSessionId, session.sessionId);
|
|
770
|
+
await subscribeZcodeSession(session);
|
|
771
|
+
return;
|
|
772
|
+
} catch (error) {
|
|
773
|
+
// The app-server child restarted or rolled the session away; fall
|
|
774
|
+
// through to a fresh one (the client-side history stays).
|
|
775
|
+
zcodeSessions.delete(session.zcodeSessionId);
|
|
776
|
+
session.zcodeSessionId = null;
|
|
777
|
+
log(`zcode session resume failed, creating a new one: ${error.message}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const result = await bridge.request('session/create', {
|
|
781
|
+
workspace: { workspacePath: session.projectPath, workspaceKey: session.projectPath },
|
|
782
|
+
mode: MODE_FOR_PERMISSION[session.permissionMode] || 'build',
|
|
783
|
+
...(protocolMode === 'selection' ? { titleGenerationEnabled: false } : runtimeModel ? { runtimeModel } : {}),
|
|
784
|
+
}, { timeoutMs: 60000 });
|
|
785
|
+
const zcodeId = result && result.session && (result.session.sessionId || result.session.id);
|
|
786
|
+
if (!zcodeId) throw new Error('zcode app-server did not return a session');
|
|
787
|
+
session.zcodeSessionId = zcodeId;
|
|
788
|
+
if (protocolMode === 'selection') { session.nativeModel = result.settings?.model?.current || null; await selectNativeModel(session); }
|
|
789
|
+
zcodeSessions.set(zcodeId, session.sessionId);
|
|
790
|
+
if (result.session && result.session.title && !session.title) session.title = result.session.title;
|
|
791
|
+
await subscribeZcodeSession(session);
|
|
792
|
+
persistSession(session);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function selectNativeModel(session) {
|
|
796
|
+
if (profileRuntime) session.nativeModel = profileRuntime.selection;
|
|
797
|
+
else if (session.selectedModel) {
|
|
798
|
+
const slash = session.selectedModel.indexOf('/');
|
|
799
|
+
if (slash > 0) session.nativeModel = { providerId: session.selectedModel.slice(0, slash), modelId: session.selectedModel.slice(slash + 1), options: { reasoningLevel: 'enabled' } };
|
|
800
|
+
else if (session.nativeModel) session.nativeModel = { ...session.nativeModel, modelId: session.selectedModel };
|
|
801
|
+
}
|
|
802
|
+
await bridge.request('session/setMode', { sessionId: session.zcodeSessionId, mode: MODE_FOR_PERMISSION[session.permissionMode] || 'build' }, { timeoutMs: 10000 });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function subscribeZcodeSession(session) {
|
|
806
|
+
await bridge.request('session/subscribe', {
|
|
807
|
+
sessionId: session.zcodeSessionId,
|
|
808
|
+
deliveryKind: 'desktop-continuous',
|
|
809
|
+
}, { timeoutMs: 30000 });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// -- notifications ---------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
function handleNotification({ method, params }) {
|
|
815
|
+
if (!params) return;
|
|
816
|
+
const session = routeZcodeSession(params.sessionId);
|
|
817
|
+
if (!session) return; // notifications for unknown sessions are ignored
|
|
818
|
+
if (method === 'session/event') return onSessionEvent(session, params);
|
|
819
|
+
if (method === 'state.updated') {
|
|
820
|
+
// status patches (running/idle) only mark liveness here; turn completion
|
|
821
|
+
// is authoritative on the session/event stream.
|
|
822
|
+
markActivity(session);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
// computer-use/operation-event, v4/telemetry/event, process/*, ... are noise.
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function routeZcodeSession(zcodeSessionId) {
|
|
829
|
+
if (!zcodeSessionId) return null;
|
|
830
|
+
const driverId = zcodeSessions.get(zcodeSessionId);
|
|
831
|
+
return driverId ? sessions.get(driverId) : null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function markActivity(session) {
|
|
835
|
+
const turn = session.currentTurn;
|
|
836
|
+
if (!turn) return;
|
|
837
|
+
if (!turn.sawEvent) {
|
|
838
|
+
turn.sawEvent = true;
|
|
839
|
+
if (session.state === 'thinking') setState(session, 'running');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function onSessionEvent(session, params) {
|
|
844
|
+
markActivity(session);
|
|
845
|
+
const turn = session.currentTurn;
|
|
846
|
+
if (!turn) return; // events outside a driver-tracked turn (e.g. background) are ignored
|
|
847
|
+
const { type, payload } = params;
|
|
848
|
+
if (params.turnId && !turn.turnId) turn.turnId = params.turnId;
|
|
849
|
+
if (turn.turnId && params.turnId && params.turnId !== turn.turnId) return; // stale turn
|
|
850
|
+
|
|
851
|
+
switch (type) {
|
|
852
|
+
case 'turn.started': return; // announced locally at send time
|
|
853
|
+
case 'model.streaming': return onModelStreaming(session, payload || {});
|
|
854
|
+
case 'tool.updated': return onToolUpdated(session, payload || {});
|
|
855
|
+
case 'turn.completed': return finishTurn(session, payload || {});
|
|
856
|
+
case 'turn.failed': return finishTurn(session, { resultType: 'error', response: '' }, payload?.error?.message || payload?.error?.code || 'ZCode 任务失败');
|
|
857
|
+
case 'session.titleUpdated': {
|
|
858
|
+
if (payload && payload.title) session.title = String(payload.title).slice(0, 200);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
case 'permission.requested':
|
|
862
|
+
case 'permission.resolved':
|
|
863
|
+
return; // approvals are driven by the interaction/requestPermission RPC
|
|
864
|
+
default: return; // session.updated, checkpoint.*, streamRecovery.*, ...
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function onModelStreaming(session, payload) {
|
|
869
|
+
const turn = session.currentTurn;
|
|
870
|
+
if (!turn) return;
|
|
871
|
+
const { kind } = payload;
|
|
872
|
+
const messageId = payload.assistantMessageId || `text-${session.turns}`;
|
|
873
|
+
if (kind === 'text_start') {
|
|
874
|
+
turn.announcedText.add(messageId);
|
|
875
|
+
emit(session, { type: 'claude/text-start', id: messageId });
|
|
876
|
+
} else if (kind === 'text_delta') {
|
|
877
|
+
if (!turn.announcedText.has(messageId)) {
|
|
878
|
+
turn.announcedText.add(messageId);
|
|
879
|
+
emit(session, { type: 'claude/text-start', id: messageId });
|
|
880
|
+
}
|
|
881
|
+
turn.streamedText.add(messageId);
|
|
882
|
+
emit(session, { type: 'claude/text-delta', id: messageId, text: String(payload.delta || '') });
|
|
883
|
+
} else if (kind === 'reasoning_start') {
|
|
884
|
+
turn.announcedThinking.add(messageId);
|
|
885
|
+
emit(session, { type: 'claude/thinking-start', id: messageId });
|
|
886
|
+
} else if (kind === 'reasoning_delta') {
|
|
887
|
+
if (!turn.announcedThinking.has(messageId)) {
|
|
888
|
+
turn.announcedThinking.add(messageId);
|
|
889
|
+
emit(session, { type: 'claude/thinking-start', id: messageId });
|
|
890
|
+
}
|
|
891
|
+
turn.streamedThinking.add(messageId);
|
|
892
|
+
emit(session, { type: 'claude/thinking-delta', id: messageId, text: String(payload.delta || '') });
|
|
893
|
+
} else if (kind === 'tool_call') {
|
|
894
|
+
setState(session, 'tool-running');
|
|
895
|
+
emit(session, {
|
|
896
|
+
type: 'claude/tool-use',
|
|
897
|
+
id: payload.toolCallId || `tool-${session.turns}-${session.outputBuffer.length}`,
|
|
898
|
+
name: payload.toolName || 'Tool',
|
|
899
|
+
input: payload.input && typeof payload.input === 'object' ? payload.input : { input: payload.input },
|
|
900
|
+
});
|
|
901
|
+
} else if (kind === 'tool_error') {
|
|
902
|
+
emit(session, {
|
|
903
|
+
type: 'claude/tool-result',
|
|
904
|
+
id: payload.toolCallId || `tool-${session.turns}-${session.outputBuffer.length}`,
|
|
905
|
+
output: String(payload.delta || payload.error || 'tool failed').slice(0, 4000),
|
|
906
|
+
isError: true,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
// text_end / reasoning_end / tool_input_* deltas need no normalized event;
|
|
910
|
+
// the tool_use input arrives complete on the tool_call kind.
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function onToolUpdated(session, payload) {
|
|
914
|
+
if (!payload.result || !payload.toolCallId) return; // scheduling metadata only
|
|
915
|
+
setState(session, 'running');
|
|
916
|
+
emit(session, {
|
|
917
|
+
type: 'claude/tool-result',
|
|
918
|
+
id: payload.toolCallId,
|
|
919
|
+
output: toolResultText(payload.result).slice(0, 4000),
|
|
920
|
+
isError: payload.result.success === false,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function finishTurn(session, payload, forcedError) {
|
|
925
|
+
const current = session.currentTurn;
|
|
926
|
+
if (!current) return;
|
|
927
|
+
session.currentTurn = null;
|
|
928
|
+
denyPendingApproval(session, 'turn completed'); // the server cancels unanswered approvals
|
|
929
|
+
session.turns += 1;
|
|
930
|
+
session.updatedAt = new Date().toISOString();
|
|
931
|
+
const usage = payload.usage || {};
|
|
932
|
+
const input = Number(usage.inputTokens || 0);
|
|
933
|
+
const output = Number(usage.outputTokens || 0);
|
|
934
|
+
const cached = Number(usage.cacheReadTokens || 0);
|
|
935
|
+
if (input || output || cached) {
|
|
936
|
+
session.usage.inputTokens += input;
|
|
937
|
+
session.usage.outputTokens += output;
|
|
938
|
+
session.usage.cachedTokens += cached;
|
|
939
|
+
session.peakTurnTokens = Math.max(session.peakTurnTokens || 0, input + output + cached);
|
|
940
|
+
emit(session, { type: 'claude/usage', usage: { input_tokens: input, output_tokens: output } });
|
|
941
|
+
}
|
|
942
|
+
const resultType = String(payload.resultType || 'success');
|
|
943
|
+
const response = String(payload.response != null ? payload.response : session.lastResult || '');
|
|
944
|
+
if (response) session.lastResult = response;
|
|
945
|
+
const failed = resultType === 'error' || resultType === 'failed' || Boolean(forcedError);
|
|
946
|
+
if (failed) {
|
|
947
|
+
emitDiag(session, { type: 'claude/error', message: String(forcedError || `zcode turn ${resultType}`).slice(0, 500) });
|
|
948
|
+
}
|
|
949
|
+
if (resultType === 'cancelled' && !session.abortedByUser) {
|
|
950
|
+
emit(session, { type: 'claude/aborted' });
|
|
951
|
+
}
|
|
952
|
+
emit(session, {
|
|
953
|
+
type: 'claude/result',
|
|
954
|
+
result: failed ? '' : response,
|
|
955
|
+
model: session.model,
|
|
956
|
+
isError: failed,
|
|
957
|
+
});
|
|
958
|
+
session.abortedByUser = false;
|
|
959
|
+
if (!session.endedAt) session.state = 'idle';
|
|
960
|
+
emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
|
|
961
|
+
persistSession(session);
|
|
962
|
+
if (current.resolve) current.resolve();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function emitDiag(session, event) {
|
|
966
|
+
const text = event.text != null ? event.text : event.message;
|
|
967
|
+
if (text === session.lastDiag) return;
|
|
968
|
+
session.lastDiag = String(text == null ? '' : text);
|
|
969
|
+
emit(session, event);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// -- server-initiated requests (preferences + approvals) ------------------
|
|
973
|
+
|
|
974
|
+
async function handleServerRequest(method, params) {
|
|
975
|
+
if (method === 'session/requestRuntimePreferences') {
|
|
976
|
+
// Defaults verified live: native search off, memory off, question
|
|
977
|
+
// auto-resolution off; answering unblocks session/create and session/send.
|
|
978
|
+
return RUNTIME_PREFERENCES;
|
|
979
|
+
}
|
|
980
|
+
if (method === 'interaction/requestPermission') {
|
|
981
|
+
const session = routeZcodeSession(params && params.sessionId);
|
|
982
|
+
if (!session) throw Object.assign(new Error('zcode approval for an unknown session'), { status: 404 });
|
|
983
|
+
return await waitForApproval(session, method, params || {});
|
|
984
|
+
}
|
|
985
|
+
// Official MCP plugin auth (image_search & friends) and user-input
|
|
986
|
+
// interactions have no terminal surface yet: rejecting them is verified
|
|
987
|
+
// harmless (the session continues; affected tools report a failure).
|
|
988
|
+
throw Object.assign(new Error(`zcode app-server request ${method} is not supported`), { status: 501 });
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function waitForApproval(session, method, params) {
|
|
992
|
+
const choices = Array.isArray(params.options) ? params.options.filter(Boolean) : [];
|
|
993
|
+
const allowChoice = choices.find(choice => choice.kind === 'allow_once' || choice.kind === 'allow_always');
|
|
994
|
+
const denyChoice = choices.find(choice => choice.kind === 'deny');
|
|
995
|
+
const described = describeApproval(params);
|
|
996
|
+
return new Promise(resolve => {
|
|
997
|
+
const requestId = params.requestId || `zcode-perm-${nextPermissionId++}`;
|
|
998
|
+
session.pendingApproval = {
|
|
999
|
+
requestId,
|
|
1000
|
+
allowResponse: allowChoice && allowChoice.response || { decision: 'allow', reason: 'Approved by the terminal user' },
|
|
1001
|
+
denyResponse: denyChoice && denyChoice.response || { decision: 'deny', reason: 'Denied' },
|
|
1002
|
+
resolve,
|
|
1003
|
+
};
|
|
1004
|
+
session.pendingPermission = {
|
|
1005
|
+
requestId,
|
|
1006
|
+
status: 'pending',
|
|
1007
|
+
createdAt: new Date().toISOString(),
|
|
1008
|
+
summary: described.summary,
|
|
1009
|
+
};
|
|
1010
|
+
setState(session, 'tool-running');
|
|
1011
|
+
emit(session, { type: 'claude/permission-request', requestId, ...described, status: 'pending', createdAt: session.pendingPermission.createdAt });
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function describeApproval(params) {
|
|
1016
|
+
const input = params.input && typeof params.input === 'object' ? params.input : {};
|
|
1017
|
+
const commandText = String(input.command || input.path || input.file || '');
|
|
1018
|
+
const toolName = String(params.toolName || (/file|edit|patch/i.test(commandText) ? 'Edit' : 'Bash'));
|
|
1019
|
+
const title = toolName === 'Edit' ? 'ZCode wants to modify files' : 'ZCode wants to run a command';
|
|
1020
|
+
return {
|
|
1021
|
+
toolName,
|
|
1022
|
+
title,
|
|
1023
|
+
description: commandText.slice(0, 200) || String(params.reason || 'tool approval').slice(0, 200),
|
|
1024
|
+
promptPreview: JSON.stringify({ toolName, command: commandText, riskLevel: params.riskLevel || '' }).slice(0, 500),
|
|
1025
|
+
summary: {
|
|
1026
|
+
toolName,
|
|
1027
|
+
title,
|
|
1028
|
+
description: commandText.slice(0, 200) || String(params.reason || '').slice(0, 200),
|
|
1029
|
+
promptPreview: commandText.slice(0, 500),
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function denyPendingApproval(session, reason) {
|
|
1035
|
+
if (!session.pendingApproval) return;
|
|
1036
|
+
const approval = session.pendingApproval;
|
|
1037
|
+
session.pendingApproval = null;
|
|
1038
|
+
session.pendingPermission = null;
|
|
1039
|
+
emit(session, {
|
|
1040
|
+
type: 'claude/permission-resolved',
|
|
1041
|
+
requestId: approval.requestId,
|
|
1042
|
+
allow: false,
|
|
1043
|
+
decision: approval.denyResponse && approval.denyResponse.decision,
|
|
1044
|
+
message: reason,
|
|
1045
|
+
resolvedAt: new Date().toISOString(),
|
|
1046
|
+
});
|
|
1047
|
+
approval.resolve(approval.denyResponse);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// -- model runtime ----------------------------------------------------------
|
|
1051
|
+
|
|
1052
|
+
// Profile-injected providers become inline runtimeModel configs; agent-owned
|
|
1053
|
+
// auth bridges the desktop's own provider store so a logged-in ZCode desktop
|
|
1054
|
+
// works with zero terminal-side configuration. Returns null when the
|
|
1055
|
+
// app-server should use its own ~/.zcode/cli/config.json. The desktop store
|
|
1056
|
+
// is never read under the test runner without an explicit zcodeHome, so
|
|
1057
|
+
// injected-double tests stay machine-independent.
|
|
1058
|
+
function runtimeModelFor(profile) {
|
|
1059
|
+
if (profile && profile.apiKey) return injectedRuntimeModel(profile);
|
|
1060
|
+
if (desktopProviderCache === undefined) {
|
|
1061
|
+
desktopProviderCache = options.zcodeHome || !process.env.NODE_TEST_CONTEXT
|
|
1062
|
+
? readDesktopRuntimeModel(cli, options)
|
|
1063
|
+
: null;
|
|
1064
|
+
}
|
|
1065
|
+
return desktopProviderCache;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Driver session ids must never collide with persisted records from a
|
|
1069
|
+
// previous process: a collision would shadow a restartable session behind
|
|
1070
|
+
// an empty one.
|
|
1071
|
+
function nextDriverSessionId() {
|
|
1072
|
+
const taken = new Set(sessions.keys());
|
|
1073
|
+
const index = syncDiskIndex();
|
|
1074
|
+
if (index) for (const id of index.keys()) taken.add(id);
|
|
1075
|
+
let id = `zcode-${nextId++}`;
|
|
1076
|
+
while (taken.has(id)) id = `zcode-${nextId++}`;
|
|
1077
|
+
return id;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return runner;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
// module helpers
|
|
1085
|
+
// ---------------------------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
function modelForProfile(profile) {
|
|
1088
|
+
if (!profile) return null;
|
|
1089
|
+
const model = resolveOpenAIModel(profile);
|
|
1090
|
+
return model ? String(model) : null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function runtimeModelRevision(spec) {
|
|
1094
|
+
return `wb-${crypto.createHash('sha1').update(JSON.stringify(spec)).digest('hex').slice(0, 16)}`;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Workbench profile -> ZCode runtimeModel. Anthropic-compatible base URLs
|
|
1098
|
+
// (the profile default) map to the anthropic provider kind; everything else
|
|
1099
|
+
// falls back to the resolved OpenAI-compatible endpoint.
|
|
1100
|
+
function injectedRuntimeModel(profile) {
|
|
1101
|
+
const anthropicBase = String(profile.baseUrl || '').trim();
|
|
1102
|
+
const openaiBase = resolveOpenAIBaseUrl(profile);
|
|
1103
|
+
const useAnthropic = Boolean(anthropicBase) && profile.protocol !== 'openai-compatible';
|
|
1104
|
+
if (!useAnthropic && !openaiBase) {
|
|
1105
|
+
throw new Error('the selected provider needs a base URL for ZCode');
|
|
1106
|
+
}
|
|
1107
|
+
const modelId = resolveOpenAIModel(profile) || 'default';
|
|
1108
|
+
const providerId = 'agent-terminal';
|
|
1109
|
+
const spec = {
|
|
1110
|
+
providerId, modelId,
|
|
1111
|
+
kind: useAnthropic ? 'anthropic' : 'openai-compatible',
|
|
1112
|
+
baseURL: useAnthropic ? anthropicBase : openaiBase,
|
|
1113
|
+
apiKey: profile.apiKey,
|
|
1114
|
+
};
|
|
1115
|
+
return {
|
|
1116
|
+
revision: runtimeModelRevision(spec),
|
|
1117
|
+
generatedAt: Date.now(),
|
|
1118
|
+
model: { providerId, modelId },
|
|
1119
|
+
provider: {
|
|
1120
|
+
providerId,
|
|
1121
|
+
kind: spec.kind,
|
|
1122
|
+
label: 'Agent Terminal',
|
|
1123
|
+
source: 'ephemeral',
|
|
1124
|
+
baseURL: spec.baseURL,
|
|
1125
|
+
apiKey: { source: 'inline', value: spec.apiKey },
|
|
1126
|
+
models: [{
|
|
1127
|
+
modelId,
|
|
1128
|
+
...(Number(profile.contextWindow) > 0 ? { contextWindow: profile.contextWindow } : {}),
|
|
1129
|
+
supportsTools: true,
|
|
1130
|
+
}],
|
|
1131
|
+
},
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Agent-owned auth: mirror what the ZCode desktop itself does — its provider
|
|
1136
|
+
// selection lives in ~/.zcode/v2/config.json (keyed like "builtin:bigmodel"),
|
|
1137
|
+
// and model catalogs ship beside the CLI in resources/model-providers. Any
|
|
1138
|
+
// read/parse failure degrades to null (the app-server then applies its own
|
|
1139
|
+
// config or reports an actionable model_config_missing error).
|
|
1140
|
+
function readDesktopRuntimeModel(cli, options) {
|
|
1141
|
+
try {
|
|
1142
|
+
const home = options.zcodeHome || path.join(os.homedir(), '.zcode');
|
|
1143
|
+
const configPath = path.join(home, 'v2', 'config.json');
|
|
1144
|
+
if (!fs.existsSync(configPath)) return null;
|
|
1145
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
1146
|
+
const providers = config && config.provider && typeof config.provider === 'object' ? config.provider : {};
|
|
1147
|
+
let chosen = null;
|
|
1148
|
+
let chosenScore = -1;
|
|
1149
|
+
for (const [key, value] of Object.entries(providers)) {
|
|
1150
|
+
if (!value || typeof value !== 'object') continue;
|
|
1151
|
+
if (value.enabled === false || value.enabled === 'false') continue;
|
|
1152
|
+
const options2 = value.options && typeof value.options === 'object' ? value.options : {};
|
|
1153
|
+
if (!options2.apiKey || !options2.baseURL) continue;
|
|
1154
|
+
const providerId = String(key).split(':').pop() || key;
|
|
1155
|
+
let score = 1;
|
|
1156
|
+
if (/coding-plan/i.test(providerId)) score += 2;
|
|
1157
|
+
if (/bigmodel|zai|glm/i.test(providerId)) score += 1;
|
|
1158
|
+
if (score > chosenScore) { chosenScore = score; chosen = { providerId, value, options: options2 }; }
|
|
1159
|
+
}
|
|
1160
|
+
if (!chosen) return null;
|
|
1161
|
+
const modelId = desktopDefaultModel(cli, chosen.providerId, chosen.value);
|
|
1162
|
+
if (!modelId) return null;
|
|
1163
|
+
const spec = { providerId: chosen.providerId, modelId, baseURL: chosen.options.baseURL };
|
|
1164
|
+
return {
|
|
1165
|
+
revision: runtimeModelRevision(spec),
|
|
1166
|
+
generatedAt: Date.now(),
|
|
1167
|
+
model: { providerId: chosen.providerId, modelId },
|
|
1168
|
+
provider: {
|
|
1169
|
+
providerId: chosen.providerId,
|
|
1170
|
+
kind: chosen.value.kind === 'openai' || chosen.value.kind === 'openai-compatible' ? chosen.value.kind : 'anthropic',
|
|
1171
|
+
label: String(chosen.value.name || chosen.providerId).slice(0, 100),
|
|
1172
|
+
source: 'ephemeral',
|
|
1173
|
+
baseURL: chosen.options.baseURL,
|
|
1174
|
+
apiKey: { source: 'inline', value: String(chosen.options.apiKey) },
|
|
1175
|
+
models: [{ modelId }],
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
} catch {
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Model catalog lookup: resources/model-providers/*.json next to the CLI
|
|
1184
|
+
// bundle; falls back to the provider's own models list, then 'glm-4.7'.
|
|
1185
|
+
function desktopDefaultModel(cli, providerId, providerValue) {
|
|
1186
|
+
try {
|
|
1187
|
+
if (Array.isArray(providerValue.models) && providerValue.models.length) {
|
|
1188
|
+
const first = providerValue.models[0];
|
|
1189
|
+
return String(first.modelId || first.id || first).slice(0, 128);
|
|
1190
|
+
}
|
|
1191
|
+
const catalogDir = path.resolve(cli.path, '..', '..', 'model-providers');
|
|
1192
|
+
for (const name of fs.readdirSync(catalogDir).filter(item => item.endsWith('.json')).sort().reverse()) {
|
|
1193
|
+
const catalog = JSON.parse(fs.readFileSync(path.join(catalogDir, name), 'utf8'));
|
|
1194
|
+
const entry = Array.isArray(catalog && catalog.providers)
|
|
1195
|
+
&& catalog.providers.find(item => item && (item.id === providerId || item.id === `builtin:${providerId}`));
|
|
1196
|
+
const models = entry && Array.isArray(entry.models) ? entry.models : [];
|
|
1197
|
+
const model = models.find(item => item && item.default) || models[0];
|
|
1198
|
+
if (model && (model.id || model.modelId)) return String(model.id || model.modelId).slice(0, 128);
|
|
1199
|
+
}
|
|
1200
|
+
} catch { /* catalog is best-effort */ }
|
|
1201
|
+
return 'glm-4.7';
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Tool results arrive as { success, content, display } where content may be a
|
|
1205
|
+
// string or an array of parts; normalize to displayable text.
|
|
1206
|
+
function toolResultText(result) {
|
|
1207
|
+
if (result == null) return '';
|
|
1208
|
+
if (typeof result.display === 'string' && result.display) return result.display;
|
|
1209
|
+
const content = result.content;
|
|
1210
|
+
if (typeof content === 'string') return content;
|
|
1211
|
+
if (Array.isArray(content)) {
|
|
1212
|
+
return content.map(part => {
|
|
1213
|
+
if (typeof part === 'string') return part;
|
|
1214
|
+
if (part && typeof part.text === 'string') return part.text;
|
|
1215
|
+
return '';
|
|
1216
|
+
}).filter(Boolean).join('\n');
|
|
1217
|
+
}
|
|
1218
|
+
if (result.success === false && result.error) return String(result.error);
|
|
1219
|
+
return '';
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function safeFileStem(value) {
|
|
1223
|
+
return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Mirrors the CLI runners' resolution: explicit config always wins; otherwise
|
|
1227
|
+
// persistence is suppressed under the node:test runner so test runs never
|
|
1228
|
+
// touch the real session store. An injected app-server spawn doubles the
|
|
1229
|
+
// app-server child only and does NOT suppress persistence.
|
|
1230
|
+
function resolvePersistence(options) {
|
|
1231
|
+
const configured = options.persistence && typeof options.persistence === 'object' ? options.persistence : {};
|
|
1232
|
+
const explicitlyConfigured = options.persistence != null;
|
|
1233
|
+
const ephemeralTestContext = Boolean(process.env.NODE_TEST_CONTEXT);
|
|
1234
|
+
const enabled = options.persistence !== null
|
|
1235
|
+
&& configured.enabled !== false
|
|
1236
|
+
&& (explicitlyConfigured || !ephemeralTestContext);
|
|
1237
|
+
const dir = path.resolve(configured.dir || path.join(os.homedir(), '.agent-terminal', 'cli-sessions'));
|
|
1238
|
+
return { enabled, dir };
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
module.exports = {
|
|
1242
|
+
createZCodeDriver,
|
|
1243
|
+
discoverZCodeCli,
|
|
1244
|
+
probeZCodeVersion,
|
|
1245
|
+
providerRuntimeEnvironment,
|
|
1246
|
+
runtimeModelForProfile: injectedRuntimeModel,
|
|
1247
|
+
toolResultText,
|
|
1248
|
+
MODE_FOR_PERMISSION,
|
|
1249
|
+
APP_SERVER_PERSIST_SCHEMA,
|
|
1250
|
+
};
|