@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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