dot-studio 0.0.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/client/assets/index-C2eIILoa.css +41 -0
  4. package/client/assets/index-DUPZ_Lw5.js +616 -0
  5. package/client/assets/index.es-Btlrnc3g.js +1 -0
  6. package/client/index.html +14 -0
  7. package/dist/cli.js +196 -0
  8. package/dist/server/index.js +79 -0
  9. package/dist/server/lib/act-runtime.js +1282 -0
  10. package/dist/server/lib/cache.js +31 -0
  11. package/dist/server/lib/config.js +53 -0
  12. package/dist/server/lib/dot-authoring.js +245 -0
  13. package/dist/server/lib/dot-loader.js +61 -0
  14. package/dist/server/lib/dot-login.js +190 -0
  15. package/dist/server/lib/model-catalog.js +111 -0
  16. package/dist/server/lib/opencode-auth.js +69 -0
  17. package/dist/server/lib/opencode-errors.js +220 -0
  18. package/dist/server/lib/opencode-sidecar.js +144 -0
  19. package/dist/server/lib/opencode.js +12 -0
  20. package/dist/server/lib/package-bin.js +63 -0
  21. package/dist/server/lib/project-config.js +39 -0
  22. package/dist/server/lib/prompt.js +222 -0
  23. package/dist/server/lib/request-context.js +27 -0
  24. package/dist/server/lib/runtime-tools.js +208 -0
  25. package/dist/server/routes/assets.js +161 -0
  26. package/dist/server/routes/chat.js +356 -0
  27. package/dist/server/routes/compile.js +105 -0
  28. package/dist/server/routes/dot.js +270 -0
  29. package/dist/server/routes/health.js +56 -0
  30. package/dist/server/routes/opencode.js +421 -0
  31. package/dist/server/routes/stages.js +137 -0
  32. package/dist/server/start.js +23 -0
  33. package/dist/server/terminal.js +282 -0
  34. package/dist/shared/mcp-config.js +19 -0
  35. package/dist/shared/model-variants.js +50 -0
  36. package/dist/shared/project-mcp.js +22 -0
  37. package/dist/shared/session-metadata.js +26 -0
  38. package/package.json +103 -0
@@ -0,0 +1,282 @@
1
+ // Terminal WebSocket handler — proxies PTY sessions via OpenCode SDK v2 PTY API
2
+ import { WebSocketServer, WebSocket } from 'ws';
3
+ import { OPENCODE_URL } from './lib/config.js';
4
+ const MAX_BUFFER = 500;
5
+ const sessions = new Map();
6
+ let sessionCounter = 0;
7
+ async function opencodePtyRequest(path, init, directory) {
8
+ const url = new URL(path, OPENCODE_URL);
9
+ if (directory) {
10
+ url.searchParams.set('directory', directory);
11
+ }
12
+ const res = await fetch(url, {
13
+ ...init,
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ ...(init.headers || {}),
17
+ },
18
+ });
19
+ const raw = await res.text();
20
+ const payload = raw ? JSON.parse(raw) : null;
21
+ if (!res.ok) {
22
+ const message = payload?.error?.message
23
+ || payload?.error
24
+ || payload?.data?.message
25
+ || payload?.message
26
+ || raw
27
+ || `OpenCode PTY request failed (${res.status})`;
28
+ throw new Error(String(message));
29
+ }
30
+ return payload;
31
+ }
32
+ export function setupTerminalWs(server, defaultCwd) {
33
+ const wss = new WebSocketServer({ noServer: true });
34
+ const resolveDefaultCwd = () => typeof defaultCwd === 'function' ? defaultCwd() : defaultCwd;
35
+ server.on('upgrade', (req, socket, head) => {
36
+ if (req.url?.startsWith('/ws/terminal')) {
37
+ wss.handleUpgrade(req, socket, head, (ws) => {
38
+ wss.emit('connection', ws, req);
39
+ });
40
+ }
41
+ });
42
+ wss.on('connection', (ws, req) => {
43
+ const url = new URL(req.url || '', 'http://localhost');
44
+ const action = url.searchParams.get('action') || 'create';
45
+ const targetId = url.searchParams.get('id') || '';
46
+ const cwd = url.searchParams.get('cwd') || resolveDefaultCwd();
47
+ const targetSession = targetId ? sessions.get(targetId) : null;
48
+ if (action === 'attach' && targetSession && targetSession.cwd === cwd) {
49
+ attachSession(ws, targetId);
50
+ }
51
+ else {
52
+ createSession(ws, cwd);
53
+ }
54
+ });
55
+ async function createSession(ws, cwd) {
56
+ sessionCounter++;
57
+ const id = `term-${sessionCounter}`;
58
+ const shell = process.env.SHELL || '/bin/zsh';
59
+ const title = `Terminal ${sessionCounter}`;
60
+ try {
61
+ const ptyData = await opencodePtyRequest('/pty', {
62
+ method: 'POST',
63
+ body: JSON.stringify({
64
+ command: shell,
65
+ args: ['-l'],
66
+ cwd,
67
+ title,
68
+ env: {
69
+ TERM: 'xterm-256color',
70
+ COLORTERM: 'truecolor',
71
+ },
72
+ }),
73
+ }, cwd);
74
+ const ptyID = ptyData?.id;
75
+ if (!ptyID) {
76
+ throw new Error('Failed to create PTY session: no ID returned');
77
+ }
78
+ const session = { id, ptyID, ws, title, buffer: [], cwd, ptyWs: null, initialized: false };
79
+ sessions.set(id, session);
80
+ // Connect to OpenCode PTY WebSocket for real-time I/O
81
+ connectPtyWebSocket(session);
82
+ ws.send(JSON.stringify({
83
+ type: 'connected',
84
+ id, title, shell, cwd,
85
+ sessions: getSessionList(cwd),
86
+ }));
87
+ setupWsHandlers(ws, session);
88
+ broadcastSessionList(session.cwd);
89
+ }
90
+ catch (err) {
91
+ console.error('Failed to create PTY via OpenCode:', err.message);
92
+ ws.send(JSON.stringify({ type: 'error', message: `Failed: ${err.message}` }));
93
+ ws.close();
94
+ }
95
+ }
96
+ function connectPtyWebSocket(session) {
97
+ // Build OpenCode PTY WebSocket URL
98
+ const baseUrl = OPENCODE_URL.replace(/^http/, 'ws');
99
+ const ptyUrl = new URL(`/pty/${session.ptyID}/connect`, baseUrl);
100
+ if (session.cwd) {
101
+ ptyUrl.searchParams.set('directory', session.cwd);
102
+ }
103
+ const ptyWsUrl = ptyUrl.toString();
104
+ const ptyWs = new WebSocket(ptyWsUrl);
105
+ session.ptyWs = ptyWs;
106
+ ptyWs.on('message', (rawData) => {
107
+ const data = rawData.toString();
108
+ // Filter initial noise like {"cursor":0}% that zsh/bash emit before the first prompt.
109
+ // The data may arrive in multiple small chunks with ANSI sequences mixed in.
110
+ if (!session.initialized) {
111
+ // Strip ALL ANSI escape sequences (CSI, OSC, etc.)
112
+ const stripped = data
113
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '') // OSC sequences
114
+ .replace(/\x1b\[[^a-zA-Z]*[a-zA-Z]/g, '') // CSI sequences
115
+ .replace(/\x1b[^[\]].?/g, '') // Other escapes
116
+ .replace(/[\r\n]/g, '')
117
+ .trim();
118
+ // Skip if it's cursor control noise, percent signs, or empty after stripping
119
+ if (!stripped || /\{"cursor":\d+\}/.test(stripped) || /^%+$/.test(stripped)) {
120
+ return;
121
+ }
122
+ session.initialized = true;
123
+ }
124
+ addToBuffer(session, data);
125
+ if (session.ws?.readyState === WebSocket.OPEN) {
126
+ session.ws.send(JSON.stringify({ type: 'output', data }));
127
+ }
128
+ });
129
+ // Poll PTY status to detect exit (OpenCode PTY WS may not close immediately)
130
+ const statusInterval = setInterval(async () => {
131
+ if (!sessions.has(session.id)) {
132
+ clearInterval(statusInterval);
133
+ return;
134
+ }
135
+ try {
136
+ const info = await opencodePtyRequest(`/pty/${session.ptyID}`, {
137
+ method: 'GET',
138
+ }, session.cwd);
139
+ if (info?.status === 'exited') {
140
+ clearInterval(statusInterval);
141
+ handleSessionExit(session);
142
+ }
143
+ }
144
+ catch {
145
+ // PTY not found (404) means it already exited
146
+ clearInterval(statusInterval);
147
+ handleSessionExit(session);
148
+ }
149
+ }, 2000);
150
+ ptyWs.on('close', () => {
151
+ clearInterval(statusInterval);
152
+ session.ptyWs = null;
153
+ handleSessionExit(session);
154
+ });
155
+ ptyWs.on('error', (err) => {
156
+ console.error(`PTY WebSocket error for ${session.id}:`, err.message);
157
+ });
158
+ }
159
+ function handleSessionExit(session) {
160
+ if (!sessions.has(session.id))
161
+ return; // Already handled
162
+ // Notify client that the session has exited
163
+ if (session.ws?.readyState === WebSocket.OPEN) {
164
+ session.ws.send(JSON.stringify({ type: 'exit', id: session.id }));
165
+ }
166
+ session.ptyWs?.close();
167
+ session.ptyWs = null;
168
+ sessions.delete(session.id);
169
+ broadcastSessionList(session.cwd);
170
+ }
171
+ function attachSession(ws, targetId) {
172
+ const session = sessions.get(targetId);
173
+ if (!session)
174
+ return;
175
+ if (session.ws && session.ws !== ws && session.ws.readyState === WebSocket.OPEN) {
176
+ session.ws.close();
177
+ }
178
+ session.ws = ws;
179
+ // Replay buffer
180
+ if (session.buffer.length > 0) {
181
+ ws.send(JSON.stringify({ type: 'output', data: session.buffer.join('') }));
182
+ }
183
+ ws.send(JSON.stringify({
184
+ type: 'attached',
185
+ id: session.id,
186
+ title: session.title,
187
+ sessions: getSessionList(session.cwd),
188
+ }));
189
+ setupWsHandlers(ws, session);
190
+ }
191
+ function setupWsHandlers(ws, session) {
192
+ ws.on('message', async (msg) => {
193
+ try {
194
+ const parsed = JSON.parse(msg.toString());
195
+ switch (parsed.type) {
196
+ case 'input':
197
+ // Forward input to OpenCode PTY WebSocket
198
+ if (session.ptyWs?.readyState === WebSocket.OPEN) {
199
+ session.ptyWs.send(parsed.data);
200
+ }
201
+ break;
202
+ case 'resize':
203
+ try {
204
+ await opencodePtyRequest(`/pty/${session.ptyID}`, {
205
+ method: 'PUT',
206
+ body: JSON.stringify({
207
+ size: { rows: parsed.rows, cols: parsed.cols },
208
+ }),
209
+ }, session.cwd);
210
+ }
211
+ catch (e) {
212
+ console.error('PTY resize failed:', e.message);
213
+ }
214
+ break;
215
+ case 'create':
216
+ createSession(ws, parsed.cwd || session.cwd || resolveDefaultCwd());
217
+ break;
218
+ case 'kill': {
219
+ const target = sessions.get(parsed.id);
220
+ if (target && target.cwd === session.cwd) {
221
+ try {
222
+ await opencodePtyRequest(`/pty/${target.ptyID}`, {
223
+ method: 'DELETE',
224
+ }, target.cwd);
225
+ }
226
+ catch { /* ignore */ }
227
+ target.ptyWs?.close();
228
+ sessions.delete(parsed.id);
229
+ broadcastSessionList(session.cwd);
230
+ }
231
+ break;
232
+ }
233
+ case 'rename': {
234
+ const target = sessions.get(parsed.id);
235
+ if (target && target.cwd === session.cwd && parsed.title) {
236
+ target.title = parsed.title;
237
+ broadcastSessionList(session.cwd);
238
+ }
239
+ break;
240
+ }
241
+ case 'list':
242
+ ws.send(JSON.stringify({ type: 'sessions', sessions: getSessionList(session.cwd) }));
243
+ break;
244
+ }
245
+ }
246
+ catch {
247
+ // Raw input fallback
248
+ if (session.ptyWs?.readyState === WebSocket.OPEN) {
249
+ session.ptyWs.send(msg.toString());
250
+ }
251
+ }
252
+ });
253
+ ws.on('close', () => {
254
+ if (session.ws === ws)
255
+ session.ws = null;
256
+ });
257
+ }
258
+ function addToBuffer(session, data) {
259
+ session.buffer.push(data);
260
+ if (session.buffer.length > MAX_BUFFER) {
261
+ session.buffer.splice(0, session.buffer.length - MAX_BUFFER);
262
+ }
263
+ }
264
+ function getSessionList(cwd) {
265
+ return Array.from(sessions.values())
266
+ .filter((session) => session.cwd === cwd)
267
+ .map(s => ({
268
+ id: s.id,
269
+ title: s.title,
270
+ connected: s.ws !== null && s.ws.readyState === WebSocket.OPEN,
271
+ }));
272
+ }
273
+ function broadcastSessionList(cwd) {
274
+ const list = getSessionList(cwd);
275
+ for (const s of sessions.values()) {
276
+ if (s.cwd === cwd && s.ws?.readyState === WebSocket.OPEN) {
277
+ s.ws.send(JSON.stringify({ type: 'sessions', sessions: list }));
278
+ }
279
+ }
280
+ }
281
+ console.log(' Terminal: WebSocket on /ws/terminal (via OpenCode PTY)');
282
+ }
@@ -0,0 +1,19 @@
1
+ function unique(values) {
2
+ return Array.from(new Set(values.filter(Boolean)));
3
+ }
4
+ export function extractMcpServerNamesFromConfig(value) {
5
+ if (!value || typeof value !== 'object') {
6
+ return [];
7
+ }
8
+ const record = value;
9
+ const nestedServers = record.servers;
10
+ if (Array.isArray(nestedServers)) {
11
+ return unique(nestedServers.filter((item) => typeof item === 'string'));
12
+ }
13
+ if (nestedServers && typeof nestedServers === 'object') {
14
+ return unique(Object.keys(nestedServers));
15
+ }
16
+ return unique(Object.entries(record)
17
+ .filter(([, config]) => config !== null && typeof config === 'object')
18
+ .map(([name]) => name));
19
+ }
@@ -0,0 +1,50 @@
1
+ function flattenVariantOptions(value, prefix = '', acc = []) {
2
+ for (const [key, raw] of Object.entries(value)) {
3
+ const path = prefix ? `${prefix}.${key}` : key;
4
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
5
+ flattenVariantOptions(raw, path, acc);
6
+ continue;
7
+ }
8
+ if (Array.isArray(raw)) {
9
+ acc.push([path, raw.join(', ')]);
10
+ continue;
11
+ }
12
+ acc.push([path, String(raw)]);
13
+ }
14
+ return acc;
15
+ }
16
+ export function summarizeVariantOptions(options) {
17
+ const entries = flattenVariantOptions(options).slice(0, 4);
18
+ if (entries.length === 0) {
19
+ return 'Variant preset';
20
+ }
21
+ return entries.map(([key, value]) => `${key}=${value}`).join(' · ');
22
+ }
23
+ export function normalizeRuntimeVariants(raw) {
24
+ if (!raw || typeof raw !== 'object') {
25
+ return [];
26
+ }
27
+ return Object.entries(raw).map(([id, options]) => {
28
+ const normalizedOptions = options && typeof options === 'object' && !Array.isArray(options)
29
+ ? options
30
+ : {};
31
+ return {
32
+ id,
33
+ options: normalizedOptions,
34
+ summary: summarizeVariantOptions(normalizedOptions),
35
+ };
36
+ });
37
+ }
38
+ export function findRuntimeModel(models, provider, modelId) {
39
+ if (!provider || !modelId) {
40
+ return null;
41
+ }
42
+ return models.find((model) => model.provider === provider && model.id === modelId) || null;
43
+ }
44
+ export function findRuntimeModelVariant(models, provider, modelId, variantId) {
45
+ if (!variantId) {
46
+ return null;
47
+ }
48
+ const model = findRuntimeModel(models, provider, modelId);
49
+ return model?.variants.find((variant) => variant.id === variantId) || null;
50
+ }
@@ -0,0 +1,22 @@
1
+ export function isProjectMcpCatalog(value) {
2
+ return !!value && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+ export function extractProjectMcpCatalog(config) {
5
+ if (!config || typeof config !== 'object') {
6
+ return {};
7
+ }
8
+ const record = config;
9
+ return isProjectMcpCatalog(record.mcp) ? record.mcp : {};
10
+ }
11
+ export function projectMcpServerNames(config) {
12
+ return Object.keys(extractProjectMcpCatalog(config));
13
+ }
14
+ export function projectMcpEntryEnabled(entry) {
15
+ return entry?.enabled !== false;
16
+ }
17
+ export function projectMcpEntryType(entry) {
18
+ if (entry && typeof entry === 'object' && 'type' in entry) {
19
+ return entry.type === 'remote' ? 'remote' : 'local';
20
+ }
21
+ return 'toggle';
22
+ }
@@ -0,0 +1,26 @@
1
+ const SESSION_TITLE_PREFIX = 'DOT Studio:';
2
+ const SESSION_METADATA_PATTERN = /^DOT Studio:\s*(.*?)\s*\[studio:([^:\]]+):([^\]]+)\]\s*$/;
3
+ export function buildStudioSessionTitle(performerId, performerName, configHash) {
4
+ return `${SESSION_TITLE_PREFIX} ${performerName} [studio:${performerId}:${configHash}]`;
5
+ }
6
+ export function parseStudioSessionTitle(title) {
7
+ if (!title || !title.startsWith(SESSION_TITLE_PREFIX)) {
8
+ return null;
9
+ }
10
+ const match = title.match(SESSION_METADATA_PATTERN);
11
+ if (!match) {
12
+ return null;
13
+ }
14
+ return {
15
+ label: match[1].trim(),
16
+ performerId: match[2],
17
+ configHash: match[3],
18
+ };
19
+ }
20
+ export function renameStudioSessionTitle(title, nextLabel) {
21
+ const parsed = parseStudioSessionTitle(title);
22
+ if (!parsed) {
23
+ return null;
24
+ }
25
+ return buildStudioSessionTitle(parsed.performerId, nextLabel.trim(), parsed.configHash);
26
+ }
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "dot-studio",
3
+ "version": "0.0.1",
4
+ "description": "DOT Studio visual workspace for composing and running Dance of Tal performers and acts on top of OpenCode.",
5
+ "license": "MIT",
6
+ "author": "monarchjuno",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dance-of-tal/dance-of-tal.git"
10
+ },
11
+ "homepage": "https://github.com/dance-of-tal/dance-of-tal",
12
+ "bugs": {
13
+ "url": "https://github.com/dance-of-tal/dance-of-tal/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": [
19
+ "dance-of-tal",
20
+ "dot",
21
+ "studio",
22
+ "opencode",
23
+ "agentic-ai",
24
+ "visual-editor"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20.19.0"
28
+ },
29
+ "type": "module",
30
+ "scripts": {
31
+ "start": "tsx server/start.ts",
32
+ "dev": "vite",
33
+ "kill-ports": "lsof -ti:5173,3001,4096 | xargs kill -9 2>/dev/null || true",
34
+ "dev:all": "npm run kill-ports && concurrently -k -n vite,server,opencode -c cyan,yellow,magenta \"vite\" \"tsx --watch server/index.ts\" \"opencode --port 4096 .\"",
35
+ "server": "tsx server/index.ts",
36
+ "server:dev": "tsx --watch server/index.ts",
37
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true }); require('fs').rmSync('client', { recursive: true, force: true })\"",
38
+ "build": "npm run build:all",
39
+ "build:client": "vite build --outDir client",
40
+ "build:server": "tsc -p tsconfig.server.json",
41
+ "build:all": "npm run clean && npm run build:client && npm run build:server",
42
+ "pack:check": "npm run build:all && npm pack --dry-run",
43
+ "type-check": "tsc -b --noEmit && tsc -p tsconfig.server.json --noEmit",
44
+ "lint": "eslint .",
45
+ "preview": "vite preview",
46
+ "opencode": "opencode --port 4096 .",
47
+ "prepublishOnly": "npm run build:all"
48
+ },
49
+ "bin": {
50
+ "dot-studio": "./dist/cli.js"
51
+ },
52
+ "files": [
53
+ "dist/",
54
+ "client/",
55
+ "README.md",
56
+ "LICENSE"
57
+ ],
58
+ "dependencies": {
59
+ "@dnd-kit/core": "^6.3.1",
60
+ "@dnd-kit/sortable": "^10.0.0",
61
+ "@dnd-kit/utilities": "^3.2.2",
62
+ "@hono/node-server": "^1.19.11",
63
+ "@opencode-ai/sdk": "^1.2.17",
64
+ "@tanstack/react-query": "^5.90.21",
65
+ "@xterm/addon-fit": "^0.11.0",
66
+ "@xterm/addon-webgl": "^0.19.0",
67
+ "@xterm/xterm": "^6.0.0",
68
+ "@xyflow/react": "^12.10.1",
69
+ "dance-of-tal": "^3.2.3",
70
+ "elkjs": "^0.11.1",
71
+ "highlight.js": "^11.11.1",
72
+ "hono": "^4.12.5",
73
+ "lucide-react": "^0.577.0",
74
+ "material-file-icons": "^2.4.0",
75
+ "open": "^10.0.0",
76
+ "opencode-ai": "1.2.24",
77
+ "react": "^19.2.0",
78
+ "react-dom": "^19.2.0",
79
+ "react-markdown": "^10.1.0",
80
+ "rehype-highlight": "^7.0.2",
81
+ "remark-gfm": "^4.0.1",
82
+ "tsx": "^4.21.0",
83
+ "ws": "^8.19.0",
84
+ "xstate": "^5.28.0",
85
+ "zustand": "^5.0.11"
86
+ },
87
+ "devDependencies": {
88
+ "@eslint/js": "^9.39.1",
89
+ "@types/node": "^24.10.1",
90
+ "@types/react": "^19.2.7",
91
+ "@types/react-dom": "^19.2.3",
92
+ "@types/ws": "^8.18.1",
93
+ "@vitejs/plugin-react": "^4.7.0",
94
+ "concurrently": "^9.2.1",
95
+ "eslint": "^9.39.1",
96
+ "eslint-plugin-react-hooks": "^7.0.1",
97
+ "eslint-plugin-react-refresh": "^0.4.24",
98
+ "globals": "^16.5.0",
99
+ "typescript": "~5.9.3",
100
+ "typescript-eslint": "^8.48.0",
101
+ "vite": "^6.4.1"
102
+ }
103
+ }