@sanqianx/claude-ai-workbench 0.11.0

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