@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.
Files changed (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. 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
+ };