@sanqianx/claude-ai-workbench 0.13.2-rc.2 → 0.13.2-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,7 @@ npm run start:agent-terminal
62
62
  - 数据目录:`~/.agent-terminal`(`AGENT_TERMINAL_DATA_DIR` 覆盖;端口 `AGENT_TERMINAL_PORT`)
63
63
  - 首次使用:设置 → 模型供应商 → 添加供应商(Base URL / API Key / 模型列表),然后添加本地项目文件夹即可开聊。
64
64
  - Agent 发现自动扫描本机:Claude Code(Agent SDK,支持 Anthropic 兼容端点如 MiniMax)、Codex(app-server,保留 CLI 回退)、OpenCode(serve + SSE,保留 CLI 回退)、ZCode(桌面内置 app-server)。版本探测成功不等于认证或任务执行成功,实际运行失败另写诊断日志。
65
- - ZCode 启动只引用安装目录里已有的供应商配置,并保留显式环境变量覆盖及个人配置。不会改写桌面安装资源、生成假配置或复制凭据。
65
+ - ZCode 原生认证使用已有桌面供应商配置,保留显式环境变量覆盖及个人配置;共享 Workbench 模型通过临时本机连接接入,上游 API Key 只在内存中,临时文件仅保存本机访问令牌,关闭时清理。不改写桌面安装资源或用户模型配置。同时支持旧版 runtimeModel 与新版 modelSelection 协议,失败和完成事件均结束当前任务。
66
66
  - 零点击模型检测:填好 Base URL 与 API Key 后自动探测 `/v1/models` 并合并 models.dev 能力数据(上下文窗口、模态、推理),保存时自动填充模型列表与上下文大小。
67
67
  - 会话内模型切换:切换供应商/模型不换会话,历史跨供应商延续(Claude 保留 resume,CLI 保留线程参数)。
68
68
  - 运行时体验对齐:中断不再误报退出码错误、stderr 聚合为单个折叠、上下文用量按轮取最大值而非累计求和。
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: Claude AI Workbench API
4
- version: 0.13.2-rc.2
4
+ version: 0.13.2-rc.3
5
5
  description: Versioned local API for AI Profile, credential and Claude Code session management.
6
6
  servers:
7
7
  - url: http://127.0.0.1:5760/api/claude-workbench/v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/claude-ai-workbench",
3
- "version": "0.13.2-rc.2",
3
+ "version": "0.13.2-rc.3",
4
4
  "description": "Shared Claude Code terminal and Anthropic-compatible AI profile modules for local developer tools.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/client",
3
- "version": "0.13.2-rc.2",
3
+ "version": "0.13.2-rc.3",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.2-rc.2"
23
+ "@claude-ai-workbench/contracts": "0.13.2-rc.3"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/contracts",
3
- "version": "0.13.2-rc.2",
3
+ "version": "0.13.2-rc.3",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/core",
3
- "version": "0.13.2-rc.2",
3
+ "version": "0.13.2-rc.3",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "browser": "index.js",
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@claude-ai-workbench/contracts": "0.13.2-rc.2"
23
+ "@claude-ai-workbench/contracts": "0.13.2-rc.3"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18"
@@ -29,12 +29,13 @@ app.server.listen(port, host, () => {
29
29
  });
30
30
 
31
31
  let stopping = false;
32
- function shutdown() {
32
+ async function shutdown() {
33
33
  if (stopping) return; stopping = true;
34
- app.service.destroy();
34
+ setTimeout(() => process.exit(1), 10000).unref();
35
+ try { await app.service.destroy(); }
36
+ catch { process.stderr.write('SHUTDOWN_FAILED: 客户端运行环境未能完整关闭。\n'); process.exitCode = 1; }
35
37
  app.server.closeAllConnections?.();
36
- app.server.close(() => process.exit(0));
37
- setTimeout(() => process.exit(0), 3000).unref();
38
+ app.server.close(() => process.exit(process.exitCode || 0));
38
39
  }
39
40
  process.on('SIGINT', shutdown);
40
41
  process.on('SIGTERM', shutdown);
@@ -41,6 +41,14 @@ app.server.listen(port, host, () => {
41
41
  warmFolderPicker({ title: '选择项目文件夹' }).catch(() => {});
42
42
  });
43
43
 
44
- function shutdown() { app.server.close(() => process.exit(0)); }
44
+ let stopping = false;
45
+ async function shutdown() {
46
+ if (stopping) return; stopping = true;
47
+ setTimeout(() => process.exit(1), 10000).unref();
48
+ try { await app.service.destroy(); }
49
+ catch { process.stderr.write('SHUTDOWN_FAILED: 客户端运行环境未能完整关闭。\n'); process.exitCode = 1; }
50
+ app.server.closeAllConnections?.();
51
+ app.server.close(() => process.exit(process.exitCode || 0));
52
+ }
45
53
  process.on('SIGINT', shutdown);
46
54
  process.on('SIGTERM', shutdown);
@@ -30,6 +30,7 @@ function createWorkbenchServer(options = {}) {
30
30
  const apiPrefix = options.apiPrefix || DEFAULT_API_PREFIX;
31
31
  const testRoot = path.join(dataDir, 'test-workspaces');
32
32
  const projects = options.projects || new ProjectStore({ dataDir });
33
+ require('./lib/cli-session-recovery').recoverOwnedCliSessions({ dataDir, projects });
33
34
  const sessionArchive = options.sessionArchive || new SessionArchiveStore({ dataDir });
34
35
  const workspaces = {
35
36
  'test-workspace-a': path.join(testRoot, 'context-a'),
@@ -62,9 +63,9 @@ function createWorkbenchServer(options = {}) {
62
63
  drivers: {
63
64
  'claude-code': claudeDriver,
64
65
  ...(options.drivers || {
65
- codex: createCodexDriver(),
66
- opencode: createOpenCodeDriver(),
67
- zcode: createZCodeDriver(),
66
+ codex: createCodexDriver({ persistence: { dir: path.join(dataDir, 'cli-sessions') } }),
67
+ opencode: createOpenCodeDriver({ persistence: { dir: path.join(dataDir, 'cli-sessions') } }),
68
+ zcode: createZCodeDriver({ persistence: { dir: path.join(dataDir, 'cli-sessions') } }),
68
69
  }),
69
70
  },
70
71
  });
@@ -136,7 +137,7 @@ function createWorkbenchServer(options = {}) {
136
137
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
137
138
  res.end(JSON.stringify({ ok: false, error: 'not found' }));
138
139
  });
139
- server.on('close', () => service.destroy());
140
+ server.on('close', () => { Promise.resolve(service.destroy()).catch(error => diagnostics.error('agent.shutdown.failed', '客户端运行环境关闭失败。', { phase: 'shutdown', error: { code: error.code || 'SHUTDOWN_FAILED', message: error.message } })); });
140
141
  return { server, service, profiles, contexts, attachments, projects, sessionArchive, registry, agentDiscovery, apiPrefix, dataDir };
141
142
  }
142
143
 
@@ -46,6 +46,9 @@ class AgentRegistry {
46
46
  asRunner() {
47
47
  const registry = this;
48
48
  return {
49
+ async destroy() {
50
+ await Promise.all([...registry.drivers.values()].map(driver => typeof driver.destroy === 'function' ? driver.destroy() : undefined));
51
+ },
49
52
  startChatSession(args = {}) {
50
53
  const agentId = normalizeAgentId(args.agentId);
51
54
  const driver = registry.driverFor(agentId);
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const SCHEMAS = new Set(['cli-json-runner-session/v1', 'codex-appserver-session/v1', 'opencode-serve-session/v1', 'zcode-appserver-session/v1']);
6
+
7
+ function recoverOwnedCliSessions({ dataDir, projects, sourceDir = path.join(os.homedir(), '.agent-terminal/cli-sessions') }) {
8
+ const targetDir = path.join(dataDir, 'cli-sessions');
9
+ if (path.resolve(sourceDir) === path.resolve(targetDir) || !fs.existsSync(sourceDir)) return;
10
+ const owners = new Map(projects.list().map(project => [project.id, path.resolve(project.rootPath)]));
11
+ // Earlier v5 wiring put CLI records in the standalone directory. Preserve
12
+ // only records whose project ID AND code directory belong to this product.
13
+ for (const name of fs.readdirSync(sourceDir)) {
14
+ if (!/^[\w.-]+\.json$/.test(name)) continue;
15
+ const source = path.join(sourceDir, name); const target = path.join(targetDir, name);
16
+ let record;
17
+ try {
18
+ const stat = fs.lstatSync(source);
19
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8 * 1024 * 1024 || fs.existsSync(target)) continue;
20
+ record = JSON.parse(fs.readFileSync(source, 'utf8'));
21
+ } catch { continue; }
22
+ if (!SCHEMAS.has(record.schema) || !owners.has(record.projectSlug) || typeof record.projectPath !== 'string' || path.resolve(record.projectPath) !== owners.get(record.projectSlug) || `${record.sessionId}.json` !== name) continue;
23
+ fs.mkdirSync(targetDir, { recursive: true });
24
+ try { fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); }
25
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
26
+ }
27
+ }
28
+ module.exports = { recoverOwnedCliSessions };
@@ -493,6 +493,7 @@ function createServeOpenCodeDriver(options = {}) {
493
493
  session.lastResult = '';
494
494
  session.currentTurn = {
495
495
  assistantMessageID: null,
496
+ assistantMessageIDs: new Set(),
496
497
  userMessageIDs: new Set(), // echoed prompts never stream as output
497
498
  parts: new Map(), // partID -> tracking state
498
499
  sawEvent: false,
@@ -849,8 +850,11 @@ function createServeOpenCodeDriver(options = {}) {
849
850
  return;
850
851
  }
851
852
  if (info.role !== 'assistant') return;
853
+ const turn = session.currentTurn;
854
+ if (info.id && !turn.assistantMessageIDs.has(info.id) && info.time?.created && info.time.created < turn.startedAt - 5000) return;
852
855
  markActivity(session);
853
- if (!session.currentTurn.assistantMessageID && info.id) session.currentTurn.assistantMessageID = info.id;
856
+ // Tool rounds and the final answer have distinct assistant message IDs.
857
+ if (info.id) { turn.assistantMessageIDs.add(info.id); turn.assistantMessageID = info.id; }
854
858
  // Completed assistant messages carry the final token totals; they back
855
859
  // up the per-step-finish usage when a turn had no step-finish parts.
856
860
  if (info.time && info.time.completed && info.tokens && info.tokens.total != null) {
@@ -863,6 +867,7 @@ function createServeOpenCodeDriver(options = {}) {
863
867
  // the assistant message this turn belongs to.
864
868
  function partBelongsToTurn(turn, messageID) {
865
869
  if (messageID && turn.userMessageIDs.has(messageID)) return false;
870
+ if (messageID && turn.assistantMessageIDs.has(messageID)) return true;
866
871
  if (turn.assistantMessageID && messageID && messageID !== turn.assistantMessageID) return false;
867
872
  return true;
868
873
  }
@@ -872,6 +877,7 @@ function createServeOpenCodeDriver(options = {}) {
872
877
  if (!partBelongsToTurn(session.currentTurn, part.messageID)) return;
873
878
  if (!session.currentTurn.assistantMessageID && part.messageID) {
874
879
  session.currentTurn.assistantMessageID = part.messageID;
880
+ session.currentTurn.assistantMessageIDs.add(part.messageID);
875
881
  }
876
882
  markActivity(session);
877
883
  if (part.type === 'step-start') {
@@ -961,7 +967,7 @@ function createServeOpenCodeDriver(options = {}) {
961
967
  const turn = session.currentTurn;
962
968
  if (!turn || field !== 'text' || !partID) return;
963
969
  if (!partBelongsToTurn(turn, messageID)) return;
964
- if (!turn.assistantMessageID && messageID) turn.assistantMessageID = messageID;
970
+ if (!turn.assistantMessageID && messageID) { turn.assistantMessageID = messageID; turn.assistantMessageIDs.add(messageID); }
965
971
  markActivity(session);
966
972
  const tracker = partTracker(session, partID);
967
973
  if (!tracker) return;
@@ -234,6 +234,11 @@ function createAppServerZCodeDriver(options, cli) {
234
234
  let nextId = 1;
235
235
  let nextPermissionId = 1;
236
236
  let bridge = null;
237
+ let protocolMode = null;
238
+ let profileRuntime = null;
239
+ let profileRuntimeSignature = null;
240
+ let bridgeSetup = Promise.resolve();
241
+ let destroyed = false;
237
242
  let desktopProviderCache; // lazily-read ~/.zcode/v2 providers (agent-owned auth)
238
243
 
239
244
  function summary(session) {
@@ -519,13 +524,15 @@ function createAppServerZCodeDriver(options, cli) {
519
524
  // Invalid injected profiles (no base URL) throw here and surface as a
520
525
  // turn error, mirroring the codex driver's bridgeSpecFor contract.
521
526
  runtimeModel = runtimeModelFor(profile);
522
- await ensureBridge();
527
+ session.preparing = true;
528
+ session.runtimeSignature = profile?.apiKey ? runtimeModel.revision : 'agent-owned';
529
+ await ensureBridge(profile, session);
523
530
  await ensureZcodeSession(session, runtimeModel);
524
531
  } catch (error) {
525
532
  emit(session, { type: 'claude/error', message: `zcode app-server unavailable: ${error.message}`.slice(0, 500) });
526
533
  setState(session, 'idle');
527
534
  return { started: false };
528
- }
535
+ } finally { session.preparing = false; }
529
536
 
530
537
  emit(session, {
531
538
  type: 'claude/user-prompt',
@@ -544,11 +551,6 @@ function createAppServerZCodeDriver(options, cli) {
544
551
  if (images.length) {
545
552
  emitDiag(session, { type: 'claude/stderr', text: 'the zcode app-server driver does not support image attachments yet' });
546
553
  }
547
- await bridge.request('session/send', {
548
- sessionId: session.zcodeSessionId,
549
- content: appendTextAttachments(value, attachments) + (sendOptions.knowledgeContext || ''),
550
- ...(runtimeModel ? { runtimeModel } : {}),
551
- }, { timeoutMs: 30000 });
552
554
  session.currentTurn = {
553
555
  turnId: null,
554
556
  announcedText: new Set(),
@@ -558,11 +560,14 @@ function createAppServerZCodeDriver(options, cli) {
558
560
  sawEvent: false,
559
561
  };
560
562
  session.turnPromise = new Promise(resolve => { session.currentTurn.resolve = resolve; });
563
+ await bridge.request('session/send', {
564
+ sessionId: session.zcodeSessionId,
565
+ content: appendTextAttachments(value, attachments) + (sendOptions.knowledgeContext || ''),
566
+ ...(protocolMode === 'selection' ? (session.nativeModel ? { modelSelection: session.nativeModel } : {}) : runtimeModel ? { runtimeModel } : {}),
567
+ }, { timeoutMs: 30000 });
561
568
  return { started: true };
562
569
  } catch (error) {
563
- emit(session, { type: 'claude/error', message: `zcode turn failed to start: ${error.message}`.slice(0, 500) });
564
- session.currentTurn = null;
565
- setState(session, 'idle');
570
+ finishTurn(session, { resultType: 'error', response: '' }, `zcode turn failed to start: ${error.message}`);
566
571
  return { started: false };
567
572
  }
568
573
  },
@@ -650,6 +655,15 @@ function createAppServerZCodeDriver(options, cli) {
650
655
 
651
656
  findClaudeExecutableForSdk() { return null; },
652
657
 
658
+ async destroy() {
659
+ destroyed = true;
660
+ for (const session of sessions.values()) if (ACTIVE_STATES.has(session.state)) runner.abort(session.sessionId);
661
+ await bridge?.close(); bridge = null;
662
+ await bridgeSetup.catch(() => {});
663
+ await bridge?.close(); bridge = null;
664
+ await profileRuntime?.close(); profileRuntime = null;
665
+ },
666
+
653
667
  // Test seams.
654
668
  get transportMode() { return 'app-server'; },
655
669
  get bridgeState() { return bridge ? bridge.state : 'uncreated'; },
@@ -658,7 +672,22 @@ function createAppServerZCodeDriver(options, cli) {
658
672
 
659
673
  // -- bridge lifecycle ----------------------------------------------------
660
674
 
661
- function ensureBridge() {
675
+ function ensureBridge(profile, session) {
676
+ const preparing = bridgeSetup.then(() => prepareBridge(profile, session));
677
+ bridgeSetup = preparing.catch(() => {});
678
+ return preparing;
679
+ }
680
+
681
+ async function prepareBridge(profile, session) {
682
+ if (destroyed) throw new Error('ZCode 工作台正在关闭。');
683
+ if (protocolMode === 'selection' && profileRuntimeSignature !== session.runtimeSignature) {
684
+ if ([...sessions.values()].some(other => other !== session && (other.preparing || ACTIVE_STATES.has(other.state)) && other.runtimeSignature !== session.runtimeSignature)) throw new Error('另一个 ZCode 任务正在使用不同的模型配置,请等待任务结束。');
685
+ if (profileRuntimeSignature !== null || profile?.apiKey) { await bridge?.close(); bridge = null; }
686
+ await profileRuntime?.close(); profileRuntime = null;
687
+ if (profile?.apiKey) profileRuntime = await require('./zcode-profile-runtime').createZCodeProfileRuntime(injectedRuntimeModel(profile));
688
+ profileRuntimeSignature = session.runtimeSignature;
689
+ }
690
+ if (destroyed) throw new Error('ZCode 工作台正在关闭。');
662
691
  if (bridge && bridge.state === 'closed') { bridge = null; }
663
692
  if (bridge && bridge.state === 'crashed') { recycleBridge('bridge exhausted its restart budget'); }
664
693
  if (!bridge) {
@@ -667,7 +696,7 @@ function createAppServerZCodeDriver(options, cli) {
667
696
  // runtime with the app-server stdio subcommand.
668
697
  command: options.nodePath || process.execPath,
669
698
  args: [cli.path, 'app-server', '--stdio'],
670
- env: providerRuntimeEnvironment(cli.path, { env: options.env || process.env }),
699
+ env: { ...providerRuntimeEnvironment(cli.path, { env: options.env || process.env }), ...(profileRuntime ? { ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: profileRuntime.file } : {}) },
671
700
  spawn: options.appServerSpawn,
672
701
  onEvent: handleNotification,
673
702
  onServerRequest: handleServerRequest,
@@ -678,7 +707,12 @@ function createAppServerZCodeDriver(options, cli) {
678
707
  log,
679
708
  });
680
709
  }
681
- return bridge.start();
710
+ await bridge.start();
711
+ if (protocolMode === null) {
712
+ try { const capabilities = await bridge.request('runtime/capabilities', {}, { timeoutMs: 10000 }); protocolMode = capabilities?.independentPlanState === true ? 'selection' : 'runtime-model'; }
713
+ catch (error) { if (error.rpcCode !== -32601) throw error; protocolMode = 'runtime-model'; }
714
+ if (protocolMode === 'selection') return prepareBridge(profile, session);
715
+ }
682
716
  }
683
717
 
684
718
  function recycleBridge(reason) {
@@ -708,10 +742,11 @@ function createAppServerZCodeDriver(options, cli) {
708
742
  async function ensureZcodeSession(session, runtimeModel) {
709
743
  if (session.zcodeSessionId) {
710
744
  try {
711
- await bridge.request('session/resume', {
745
+ const resumed = await bridge.request('session/resume', {
712
746
  sessionId: session.zcodeSessionId,
713
- ...(runtimeModel ? { runtimeModel } : {}),
747
+ ...(protocolMode !== 'selection' && runtimeModel ? { runtimeModel } : {}),
714
748
  }, { timeoutMs: 30000 });
749
+ if (protocolMode === 'selection') { session.nativeModel = resumed.settings?.model?.current || session.nativeModel || null; await selectNativeModel(session); }
715
750
  zcodeSessions.set(session.zcodeSessionId, session.sessionId);
716
751
  await subscribeZcodeSession(session);
717
752
  return;
@@ -726,17 +761,28 @@ function createAppServerZCodeDriver(options, cli) {
726
761
  const result = await bridge.request('session/create', {
727
762
  workspace: { workspacePath: session.projectPath, workspaceKey: session.projectPath },
728
763
  mode: MODE_FOR_PERMISSION[session.permissionMode] || 'build',
729
- ...(runtimeModel ? { runtimeModel } : {}),
764
+ ...(protocolMode === 'selection' ? { titleGenerationEnabled: false } : runtimeModel ? { runtimeModel } : {}),
730
765
  }, { timeoutMs: 60000 });
731
766
  const zcodeId = result && result.session && (result.session.sessionId || result.session.id);
732
767
  if (!zcodeId) throw new Error('zcode app-server did not return a session');
733
768
  session.zcodeSessionId = zcodeId;
769
+ if (protocolMode === 'selection') { session.nativeModel = result.settings?.model?.current || null; await selectNativeModel(session); }
734
770
  zcodeSessions.set(zcodeId, session.sessionId);
735
771
  if (result.session && result.session.title && !session.title) session.title = result.session.title;
736
772
  await subscribeZcodeSession(session);
737
773
  persistSession(session);
738
774
  }
739
775
 
776
+ async function selectNativeModel(session) {
777
+ if (profileRuntime) session.nativeModel = profileRuntime.selection;
778
+ else if (session.selectedModel) {
779
+ const slash = session.selectedModel.indexOf('/');
780
+ if (slash > 0) session.nativeModel = { providerId: session.selectedModel.slice(0, slash), modelId: session.selectedModel.slice(slash + 1), options: { reasoningLevel: 'enabled' } };
781
+ else if (session.nativeModel) session.nativeModel = { ...session.nativeModel, modelId: session.selectedModel };
782
+ }
783
+ await bridge.request('session/setMode', { sessionId: session.zcodeSessionId, mode: MODE_FOR_PERMISSION[session.permissionMode] || 'build' }, { timeoutMs: 10000 });
784
+ }
785
+
740
786
  async function subscribeZcodeSession(session) {
741
787
  await bridge.request('session/subscribe', {
742
788
  sessionId: session.zcodeSessionId,
@@ -788,6 +834,7 @@ function createAppServerZCodeDriver(options, cli) {
788
834
  case 'model.streaming': return onModelStreaming(session, payload || {});
789
835
  case 'tool.updated': return onToolUpdated(session, payload || {});
790
836
  case 'turn.completed': return finishTurn(session, payload || {});
837
+ case 'turn.failed': return finishTurn(session, { resultType: 'error', response: '' }, payload?.error?.message || payload?.error?.code || 'ZCode 任务失败');
791
838
  case 'session.titleUpdated': {
792
839
  if (payload && payload.title) session.title = String(payload.title).slice(0, 200);
793
840
  return;
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ const http = require('node:http');
3
+ const https = require('node:https');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { writeJson } = require('../json-file');
9
+
10
+ // ZCode 0.16.9 removed inline provider configuration. Its requestAuth override
11
+ // only applies to off-peak account models, not ordinary API-key providers.
12
+ // A private loopback transport keeps the upstream credential in memory; the
13
+ // native provider file contains only a short-lived local capability.
14
+ async function createZCodeProfileRuntime(spec) {
15
+ const upstream = new URL(spec.provider.baseURL);
16
+ if (!['http:', 'https:'].includes(upstream.protocol) || upstream.username || upstream.password || upstream.search || upstream.hash) throw new Error('ZCode 模型地址不支持内嵌凭据或查询参数。');
17
+ const key = spec.provider.apiKey.value;
18
+ const token = crypto.randomBytes(24).toString('hex');
19
+ const requests = new Set();
20
+ const prefix = `/${token}`;
21
+ const server = http.createServer((req, res) => {
22
+ const parsed = new URL(req.url, 'http://127.0.0.1');
23
+ const supplied = req.headers['x-api-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
24
+ const suffix = parsed.pathname.slice(prefix.length);
25
+ if (supplied !== token || !parsed.pathname.startsWith(prefix + '/') || req.method !== 'POST' || !['/v1/messages', '/messages', '/chat/completions', '/v1/chat/completions', '/responses', '/v1/responses'].includes(suffix) || parsed.search) { req.resume(); res.writeHead(404); res.end(); return; }
26
+ const target = new URL(upstream);
27
+ target.pathname = upstream.pathname.replace(/\/$/, '') + suffix;
28
+ if (upstream.pathname.endsWith('/v1') && suffix.startsWith('/v1/')) target.pathname = upstream.pathname + suffix.slice(3);
29
+ const headers = { 'content-type': req.headers['content-type'] || 'application/json' };
30
+ for (const name of ['anthropic-version', 'anthropic-beta', 'content-length']) if (req.headers[name]) headers[name] = req.headers[name];
31
+ if (spec.provider.kind === 'anthropic') headers['x-api-key'] = key;
32
+ else headers.authorization = `Bearer ${key}`;
33
+ const remote = (target.protocol === 'https:' ? https : http).request(target, { method: 'POST', headers }, incoming => {
34
+ const allowed = {};
35
+ for (const name of ['content-type', 'cache-control', 'x-request-id', 'request-id']) if (incoming.headers[name]) allowed[name] = incoming.headers[name];
36
+ res.writeHead(incoming.statusCode, allowed); res.flushHeaders(); incoming.pipe(res);
37
+ });
38
+ requests.add(remote);
39
+ remote.once('close', () => requests.delete(remote));
40
+ remote.on('error', () => { if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' }); res.end('{"error":{"message":"模型连接失败"}}'); });
41
+ remote.setTimeout(300000, () => remote.destroy());
42
+ req.on('aborted', () => remote.destroy());
43
+ res.on('close', () => { if (!res.writableFinished) remote.destroy(); });
44
+ let size = 0;
45
+ req.on('data', bytes => { size += bytes.length; if (size > 8 * 1024 * 1024) { remote.destroy(); req.destroy(); } });
46
+ req.pipe(remote);
47
+ });
48
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
49
+ let root;
50
+ let file;
51
+ const selection = { ...spec.model, options: { reasoningLevel: 'enabled' } };
52
+ try {
53
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'workbench-zcode-runtime-'));
54
+ file = path.join(root, 'provider-config.json');
55
+ writeJson(file, { schemaVersion: 1, config: { providerOrder: [selection.providerId], defaultModelSelection: selection, providerConfigRules: { providerRules: [{ providerId: selection.providerId, providerName: 'Workbench', enabled: true, config: { group: 'standard-personal', access: { type: 'api-key', apiKey: token }, api: { type: spec.provider.kind === 'anthropic' ? 'anthropic-messages' : 'openai-chat-completions', baseUrl: `http://127.0.0.1:${server.address().port}${prefix}` }, personalModelIds: [selection.modelId] } }] }, modelConfigRules: { providerModelRules: [], manualProviderModelRules: [] } } });
56
+ } catch (error) {
57
+ await new Promise(resolve => server.close(resolve));
58
+ if (file) { fs.rmSync(file, { force: true }); fs.rmSync(`${file}.${process.pid}.tmp`, { force: true }); }
59
+ if (root) { try { fs.rmdirSync(root); } catch {} }
60
+ throw error;
61
+ }
62
+ let closed = false;
63
+ return { selection, file, async close() {
64
+ if (closed) return; closed = true;
65
+ for (const request of requests) request.destroy();
66
+ server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
67
+ fs.rmSync(file, { force: true });
68
+ try { fs.rmdirSync(root); } catch { /* never recursively remove native-created files */ }
69
+ } };
70
+ }
71
+ module.exports = { createZCodeProfileRuntime };
@@ -503,6 +503,7 @@ class WorkbenchService {
503
503
  }
504
504
 
505
505
  destroy() {
506
+ if (this.destroyPromise) return this.destroyPromise;
506
507
  this.conversationCapture?.close();
507
508
  if (this.archiveSweeper) { clearInterval(this.archiveSweeper); this.archiveSweeper = null; }
508
509
  for (const sessionId of this.timers.keys()) this._clearTimeout(sessionId);
@@ -510,6 +511,8 @@ class WorkbenchService {
510
511
  for (const session of this.runner.listSessions({})) {
511
512
  if (ACTIVE_STATES.has(session.state)) try { this.runner.abort(session.sessionId); } catch {}
512
513
  }
514
+ this.destroyPromise = Promise.resolve(this.runner.destroy?.());
515
+ return this.destroyPromise;
513
516
  }
514
517
 
515
518
  _runtimeProfile(profileId, selectedModel) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/server",
3
- "version": "0.13.2-rc.2",
3
+ "version": "0.13.2-rc.3",
4
4
  "type": "commonjs",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
28
- "@claude-ai-workbench/contracts": "0.13.2-rc.2"
28
+ "@claude-ai-workbench/contracts": "0.13.2-rc.3"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=18"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-ai-workbench/ui",
3
- "version": "0.13.2-rc.2",
3
+ "version": "0.13.2-rc.3",
4
4
  "type": "commonjs",
5
5
  "main": "ai-profile-settings.js",
6
6
  "browser": "ai-profile-settings.js",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@claude-ai-workbench/core": "0.13.2-rc.2"
36
+ "@claude-ai-workbench/core": "0.13.2-rc.3"
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=18"