@sanqianx/claude-ai-workbench 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ const API_VERSION = 'v1';
4
+ const DEFAULT_API_PREFIX = `/api/claude-workbench/${API_VERSION}`;
5
+ const RUNTIME = 'claude-code';
6
+ const DEFAULT_AGENT = 'claude-code';
7
+ const EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'max']);
8
+ const PERMISSION_MODES = Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']);
9
+ const IMAGE_MEDIA_TYPES = Object.freeze(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
10
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
11
+ const MAX_IMAGES_PER_MESSAGE = 20;
12
+ const MAX_IMAGE_MESSAGE_BYTES = 20 * 1024 * 1024;
13
+ const TEXT_MEDIA_TYPES = Object.freeze(['text/plain', 'text/markdown', 'application/json', 'text/csv']);
14
+ const TEXT_FILE_TYPES = Object.freeze({ '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json', '.csv': 'text/csv' });
15
+ const MAX_TEXT_BYTES = 512 * 1024;
16
+ const MAX_ATTACHMENTS_PER_MESSAGE = MAX_IMAGES_PER_MESSAGE;
17
+ const MAX_ATTACHMENT_MESSAGE_BYTES = MAX_IMAGE_MESSAGE_BYTES;
18
+
19
+ // Capability matrix for every agent the terminal knows about. Drivers must
20
+ // declare the same shape; the UI hides controls the active agent does not
21
+ // support instead of failing.
22
+ const AGENTS = Object.freeze({
23
+ 'claude-code': Object.freeze({
24
+ id: 'claude-code', label: 'Claude Code', transport: 'agent-sdk',
25
+ authModes: Object.freeze(['profile-injected']),
26
+ capabilities: Object.freeze({
27
+ thinking: true, permissions: true, toolStream: true, images: true,
28
+ resume: true, modelOverride: true, generationParams: Object.freeze(['effort']),
29
+ }),
30
+ }),
31
+ codex: Object.freeze({
32
+ id: 'codex', label: 'Codex', transport: 'cli-json',
33
+ authModes: Object.freeze(['agent-owned', 'profile-injected']),
34
+ capabilities: Object.freeze({
35
+ thinking: false,
36
+ // Permission modes map to exec sandboxes (read-only/workspace-write/danger).
37
+ permissions: true,
38
+ toolStream: true, images: true,
39
+ resume: true, modelOverride: true,
40
+ // The driver maps effort to `model_reasoning_effort` (low/medium/high/xhigh).
41
+ generationParams: Object.freeze(['effort']),
42
+ }),
43
+ }),
44
+ opencode: Object.freeze({
45
+ id: 'opencode', label: 'OpenCode', transport: 'cli',
46
+ authModes: Object.freeze(['agent-owned', 'profile-injected']),
47
+ capabilities: Object.freeze({
48
+ // The CLI streams reasoning parts; the driver maps them to thinking events.
49
+ thinking: true,
50
+ permissions: true, toolStream: true, images: true,
51
+ resume: true, modelOverride: true, generationParams: Object.freeze([]),
52
+ }),
53
+ }),
54
+ zcode: Object.freeze({
55
+ id: 'zcode', label: 'ZCode', transport: 'cli',
56
+ authModes: Object.freeze(['agent-owned', 'profile-injected']),
57
+ capabilities: Object.freeze({
58
+ // The bundled zcode CLI (app-server --stdio) exists, but the terminal has
59
+ // no bridge driver yet; every capability stays off until one ships.
60
+ thinking: false, permissions: false, toolStream: false, images: false,
61
+ resume: false, modelOverride: false, generationParams: Object.freeze([]),
62
+ }),
63
+ }),
64
+ });
65
+ const AGENT_IDS = Object.freeze(Object.keys(AGENTS));
66
+
67
+ const EVENT_TYPES = Object.freeze([
68
+ 'workbench/stream-open', 'workbench/stream-reconnecting',
69
+ 'claude/init', 'claude/state', 'claude/session-ready', 'claude/user-prompt',
70
+ 'claude/system-prompt', 'claude/text-start', 'claude/text-delta', 'claude/result',
71
+ 'claude/thinking-start', 'claude/thinking-delta', 'claude/tool-use', 'claude/tool-result',
72
+ 'claude/permission-request', 'claude/permission-resolved', 'claude/usage',
73
+ 'claude/commands', 'claude/image', 'claude/retry', 'claude/error', 'claude/stderr', 'claude/aborted',
74
+ 'claude/selection-changed',
75
+ ]);
76
+
77
+ function cleanId(value, field = 'id') {
78
+ const id = String(value || '').trim();
79
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(id)) {
80
+ throw Object.assign(new Error(`${field} is invalid`), { status: 400 });
81
+ }
82
+ return id;
83
+ }
84
+
85
+ function normalizeContext(input = {}) {
86
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
87
+ throw Object.assign(new Error('context must be an object'), { status: 400 });
88
+ }
89
+ return {
90
+ contextId: cleanId(input.contextId, 'contextId'),
91
+ contextType: input.contextType ? String(input.contextType).slice(0, 64) : undefined,
92
+ displayName: input.displayName ? String(input.displayName).slice(0, 256) : undefined,
93
+ workspaceRef: input.workspaceRef ? cleanId(input.workspaceRef, 'workspaceRef') : undefined,
94
+ aiProfileId: input.aiProfileId === null ? null : input.aiProfileId ? cleanId(input.aiProfileId, 'aiProfileId') : undefined,
95
+ model: input.model === null ? null : input.model ? String(input.model).trim().slice(0, 128) : undefined,
96
+ metadata: input.metadata && typeof input.metadata === 'object' && !Array.isArray(input.metadata)
97
+ ? input.metadata : undefined,
98
+ };
99
+ }
100
+
101
+ function normalizeAgentId(value) {
102
+ const id = String(value || DEFAULT_AGENT).trim();
103
+ if (!AGENTS[id]) throw Object.assign(new Error(`unknown agent: ${id}`), { status: 400 });
104
+ return id;
105
+ }
106
+
107
+ function normalizeEffort(value) {
108
+ if (value == null || value === '') return undefined;
109
+ const effort = String(value).trim();
110
+ if (!EFFORT_LEVELS.includes(effort)) {
111
+ throw Object.assign(new Error(`effort must be one of ${EFFORT_LEVELS.join(', ')}`), { status: 400 });
112
+ }
113
+ return effort;
114
+ }
115
+
116
+ function isAbsoluteLikePath(value) {
117
+ return /^([a-zA-Z]:[\\/]|\\\\|\/)/.test(String(value || ''));
118
+ }
119
+
120
+ function normalizeProject(input = {}, idOverride) {
121
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
122
+ throw Object.assign(new Error('project must be an object'), { status: 400 });
123
+ }
124
+ const id = cleanId(idOverride || input.id, 'projectId');
125
+ const name = String(input.name || '').trim().slice(0, 128);
126
+ if (!name) throw Object.assign(new Error('project name is required'), { status: 400 });
127
+ const rootPath = String(input.rootPath || '').trim();
128
+ if (!isAbsoluteLikePath(rootPath)) {
129
+ throw Object.assign(new Error('project rootPath must be an absolute local directory'), { status: 400 });
130
+ }
131
+ return {
132
+ id, name, rootPath,
133
+ ...(input.defaultAiProfileId !== undefined ? { defaultAiProfileId: input.defaultAiProfileId === null ? null : cleanId(input.defaultAiProfileId, 'defaultAiProfileId') } : {}),
134
+ ...(input.defaultModel !== undefined ? { defaultModel: input.defaultModel === null ? null : String(input.defaultModel).trim().slice(0, 128) || null } : {}),
135
+ ...(input.defaultAgentId !== undefined ? { defaultAgentId: normalizeAgentId(input.defaultAgentId) } : {}),
136
+ createdAt: input.createdAt || new Date().toISOString(),
137
+ updatedAt: input.updatedAt || input.createdAt || new Date().toISOString(),
138
+ };
139
+ }
140
+
141
+ function normalizeModelEntries(input) {
142
+ if (input == null) return [];
143
+ if (!Array.isArray(input)) throw Object.assign(new Error('models.list must be an array'), { status: 400 });
144
+ if (input.length > 100) throw Object.assign(new Error('models.list exceeds 100 entries'), { status: 400 });
145
+ const seen = new Set();
146
+ const entries = [];
147
+ for (const item of input) {
148
+ const raw = item && typeof item === 'object' && !Array.isArray(item) ? item : { id: item };
149
+ const id = String(raw.id || raw.model || '').trim().slice(0, 128);
150
+ if (!id) throw Object.assign(new Error('model entry id is required'), { status: 400 });
151
+ if (seen.has(id)) continue;
152
+ seen.add(id);
153
+ const tags = Array.isArray(raw.tags)
154
+ ? raw.tags.map(tag => String(tag).trim().slice(0, 32)).filter(Boolean).slice(0, 8)
155
+ : [];
156
+ // Detected context windows ride along so drivers (opencode limit.context)
157
+ // and the capacity bubble can use them; invalid values are dropped.
158
+ const contextWindow = Number(raw.contextWindow);
159
+ const limits = Number.isFinite(contextWindow) && contextWindow > 0 && contextWindow <= 10_000_000
160
+ ? { contextWindow: Math.floor(contextWindow) }
161
+ : {};
162
+ entries.push({ id, openaiId: String(raw.openaiId || id.replace(/\[1m\]$/i, '')).trim().slice(0, 128), label: String(raw.label || id).slice(0, 128), tags, ...limits });
163
+ }
164
+ return entries;
165
+ }
166
+
167
+ function normalizeProfile(input = {}, idOverride) {
168
+ const id = cleanId(idOverride || input.id, 'profileId');
169
+ const models = input.models && typeof input.models === 'object' ? input.models : {};
170
+ const list = normalizeModelEntries(models.list);
171
+ const fallbackModel = list.length ? list[0].id : '';
172
+ const defaultModel = String(models.default || input.mainModel || input.model || fallbackModel).trim();
173
+ return {
174
+ id,
175
+ name: String(input.name || id).trim().slice(0, 256),
176
+ provider: String(input.provider || '').trim().slice(0, 128),
177
+ protocol: String(input.protocol || 'anthropic-compatible').trim(),
178
+ runtime: RUNTIME,
179
+ baseUrl: String(input.baseUrl || '').trim().replace(/\/+$/, ''),
180
+ openaiBaseUrl: resolveOpenAIBaseUrl(input),
181
+ credentialRef: String(input.credentialRef || input.secretId || `credential:${id}`).trim(),
182
+ models: {
183
+ default: defaultModel,
184
+ fast: String(models.fast || defaultModel).trim(),
185
+ reasoning: String(models.reasoning || defaultModel).trim(),
186
+ coding: String(models.coding || defaultModel).trim(),
187
+ largeContext: String(models.largeContext || defaultModel).trim(),
188
+ list,
189
+ },
190
+ contextWindow: positiveInt(input.contextWindow, 200000),
191
+ timeoutMs: positiveInt(input.timeoutMs, 300000),
192
+ permissionMode: PERMISSION_MODES.includes(input.permissionMode) ? input.permissionMode : 'default',
193
+ systemPrompt: normalizeSystemPrompt(input.systemPrompt),
194
+ enabled: input.enabled !== false,
195
+ };
196
+ }
197
+
198
+ function positiveInt(value, fallback) {
199
+ const parsed = Number(value);
200
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
201
+ }
202
+
203
+ function resolveOpenAIBaseUrl(profile = {}) {
204
+ const explicit = String(profile.openaiBaseUrl || '').trim().replace(/\/+$/, '');
205
+ if (explicit) return explicit;
206
+ const baseUrl = String(profile.baseUrl || '').trim().replace(/\/+$/, '');
207
+ if (profile.protocol === 'openai-compatible') return baseUrl;
208
+ let hostname = '';
209
+ try { hostname = new URL(baseUrl).hostname; } catch {}
210
+ if (hostname === 'api.minimax.io') return 'https://api.minimax.io/v1';
211
+ if (hostname === 'api.minimaxi.com' || /minimax/i.test(String(profile.provider || ''))) return 'https://api.minimaxi.com/v1';
212
+ return '';
213
+ }
214
+
215
+ function resolveOpenAIModel(profile = {}, model) {
216
+ const id = String(model || profile.mainModel || profile.models && profile.models.default || '').trim();
217
+ const entries = profile.models && profile.models.list;
218
+ const entry = Array.isArray(entries) && entries.find(item => item.id === id);
219
+ return String(entry && entry.openaiId || id.replace(/\[1m\]$/i, '')).trim();
220
+ }
221
+
222
+ function normalizeAttachments(input) {
223
+ if (input == null) return [];
224
+ if (!Array.isArray(input)) throw Object.assign(new Error('attachments must be an array'), { status: 400 });
225
+ if (input.length > MAX_ATTACHMENTS_PER_MESSAGE) throw Object.assign(new Error(`at most ${MAX_ATTACHMENTS_PER_MESSAGE} attachments are allowed`), { status: 413 });
226
+ let totalBytes = 0;
227
+ return input.map((item, index) => {
228
+ if (!item || typeof item !== 'object' || Array.isArray(item)) throw Object.assign(new Error(`attachment ${index + 1} is invalid`), { status: 400 });
229
+ const name = String(item.name || `image-${index + 1}`).replace(/[\u0000-\u001f]/g, '').slice(0, 255);
230
+ let mediaType = String(item.mediaType || item.mimeType || '').toLowerCase().split(';')[0].trim();
231
+ const isImage = IMAGE_MEDIA_TYPES.includes(mediaType);
232
+ if (!isImage) {
233
+ const extension = (name.match(/\.[^.\\/]+$/) || [''])[0].toLowerCase();
234
+ const textType = TEXT_FILE_TYPES[extension];
235
+ if (!textType || !['', 'application/octet-stream', 'text/plain', textType].includes(mediaType)) {
236
+ throw Object.assign(new Error(`attachment ${index + 1} has an unsupported type; use images or .txt, .md, .json, .csv files`), { status: 415 });
237
+ }
238
+ mediaType = textType;
239
+ }
240
+ const data = String(item.data || '').replace(/^data:[^;]+;base64,/, '').replace(/\s+/g, '');
241
+ if (!data || !/^[a-zA-Z0-9+/]+={0,2}$/.test(data) || data.length % 4 === 1 || (data.includes('=') && data.length % 4 !== 0)) throw Object.assign(new Error(`attachment ${index + 1} has invalid base64 data`), { status: 400 });
242
+ const size = Math.floor((data.length * 3) / 4) - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0);
243
+ const maxBytes = isImage ? MAX_IMAGE_BYTES : MAX_TEXT_BYTES;
244
+ if (size <= 0 || size > maxBytes) throw Object.assign(new Error(`attachment ${index + 1} exceeds ${maxBytes} bytes`), { status: 413 });
245
+ const bytes = Buffer.from(data, 'base64');
246
+ if (bytes.toString('base64').replace(/=+$/, '') !== data.replace(/=+$/, '')) throw Object.assign(new Error(`attachment ${index + 1} has invalid base64 data`), { status: 400 });
247
+ if (!isImage) {
248
+ try {
249
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
250
+ if (content.includes('\u0000')) throw new Error('binary content');
251
+ } catch { throw Object.assign(new Error(`attachment ${index + 1} must contain UTF-8 text`), { status: 415 }); }
252
+ }
253
+ totalBytes += size;
254
+ if (totalBytes > MAX_ATTACHMENT_MESSAGE_BYTES) throw Object.assign(new Error(`attachment payload exceeds ${MAX_ATTACHMENT_MESSAGE_BYTES} bytes`), { status: 413 });
255
+ return {
256
+ id: item.id ? cleanId(item.id, 'attachmentId') : undefined,
257
+ name,
258
+ mediaType,
259
+ size,
260
+ width: positiveInt(item.width, 0) || undefined,
261
+ height: positiveInt(item.height, 0) || undefined,
262
+ data,
263
+ };
264
+ });
265
+ }
266
+
267
+ function normalizeImageAttachments(input) {
268
+ const attachments = normalizeAttachments(input);
269
+ if (attachments.some(item => !IMAGE_MEDIA_TYPES.includes(item.mediaType))) throw Object.assign(new Error('unsupported image type'), { status: 415 });
270
+ return attachments;
271
+ }
272
+
273
+ function appendTextAttachments(text, attachments = []) {
274
+ const blocks = attachments.filter(item => TEXT_MEDIA_TYPES.includes(item.mediaType)).map(item => {
275
+ const name = String(item.name).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
276
+ return `<attachment name="${name}">\n${Buffer.from(item.data, 'base64').toString('utf8')}\n</attachment>`;
277
+ });
278
+ return [String(text || ''), ...blocks].filter(Boolean).join('\n\n');
279
+ }
280
+
281
+ function normalizeSystemPrompt(value) {
282
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
283
+ if (typeof value === 'string' && value.trim()) return { type: 'custom', content: value };
284
+ return { type: 'preset', preset: 'claude_code', content: '' };
285
+ }
286
+
287
+ module.exports = {
288
+ API_VERSION, DEFAULT_API_PREFIX, RUNTIME, DEFAULT_AGENT, AGENTS, AGENT_IDS, EFFORT_LEVELS,
289
+ PERMISSION_MODES, IMAGE_MEDIA_TYPES, TEXT_MEDIA_TYPES, TEXT_FILE_TYPES,
290
+ MAX_IMAGE_BYTES, MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_MESSAGE_BYTES, EVENT_TYPES,
291
+ MAX_TEXT_BYTES, MAX_ATTACHMENTS_PER_MESSAGE, MAX_ATTACHMENT_MESSAGE_BYTES,
292
+ cleanId, normalizeContext, normalizeProfile, normalizeProject, normalizeAgentId, normalizeEffort,
293
+ normalizeModelEntries, normalizeImageAttachments, normalizeAttachments, appendTextAttachments,
294
+ resolveOpenAIBaseUrl, resolveOpenAIModel, positiveInt,
295
+ };
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@claude-ai-workbench/contracts",
3
+ "version": "0.11.0",
4
+ "type": "commonjs",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "require": "./index.js",
11
+ "default": "./index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "index.d.ts",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "license": "Apache-2.0"
24
+ }
@@ -0,0 +1,8 @@
1
+ # @claude-ai-workbench/core
2
+
3
+ Framework-neutral Claude Code terminal state machine. It owns context switching,
4
+ session lifecycle, SSE sequence de-duplication, permission state, token usage,
5
+ request cancellation, and resource cleanup.
6
+
7
+ Most applications consume it indirectly through
8
+ `@claude-ai-workbench/ui/claude-terminal`.
@@ -0,0 +1,76 @@
1
+ import type {
2
+ ImageAttachmentInput,
3
+ PermissionMode,
4
+ SessionSummary,
5
+ SlashCommand,
6
+ WorkbenchContext,
7
+ WorkbenchEvent,
8
+ } from "@claude-ai-workbench/contracts";
9
+
10
+ export interface TerminalLine {
11
+ id: string;
12
+ at: string;
13
+ kind: string;
14
+ text?: string;
15
+ name?: string;
16
+ input?: Record<string, unknown>;
17
+ result?: string | null;
18
+ status?: string;
19
+ requestId?: string;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ export interface TerminalState {
24
+ context: WorkbenchContext | null;
25
+ contextGeneration: number;
26
+ sessionId: string;
27
+ sessions: SessionSummary[];
28
+ session: Partial<SessionSummary> & { state: string; connectionState: string; model: string | null; aiProfileId: string | null; turns: number };
29
+ lines: TerminalLine[];
30
+ commands: SlashCommand[];
31
+ commandError?: string;
32
+ permission: Record<string, unknown> | null;
33
+ permissionMode: PermissionMode;
34
+ tokenUsage: { used: number; total: number; hasUsage: boolean };
35
+ requestPending: boolean;
36
+ turnRunning: boolean;
37
+ lastSequence: number;
38
+ error: string | null;
39
+ }
40
+
41
+ export interface TerminalAdapter {
42
+ listSessions(context: WorkbenchContext, options?: Record<string, unknown>): Promise<SessionSummary[]>;
43
+ startSession(context: WorkbenchContext, options?: Record<string, unknown>): Promise<SessionSummary>;
44
+ loadSession(id: string, options?: Record<string, unknown>): Promise<Record<string, unknown>>;
45
+ listCommands?(id: string, options?: Record<string, unknown>): Promise<SlashCommand[]>;
46
+ subscribe(id: string, onEvent: (event: WorkbenchEvent) => void, onError?: (error: Error) => void, options?: Record<string, unknown>): () => void;
47
+ send(args: Record<string, unknown>): Promise<unknown>;
48
+ resolvePermission(args: Record<string, unknown>): Promise<unknown>;
49
+ abort(args: Record<string, unknown>): Promise<unknown>;
50
+ setPermissionMode(args: Record<string, unknown>): Promise<unknown>;
51
+ destroy?(): void;
52
+ }
53
+
54
+ export interface TerminalController {
55
+ readonly state: TerminalState;
56
+ setContext(context: WorkbenchContext | null): Promise<void>;
57
+ newSession(): Promise<SessionSummary>;
58
+ restoreSession(sessionId: string): Promise<void>;
59
+ send(text: string, options?: {attachments?: ImageAttachmentInput[]; [key: string]: unknown}): Promise<unknown>;
60
+ resolvePermission(allow: boolean, message?: string): Promise<unknown>;
61
+ abort(): Promise<unknown>;
62
+ setPermissionMode(mode: PermissionMode): Promise<unknown>;
63
+ setAdapter(adapter: TerminalAdapter | null): void;
64
+ handleEvent(event: WorkbenchEvent): void;
65
+ subscribeState(listener: (state: TerminalState) => void): () => void;
66
+ destroy(): void;
67
+ }
68
+
69
+ export declare function createState(): TerminalState;
70
+ export declare function reduceEvent(state: TerminalState, event: WorkbenchEvent): boolean;
71
+ export declare function createController(options?: {
72
+ state?: TerminalState;
73
+ adapter?: TerminalAdapter | null;
74
+ onChange?: (state: TerminalState) => void;
75
+ destroyAdapter?: boolean;
76
+ }): TerminalController;
@@ -0,0 +1,214 @@
1
+ (function (global, factory) {
2
+ const exported = factory();
3
+ if (typeof module !== 'undefined' && module.exports) module.exports = exported;
4
+ global.ClaudeWorkbenchCore = exported;
5
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
6
+ 'use strict';
7
+
8
+ function createState() {
9
+ return {
10
+ context: null, contextGeneration: 0, sessionId: '', sessions: [],
11
+ session: { state: 'idle', model: null, aiProfileId: null, turns: 0, connectionState: 'offline' },
12
+ lines: [], commands: [], permission: null, permissionMode: 'default',
13
+ tokenUsage: { used: 0, total: 200000, hasUsage: false },
14
+ requestPending: false, turnRunning: false, lastSequence: 0, error: null,
15
+ };
16
+ }
17
+
18
+ function text(value) {
19
+ if (value == null) return '';
20
+ if (typeof value === 'string') return value;
21
+ try { return JSON.stringify(value, null, 2); } catch { return String(value); }
22
+ }
23
+ function id(prefix) { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; }
24
+ function append(state, line) {
25
+ const next = { id: line.id || id(line.kind || 'line'), at: line.at || new Date().toISOString(), ...line };
26
+ state.lines.push(next);
27
+ if (state.lines.length > 3000) state.lines.splice(0, 300);
28
+ return next;
29
+ }
30
+ function find(state, lineId, kind) { return state.lines.find(item => item.id === lineId && (!kind || item.kind === kind)); }
31
+
32
+ function reduceEvent(state, event) {
33
+ if (!event || !event.type) return false;
34
+ if (event.contextId && state.context && event.contextId !== state.context.contextId) return false;
35
+ const sequence = Number(event.sequence || 0);
36
+ if (sequence && sequence <= state.lastSequence) return false;
37
+ if (sequence) state.lastSequence = sequence;
38
+ switch (event.type) {
39
+ case 'workbench/context-loading': state.session.connectionState = 'connecting'; state.error = null; break;
40
+ case 'workbench/context-ready': state.session.connectionState = 'connected'; break;
41
+ case 'workbench/stream-open': state.session.connectionState = 'connected'; break;
42
+ case 'workbench/stream-reconnecting': state.session.connectionState = 'reconnecting'; break;
43
+ case 'workbench/context-error':
44
+ state.session.connectionState = 'offline'; state.error = text(event.error || event.message);
45
+ append(state, { kind: 'error', text: state.error }); break;
46
+ case 'claude/init':
47
+ state.session = { ...state.session, model: event.model || state.session.model, aiProfileId: event.aiProfileId || state.session.aiProfileId, connectionState: 'connected' }; break;
48
+ case 'claude/state':
49
+ state.session.state = event.state || state.session.state;
50
+ state.session.turns = Number(event.turn ?? event.turns ?? state.session.turns);
51
+ state.turnRunning = ['running', 'thinking', 'tool-running', 'spawning'].includes(event.state); break;
52
+ case 'claude/session-ready':
53
+ state.session = { ...state.session, state: 'idle', model: event.model || state.session.model, aiProfileId: event.aiProfileId || state.session.aiProfileId, connectionState: 'connected' };
54
+ state.permissionMode = event.permissionMode || state.permissionMode; state.turnRunning = false; break;
55
+ case 'claude/user-prompt': append(state, { id: event.id, kind: 'user', text: text(event.text), attachments: Array.isArray(event.attachments) ? event.attachments : [], at: event.at }); state.turnRunning = true; break;
56
+ case 'claude/system-prompt': append(state, { id: event.id, kind: 'system', text: text(event.text), at: event.at }); break;
57
+ case 'claude/text-start': append(state, { id: event.id || event.messageId, kind: 'assistant', text: '', at: event.at }); break;
58
+ case 'claude/text-delta': {
59
+ const lineId = event.id || event.messageId; const line = find(state, lineId, 'assistant') || append(state, { id: lineId, kind: 'assistant', text: '', at: event.at });
60
+ line.text += text(event.text ?? event.delta); break;
61
+ }
62
+ case 'claude/result': {
63
+ if (!event.isError) {
64
+ const lineId = event.id || event.messageId; const line = find(state, lineId, 'assistant') || append(state, { id: lineId, kind: 'assistant', text: '', at: event.at });
65
+ line.text = text(event.result ?? event.text); state.session.turns += 1;
66
+ }
67
+ state.turnRunning = false; state.session.state = event.isError ? 'error' : 'idle'; break;
68
+ }
69
+ case 'claude/thinking-start': append(state, { id: event.id || event.messageId, kind: 'thinking', text: text(event.text || 'Thinking…'), at: event.at }); break;
70
+ case 'claude/thinking-delta': {
71
+ const lineId = event.id || event.messageId; const line = find(state, lineId, 'thinking') || append(state, { id: lineId, kind: 'thinking', text: '', at: event.at });
72
+ line.text += text(event.text ?? event.delta); break;
73
+ }
74
+ case 'claude/tool-use':
75
+ append(state, { id: event.id || event.toolUseId, kind: 'tool', name: event.name || event.toolName || 'tool', input: event.input || event.toolInput || {}, result: null, status: 'running', at: event.at });
76
+ state.turnRunning = true; state.session.state = 'tool-running'; break;
77
+ case 'claude/tool-result': {
78
+ const lineId = event.toolUseId || event.id; const line = find(state, lineId, 'tool');
79
+ if (line) { line.result = text(event.result ?? event.text ?? event.content); line.status = event.isError ? 'error' : 'done'; }
80
+ else append(state, { kind: 'tool-result', toolUseId: lineId, text: text(event.result ?? event.text ?? event.content), at: event.at });
81
+ break;
82
+ }
83
+ case 'claude/permission-request':
84
+ state.permission = { requestId: event.requestId || event.id, toolName: event.toolName || event.name || 'tool', input: event.input || event.toolInput || {}, message: event.message || '' };
85
+ append(state, { id: state.permission.requestId, kind: 'permission', name: state.permission.toolName, input: state.permission.input, text: state.permission.message, at: event.at });
86
+ state.turnRunning = false; state.session.state = 'pending-permission'; break;
87
+ case 'claude/permission-resolved': state.permission = null; state.turnRunning = Boolean(event.allow); state.session.state = event.allow ? 'running' : 'idle'; break;
88
+ case 'claude/usage': {
89
+ const usage = event.usage || event; const used = Number(event.used ?? usage.input_tokens ?? 0) + Number(usage.output_tokens || 0) + Number(usage.cache_creation_input_tokens || 0) + Number(usage.cache_read_input_tokens || 0);
90
+ state.tokenUsage = { used, total: Number(event.total || state.tokenUsage.total || 200000), hasUsage: true }; break;
91
+ }
92
+ case 'claude/commands': state.commands = Array.isArray(event.commands) ? event.commands : []; break;
93
+ case 'claude/image': append(state, { id: event.id, kind: 'image', name: event.name || 'image', mediaType: event.mediaType, url: event.url, data: event.data, at: event.at }); break;
94
+ case 'claude/retry': append(state, { kind: 'status', text: `Runtime retry ${event.attempt || 1}/${event.maxRetries || 3}`, at: event.at }); break;
95
+ case 'claude/error': case 'claude/stderr':
96
+ state.error = text(event.message || event.error || event.text); if (state.error) append(state, { kind: 'error', text: state.error, at: event.at });
97
+ state.turnRunning = false; state.session.state = 'error'; break;
98
+ case 'claude/aborted': append(state, { kind: 'status', text: event.message || 'Current turn aborted.', at: event.at }); state.turnRunning = false; state.session.state = 'aborted'; break;
99
+ default: return false;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ function createController(options = {}) {
105
+ const state = options.state || createState();
106
+ let adapter = options.adapter || null;
107
+ let streamClose = null;
108
+ let requestController = null;
109
+ let destroyed = false;
110
+ const listeners = new Set(options.onChange ? [options.onChange] : []);
111
+
112
+ const notify = () => { if (!destroyed) for (const listener of listeners) try { listener(state); } catch {} };
113
+ const emit = event => { const changed = reduceEvent(state, event); if (changed) notify(); };
114
+ const closeStream = () => { if (streamClose) try { streamClose(); } catch {} streamClose = null; };
115
+ const cancelRequest = () => { if (requestController) requestController.abort(); requestController = null; };
116
+ function reset() {
117
+ state.sessionId = ''; state.sessions = []; state.lines = []; state.commands = []; state.permission = null;
118
+ state.tokenUsage = { used: 0, total: 200000, hasUsage: false };
119
+ state.session = { state: 'idle', model: null, aiProfileId: state.context && state.context.aiProfileId || null, turns: 0, connectionState: 'offline' };
120
+ state.requestPending = false; state.turnRunning = false; state.lastSequence = 0; state.error = null;
121
+ }
122
+ function subscribe(sessionId, generation) {
123
+ closeStream();
124
+ streamClose = adapter.subscribe(sessionId, event => {
125
+ if (destroyed || generation !== state.contextGeneration || sessionId !== state.sessionId) return;
126
+ emit(event);
127
+ }, () => {
128
+ if (!destroyed && generation === state.contextGeneration) emit({ type: 'workbench/stream-reconnecting' });
129
+ }, { afterSequence: state.lastSequence });
130
+ }
131
+ async function loadSession(sessionId, generation, signal) {
132
+ const snapshot = await adapter.loadSession(sessionId, { signal });
133
+ if (destroyed || generation !== state.contextGeneration) return;
134
+ state.sessionId = sessionId;
135
+ state.session = { ...state.session, ...(snapshot.session || {}), connectionState: 'connected' };
136
+ state.lines = []; state.lastSequence = 0;
137
+ for (const event of snapshot.events || []) reduceEvent(state, event);
138
+ state.permission = snapshot.permission || null;
139
+ state.permissionMode = snapshot.permissionMode || state.permissionMode;
140
+ state.tokenUsage = snapshot.tokenUsage || state.tokenUsage;
141
+ subscribe(sessionId, generation); notify();
142
+ if (typeof adapter.listCommands === 'function') {
143
+ try {
144
+ const commands = await adapter.listCommands(sessionId, { signal });
145
+ if (!destroyed && generation === state.contextGeneration && sessionId === state.sessionId) {
146
+ state.commands = Array.isArray(commands) ? commands : [];
147
+ notify();
148
+ }
149
+ } catch (error) {
150
+ if (!(error && error.name === 'AbortError') && generation === state.contextGeneration) {
151
+ state.commandError = text(error);
152
+ notify();
153
+ }
154
+ }
155
+ }
156
+ }
157
+ async function setContext(input) {
158
+ if (destroyed) return;
159
+ const next = input ? { ...input, contextId: input.contextId || input.projectId } : null;
160
+ if (state.context && next && state.context.contextId === next.contextId) { state.context = { ...state.context, ...next }; notify(); return; }
161
+ state.contextGeneration += 1;
162
+ const generation = state.contextGeneration;
163
+ closeStream(); cancelRequest(); requestController = new AbortController();
164
+ state.context = next; reset(); emit({ type: 'workbench/context-loading' });
165
+ if (!next || !next.contextId) { emit({ type: 'workbench/context-ready' }); return; }
166
+ if (!adapter) { emit({ type: 'workbench/context-error', error: 'Claude Workbench client is not configured.' }); return; }
167
+ try {
168
+ state.sessions = await adapter.listSessions(next, { signal: requestController.signal });
169
+ if (destroyed || generation !== state.contextGeneration) return;
170
+ let active = state.sessions.find(item => item.active) || state.sessions[0];
171
+ if (!active) { active = await adapter.startSession(next, { signal: requestController.signal, permissionMode: state.permissionMode }); state.sessions.unshift(active); }
172
+ await loadSession(active.sessionId, generation, requestController.signal);
173
+ emit({ type: 'workbench/context-ready' });
174
+ } catch (error) {
175
+ if (error && error.name === 'AbortError') return;
176
+ if (generation === state.contextGeneration) emit({ type: 'workbench/context-error', error });
177
+ }
178
+ }
179
+ async function newSession() {
180
+ if (!adapter || !state.context) throw new Error('No active context');
181
+ const session = await adapter.startSession(state.context, { permissionMode: state.permissionMode });
182
+ state.sessions = [session, ...state.sessions.filter(item => item.sessionId !== session.sessionId)];
183
+ await loadSession(session.sessionId, state.contextGeneration); return session;
184
+ }
185
+ async function restoreSession(sessionId) { await loadSession(sessionId, state.contextGeneration); }
186
+ async function send(value, options) {
187
+ const message = String(value || '').trim();
188
+ const attachments = options && Array.isArray(options.attachments) ? options.attachments : [];
189
+ if (!message && !attachments.length) return;
190
+ if (!state.sessionId) await newSession();
191
+ state.requestPending = true; notify();
192
+ try {
193
+ const sendOptions = { ...(options || {}) }; delete sendOptions.attachments;
194
+ return await adapter.send({ sessionId: state.sessionId, context: state.context, text: message, attachments, options: sendOptions });
195
+ }
196
+ finally { state.requestPending = false; notify(); }
197
+ }
198
+ async function resolvePermission(allow, message) {
199
+ if (!state.permission) return;
200
+ return adapter.resolvePermission({ sessionId: state.sessionId, requestId: state.permission.requestId, allow: allow === true, message: message || '' });
201
+ }
202
+ async function abort() { if (state.sessionId) return adapter.abort({ sessionId: state.sessionId }); }
203
+ async function setPermissionMode(mode) { state.permissionMode = mode || 'default'; notify(); if (state.sessionId) return adapter.setPermissionMode({ sessionId: state.sessionId, mode: state.permissionMode }); }
204
+ function destroy() { destroyed = true; closeStream(); cancelRequest(); listeners.clear(); if (options.destroyAdapter && adapter && adapter.destroy) adapter.destroy(); }
205
+ return {
206
+ state, setContext, newSession, restoreSession, send, resolvePermission, abort, setPermissionMode,
207
+ setAdapter(value) { adapter = value; }, handleEvent: emit,
208
+ subscribeState(listener) { listeners.add(listener); listener(state); return () => listeners.delete(listener); },
209
+ destroy,
210
+ };
211
+ }
212
+
213
+ return { createState, reduceEvent, createController };
214
+ });
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@claude-ai-workbench/core",
3
+ "version": "0.11.0",
4
+ "type": "commonjs",
5
+ "main": "index.js",
6
+ "browser": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "browser": "./index.js",
12
+ "require": "./index.js",
13
+ "default": "./index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "index.d.ts",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@claude-ai-workbench/contracts": "0.11.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "license": "Apache-2.0"
29
+ }