@sanqianx/claude-ai-workbench 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +129 -0
- package/apps/agent-terminal/app.css +567 -0
- package/apps/agent-terminal/app.js +2392 -0
- package/apps/agent-terminal/index.html +158 -0
- package/apps/ai-profile-settings-host/index.html +25 -0
- package/apps/ai-profile-settings-host/page.css +1 -0
- package/apps/ai-profile-settings-host/page.js +113 -0
- package/apps/minimal-test-host/README.md +15 -0
- package/apps/minimal-test-host/host.css +2 -0
- package/apps/minimal-test-host/host.js +35 -0
- package/apps/minimal-test-host/index.html +58 -0
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
- package/docs/sdk/FILE_LAYOUT.md +31 -0
- package/docs/sdk/INTEGRATION.md +126 -0
- package/docs/sdk/README.md +21 -0
- package/docs/sdk/openapi.yaml +760 -0
- package/package.json +51 -0
- package/packages/client/README.md +11 -0
- package/packages/client/index.d.ts +81 -0
- package/packages/client/index.js +198 -0
- package/packages/client/package.json +29 -0
- package/packages/contracts/README.md +9 -0
- package/packages/contracts/index.d.ts +252 -0
- package/packages/contracts/index.js +295 -0
- package/packages/contracts/package.json +24 -0
- package/packages/core/README.md +8 -0
- package/packages/core/index.d.ts +76 -0
- package/packages/core/index.js +214 -0
- package/packages/core/package.json +29 -0
- package/packages/server/README.md +17 -0
- package/packages/server/bin/agent-terminal-server.js +36 -0
- package/packages/server/bin/claude-workbench-server.js +46 -0
- package/packages/server/bin/claude-workbench.js +225 -0
- package/packages/server/index.d.ts +145 -0
- package/packages/server/index.js +183 -0
- package/packages/server/lib/agent-discovery.js +112 -0
- package/packages/server/lib/agent-registry.js +98 -0
- package/packages/server/lib/attachment-store.js +78 -0
- package/packages/server/lib/bridges/app-server-bridge.js +372 -0
- package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
- package/packages/server/lib/capture-settings-store.js +34 -0
- package/packages/server/lib/context-resolver.js +40 -0
- package/packages/server/lib/drivers/cli-json-runner.js +960 -0
- package/packages/server/lib/drivers/codex-driver.js +1413 -0
- package/packages/server/lib/drivers/opencode-driver.js +1527 -0
- package/packages/server/lib/drivers/zcode-driver.js +1167 -0
- package/packages/server/lib/event-stream.js +35 -0
- package/packages/server/lib/folder-picker.js +623 -0
- package/packages/server/lib/http-handler.js +254 -0
- package/packages/server/lib/json-file.js +19 -0
- package/packages/server/lib/model-detection.js +295 -0
- package/packages/server/lib/profile-store.js +149 -0
- package/packages/server/lib/project-store.js +102 -0
- package/packages/server/lib/session-archive-store.js +53 -0
- package/packages/server/lib/system-doctor.js +119 -0
- package/packages/server/lib/ui-preferences-store.js +48 -0
- package/packages/server/lib/workbench-service.js +620 -0
- package/packages/server/package.json +34 -0
- package/packages/ui/README.md +30 -0
- package/packages/ui/ai-profile-settings.d.ts +45 -0
- package/packages/ui/ai-profile-settings.js +283 -0
- package/packages/ui/claude-terminal.d.ts +31 -0
- package/packages/ui/claude-terminal.js +343 -0
- package/packages/ui/index.d.ts +18 -0
- package/packages/ui/index.js +51 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/theme.css +13 -0
- package/src/assets/claude-prompts.json +21 -0
- package/src/backend/http/ai-profile-routes.js +61 -0
- package/src/backend/http/claude-routes.js +170 -0
- package/src/backend/http/node-helpers.js +45 -0
- package/src/backend/index.js +21 -0
- package/src/backend/lib/ai-adapter.js +144 -0
- package/src/backend/lib/ai-profile-store.js +259 -0
- package/src/backend/lib/ai-vendor-presets.js +83 -0
- package/src/backend/lib/ai-workspace.js +140 -0
- package/src/backend/lib/automation-config.js +253 -0
- package/src/backend/lib/claude-cli-runner.js +1749 -0
- package/src/backend/lib/data-dir.js +169 -0
- package/src/backend/lib/llm-client.js +176 -0
- package/src/backend/lib/model-context-windows.js +82 -0
- package/src/backend/lib/prompt-registry.js +166 -0
- package/src/backend/lib/runtime-config.js +121 -0
- package/src/index.js +7 -0
- package/src/sdk-wave-a.ts +1 -0
- package/src/sdk-wave-b.ts +1 -0
- package/src/sdk-wave-c.ts +1 -0
- package/src/sdk-wave-d.ts +1 -0
- package/src/sdk-wave-e.ts +1 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
normalizeContext, normalizeAttachments, normalizeAgentId, normalizeEffort, cleanId, PERMISSION_MODES, AGENTS,
|
|
7
|
+
} = require('../../contracts');
|
|
8
|
+
const { snapshotEvents, subscribeNormalized } = require('./event-stream');
|
|
9
|
+
|
|
10
|
+
const ACTIVE_STATES = new Set(['created', 'spawning', 'running', 'thinking', 'tool-running', 'pending-permission']);
|
|
11
|
+
|
|
12
|
+
class WorkbenchService {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.runner = options.runner;
|
|
15
|
+
this.profiles = options.profiles;
|
|
16
|
+
this.contexts = options.contexts;
|
|
17
|
+
this.attachments = options.attachments;
|
|
18
|
+
this.projects = options.projects || null;
|
|
19
|
+
this.sessionArchive = options.sessionArchive || null;
|
|
20
|
+
this.uiPreferences = options.uiPreferences || null;
|
|
21
|
+
this.captureSettings = options.captureSettings || null;
|
|
22
|
+
this.onCaptureSettingsSaved = options.onCaptureSettingsSaved || null;
|
|
23
|
+
this.agentDiscovery = options.agentDiscovery || null;
|
|
24
|
+
this.modelContextWindows = options.modelContextWindows || null;
|
|
25
|
+
this.modelDetection = options.modelDetection || null;
|
|
26
|
+
this.apiPrefix = String(options.apiPrefix || '/api/claude-workbench/v1').replace(/\/$/, '');
|
|
27
|
+
// Read from the synced manifest — a hardcoded literal here shipped 0.3.0
|
|
28
|
+
// in every /health response long after the actual release moved on.
|
|
29
|
+
this.version = options.version || require('../package.json').version;
|
|
30
|
+
this.maxSessions = Number(options.maxSessions || 50);
|
|
31
|
+
this.maxConcurrentRuns = Number(options.maxConcurrentRuns || 4);
|
|
32
|
+
this.testWorkspace = path.resolve(options.testWorkspace);
|
|
33
|
+
this.auditFile = options.auditFile;
|
|
34
|
+
this.timers = new Map();
|
|
35
|
+
this.turnMonitors = new Map();
|
|
36
|
+
this.sessionAgents = new Map();
|
|
37
|
+
const archiveRetentionDays = options.archiveRetentionDays === undefined ? 7 : Number(options.archiveRetentionDays);
|
|
38
|
+
this.archiveRetentionMs = Number.isFinite(archiveRetentionDays) && archiveRetentionDays > 0
|
|
39
|
+
? archiveRetentionDays * 24 * 60 * 60 * 1000
|
|
40
|
+
: 0;
|
|
41
|
+
this.archiveSweeper = null;
|
|
42
|
+
// Archived sessions past the retention window are purged on boot and
|
|
43
|
+
// once a day for the lifetime of the process.
|
|
44
|
+
if (this.sessionArchive && this.archiveRetentionMs > 0) {
|
|
45
|
+
this.purgeExpiredArchivedSessions();
|
|
46
|
+
this.archiveSweeper = setInterval(() => {
|
|
47
|
+
try { this.purgeExpiredArchivedSessions(); } catch { /* retried next sweep */ }
|
|
48
|
+
}, Number(options.archiveSweepIntervalMs || 24 * 60 * 60 * 1000));
|
|
49
|
+
if (typeof this.archiveSweeper.unref === 'function') this.archiveSweeper.unref();
|
|
50
|
+
}
|
|
51
|
+
fs.mkdirSync(this.testWorkspace, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
health() {
|
|
55
|
+
return {
|
|
56
|
+
status: 'ok', version: this.version, apiVersion: 'v1',
|
|
57
|
+
activeProcesses: this._activeCount(), sessions: this.runner.listSessions({}).length,
|
|
58
|
+
time: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
runtime() {
|
|
63
|
+
let executable = null;
|
|
64
|
+
try { executable = this.runner.findClaudeExecutableForSdk(); } catch {}
|
|
65
|
+
const command = executable && (executable.cmd || executable);
|
|
66
|
+
return { runtime: 'claude-code', available: Boolean(executable), executable: command ? path.basename(String(command)) : null, transport: 'agent-sdk' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Live availability probing when a discovery provider is wired in,
|
|
70
|
+
// otherwise fall back to what the injected runner can see.
|
|
71
|
+
listAgents() {
|
|
72
|
+
if (this.agentDiscovery) {
|
|
73
|
+
return { agents: Object.values(this.agentDiscovery()) };
|
|
74
|
+
}
|
|
75
|
+
let claudeAvailable = false;
|
|
76
|
+
try { claudeAvailable = Boolean(this.runner.findClaudeExecutableForSdk()); } catch {}
|
|
77
|
+
return {
|
|
78
|
+
agents: Object.keys(AGENTS).map(id => {
|
|
79
|
+
const agent = AGENTS[id];
|
|
80
|
+
return {
|
|
81
|
+
id: agent.id, label: agent.label, transport: agent.transport,
|
|
82
|
+
authModes: [...agent.authModes],
|
|
83
|
+
capabilities: { ...agent.capabilities, generationParams: [...agent.capabilities.generationParams] },
|
|
84
|
+
available: id === 'claude-code' ? claudeAvailable : false,
|
|
85
|
+
version: null, reason: '',
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
listProfiles() { return { profiles: this.profiles.list() }; }
|
|
92
|
+
getProfile(id) { return this.profiles.get(id); }
|
|
93
|
+
saveProfile(input, id) { return this.profiles.save(input, id); }
|
|
94
|
+
deleteProfile(id) { return this.profiles.delete(id); }
|
|
95
|
+
setCredential(id, credential) { return this.profiles.setCredential(id, credential); }
|
|
96
|
+
|
|
97
|
+
getCaptureSettings() {
|
|
98
|
+
if (!this.captureSettings) return { schema: 'capture-settings/v1', terminalConversations: false };
|
|
99
|
+
return this.captureSettings.read();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
saveCaptureSettings(input = {}) {
|
|
103
|
+
if (!this.captureSettings) {
|
|
104
|
+
throw Object.assign(new Error('capture settings are not wired into this service'), { status: 501 });
|
|
105
|
+
}
|
|
106
|
+
const saved = this.captureSettings.save(input);
|
|
107
|
+
// Push the new scope into the driver immediately: sessions started after
|
|
108
|
+
// this call carry the matching AI_CODING_EVENT_BRIDGE_CAPTURE env.
|
|
109
|
+
if (typeof this.onCaptureSettingsSaved === 'function') this.onCaptureSettingsSaved(saved.terminalConversations);
|
|
110
|
+
return saved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- projects ----
|
|
114
|
+
listProjects() {
|
|
115
|
+
this._requireProjects();
|
|
116
|
+
return { projects: this.projects.list() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getProject(id) {
|
|
120
|
+
this._requireProjects();
|
|
121
|
+
const projectId = cleanId(id, 'projectId');
|
|
122
|
+
const project = this.projects.get(projectId);
|
|
123
|
+
if (!project) throw Object.assign(new Error('project not found'), { status: 404 });
|
|
124
|
+
return project;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
createProject(input = {}) {
|
|
128
|
+
this._requireProjects();
|
|
129
|
+
const project = this.projects.create(input);
|
|
130
|
+
this.contexts.register(project.id, project.rootPath);
|
|
131
|
+
this._audit('project-create', { projectId: project.id, rootPath: project.rootPath });
|
|
132
|
+
return project;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
renameProject(id, input = {}) {
|
|
136
|
+
this._requireProjects();
|
|
137
|
+
const projectId = cleanId(id, 'projectId');
|
|
138
|
+
if (input.defaultAiProfileId) this.profiles.runtimeProfile(input.defaultAiProfileId);
|
|
139
|
+
const project = this.projects.update(projectId, input);
|
|
140
|
+
this._audit('project-rename', { projectId, name: project.name });
|
|
141
|
+
return project;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
removeProject(id, options = {}) {
|
|
145
|
+
this._requireProjects();
|
|
146
|
+
const projectId = cleanId(id, 'projectId');
|
|
147
|
+
if (!this.projects.get(projectId)) throw Object.assign(new Error('project not found'), { status: 404 });
|
|
148
|
+
const keepSessions = options.keepSessions === true;
|
|
149
|
+
for (const session of this.runner.listSessions({ projectSlug: projectId })) {
|
|
150
|
+
if (ACTIVE_STATES.has(session.state)) try { this.runner.abort(session.sessionId); } catch {}
|
|
151
|
+
if (!keepSessions) {
|
|
152
|
+
if (typeof this.runner.deleteSession === 'function') {
|
|
153
|
+
try { this.runner.deleteSession(session.sessionId); } catch {}
|
|
154
|
+
}
|
|
155
|
+
this.sessionArchive && this.sessionArchive.forget(session.sessionId);
|
|
156
|
+
}
|
|
157
|
+
this.sessionAgents.delete(session.sessionId);
|
|
158
|
+
}
|
|
159
|
+
this.projects.remove(projectId);
|
|
160
|
+
this.contexts.unregister && this.contexts.unregister(projectId);
|
|
161
|
+
this._audit('project-remove', { projectId, keepSessions });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_requireProjects() {
|
|
166
|
+
if (!this.projects) throw Object.assign(new Error('project workspace support is not enabled'), { status: 501 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- session archive ----
|
|
170
|
+
archiveSession(sessionId) {
|
|
171
|
+
this._requireArchive();
|
|
172
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
173
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
174
|
+
const record = this.sessionArchive.archive(id);
|
|
175
|
+
this._audit('session-archive', { sessionId: id });
|
|
176
|
+
return { sessionId: id, ...record };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
restoreSession(sessionId) {
|
|
180
|
+
this._requireArchive();
|
|
181
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
182
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
183
|
+
const restored = this.sessionArchive.restore(id);
|
|
184
|
+
this._audit('session-restore', { sessionId: id });
|
|
185
|
+
return { sessionId: id, restored };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Archived sessions never restored within the retention window are deleted
|
|
189
|
+
// outright: the runner record goes first (best effort) and the archive mark
|
|
190
|
+
// only once the record is gone — a failed delete keeps the session hidden
|
|
191
|
+
// and retries on the next sweep instead of resurfacing unarchived.
|
|
192
|
+
purgeExpiredArchivedSessions() {
|
|
193
|
+
if (!this.sessionArchive || !(this.archiveRetentionMs > 0)) return [];
|
|
194
|
+
const purged = [];
|
|
195
|
+
for (const sessionId of this.sessionArchive.expired(this.archiveRetentionMs)) {
|
|
196
|
+
let deleted = true;
|
|
197
|
+
if (typeof this.runner.deleteSession === 'function') {
|
|
198
|
+
try { this.runner.deleteSession(sessionId); } catch { deleted = false; }
|
|
199
|
+
}
|
|
200
|
+
this.sessionAgents.delete(sessionId);
|
|
201
|
+
if (deleted) {
|
|
202
|
+
this.sessionArchive.forget(sessionId);
|
|
203
|
+
purged.push(sessionId);
|
|
204
|
+
this._audit('archive-expired', { sessionId });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return purged;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getUiPreferences() {
|
|
211
|
+
if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
|
|
212
|
+
return this.uiPreferences.read();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
saveUiPreferences(input = {}) {
|
|
216
|
+
if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
|
|
217
|
+
return this.uiPreferences.save(input);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_requireArchive() {
|
|
221
|
+
if (!this.sessionArchive) throw Object.assign(new Error('session archive support is not enabled'), { status: 501 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
listSessions(contextId, options = {}) {
|
|
225
|
+
const id = cleanId(contextId, 'contextId');
|
|
226
|
+
const includeArchived = Boolean(options.includeArchived);
|
|
227
|
+
const all = this.runner.listSessions({ projectSlug: id });
|
|
228
|
+
const visible = includeArchived || !this.sessionArchive
|
|
229
|
+
? all
|
|
230
|
+
: all.filter(session => !this.sessionArchive.isArchived(session.sessionId));
|
|
231
|
+
return visible.map((session, index) => this._summary(session, index === 0));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
startSession(contextInput, aiProfileId, options = {}) {
|
|
235
|
+
const context = normalizeContext(contextInput);
|
|
236
|
+
if (this.runner.listSessions({}).length >= this.maxSessions) {
|
|
237
|
+
throw Object.assign(new Error('session limit reached'), { status: 429 });
|
|
238
|
+
}
|
|
239
|
+
const resolved = this.contexts.resolve(context);
|
|
240
|
+
const project = this.projects && this.projects.get(context.workspaceRef);
|
|
241
|
+
const agentId = normalizeAgentId(options.agentId || context.agentId || project && project.defaultAgentId);
|
|
242
|
+
const effort = normalizeEffort(options.effort);
|
|
243
|
+
// null explicitly selects the agent's login; omission inherits the project.
|
|
244
|
+
const profileChoice = aiProfileId !== undefined ? aiProfileId
|
|
245
|
+
: context.aiProfileId !== undefined ? context.aiProfileId : project && project.defaultAiProfileId;
|
|
246
|
+
const profileId = profileChoice ? cleanId(profileChoice, 'aiProfileId') : null;
|
|
247
|
+
const inheritsProject = aiProfileId === undefined && context.aiProfileId === undefined;
|
|
248
|
+
const selectedModel = options.model !== undefined ? options.model
|
|
249
|
+
: context.model !== undefined ? context.model : inheritsProject && project ? project.defaultModel : undefined;
|
|
250
|
+
const profile = this._runtimeProfile(profileId, selectedModel);
|
|
251
|
+
const started = this.runner.startChatSession({
|
|
252
|
+
slug: context.contextId,
|
|
253
|
+
projectPath: resolved.workspacePath,
|
|
254
|
+
kbPath: resolved.workspacePath,
|
|
255
|
+
aiProfile: profile,
|
|
256
|
+
permissionMode: options.permissionMode || profile.permissionMode || 'default',
|
|
257
|
+
agentId,
|
|
258
|
+
effort,
|
|
259
|
+
});
|
|
260
|
+
// CLI drivers recycle numeric ids after a restart; a fresh session must
|
|
261
|
+
// not inherit the archived flag of an unrelated predecessor that happened
|
|
262
|
+
// to hold the same id.
|
|
263
|
+
if (this.sessionArchive && this.sessionArchive.isArchived(started.sessionId)) {
|
|
264
|
+
this.sessionArchive.forget(started.sessionId);
|
|
265
|
+
}
|
|
266
|
+
this.sessionAgents.set(started.sessionId, agentId);
|
|
267
|
+
const session = this.runner.getSession(started.sessionId);
|
|
268
|
+
if (session) session.selectedModel = profile.mainModel || null;
|
|
269
|
+
this._audit('session-start', { sessionId: started.sessionId, contextId: context.contextId, profileId, agentId });
|
|
270
|
+
return this._summary(this.runner.getState(started.sessionId), true);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
loadSession(sessionId) {
|
|
274
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
275
|
+
const state = this.runner.getState(id);
|
|
276
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
277
|
+
const usage = this.runner.getSessionTokenUsage(id);
|
|
278
|
+
return {
|
|
279
|
+
session: this._summary(state, true),
|
|
280
|
+
events: snapshotEvents(this.runner, id),
|
|
281
|
+
permission: state.pendingPermission || null,
|
|
282
|
+
permissionMode: state.permissionMode || 'default',
|
|
283
|
+
tokenUsage: { ...usage, hasUsage: Boolean(usage && (usage.used || usage.inputTokens || usage.outputTokens)) },
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
subscribe(sessionId, afterSequence, onEvent) {
|
|
288
|
+
return subscribeNormalized(this.runner, cleanId(sessionId, 'sessionId'), Number(afterSequence || 0), onEvent);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async listCommands(sessionId) {
|
|
292
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
293
|
+
const state = this.runner.getState(id);
|
|
294
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
295
|
+
if (typeof this.runner.listSupportedCommands !== 'function') return { commands: [] };
|
|
296
|
+
const profile = this._profileForSession(id, state);
|
|
297
|
+
const commands = await this.runner.listSupportedCommands(id, profile);
|
|
298
|
+
return { commands: Array.isArray(commands) ? commands : [] };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
readAttachment(sessionId, attachmentId) {
|
|
302
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
303
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
304
|
+
const result = this.attachments && this.attachments.read(id, attachmentId);
|
|
305
|
+
if (!result) throw Object.assign(new Error('attachment not found'), { status: 404 });
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async send(sessionId, text, options = {}, attachmentInput = []) {
|
|
310
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
311
|
+
const state = this.runner.getState(id);
|
|
312
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
313
|
+
if (ACTIVE_STATES.has(state.state)) {
|
|
314
|
+
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
315
|
+
}
|
|
316
|
+
if (this._activeCount() >= this.maxConcurrentRuns) {
|
|
317
|
+
throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429 });
|
|
318
|
+
}
|
|
319
|
+
const value = String(text || '').trim();
|
|
320
|
+
const attachments = normalizeAttachments(attachmentInput);
|
|
321
|
+
if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
|
|
322
|
+
if (value.length > 200000) throw Object.assign(new Error('input exceeds 200000 characters'), { status: 413 });
|
|
323
|
+
const profile = this._profileForSession(id, state);
|
|
324
|
+
const session = this.runner.getSession(id);
|
|
325
|
+
const previousEventCount = session && session.outputBuffer ? session.outputBuffer.length : 0;
|
|
326
|
+
const stored = this.attachments ? this.attachments.saveMany(id, attachments) : attachments;
|
|
327
|
+
const displayAttachments = stored.map(item => ({
|
|
328
|
+
id: item.id, name: item.name, mediaType: item.mediaType, size: item.size,
|
|
329
|
+
width: item.width, height: item.height,
|
|
330
|
+
url: `${this.apiPrefix}/sessions/${encodeURIComponent(id)}/attachments/${encodeURIComponent(item.id)}`,
|
|
331
|
+
}));
|
|
332
|
+
const runtimeAttachments = attachments.map((item, index) => ({ ...displayAttachments[index], data: item.data }));
|
|
333
|
+
await this.runner.sendInput(id, value, profile, { ...(options || {}), attachments: runtimeAttachments, displayAttachments });
|
|
334
|
+
this._armTimeout(id, profile.timeoutMs);
|
|
335
|
+
this._watchTurn(id, previousEventCount);
|
|
336
|
+
this._audit('input', { sessionId: id, contextId: state.projectSlug, characters: value.length, attachments: attachments.length, attachmentBytes: attachments.reduce((sum, item) => sum + item.size, 0) });
|
|
337
|
+
return { accepted: true, sessionId: id };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
resolvePermission(sessionId, requestId, decision) {
|
|
341
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
342
|
+
const rid = cleanId(requestId, 'requestId');
|
|
343
|
+
const result = this.runner.resolvePermission(id, rid, decision || {});
|
|
344
|
+
this._audit('permission-resolve', { sessionId: id, requestId: rid, allow: decision && decision.allow === true });
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
abort(sessionId, reason = 'user-abort') {
|
|
349
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
350
|
+
if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
351
|
+
this._clearTimeout(id);
|
|
352
|
+
this._clearTurnMonitor(id);
|
|
353
|
+
this.runner.abort(id);
|
|
354
|
+
this._audit('abort', { sessionId: id, reason });
|
|
355
|
+
return { accepted: true, sessionId: id };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
setPermissionMode(sessionId, mode) {
|
|
359
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
360
|
+
if (!PERMISSION_MODES.includes(mode)) throw Object.assign(new Error('invalid permission mode'), { status: 400 });
|
|
361
|
+
const session = this.runner.getSession(id);
|
|
362
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
363
|
+
session.permissionMode = mode;
|
|
364
|
+
this._audit('permission-mode', { sessionId: id, mode });
|
|
365
|
+
return { sessionId: id, permissionMode: mode };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Rebind an existing session to another AI profile/model (or back to the
|
|
369
|
+
// agent's own login with aiProfileId null). The session and its history
|
|
370
|
+
// survive; the next turn simply runs under the new selection.
|
|
371
|
+
updateSessionSelection(sessionId, aiProfileId, model) {
|
|
372
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
373
|
+
const state = this.runner.getState(id);
|
|
374
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
375
|
+
if (ACTIVE_STATES.has(state.state)) {
|
|
376
|
+
throw Object.assign(new Error('session already has an active turn'), { status: 409 });
|
|
377
|
+
}
|
|
378
|
+
// _runtimeProfile resolves and validates the profile (404 unknown id,
|
|
379
|
+
// 400 disabled/missing credential/invalid model) exactly like startSession.
|
|
380
|
+
const profileId = aiProfileId ? cleanId(aiProfileId, 'aiProfileId') : null;
|
|
381
|
+
const profile = this._runtimeProfile(profileId, model === undefined ? undefined : model);
|
|
382
|
+
if (typeof this.runner.updateSelection === 'function') {
|
|
383
|
+
this.runner.updateSelection(id, profile, profile.mainModel || null);
|
|
384
|
+
} else {
|
|
385
|
+
const session = this.runner.getSession(id);
|
|
386
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
387
|
+
session.aiProfileId = profileId;
|
|
388
|
+
session.selectedModel = profile.mainModel || null;
|
|
389
|
+
}
|
|
390
|
+
this._audit('selection-update', { sessionId: id, contextId: state.projectSlug, profileId, model: profile.mainModel || null });
|
|
391
|
+
return this._summary(this.runner.getState(id), true);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async testProfile(profileId) {
|
|
395
|
+
const id = cleanId(profileId, 'profileId');
|
|
396
|
+
const profile = this.profiles.runtimeProfile(id);
|
|
397
|
+
return this._runProfileTest(profile, id);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async testProfileDraft(input = {}) {
|
|
401
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
|
|
402
|
+
throw Object.assign(new Error('profile is required'), { status: 400 });
|
|
403
|
+
}
|
|
404
|
+
const profile = this.profiles.runtimeDraft(input.profile, input.credential);
|
|
405
|
+
return this._runProfileTest(profile, profile.id);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Detect the models a provider draft can actually use (endpoint model list
|
|
409
|
+
// joined with models.dev capability metadata). Uses the same draft+credential
|
|
410
|
+
// resolution as testProfileDraft, so a blank credential falls back to the
|
|
411
|
+
// stored secret of an already-saved profile.
|
|
412
|
+
async detectProfileModels(input = {}) {
|
|
413
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
|
|
414
|
+
throw Object.assign(new Error('profile is required'), { status: 400 });
|
|
415
|
+
}
|
|
416
|
+
if (!this.modelDetection) {
|
|
417
|
+
throw Object.assign(new Error('model detection is unavailable'), { status: 501 });
|
|
418
|
+
}
|
|
419
|
+
// Detection precedes model configuration, so an empty model list is fine here.
|
|
420
|
+
const profile = this.profiles.runtimeDraft(input.profile, input.credential, { requireModel: false });
|
|
421
|
+
const result = await this.modelDetection.detectModels({ profile });
|
|
422
|
+
this._audit('profile-detect-models', { profileId: profile.id, count: result.models.length, sources: Object.keys(result.sources) });
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async _runProfileTest(profile, profileId) {
|
|
427
|
+
const id = cleanId(profileId, 'profileId');
|
|
428
|
+
const contextId = `profile-test-${Date.now().toString(36)}`;
|
|
429
|
+
const startedAt = Date.now();
|
|
430
|
+
const started = this.runner.startChatSession({
|
|
431
|
+
slug: contextId, projectPath: this.testWorkspace, kbPath: this.testWorkspace,
|
|
432
|
+
aiProfile: profile, permissionMode: 'default',
|
|
433
|
+
});
|
|
434
|
+
let firstEventMs = null;
|
|
435
|
+
let preview = '';
|
|
436
|
+
let unsubscribe = null;
|
|
437
|
+
try {
|
|
438
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
439
|
+
const timeout = setTimeout(() => reject(new Error('Claude Code profile test timed out')), Math.min(profile.timeoutMs, 300000));
|
|
440
|
+
unsubscribe = this.runner.subscribe(started.sessionId, event => {
|
|
441
|
+
if (firstEventMs == null && event.type !== 'claude/session-ready') firstEventMs = Date.now() - startedAt;
|
|
442
|
+
if (event.type === 'claude/result') {
|
|
443
|
+
clearTimeout(timeout);
|
|
444
|
+
if (event.isError) {
|
|
445
|
+
reject(new Error(String(event.result || event.message || event.error || 'Claude Code returned an error result')));
|
|
446
|
+
} else {
|
|
447
|
+
preview = String(event.result || event.text || '').slice(0, 500);
|
|
448
|
+
resolve(event);
|
|
449
|
+
}
|
|
450
|
+
} else if (event.type === 'claude/error') {
|
|
451
|
+
clearTimeout(timeout); reject(new Error(String(event.message || event.error || 'Claude Code runtime error')));
|
|
452
|
+
} else if (event.type === 'claude/aborted') {
|
|
453
|
+
clearTimeout(timeout); reject(new Error('Claude Code profile test was aborted'));
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
this.runner.sendInput(started.sessionId, 'Reply with exactly: WORKBENCH_RUNTIME_OK', profile, { permissionMode: 'default' }).catch(error => {
|
|
457
|
+
clearTimeout(timeout); reject(error);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
const state = this.runner.getState(started.sessionId);
|
|
461
|
+
return {
|
|
462
|
+
ok: true, runtime: 'claude-code', runtimeVerified: true, profileId: id,
|
|
463
|
+
model: outcome.model || state && state.model || profile.models.default,
|
|
464
|
+
firstEventMs: firstEventMs == null ? Date.now() - startedAt : firstEventMs,
|
|
465
|
+
durationMs: Date.now() - startedAt, resultPreview: redact(preview),
|
|
466
|
+
};
|
|
467
|
+
} finally {
|
|
468
|
+
try { unsubscribe && unsubscribe(); } catch {}
|
|
469
|
+
try { this.runner.abort(started.sessionId); } catch {}
|
|
470
|
+
try { this.runner.deleteSession(started.sessionId); } catch {}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
destroy() {
|
|
475
|
+
if (this.archiveSweeper) { clearInterval(this.archiveSweeper); this.archiveSweeper = null; }
|
|
476
|
+
for (const sessionId of this.timers.keys()) this._clearTimeout(sessionId);
|
|
477
|
+
for (const sessionId of this.turnMonitors.keys()) this._clearTurnMonitor(sessionId);
|
|
478
|
+
for (const session of this.runner.listSessions({})) {
|
|
479
|
+
if (ACTIVE_STATES.has(session.state)) try { this.runner.abort(session.sessionId); } catch {}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
_runtimeProfile(profileId, selectedModel) {
|
|
484
|
+
const profile = profileId ? this.profiles.runtimeProfile(profileId)
|
|
485
|
+
: { id: null, models: { default: '' }, timeoutMs: 300000 };
|
|
486
|
+
const model = selectedModel == null ? profile.models.default : String(selectedModel).trim();
|
|
487
|
+
if (model.length > 128 || /[\r\n\x00]/.test(model)) throw Object.assign(new Error('model is invalid'), { status: 400 });
|
|
488
|
+
return { ...profile, models: { ...profile.models, default: model }, mainModel: model };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
_profileForSession(id, state) {
|
|
492
|
+
const session = this.runner.getSession(id);
|
|
493
|
+
const model = session && Object.prototype.hasOwnProperty.call(session, 'selectedModel')
|
|
494
|
+
? session.selectedModel : state.selectedModel || state.model;
|
|
495
|
+
return this._runtimeProfile(state.aiProfileId, model);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
_summary(session, active) {
|
|
499
|
+
const agentId = session.agentId
|
|
500
|
+
|| (typeof this.runner.agentOf === 'function' ? this.runner.agentOf(session.sessionId) : null)
|
|
501
|
+
|| this.sessionAgents.get(session.sessionId)
|
|
502
|
+
|| 'claude-code';
|
|
503
|
+
return {
|
|
504
|
+
sessionId: session.sessionId,
|
|
505
|
+
contextId: session.projectSlug,
|
|
506
|
+
projectId: session.projectSlug,
|
|
507
|
+
agentId,
|
|
508
|
+
title: session.title || `Session ${String(session.sessionId).slice(-8)}`,
|
|
509
|
+
active: Boolean(active), state: session.state, model: session.model || null,
|
|
510
|
+
aiProfileId: session.aiProfileId || null, permissionMode: session.permissionMode || 'default',
|
|
511
|
+
selectedModel: session.selectedModel || this.runner.getSession(session.sessionId)?.selectedModel || null,
|
|
512
|
+
authSource: session.aiProfileId ? 'profile-injected' : 'agent-owned',
|
|
513
|
+
providerName: session.aiProfileId ? this.profiles.get(session.aiProfileId)?.name || 'Unavailable provider' : AGENTS[agentId].label,
|
|
514
|
+
archived: Boolean(this.sessionArchive && this.sessionArchive.isArchived(session.sessionId)),
|
|
515
|
+
turns: Number(session.turns || 0), startedAt: session.startedAt,
|
|
516
|
+
updatedAt: session.endedAt || session.startedAt, connectionState: 'connected',
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Approximate context occupancy for the capacity bubble. Token totals come
|
|
521
|
+
// from the driver; the per-category split is a characters/4 estimate over
|
|
522
|
+
// the session event log and is explicitly flagged as such.
|
|
523
|
+
contextUsage(sessionId) {
|
|
524
|
+
const id = cleanId(sessionId, 'sessionId');
|
|
525
|
+
const state = this.runner.getState(id);
|
|
526
|
+
if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
527
|
+
const usage = this.runner.getSessionTokenUsage(id) || {};
|
|
528
|
+
const contextWindow = this._contextWindowFor(state.model, this._sessionProfileContextWindow(id, state));
|
|
529
|
+
const breakdown = this._estimateBreakdown(id);
|
|
530
|
+
const used = Number(usage.used || usage.inputTokens || 0) || 0;
|
|
531
|
+
return {
|
|
532
|
+
sessionId: id,
|
|
533
|
+
agentId: this._summary(state, false).agentId,
|
|
534
|
+
model: state.model || null,
|
|
535
|
+
contextWindow,
|
|
536
|
+
used,
|
|
537
|
+
percent: contextWindow ? Math.round((used / contextWindow) * 1000) / 10 : null,
|
|
538
|
+
inputTokens: Number(usage.inputTokens || 0) || 0,
|
|
539
|
+
outputTokens: Number(usage.outputTokens || 0) || 0,
|
|
540
|
+
breakdown,
|
|
541
|
+
estimated: true,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_sessionProfileContextWindow(sessionId, state) {
|
|
546
|
+
try {
|
|
547
|
+
const profile = this._profileForSession(sessionId, state);
|
|
548
|
+
return profile ? Number(profile.contextWindow) || 0 : 0;
|
|
549
|
+
} catch { return 0; }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
_contextWindowFor(model, profileContextWindow) {
|
|
553
|
+
if (!model && !profileContextWindow) return 200000;
|
|
554
|
+
if (this.modelContextWindows && typeof this.modelContextWindows.resolveContextWindow === 'function') {
|
|
555
|
+
try {
|
|
556
|
+
// The profile store defaults contextWindow to 200000; that default must
|
|
557
|
+
// not shadow the recognition table (e.g. MiniMax → 1M). Only an explicit
|
|
558
|
+
// non-default override wins over auto-detection.
|
|
559
|
+
const explicit = Number(profileContextWindow) > 0 && Number(profileContextWindow) !== 200000 ? Number(profileContextWindow) : undefined;
|
|
560
|
+
const resolved = this.modelContextWindows.resolveContextWindow({ profileContextWindow: explicit, model, fallback: 200000 });
|
|
561
|
+
if (resolved) return resolved;
|
|
562
|
+
} catch { /* fall through to the default */ }
|
|
563
|
+
}
|
|
564
|
+
return Number(profileContextWindow) > 0 ? Number(profileContextWindow) : 200000;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
_estimateBreakdown(sessionId) {
|
|
568
|
+
const session = this.runner.getSession(sessionId);
|
|
569
|
+
const buckets = { messages: 0, tools: 0, other: 0 };
|
|
570
|
+
if (!session || !Array.isArray(session.outputBuffer)) return buckets;
|
|
571
|
+
for (const event of session.outputBuffer) {
|
|
572
|
+
const text = String(event.text || event.result || event.output || '');
|
|
573
|
+
const tokens = Math.ceil(text.length / 4);
|
|
574
|
+
if (event.type === 'claude/user-prompt' || event.type === 'claude/text-delta') buckets.messages += tokens;
|
|
575
|
+
else if (event.type === 'claude/tool-use' || event.type === 'claude/tool-result') buckets.tools += tokens;
|
|
576
|
+
else buckets.other += tokens;
|
|
577
|
+
}
|
|
578
|
+
return buckets;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
_activeCount() { return this.runner.listSessions({}).filter(item => ACTIVE_STATES.has(item.state)).length; }
|
|
582
|
+
_armTimeout(sessionId, timeoutMs) {
|
|
583
|
+
this._clearTimeout(sessionId);
|
|
584
|
+
const timer = setTimeout(() => { try { this.abort(sessionId, 'runtime-timeout'); } catch {} }, Math.min(Number(timeoutMs) || 300000, 30 * 60 * 1000));
|
|
585
|
+
timer.unref && timer.unref();
|
|
586
|
+
this.timers.set(sessionId, timer);
|
|
587
|
+
}
|
|
588
|
+
_clearTimeout(sessionId) { const timer = this.timers.get(sessionId); if (timer) clearTimeout(timer); this.timers.delete(sessionId); }
|
|
589
|
+
_watchTurn(sessionId, previousEventCount) {
|
|
590
|
+
this._clearTurnMonitor(sessionId);
|
|
591
|
+
let seen = 0;
|
|
592
|
+
let unsubscribe = null;
|
|
593
|
+
unsubscribe = this.runner.subscribe(sessionId, event => {
|
|
594
|
+
seen += 1;
|
|
595
|
+
if (seen <= previousEventCount) return;
|
|
596
|
+
if (['claude/result', 'claude/error', 'claude/aborted'].includes(event && event.type)) {
|
|
597
|
+
this._clearTimeout(sessionId);
|
|
598
|
+
if (unsubscribe) { unsubscribe(); this.turnMonitors.delete(sessionId); }
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
if (this.timers.has(sessionId)) this.turnMonitors.set(sessionId, unsubscribe);
|
|
602
|
+
else try { unsubscribe(); } catch {}
|
|
603
|
+
}
|
|
604
|
+
_clearTurnMonitor(sessionId) {
|
|
605
|
+
const unsubscribe = this.turnMonitors.get(sessionId);
|
|
606
|
+
if (unsubscribe) try { unsubscribe(); } catch {}
|
|
607
|
+
this.turnMonitors.delete(sessionId);
|
|
608
|
+
}
|
|
609
|
+
_audit(action, detail) {
|
|
610
|
+
if (!this.auditFile) return;
|
|
611
|
+
fs.mkdirSync(path.dirname(this.auditFile), { recursive: true });
|
|
612
|
+
fs.appendFileSync(this.auditFile, `${JSON.stringify({ at: new Date().toISOString(), action, ...detail })}\n`, 'utf8');
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function redact(value) {
|
|
617
|
+
return String(value || '').replace(/(?:sk|key|token)-[a-zA-Z0-9._-]{8,}/gi, '[REDACTED]');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
module.exports = { WorkbenchService, ACTIVE_STATES, redact };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@claude-ai-workbench/server",
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"type": "commonjs",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"claude-workbench-server": "bin/claude-workbench-server.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"require": "./index.js",
|
|
14
|
+
"default": "./index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.js",
|
|
20
|
+
"index.d.ts",
|
|
21
|
+
"bin",
|
|
22
|
+
"lib",
|
|
23
|
+
"legacy",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.141",
|
|
28
|
+
"@claude-ai-workbench/contracts": "0.11.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"license": "Apache-2.0"
|
|
34
|
+
}
|