@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4

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 (122) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +652 -613
  4. package/apps/agent-terminal/app.js +143 -67
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +172 -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 +189 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +652 -521
  11. package/apps/agent-terminal/user-question-card.js +68 -0
  12. package/apps/ai-profile-settings-host/index.html +25 -25
  13. package/apps/ai-profile-settings-host/page.css +1 -1
  14. package/apps/ai-profile-settings-host/page.js +113 -113
  15. package/apps/minimal-test-host/README.md +15 -15
  16. package/apps/minimal-test-host/host.css +2 -2
  17. package/apps/minimal-test-host/host.js +35 -35
  18. package/apps/minimal-test-host/index.html +58 -58
  19. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  20. package/docs/sdk/FILE_LAYOUT.md +31 -31
  21. package/docs/sdk/INTEGRATION.md +126 -126
  22. package/docs/sdk/README.md +21 -21
  23. package/docs/sdk/SESSION_EVENTS.md +15 -13
  24. package/docs/sdk/openapi.yaml +16 -1
  25. package/package.json +1 -1
  26. package/packages/client/README.md +29 -29
  27. package/packages/client/index.d.ts +122 -122
  28. package/packages/client/index.js +260 -260
  29. package/packages/client/package.json +2 -2
  30. package/packages/contracts/README.md +9 -9
  31. package/packages/contracts/index.d.ts +519 -517
  32. package/packages/contracts/index.js +312 -312
  33. package/packages/contracts/package.json +1 -1
  34. package/packages/core/README.md +8 -8
  35. package/packages/core/index.d.ts +76 -76
  36. package/packages/core/index.js +222 -222
  37. package/packages/core/package.json +2 -2
  38. package/packages/server/README.md +17 -17
  39. package/packages/server/bin/agent-terminal-server.js +42 -42
  40. package/packages/server/bin/claude-workbench.js +225 -225
  41. package/packages/server/index.d.ts +171 -171
  42. package/packages/server/index.js +230 -230
  43. package/packages/server/lib/agent-discovery.js +133 -133
  44. package/packages/server/lib/agent-registry.js +143 -143
  45. package/packages/server/lib/agent-spawn-env.js +17 -17
  46. package/packages/server/lib/attachment-store.js +78 -78
  47. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  48. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  49. package/packages/server/lib/capture-settings-store.js +35 -35
  50. package/packages/server/lib/claude-history-recovery.js +87 -87
  51. package/packages/server/lib/cli-session-recovery.js +28 -28
  52. package/packages/server/lib/context-resolver.js +55 -55
  53. package/packages/server/lib/conversation-capture.js +51 -51
  54. package/packages/server/lib/delegation-phase-store.js +357 -357
  55. package/packages/server/lib/delegation-store.js +474 -468
  56. package/packages/server/lib/diagnostics.js +32 -32
  57. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  58. package/packages/server/lib/drivers/codex-driver.js +1461 -1442
  59. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  60. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  61. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  62. package/packages/server/lib/event-stream.js +138 -138
  63. package/packages/server/lib/folder-picker.js +623 -623
  64. package/packages/server/lib/http-handler.js +339 -339
  65. package/packages/server/lib/json-file.js +26 -26
  66. package/packages/server/lib/knowledge-context.js +33 -33
  67. package/packages/server/lib/model-detection.js +295 -295
  68. package/packages/server/lib/parent-context.js +44 -0
  69. package/packages/server/lib/profile-store.js +283 -283
  70. package/packages/server/lib/project-store.js +102 -102
  71. package/packages/server/lib/runtime-loader.js +120 -120
  72. package/packages/server/lib/session-archive-store.js +53 -53
  73. package/packages/server/lib/session-history.js +612 -612
  74. package/packages/server/lib/system-doctor.js +125 -125
  75. package/packages/server/lib/ui-preferences-store.js +48 -48
  76. package/packages/server/lib/user-questions.js +67 -0
  77. package/packages/server/lib/workbench-service.js +1696 -1675
  78. package/packages/server/package.json +2 -2
  79. package/packages/ui/README.md +30 -30
  80. package/packages/ui/ai-profile-settings.d.ts +45 -45
  81. package/packages/ui/ai-profile-settings.js +283 -283
  82. package/packages/ui/claude-terminal.d.ts +31 -31
  83. package/packages/ui/claude-terminal.js +343 -343
  84. package/packages/ui/index.d.ts +18 -18
  85. package/packages/ui/index.js +51 -51
  86. package/packages/ui/package.json +2 -2
  87. package/packages/ui/theme.css +13 -13
  88. package/src/assets/claude-prompts.json +21 -21
  89. package/src/backend/http/ai-profile-routes.js +61 -61
  90. package/src/backend/http/claude-routes.js +170 -170
  91. package/src/backend/http/node-helpers.js +45 -45
  92. package/src/backend/index.js +21 -21
  93. package/src/backend/lib/ai-adapter.js +144 -144
  94. package/src/backend/lib/ai-profile-store.js +259 -259
  95. package/src/backend/lib/ai-vendor-presets.js +83 -83
  96. package/src/backend/lib/ai-workspace.js +140 -140
  97. package/src/backend/lib/automation-config.js +253 -253
  98. package/src/backend/lib/claude-cli-runner.js +2408 -2392
  99. package/src/backend/lib/data-dir.js +169 -169
  100. package/src/backend/lib/llm-client.js +176 -176
  101. package/src/backend/lib/model-context-windows.js +82 -82
  102. package/src/backend/lib/native-task-control.js +217 -217
  103. package/src/backend/lib/native-task-history-index.js +475 -475
  104. package/src/backend/lib/native-task-history-reader.js +526 -526
  105. package/src/backend/lib/native-task-messages.js +457 -457
  106. package/src/backend/lib/native-task-registry.js +325 -325
  107. package/src/backend/lib/native-task-runtime.js +242 -242
  108. package/src/backend/lib/native-task-trace.js +440 -440
  109. package/src/backend/lib/native-task-transcript.js +393 -393
  110. package/src/backend/lib/prompt-registry.js +166 -166
  111. package/src/backend/lib/runtime-config.js +121 -121
  112. package/src/backend/lib/sdk-session-stream.js +143 -143
  113. package/src/backend/lib/sdk-turn-stream.js +16 -16
  114. package/src/backend/lib/store-scope.js +131 -131
  115. package/src/backend/lib/task-identity.js +104 -104
  116. package/src/backend/lib/task-state-projection.js +58 -58
  117. package/src/index.js +7 -7
  118. package/src/sdk-wave-a.ts +1 -1
  119. package/src/sdk-wave-b.ts +1 -1
  120. package/src/sdk-wave-c.ts +1 -1
  121. package/src/sdk-wave-d.ts +1 -1
  122. package/src/sdk-wave-e.ts +1 -1
@@ -1,339 +1,339 @@
1
- 'use strict';
2
-
3
- const { DEFAULT_API_PREFIX, cleanId } = require('../../contracts');
4
- const { pickFolder: defaultPickFolder } = require('./folder-picker');
5
- const crypto = require('crypto');
6
-
7
- function createHttpHandler(service, options = {}) {
8
- const prefix = String(options.apiPrefix || DEFAULT_API_PREFIX).replace(/\/$/, '');
9
- // Stateless host capability — the picker module is required directly.
10
- const pickFolder = options.pickFolder || defaultPickFolder;
11
- return async function handle(req, res) {
12
- const url = new URL(req.url, 'http://127.0.0.1');
13
- if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return false;
14
- const path = url.pathname.slice(prefix.length) || '/';
15
- if (path.startsWith('/internal/')) {
16
- const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
17
- const token = options.runtimeToken;
18
- const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket && req.socket.remoteAddress);
19
- if (!local || req.headers.origin || !token || Buffer.byteLength(supplied) !== Buffer.byteLength(token) || !crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) return json(res, 403, { ok: false, error: 'trusted local service required' });
20
- if (req.method === 'POST' && path === '/internal/maintenance-config') {
21
- try { return json(res, 200, service.profiles.maintenanceRuntime()); }
22
- catch { return json(res, 409, { ok: false, error: 'maintenance default profile is not configured' }); }
23
- }
24
- if (req.method === 'POST' && path === '/internal/import-profiles') {
25
- try { return json(res, 200, { mapping: service.profiles.importLegacy(await body(req)) }); }
26
- catch (error) { return json(res, error.status || 409, { ok: false, error: 'legacy profile migration did not complete; original configuration is preserved' }); }
27
- }
28
- if (req.method === 'POST' && path === '/internal/embedding-config') {
29
- try { return json(res, 200, service.profiles.embeddingRuntime()); }
30
- catch { return json(res, 409, { ok: false, error: '向量模型未配置。' }); }
31
- }
32
- if (req.method === 'POST' && path === '/internal/import-embedding') {
33
- try { return json(res, 200, service.profiles.importEmbedding(await body(req))); }
34
- catch { return json(res, 409, { ok: false, error: '向量配置迁移待恢复,原配置保留。' }); }
35
- }
36
- return json(res, 404, { ok: false, error: 'route not found' });
37
- }
38
- // System endpoints trigger host-native side effects (a modal folder
39
- // dialog) and answer with a local filesystem path. The API-wide CORS `*`
40
- // would let any website stack dialogs and read the choice, so they are
41
- // restricted to loopback origins — same-origin GETs and non-browser
42
- // clients send no Origin header at all and always pass.
43
- if (path.startsWith('/system/')) {
44
- const origin = req.headers.origin;
45
- if (origin && !isLoopbackOrigin(origin) && origin !== options.corsOrigin) {
46
- return json(res, 403, { ok: false, error: 'cross-origin access to system endpoints is not allowed' });
47
- }
48
- if (origin) {
49
- res.setHeader('Access-Control-Allow-Origin', origin);
50
- res.setHeader('Vary', 'Origin');
51
- }
52
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
53
- } else {
54
- setCors(req, res, options.corsOrigin);
55
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
56
- }
57
- try {
58
- if (req.method === 'GET' && path === '/health') return json(res, 200, service.health());
59
- // {path} on success, {path:null} when the user cancelled, 501 when the
60
- // host has no folder picker, 500 on real failures. `win=x,y,w,h` (the
61
- // calling browser window's screen rect) anchors the dialog so it opens
62
- // over the UI instead of the screen's top-left corner.
63
- if (req.method === 'GET' && path === '/system/pick-folder') {
64
- const parts = (url.searchParams.get('win') || '').split(',').map(Number);
65
- const windowRect = parts.length === 4 && parts.every(Number.isFinite)
66
- ? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
67
- : undefined;
68
- return json(res, 200, await pickFolder({ windowRect, title: '选择项目文件夹' }));
69
- }
70
- // Environment diagnostics: resolved executables, live version probes,
71
- // login-state files (no secrets). Answers "discovery showed the agent
72
- // but turns failed" on fresh machines.
73
- if (req.method === 'GET' && path === '/system/doctor') {
74
- return json(res, 200, (options.doctor && await options.doctor()) || {
75
- schema: 'agent-terminal-doctor/v1',
76
- error: 'doctor not wired',
77
- });
78
- }
79
- if (req.method === 'GET' && path === '/runtime') return json(res, 200, await service.runtime());
80
- if (req.method === 'GET' && path === '/agents') return json(res, 200, service.listAgents());
81
- if (req.method === 'POST' && path === '/agents/refresh') { await body(req); return json(res, 202, service.refreshAgents()); }
82
- if (req.method === 'GET' && path === '/profiles') return json(res, 200, service.listProfiles());
83
- if (req.method === 'GET' && path === '/maintenance-default') return json(res, 200, service.profiles.maintenanceDefault());
84
- if (req.method === 'GET' && path === '/embedding-config') return json(res, 200, service.profiles.embeddingConfig());
85
- if (req.method === 'PUT' && path === '/embedding-config') return json(res, 200, service.profiles.setEmbeddingConfig(await body(req)));
86
- if (req.method === 'PUT' && path === '/maintenance-default') return json(res, 200, service.profiles.setMaintenanceDefault((await body(req)).profileId));
87
- if (req.method === 'POST' && path === '/profiles') return json(res, 201, service.saveProfile(await body(req)));
88
- if (req.method === 'POST' && path === '/profiles/test') return json(res, 200, await service.testProfileDraft(await body(req)));
89
- if (req.method === 'POST' && path === '/profiles/detect-models') return json(res, 200, await service.detectProfileModels(await body(req)));
90
- if (req.method === 'GET' && path === '/capture-settings') return json(res, 200, service.getCaptureSettings());
91
- if (req.method === 'PUT' && path === '/capture-settings') return json(res, 200, service.saveCaptureSettings(await body(req)));
92
- if (req.method === 'GET' && path === '/ui-preferences') return json(res, 200, service.getUiPreferences());
93
- if (req.method === 'PUT' && path === '/ui-preferences') return json(res, 200, service.saveUiPreferences(await body(req)));
94
-
95
- if (path === '/projects' && req.method === 'GET') return json(res, 200, service.listProjects());
96
- if (path === '/projects' && req.method === 'POST') return json(res, 201, service.createProject(await body(req)));
97
- const projectMatch = path.match(/^\/projects\/([^/]+)$/);
98
- if (projectMatch) {
99
- const id = decode(projectMatch[1]);
100
- if (req.method === 'GET') return json(res, 200, service.getProject(id));
101
- if (req.method === 'PATCH') return json(res, 200, service.renameProject(id, await body(req)));
102
- if (req.method === 'DELETE') {
103
- service.removeProject(id, { keepSessions: url.searchParams.get('keepSessions') === '1' });
104
- res.writeHead(204); res.end(); return true;
105
- }
106
- }
107
-
108
- let match = path.match(/^\/profiles\/([^/]+)$/);
109
- if (match) {
110
- const id = decode(match[1]);
111
- if (req.method === 'GET') {
112
- const profile = service.getProfile(id);
113
- if (!profile) throw Object.assign(new Error('profile not found'), { status: 404 });
114
- return json(res, 200, profile);
115
- }
116
- if (req.method === 'PUT') return json(res, 200, service.saveProfile(await body(req), id));
117
- if (req.method === 'DELETE') {
118
- if (!service.deleteProfile(id)) throw Object.assign(new Error('profile not found'), { status: 404 });
119
- res.writeHead(204); res.end(); return true;
120
- }
121
- }
122
-
123
- match = path.match(/^\/profiles\/([^/]+)\/credential$/);
124
- if (match && req.method === 'PUT') {
125
- service.setCredential(decode(match[1]), await body(req));
126
- res.writeHead(204); res.end(); return true;
127
- }
128
- match = path.match(/^\/profiles\/([^/]+)\/test$/);
129
- if (match && req.method === 'POST') return json(res, 200, await service.testProfile(decode(match[1])));
130
-
131
- if (path === '/sessions' && req.method === 'GET') {
132
- const sessions = service.listSessions(url.searchParams.get('contextId'), {
133
- includeArchived: ['1', 'true'].includes(url.searchParams.get('archived')),
134
- });
135
- return json(res, 200, { sessions });
136
- }
137
- if (path === '/sessions' && req.method === 'POST') {
138
- const input = await body(req);
139
- const session = service.startSession(input.context, input.aiProfileId, input);
140
- return json(res, 201, { session });
141
- }
142
-
143
- match = path.match(/^\/sessions\/([^/]+)\/archive$/);
144
- if (match && req.method === 'PUT') return json(res, 200, service.archiveSession(decode(match[1])));
145
- if (match && req.method === 'DELETE') {
146
- service.restoreSession(decode(match[1]));
147
- res.writeHead(204); res.end(); return true;
148
- }
149
-
150
- match = path.match(/^\/sessions\/([^/]+)\/context-usage$/);
151
- if (match && req.method === 'GET') return json(res, 200, service.contextUsage(decode(match[1])));
152
- match = path.match(/^\/sessions\/([^/]+)\/knowledge$/);
153
- if (match && req.method === 'GET') return json(res, 200, service.knowledgeContext(decode(match[1])));
154
- match = path.match(/^\/sessions\/([^/]+)\/history$/);
155
- if (match && req.method === 'GET') return json(res, 200, service.loadHistory(decode(match[1]), {
156
- before: url.searchParams.get('before'), limit: url.searchParams.get('limit') || 20,
157
- }));
158
- // Native background-task history is a pure read over the session's own
159
- // durable journal: the scope is compared against the trusted service
160
- // identity (never client paths), and 409 answers carry resync so the
161
- // client drops its cursor and restarts from the task list.
162
- match = path.match(/^\/sessions\/([^/]+)\/native-tasks$/);
163
- if (match && req.method === 'GET') {
164
- return json(res, 200, await service.listNativeTasks(decode(match[1]), {
165
- storeScope: url.searchParams.get('storeScope'),
166
- contextId: url.searchParams.get('contextId'),
167
- }));
168
- }
169
- match = path.match(/^\/sessions\/([^/]+)\/native-tasks\/history$/);
170
- if (match && req.method === 'POST') {
171
- return json(res, 200, await service.loadNativeTaskHistory(decode(match[1]), await body(req)));
172
- }
173
- // Delegated sessions: POST issues one trusted Workbench delegation
174
- // command (idempotent by requestId; accepted ≠ model receipt), GET is a
175
- // pure read of the durable relations/commands and real child status.
176
- match = path.match(/^\/sessions\/([^/]+)\/delegations$/);
177
- if (match) {
178
- const sessionId = decode(match[1]);
179
- if (req.method === 'POST') {
180
- return json(res, 202, await service.createDelegation(sessionId, await body(req)));
181
- }
182
- if (req.method === 'GET') {
183
- return json(res, 200, service.listDelegations(sessionId, {
184
- storeScope: url.searchParams.get('storeScope'),
185
- contextId: url.searchParams.get('contextId'),
186
- }));
187
- }
188
- }
189
-
190
- if (path === '/sessions/changes' && req.method === 'GET') {
191
- const contextId = url.searchParams.get('contextId') === '*' ? '*' : cleanId(url.searchParams.get('contextId'), 'contextId');
192
- res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
193
- res.write(': workbench session summaries\n\n');
194
- const unsubscribe = service.subscribeSessionChanges(contextId, sequenceFromRequest(req, url), event => writeSse(res, { ...event, sequence: event.revision }));
195
- const heartbeat = setInterval(() => { if (!res.destroyed) res.write(': keepalive\n\n'); }, 15000);
196
- heartbeat.unref?.();
197
- const close = () => { clearInterval(heartbeat); unsubscribe(); };
198
- req.on('close', close); res.on('close', close);
199
- return true;
200
- }
201
-
202
- match = path.match(/^\/sessions\/([^/]+)$/);
203
- if (match && req.method === 'GET') return json(res, 200, service.loadSession(decode(match[1])));
204
-
205
- match = path.match(/^\/sessions\/([^/]+)\/events$/);
206
- if (match && req.method === 'GET') {
207
- const sessionId = decode(match[1]);
208
- const after = sequenceFromRequest(req, url);
209
- service.loadSession(sessionId);
210
- res.writeHead(200, {
211
- 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform',
212
- Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
213
- 'Access-Control-Allow-Origin': options.corsOrigin || '*',
214
- });
215
- res.write(': claude-workbench stream\n\n');
216
- const unsubscribe = service.subscribe(sessionId, after, event => writeSse(res, event));
217
- const heartbeat = setInterval(() => { try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {} }, 15000);
218
- heartbeat.unref && heartbeat.unref();
219
- let closed = false;
220
- const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); try { unsubscribe(); } catch {} };
221
- req.on('close', close); req.on('error', close); res.on('close', close);
222
- return true;
223
- }
224
-
225
- match = path.match(/^\/sessions\/([^/]+)\/input$/);
226
- if (match && req.method === 'POST') {
227
- const input = await body(req, 32 * 1024 * 1024);
228
- return json(res, 202, await service.send(decode(match[1]), input.text, input.options, input.attachments));
229
- }
230
- match = path.match(/^\/sessions\/([^/]+)\/commands$/);
231
- if (match && req.method === 'GET') return json(res, 200, await service.listCommands(decode(match[1])));
232
-
233
- match = path.match(/^\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
234
- if (match && req.method === 'GET') {
235
- const attachment = service.readAttachment(decode(match[1]), decode(match[2]));
236
- const headers = {
237
- 'Content-Type': attachment.metadata.mediaType,
238
- 'Content-Length': attachment.data.length,
239
- 'Cache-Control': 'private, max-age=3600',
240
- 'X-Content-Type-Options': 'nosniff',
241
- };
242
- // Images stay inline for the chat UI; anything textual is forced to
243
- // download so a crafted .html/.svg attachment can never render in the
244
- // page origin.
245
- if (!String(attachment.metadata.mediaType || '').startsWith('image/')) {
246
- const safeName = String(attachment.metadata.name || 'attachment').replace(/[^\w.\-]+/g, '_').slice(0, 100) || 'attachment';
247
- headers['Content-Disposition'] = `attachment; filename="${safeName}"`;
248
- }
249
- res.writeHead(200, headers);
250
- res.end(attachment.data);
251
- return true;
252
- }
253
- match = path.match(/^\/sessions\/([^/]+)\/permissions\/([^/]+)\/resolve$/);
254
- if (match && req.method === 'POST') {
255
- return json(res, 200, service.resolvePermission(decode(match[1]), decode(match[2]), await body(req)));
256
- }
257
- match = path.match(/^\/sessions\/([^/]+)\/abort$/);
258
- if (match && req.method === 'POST') return json(res, 202, service.abort(decode(match[1])));
259
-
260
- match = path.match(/^\/sessions\/([^/]+)\/permission-mode$/);
261
- if (match && req.method === 'PUT') {
262
- const input = await body(req);
263
- return json(res, 200, service.setPermissionMode(decode(match[1]), input.mode));
264
- }
265
- match = path.match(/^\/sessions\/([^/]+)\/selection$/);
266
- if (match && req.method === 'PUT') {
267
- const input = await body(req);
268
- return json(res, 200, service.updateSessionSelection(decode(match[1]), input.aiProfileId, input.model));
269
- }
270
- throw Object.assign(new Error('route not found'), { status: 404 });
271
- } catch (error) {
272
- if (res.headersSent) {
273
- if (!res.writableEnded) try { res.end(); } catch {}
274
- return true;
275
- }
276
- const status = Number(error.status) || (error.message === 'request body too large' ? 413 : 400);
277
- return json(res, status, {
278
- ok: false, error: safeError(error),
279
- ...(error.code ? { code: error.code } : {}),
280
- ...(error.resync ? { resync: true } : {}),
281
- });
282
- }
283
- };
284
- }
285
-
286
- function body(req, limit = 1024 * 1024) {
287
- return new Promise((resolve, reject) => {
288
- const chunks = []; let size = 0; let settled = false;
289
- req.on('data', chunk => {
290
- if (settled) return;
291
- size += chunk.length;
292
- if (size > limit) { settled = true; reject(new Error('request body too large')); return; }
293
- chunks.push(chunk);
294
- });
295
- req.on('end', () => {
296
- if (settled) return;
297
- try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
298
- catch { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); }
299
- });
300
- req.on('error', reject);
301
- });
302
- }
303
-
304
- function json(res, status, value) {
305
- const payload = status === 204 ? undefined : JSON.stringify(value);
306
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
307
- res.end(payload);
308
- return true;
309
- }
310
- function writeSse(res, event) {
311
- // Control events (workbench/resync) own no sequence; an `id:` line would
312
- // otherwise poison the browser's Last-Event-ID reconnect cursor.
313
- const sequence = Number(event.sequence);
314
- if (Number.isFinite(sequence) && sequence > 0) res.write(`id: ${sequence}\n`);
315
- res.write(`event: ${event.type}\n`);
316
- res.write(`data: ${JSON.stringify(event)}\n\n`);
317
- }
318
- function sequenceFromRequest(req, url) {
319
- const value = req.headers['last-event-id'] || url.searchParams.get('after') || 0;
320
- const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
321
- }
322
- function setCors(req, res, origin) {
323
- res.setHeader('Access-Control-Allow-Origin', origin || '*');
324
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID');
325
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
326
- }
327
- // `Origin` values a system endpoint may serve: the local machine itself
328
- // (the app is same-origin already; a dev server on another loopback port is
329
- // fine). `Origin: null` (sandboxed/data frames) and foreign hosts are not.
330
- function isLoopbackOrigin(origin) {
331
- let parsed;
332
- try { parsed = new URL(origin); } catch { return false; }
333
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
334
- return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(parsed.hostname);
335
- }
336
- function decode(value) { try { return cleanId(decodeURIComponent(value)); } catch (error) { error.status = 400; throw error; } }
337
- function safeError(error) { return String(error && error.message || 'request failed').replace(/[\r\n]+/g, ' ').slice(0, 500); }
338
-
339
- module.exports = { createHttpHandler, body, writeSse, sequenceFromRequest };
1
+ 'use strict';
2
+
3
+ const { DEFAULT_API_PREFIX, cleanId } = require('../../contracts');
4
+ const { pickFolder: defaultPickFolder } = require('./folder-picker');
5
+ const crypto = require('crypto');
6
+
7
+ function createHttpHandler(service, options = {}) {
8
+ const prefix = String(options.apiPrefix || DEFAULT_API_PREFIX).replace(/\/$/, '');
9
+ // Stateless host capability — the picker module is required directly.
10
+ const pickFolder = options.pickFolder || defaultPickFolder;
11
+ return async function handle(req, res) {
12
+ const url = new URL(req.url, 'http://127.0.0.1');
13
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return false;
14
+ const path = url.pathname.slice(prefix.length) || '/';
15
+ if (path.startsWith('/internal/')) {
16
+ const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
17
+ const token = options.runtimeToken;
18
+ const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket && req.socket.remoteAddress);
19
+ if (!local || req.headers.origin || !token || Buffer.byteLength(supplied) !== Buffer.byteLength(token) || !crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) return json(res, 403, { ok: false, error: 'trusted local service required' });
20
+ if (req.method === 'POST' && path === '/internal/maintenance-config') {
21
+ try { return json(res, 200, service.profiles.maintenanceRuntime()); }
22
+ catch { return json(res, 409, { ok: false, error: 'maintenance default profile is not configured' }); }
23
+ }
24
+ if (req.method === 'POST' && path === '/internal/import-profiles') {
25
+ try { return json(res, 200, { mapping: service.profiles.importLegacy(await body(req)) }); }
26
+ catch (error) { return json(res, error.status || 409, { ok: false, error: 'legacy profile migration did not complete; original configuration is preserved' }); }
27
+ }
28
+ if (req.method === 'POST' && path === '/internal/embedding-config') {
29
+ try { return json(res, 200, service.profiles.embeddingRuntime()); }
30
+ catch { return json(res, 409, { ok: false, error: '向量模型未配置。' }); }
31
+ }
32
+ if (req.method === 'POST' && path === '/internal/import-embedding') {
33
+ try { return json(res, 200, service.profiles.importEmbedding(await body(req))); }
34
+ catch { return json(res, 409, { ok: false, error: '向量配置迁移待恢复,原配置保留。' }); }
35
+ }
36
+ return json(res, 404, { ok: false, error: 'route not found' });
37
+ }
38
+ // System endpoints trigger host-native side effects (a modal folder
39
+ // dialog) and answer with a local filesystem path. The API-wide CORS `*`
40
+ // would let any website stack dialogs and read the choice, so they are
41
+ // restricted to loopback origins — same-origin GETs and non-browser
42
+ // clients send no Origin header at all and always pass.
43
+ if (path.startsWith('/system/')) {
44
+ const origin = req.headers.origin;
45
+ if (origin && !isLoopbackOrigin(origin) && origin !== options.corsOrigin) {
46
+ return json(res, 403, { ok: false, error: 'cross-origin access to system endpoints is not allowed' });
47
+ }
48
+ if (origin) {
49
+ res.setHeader('Access-Control-Allow-Origin', origin);
50
+ res.setHeader('Vary', 'Origin');
51
+ }
52
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
53
+ } else {
54
+ setCors(req, res, options.corsOrigin);
55
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
56
+ }
57
+ try {
58
+ if (req.method === 'GET' && path === '/health') return json(res, 200, service.health());
59
+ // {path} on success, {path:null} when the user cancelled, 501 when the
60
+ // host has no folder picker, 500 on real failures. `win=x,y,w,h` (the
61
+ // calling browser window's screen rect) anchors the dialog so it opens
62
+ // over the UI instead of the screen's top-left corner.
63
+ if (req.method === 'GET' && path === '/system/pick-folder') {
64
+ const parts = (url.searchParams.get('win') || '').split(',').map(Number);
65
+ const windowRect = parts.length === 4 && parts.every(Number.isFinite)
66
+ ? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
67
+ : undefined;
68
+ return json(res, 200, await pickFolder({ windowRect, title: '选择项目文件夹' }));
69
+ }
70
+ // Environment diagnostics: resolved executables, live version probes,
71
+ // login-state files (no secrets). Answers "discovery showed the agent
72
+ // but turns failed" on fresh machines.
73
+ if (req.method === 'GET' && path === '/system/doctor') {
74
+ return json(res, 200, (options.doctor && await options.doctor()) || {
75
+ schema: 'agent-terminal-doctor/v1',
76
+ error: 'doctor not wired',
77
+ });
78
+ }
79
+ if (req.method === 'GET' && path === '/runtime') return json(res, 200, await service.runtime());
80
+ if (req.method === 'GET' && path === '/agents') return json(res, 200, service.listAgents());
81
+ if (req.method === 'POST' && path === '/agents/refresh') { await body(req); return json(res, 202, service.refreshAgents()); }
82
+ if (req.method === 'GET' && path === '/profiles') return json(res, 200, service.listProfiles());
83
+ if (req.method === 'GET' && path === '/maintenance-default') return json(res, 200, service.profiles.maintenanceDefault());
84
+ if (req.method === 'GET' && path === '/embedding-config') return json(res, 200, service.profiles.embeddingConfig());
85
+ if (req.method === 'PUT' && path === '/embedding-config') return json(res, 200, service.profiles.setEmbeddingConfig(await body(req)));
86
+ if (req.method === 'PUT' && path === '/maintenance-default') return json(res, 200, service.profiles.setMaintenanceDefault((await body(req)).profileId));
87
+ if (req.method === 'POST' && path === '/profiles') return json(res, 201, service.saveProfile(await body(req)));
88
+ if (req.method === 'POST' && path === '/profiles/test') return json(res, 200, await service.testProfileDraft(await body(req)));
89
+ if (req.method === 'POST' && path === '/profiles/detect-models') return json(res, 200, await service.detectProfileModels(await body(req)));
90
+ if (req.method === 'GET' && path === '/capture-settings') return json(res, 200, service.getCaptureSettings());
91
+ if (req.method === 'PUT' && path === '/capture-settings') return json(res, 200, service.saveCaptureSettings(await body(req)));
92
+ if (req.method === 'GET' && path === '/ui-preferences') return json(res, 200, service.getUiPreferences());
93
+ if (req.method === 'PUT' && path === '/ui-preferences') return json(res, 200, service.saveUiPreferences(await body(req)));
94
+
95
+ if (path === '/projects' && req.method === 'GET') return json(res, 200, service.listProjects());
96
+ if (path === '/projects' && req.method === 'POST') return json(res, 201, service.createProject(await body(req)));
97
+ const projectMatch = path.match(/^\/projects\/([^/]+)$/);
98
+ if (projectMatch) {
99
+ const id = decode(projectMatch[1]);
100
+ if (req.method === 'GET') return json(res, 200, service.getProject(id));
101
+ if (req.method === 'PATCH') return json(res, 200, service.renameProject(id, await body(req)));
102
+ if (req.method === 'DELETE') {
103
+ service.removeProject(id, { keepSessions: url.searchParams.get('keepSessions') === '1' });
104
+ res.writeHead(204); res.end(); return true;
105
+ }
106
+ }
107
+
108
+ let match = path.match(/^\/profiles\/([^/]+)$/);
109
+ if (match) {
110
+ const id = decode(match[1]);
111
+ if (req.method === 'GET') {
112
+ const profile = service.getProfile(id);
113
+ if (!profile) throw Object.assign(new Error('profile not found'), { status: 404 });
114
+ return json(res, 200, profile);
115
+ }
116
+ if (req.method === 'PUT') return json(res, 200, service.saveProfile(await body(req), id));
117
+ if (req.method === 'DELETE') {
118
+ if (!service.deleteProfile(id)) throw Object.assign(new Error('profile not found'), { status: 404 });
119
+ res.writeHead(204); res.end(); return true;
120
+ }
121
+ }
122
+
123
+ match = path.match(/^\/profiles\/([^/]+)\/credential$/);
124
+ if (match && req.method === 'PUT') {
125
+ service.setCredential(decode(match[1]), await body(req));
126
+ res.writeHead(204); res.end(); return true;
127
+ }
128
+ match = path.match(/^\/profiles\/([^/]+)\/test$/);
129
+ if (match && req.method === 'POST') return json(res, 200, await service.testProfile(decode(match[1])));
130
+
131
+ if (path === '/sessions' && req.method === 'GET') {
132
+ const sessions = service.listSessions(url.searchParams.get('contextId'), {
133
+ includeArchived: ['1', 'true'].includes(url.searchParams.get('archived')),
134
+ });
135
+ return json(res, 200, { sessions });
136
+ }
137
+ if (path === '/sessions' && req.method === 'POST') {
138
+ const input = await body(req);
139
+ const session = service.startSession(input.context, input.aiProfileId, input);
140
+ return json(res, 201, { session });
141
+ }
142
+
143
+ match = path.match(/^\/sessions\/([^/]+)\/archive$/);
144
+ if (match && req.method === 'PUT') return json(res, 200, service.archiveSession(decode(match[1])));
145
+ if (match && req.method === 'DELETE') {
146
+ service.restoreSession(decode(match[1]));
147
+ res.writeHead(204); res.end(); return true;
148
+ }
149
+
150
+ match = path.match(/^\/sessions\/([^/]+)\/context-usage$/);
151
+ if (match && req.method === 'GET') return json(res, 200, service.contextUsage(decode(match[1])));
152
+ match = path.match(/^\/sessions\/([^/]+)\/knowledge$/);
153
+ if (match && req.method === 'GET') return json(res, 200, service.knowledgeContext(decode(match[1])));
154
+ match = path.match(/^\/sessions\/([^/]+)\/history$/);
155
+ if (match && req.method === 'GET') return json(res, 200, service.loadHistory(decode(match[1]), {
156
+ before: url.searchParams.get('before'), limit: url.searchParams.get('limit') || 20,
157
+ }));
158
+ // Native background-task history is a pure read over the session's own
159
+ // durable journal: the scope is compared against the trusted service
160
+ // identity (never client paths), and 409 answers carry resync so the
161
+ // client drops its cursor and restarts from the task list.
162
+ match = path.match(/^\/sessions\/([^/]+)\/native-tasks$/);
163
+ if (match && req.method === 'GET') {
164
+ return json(res, 200, await service.listNativeTasks(decode(match[1]), {
165
+ storeScope: url.searchParams.get('storeScope'),
166
+ contextId: url.searchParams.get('contextId'),
167
+ }));
168
+ }
169
+ match = path.match(/^\/sessions\/([^/]+)\/native-tasks\/history$/);
170
+ if (match && req.method === 'POST') {
171
+ return json(res, 200, await service.loadNativeTaskHistory(decode(match[1]), await body(req)));
172
+ }
173
+ // Delegated sessions: POST issues one trusted Workbench delegation
174
+ // command (idempotent by requestId; accepted ≠ model receipt), GET is a
175
+ // pure read of the durable relations/commands and real child status.
176
+ match = path.match(/^\/sessions\/([^/]+)\/delegations$/);
177
+ if (match) {
178
+ const sessionId = decode(match[1]);
179
+ if (req.method === 'POST') {
180
+ return json(res, 202, await service.createDelegation(sessionId, await body(req)));
181
+ }
182
+ if (req.method === 'GET') {
183
+ return json(res, 200, service.listDelegations(sessionId, {
184
+ storeScope: url.searchParams.get('storeScope'),
185
+ contextId: url.searchParams.get('contextId'),
186
+ }));
187
+ }
188
+ }
189
+
190
+ if (path === '/sessions/changes' && req.method === 'GET') {
191
+ const contextId = url.searchParams.get('contextId') === '*' ? '*' : cleanId(url.searchParams.get('contextId'), 'contextId');
192
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
193
+ res.write(': workbench session summaries\n\n');
194
+ const unsubscribe = service.subscribeSessionChanges(contextId, sequenceFromRequest(req, url), event => writeSse(res, { ...event, sequence: event.revision }));
195
+ const heartbeat = setInterval(() => { if (!res.destroyed) res.write(': keepalive\n\n'); }, 15000);
196
+ heartbeat.unref?.();
197
+ const close = () => { clearInterval(heartbeat); unsubscribe(); };
198
+ req.on('close', close); res.on('close', close);
199
+ return true;
200
+ }
201
+
202
+ match = path.match(/^\/sessions\/([^/]+)$/);
203
+ if (match && req.method === 'GET') return json(res, 200, service.loadSession(decode(match[1])));
204
+
205
+ match = path.match(/^\/sessions\/([^/]+)\/events$/);
206
+ if (match && req.method === 'GET') {
207
+ const sessionId = decode(match[1]);
208
+ const after = sequenceFromRequest(req, url);
209
+ service.loadSession(sessionId);
210
+ res.writeHead(200, {
211
+ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform',
212
+ Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
213
+ 'Access-Control-Allow-Origin': options.corsOrigin || '*',
214
+ });
215
+ res.write(': claude-workbench stream\n\n');
216
+ const unsubscribe = service.subscribe(sessionId, after, event => writeSse(res, event));
217
+ const heartbeat = setInterval(() => { try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {} }, 15000);
218
+ heartbeat.unref && heartbeat.unref();
219
+ let closed = false;
220
+ const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); try { unsubscribe(); } catch {} };
221
+ req.on('close', close); req.on('error', close); res.on('close', close);
222
+ return true;
223
+ }
224
+
225
+ match = path.match(/^\/sessions\/([^/]+)\/input$/);
226
+ if (match && req.method === 'POST') {
227
+ const input = await body(req, 32 * 1024 * 1024);
228
+ return json(res, 202, await service.send(decode(match[1]), input.text, input.options, input.attachments));
229
+ }
230
+ match = path.match(/^\/sessions\/([^/]+)\/commands$/);
231
+ if (match && req.method === 'GET') return json(res, 200, await service.listCommands(decode(match[1])));
232
+
233
+ match = path.match(/^\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
234
+ if (match && req.method === 'GET') {
235
+ const attachment = service.readAttachment(decode(match[1]), decode(match[2]));
236
+ const headers = {
237
+ 'Content-Type': attachment.metadata.mediaType,
238
+ 'Content-Length': attachment.data.length,
239
+ 'Cache-Control': 'private, max-age=3600',
240
+ 'X-Content-Type-Options': 'nosniff',
241
+ };
242
+ // Images stay inline for the chat UI; anything textual is forced to
243
+ // download so a crafted .html/.svg attachment can never render in the
244
+ // page origin.
245
+ if (!String(attachment.metadata.mediaType || '').startsWith('image/')) {
246
+ const safeName = String(attachment.metadata.name || 'attachment').replace(/[^\w.\-]+/g, '_').slice(0, 100) || 'attachment';
247
+ headers['Content-Disposition'] = `attachment; filename="${safeName}"`;
248
+ }
249
+ res.writeHead(200, headers);
250
+ res.end(attachment.data);
251
+ return true;
252
+ }
253
+ match = path.match(/^\/sessions\/([^/]+)\/permissions\/([^/]+)\/resolve$/);
254
+ if (match && req.method === 'POST') {
255
+ return json(res, 200, service.resolvePermission(decode(match[1]), decode(match[2]), await body(req)));
256
+ }
257
+ match = path.match(/^\/sessions\/([^/]+)\/abort$/);
258
+ if (match && req.method === 'POST') return json(res, 202, service.abort(decode(match[1])));
259
+
260
+ match = path.match(/^\/sessions\/([^/]+)\/permission-mode$/);
261
+ if (match && req.method === 'PUT') {
262
+ const input = await body(req);
263
+ return json(res, 200, service.setPermissionMode(decode(match[1]), input.mode));
264
+ }
265
+ match = path.match(/^\/sessions\/([^/]+)\/selection$/);
266
+ if (match && req.method === 'PUT') {
267
+ const input = await body(req);
268
+ return json(res, 200, service.updateSessionSelection(decode(match[1]), input.aiProfileId, input.model));
269
+ }
270
+ throw Object.assign(new Error('route not found'), { status: 404 });
271
+ } catch (error) {
272
+ if (res.headersSent) {
273
+ if (!res.writableEnded) try { res.end(); } catch {}
274
+ return true;
275
+ }
276
+ const status = Number(error.status) || (error.message === 'request body too large' ? 413 : 400);
277
+ return json(res, status, {
278
+ ok: false, error: safeError(error),
279
+ ...(error.code ? { code: error.code } : {}),
280
+ ...(error.resync ? { resync: true } : {}),
281
+ });
282
+ }
283
+ };
284
+ }
285
+
286
+ function body(req, limit = 1024 * 1024) {
287
+ return new Promise((resolve, reject) => {
288
+ const chunks = []; let size = 0; let settled = false;
289
+ req.on('data', chunk => {
290
+ if (settled) return;
291
+ size += chunk.length;
292
+ if (size > limit) { settled = true; reject(new Error('request body too large')); return; }
293
+ chunks.push(chunk);
294
+ });
295
+ req.on('end', () => {
296
+ if (settled) return;
297
+ try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
298
+ catch { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); }
299
+ });
300
+ req.on('error', reject);
301
+ });
302
+ }
303
+
304
+ function json(res, status, value) {
305
+ const payload = status === 204 ? undefined : JSON.stringify(value);
306
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
307
+ res.end(payload);
308
+ return true;
309
+ }
310
+ function writeSse(res, event) {
311
+ // Control events (workbench/resync) own no sequence; an `id:` line would
312
+ // otherwise poison the browser's Last-Event-ID reconnect cursor.
313
+ const sequence = Number(event.sequence);
314
+ if (Number.isFinite(sequence) && sequence > 0) res.write(`id: ${sequence}\n`);
315
+ res.write(`event: ${event.type}\n`);
316
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
317
+ }
318
+ function sequenceFromRequest(req, url) {
319
+ const value = req.headers['last-event-id'] || url.searchParams.get('after') || 0;
320
+ const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
321
+ }
322
+ function setCors(req, res, origin) {
323
+ res.setHeader('Access-Control-Allow-Origin', origin || '*');
324
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID');
325
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
326
+ }
327
+ // `Origin` values a system endpoint may serve: the local machine itself
328
+ // (the app is same-origin already; a dev server on another loopback port is
329
+ // fine). `Origin: null` (sandboxed/data frames) and foreign hosts are not.
330
+ function isLoopbackOrigin(origin) {
331
+ let parsed;
332
+ try { parsed = new URL(origin); } catch { return false; }
333
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
334
+ return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(parsed.hostname);
335
+ }
336
+ function decode(value) { try { return cleanId(decodeURIComponent(value)); } catch (error) { error.status = 400; throw error; } }
337
+ function safeError(error) { return String(error && error.message || 'request failed').replace(/[\r\n]+/g, ' ').slice(0, 500); }
338
+
339
+ module.exports = { createHttpHandler, body, writeSse, sequenceFromRequest };