spectoflow 0.23.4 → 0.24.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/README.md +21 -10
- package/bin/postinstall.js +1 -0
- package/bin/spectoflow.js +144 -35
- package/lib/adapters.js +8 -2
- package/{templates/lib → lib}/custom-dashboard.js +2 -2
- package/{templates/lib → lib}/customize-prompts.js +1 -1
- package/lib/dashboard/handlers.js +78 -0
- package/lib/{hub-server.js → dashboard/hub-server.js} +35 -44
- package/lib/dashboard/ops.js +176 -0
- package/{templates → lib}/dashboard/orchestrator.js +17 -8
- package/{templates → lib}/dashboard/public/app.js +33 -13
- package/{templates → lib}/dashboard/public/styles.css +6 -1
- package/{templates → lib}/dashboard/runner.js +6 -5
- package/{templates → lib}/dashboard/summarize.js +1 -1
- package/lib/detect.js +16 -1
- package/lib/global-config.js +65 -0
- package/lib/init.js +10 -4
- package/lib/registry.js +8 -8
- package/{templates/lib → lib}/store.js +16 -12
- package/lib/update.js +69 -1
- package/lib/workspace.js +84 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +6 -6
- package/templates/README.md +4 -3
- package/templates/agents/framework-curator.md +11 -11
- package/templates/capabilities.md +1 -1
- package/templates/dashboards/.gitkeep +0 -0
- package/templates/skills/generate-dashboard/SKILL.md +15 -13
- package/templates/dashboard/custom/.gitkeep +0 -3
- package/templates/dashboard/handlers.js +0 -251
- package/templates/dashboard/server.js +0 -73
- package/templates/lib/agents-registry.js +0 -65
- /package/{templates → lib}/dashboard/files.js +0 -0
- /package/{templates → lib}/dashboard/public/charts.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.css +0 -0
- /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
- /package/{templates → lib}/dashboard/public/designs.js +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- /package/{templates → lib}/dashboard/public/hub.html +0 -0
- /package/{templates → lib}/dashboard/public/hub.js +0 -0
- /package/{templates → lib}/dashboard/public/i18n.js +0 -0
- /package/{templates → lib}/dashboard/public/icons.js +0 -0
- /package/{templates → lib}/dashboard/public/index.html +0 -0
- /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
- /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
- /package/{templates → lib}/dashboard/public/stats.js +0 -0
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/*
|
|
3
|
-
* spectoflow dashboard — per-project route logic, vendored into every project's
|
|
4
|
-
* .spectoflow/dashboard/ (copied by init/update, exactly like server.js). Split out of server.js so
|
|
5
|
-
* a single global hub process (lib/hub-server.js) can load a different project's routes on demand —
|
|
6
|
-
* see docs/multi-project-hub-design.md's "the server must split in two" addendum.
|
|
7
|
-
*
|
|
8
|
-
* createHandlers(root) returns the per-project surface a listener-owning process needs:
|
|
9
|
-
* - handleApi(req, res, u, emit): Promise<boolean> — true if this request was an API route and was
|
|
10
|
-
* handled (caller should not also try static/SPA fallback); false otherwise. Deliberately excludes
|
|
11
|
-
* /api/events: SSE client registration stays owned by whichever file owns the HTTP listener.
|
|
12
|
-
* - watchDirs: string[] — dirs (relative to root) whose changes should emit {type:'change'}. The
|
|
13
|
-
* caller owns the actual fs.watch calls (it owns emit).
|
|
14
|
-
* - onBoot(): call once, the first time this project is opened in a server's lifetime (creates the
|
|
15
|
-
* custom-dashboards dir if missing, reconciles any stale in-flight orchestration).
|
|
16
|
-
*/
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const store = require('../lib/store');
|
|
20
|
-
const { startRun } = require('./runner');
|
|
21
|
-
const { runSummarize } = require('./summarize');
|
|
22
|
-
const orchestrator = require('./orchestrator');
|
|
23
|
-
const agentsRegistry = require('../lib/agents-registry');
|
|
24
|
-
const files = require('./files');
|
|
25
|
-
|
|
26
|
-
function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
|
|
27
|
-
function body(req) { return new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); }); }
|
|
28
|
-
|
|
29
|
-
function createHandlers(root) {
|
|
30
|
-
// Installed framework version: the manifest records it at init/update time. Fallback to the kit's
|
|
31
|
-
// own package.json — only reachable (and only used) when the server is run straight from templates/
|
|
32
|
-
// (dev/preview), never from an installed project whose sibling package.json belongs to the user.
|
|
33
|
-
function frameworkVersion() {
|
|
34
|
-
try { return JSON.parse(fs.readFileSync(path.join(root, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
|
|
35
|
-
try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
function project() {
|
|
39
|
-
const p = store.readProject(root);
|
|
40
|
-
const v = frameworkVersion(); if (v) p.version = v;
|
|
41
|
-
p.projectName = path.basename(root);
|
|
42
|
-
p.knownAgents = agentsRegistry.KNOWN_AGENTS.map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
|
|
43
|
-
p.installedAgents = agentsRegistry.installedAgents(root);
|
|
44
|
-
return p;
|
|
45
|
-
}
|
|
46
|
-
function findPlanFileForTask(id) { for (const pl of store.readPlans(root)) for (const ph of pl.phases) if (ph.tasks.find((t) => t.id === id)) return pl.file; return null; }
|
|
47
|
-
|
|
48
|
-
const configPath = () => path.join(root, '.spectoflow', 'config.json');
|
|
49
|
-
function writeConfig(patch) {
|
|
50
|
-
const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
51
|
-
if (patch.mode && ['autopilot', 'semi', 'manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
52
|
-
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
53
|
-
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
54
|
-
if (typeof patch.agent === 'string' && patch.agent.trim()) {
|
|
55
|
-
const id = patch.agent.trim();
|
|
56
|
-
// Never activate an agent whose CLI isn't actually there — a picked-but-absent agent would just
|
|
57
|
-
// fail silently the next time something tries to run it.
|
|
58
|
-
if (!agentsRegistry.isAgentInstalled(id, root)) {
|
|
59
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
60
|
-
const label = known ? known.label : id;
|
|
61
|
-
throw new Error(`${label} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
|
|
62
|
-
}
|
|
63
|
-
cfg.agent = id;
|
|
64
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
65
|
-
if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
|
|
66
|
-
}
|
|
67
|
-
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
68
|
-
return cfg;
|
|
69
|
-
}
|
|
70
|
-
function promoteAttention(item) {
|
|
71
|
-
return store.addTask(root, { phase: 'Attention', title: item.text, owner: 'user' });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function handleApi(req, res, u, emit) {
|
|
75
|
-
const p = u.pathname;
|
|
76
|
-
|
|
77
|
-
if (p === '/api/project') { sendJSON(res, 200, project()); return true; }
|
|
78
|
-
|
|
79
|
-
if (p === '/api/agentfile' && req.method === 'GET') {
|
|
80
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
81
|
-
const base = path.join(root, '.spectoflow');
|
|
82
|
-
const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
|
|
83
|
-
const abs = path.resolve(base, rel);
|
|
84
|
-
const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
|
|
85
|
-
if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
86
|
-
{ sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
|
|
87
|
-
// Symlink guard: the resolved real path must stay within the (real) scope dirs.
|
|
88
|
-
let real; try { real = fs.realpathSync(abs); } catch { real = null; }
|
|
89
|
-
const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
|
|
90
|
-
const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
|
|
91
|
-
const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
|
|
92
|
-
if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
|
|
93
|
-
{ sendJSON(res, 400, { error: 'not an agent/skill file' }); return true; }
|
|
94
|
-
sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') }); return true;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (p === '/api/files/tree' && req.method === 'GET') { sendJSON(res, 200, { tree: files.tree(root) }); return true; }
|
|
98
|
-
if (p === '/api/files/read' && req.method === 'GET') {
|
|
99
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
100
|
-
const r = files.readFile(root, rel);
|
|
101
|
-
sendJSON(res, r.error ? 400 : 200, r); return true;
|
|
102
|
-
}
|
|
103
|
-
if (p === '/api/files/write' && req.method === 'POST') {
|
|
104
|
-
const { path: rel, content } = await body(req);
|
|
105
|
-
const r = files.writeFile(root, rel, content);
|
|
106
|
-
if (r.error) { sendJSON(res, 400, r); return true; }
|
|
107
|
-
emit({ type: 'change' }); sendJSON(res, 200, r); return true;
|
|
108
|
-
}
|
|
109
|
-
if (p === '/api/files/mkdir' && req.method === 'POST') {
|
|
110
|
-
const { path: rel } = await body(req);
|
|
111
|
-
const r = files.mkdir(root, rel);
|
|
112
|
-
if (r.error) { sendJSON(res, 400, r); return true; }
|
|
113
|
-
emit({ type: 'change' }); sendJSON(res, 200, r); return true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (p === '/api/task' && req.method === 'POST') {
|
|
117
|
-
const { title, phase, file, owner, level } = await body(req);
|
|
118
|
-
if (!title || !String(title).trim()) { sendJSON(res, 400, { error: 'A title is required.' }); return true; }
|
|
119
|
-
const t = store.addTask(root, { title: String(title).trim(), phase, file, owner, level });
|
|
120
|
-
emit({ type: 'change' }); sendJSON(res, 200, { task: t }); return true;
|
|
121
|
-
}
|
|
122
|
-
if (p.startsWith('/api/task/') && req.method === 'PATCH') {
|
|
123
|
-
const id = decodeURIComponent(p.split('/')[3] || ''); const patch = await body(req);
|
|
124
|
-
const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
|
|
125
|
-
store.updateTaskLine(root, file, id, patch); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
126
|
-
}
|
|
127
|
-
if (/^\/api\/task\/[^/]+\/comment$/.test(p) && req.method === 'POST') {
|
|
128
|
-
const id = decodeURIComponent(p.split('/')[3] || ''); const { text, action } = await body(req);
|
|
129
|
-
if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty comment.' }); return true; }
|
|
130
|
-
const file = findPlanFileForTask(id); if (!file) { sendJSON(res, 404, { error: `Task ${id} not found.` }); return true; }
|
|
131
|
-
store.addTaskComment(root, file, id, String(text).trim(), 'me');
|
|
132
|
-
if (action === 'analyze') store.updateTaskLine(root, file, id, { status: 'to_analyze' });
|
|
133
|
-
emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
134
|
-
}
|
|
135
|
-
if (p === '/api/workflow/toggle' && req.method === 'POST') {
|
|
136
|
-
const { name } = await body(req); const wf = path.join(root, '.spectoflow', 'workflow.md');
|
|
137
|
-
const lines = fs.readFileSync(wf, 'utf8').split('\n');
|
|
138
|
-
// Must strip a trailing {cap:... skill:... policy} annotation (added in D29) BEFORE stripping
|
|
139
|
-
// "(optional)" — same order as store.js's readWorkflow(), which is what the client's own step
|
|
140
|
-
// names (and so `name` here) are actually derived from. Every step in the default workflow.md
|
|
141
|
-
// template carries one of these annotations, so getting this order wrong breaks toggling
|
|
142
|
-
// every single step, not just an edge case.
|
|
143
|
-
const stepName = (rest) => {
|
|
144
|
-
const ann = rest.match(/\{([^}]*)\}\s*$/);
|
|
145
|
-
if (ann) rest = rest.slice(0, ann.index).trim();
|
|
146
|
-
return rest.replace(/\s*\(optional\)\s*$/i, '').trim();
|
|
147
|
-
};
|
|
148
|
-
for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
|
|
149
|
-
if (m && stepName(m[4]) === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4]; }
|
|
150
|
-
fs.writeFileSync(wf, lines.join('\n')); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (p === '/api/run' && req.method === 'POST') {
|
|
154
|
-
const { prompt, agent } = await body(req);
|
|
155
|
-
if (!prompt || !String(prompt).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
|
|
156
|
-
const r = startRun(root, { prompt, agent }, emit);
|
|
157
|
-
if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
|
|
158
|
-
sendJSON(res, 200, { runId: r.runId }); return true;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (p === '/api/chat/summarize' && req.method === 'POST') {
|
|
162
|
-
const { agent } = await body(req);
|
|
163
|
-
const r = runSummarize(root, { agent }, emit);
|
|
164
|
-
if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
|
|
165
|
-
sendJSON(res, 200, { ok: true }); return true;
|
|
166
|
-
}
|
|
167
|
-
if (p === '/api/chat/clear' && req.method === 'POST') {
|
|
168
|
-
const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
|
|
169
|
-
emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (p === '/api/orchestrate' && req.method === 'POST') {
|
|
173
|
-
const { request } = await body(req);
|
|
174
|
-
if (!request || !String(request).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
|
|
175
|
-
const active = store.readRuntime(root).orchestration;
|
|
176
|
-
if (active && ['running', 'awaiting_approval'].includes(active.status))
|
|
177
|
-
{ sendJSON(res, 409, { error: 'An orchestration is already active.' }); return true; }
|
|
178
|
-
const mode = store.readConfig(root).mode || 'semi';
|
|
179
|
-
orchestrator.runOrchestration({ root, request: String(request).trim(), mode,
|
|
180
|
-
runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
|
|
181
|
-
.catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
|
|
182
|
-
const o = store.readRuntime(root).orchestration;
|
|
183
|
-
sendJSON(res, 200, { orchestrationId: o && o.id }); return true;
|
|
184
|
-
}
|
|
185
|
-
if (p === '/api/orchestrate/approve' && req.method === 'POST') {
|
|
186
|
-
const { decision, note } = await body(req);
|
|
187
|
-
const ok = orchestrator.submitDecision(decision, note);
|
|
188
|
-
sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' }); return true;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (p === '/api/settings' && req.method === 'POST') {
|
|
192
|
-
const patch = await body(req);
|
|
193
|
-
try { const cfg = writeConfig(patch); emit({ type: 'change' }); sendJSON(res, 200, { config: cfg }); }
|
|
194
|
-
catch (e) { sendJSON(res, 400, { error: String(e && e.message || e) }); }
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (p === '/api/attention' && req.method === 'POST') {
|
|
199
|
-
const { text } = await body(req);
|
|
200
|
-
if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty note.' }); return true; }
|
|
201
|
-
const rt = store.readRuntime(root); rt.attention = rt.attention || [];
|
|
202
|
-
const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
|
|
203
|
-
rt.attention.unshift(item); store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
204
|
-
sendJSON(res, 200, { item }); return true;
|
|
205
|
-
}
|
|
206
|
-
if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
|
|
207
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
208
|
-
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
209
|
-
if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
|
|
210
|
-
const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
|
|
211
|
-
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
212
|
-
sendJSON(res, 200, { task: t }); return true;
|
|
213
|
-
}
|
|
214
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
|
|
215
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
216
|
-
const patch = await body(req);
|
|
217
|
-
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
218
|
-
if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
|
|
219
|
-
if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
|
|
220
|
-
if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
|
|
221
|
-
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
222
|
-
sendJSON(res, 200, { item: it }); return true;
|
|
223
|
-
}
|
|
224
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
225
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
226
|
-
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
|
|
227
|
-
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
228
|
-
sendJSON(res, 200, { ok: true }); return true;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function onBoot() {
|
|
235
|
-
// A project that hasn't used Customize yet won't have this dir on disk, and `spectoflow init` on
|
|
236
|
-
// an older install won't have created it either.
|
|
237
|
-
try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboard', 'custom'), { recursive: true }); } catch (_) {}
|
|
238
|
-
// A process restart loses any in-flight orchestration; without this, a stale 'running' or
|
|
239
|
-
// 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real resume —
|
|
240
|
-
// just clears the wedge so a fresh orchestration can start.
|
|
241
|
-
try { orchestrator.reconcileOnBoot(root); } catch (_) {}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
handleApi,
|
|
246
|
-
watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboard/custom'],
|
|
247
|
-
onBoot,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
module.exports = { createHandlers };
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/*
|
|
3
|
-
* spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch), single project.
|
|
4
|
-
* The actual /api/* route behavior lives in ./handlers.js — split out so the future multi-project hub
|
|
5
|
-
* (lib/hub-server.js) can load a different project's handlers.js on demand (see
|
|
6
|
-
* docs/multi-project-hub-design.md's "the server must split in two" addendum). This file remains the
|
|
7
|
-
* direct single-project entry point (`node .spectoflow/dashboard/server.js`, today's `spectoflow
|
|
8
|
-
* dashboard`) — its own external behavior is unchanged by the split.
|
|
9
|
-
*/
|
|
10
|
-
const http = require('http');
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const { createHandlers } = require('./handlers');
|
|
14
|
-
|
|
15
|
-
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
|
|
16
|
-
const PUBLIC = path.join(__dirname, 'public');
|
|
17
|
-
const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
|
|
18
|
-
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.woff2':'font/woff2', '.woff':'font/woff' };
|
|
19
|
-
const clients = new Set();
|
|
20
|
-
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
21
|
-
function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
|
|
22
|
-
|
|
23
|
-
const handlers = createHandlers(ROOT);
|
|
24
|
-
|
|
25
|
-
function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
|
|
26
|
-
handlers.onBoot();
|
|
27
|
-
handlers.watchDirs.forEach((d)=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
|
|
28
|
-
|
|
29
|
-
const server = http.createServer(async (req,res)=>{
|
|
30
|
-
const u=new URL(req.url,`http://localhost:${PORT}`); const p=u.pathname;
|
|
31
|
-
try{
|
|
32
|
-
if(p==='/api/events'){
|
|
33
|
-
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
|
|
34
|
-
res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
|
|
35
|
-
clients.add(res); req.on('close',()=>clients.delete(res)); return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (p.startsWith('/api/')) {
|
|
39
|
-
const handled = await handlers.handleApi(req, res, u, emit);
|
|
40
|
-
if (handled) return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---- static files, with SPA fallback: a route like /backlog (no file extension)
|
|
44
|
-
// that isn't a real asset serves index.html so client-side routing can take over ----
|
|
45
|
-
let file=p==='/'?'/index.html':p;
|
|
46
|
-
const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
|
|
47
|
-
if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
|
|
48
|
-
// Local tool: always serve the freshest asset — never let the browser cache a stale app.js/css.
|
|
49
|
-
const noCache = { 'Cache-Control': 'no-store, must-revalidate' };
|
|
50
|
-
fs.readFile(full,(err,data)=>{
|
|
51
|
-
if(err){
|
|
52
|
-
if(req.method==='GET' && !path.extname(p) && !p.startsWith('/api/')){
|
|
53
|
-
return fs.readFile(path.join(PUBLIC,'index.html'),(e2,d2)=>{
|
|
54
|
-
if(e2){ res.writeHead(404); return res.end('Not found'); }
|
|
55
|
-
res.writeHead(200,Object.assign({'Content-Type':MIME['.html']},noCache)); res.end(d2);
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
res.writeHead(404); return res.end('Not found');
|
|
59
|
-
}
|
|
60
|
-
const ext=path.extname(full);
|
|
61
|
-
// fonts are content-hashed by name and safe to cache long-term; everything else is no-store
|
|
62
|
-
const headers = ext==='.woff2'||ext==='.woff' ? { 'Cache-Control':'public, max-age=604800' } : noCache;
|
|
63
|
-
res.writeHead(200,Object.assign({'Content-Type':MIME[ext]||'application/octet-stream'},headers)); res.end(data);
|
|
64
|
-
});
|
|
65
|
-
}catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
|
|
66
|
-
});
|
|
67
|
-
// pidfile so `spectoflow dashboard stop` can find and stop this server; cleared on exit.
|
|
68
|
-
const LOCK = path.join(ROOT, '.spectoflow', '.dashboard.lock');
|
|
69
|
-
function writeLock(){ try{ fs.mkdirSync(path.dirname(LOCK),{recursive:true}); fs.writeFileSync(LOCK, JSON.stringify({ pid:process.pid, port:PORT, url:`http://localhost:${PORT}`, startedAt:new Date().toISOString() })+'\n'); }catch{} }
|
|
70
|
-
function clearLock(){ try{ const l=JSON.parse(fs.readFileSync(LOCK,'utf8')); if(l.pid===process.pid) fs.unlinkSync(LOCK); }catch{} }
|
|
71
|
-
process.on('exit', clearLock);
|
|
72
|
-
['SIGINT','SIGTERM'].forEach((s)=> process.on(s, ()=>{ clearLock(); process.exit(0); }));
|
|
73
|
-
server.listen(PORT,()=>{ writeLock(); console.log(`spectoflow · dashboard → http://localhost:${PORT}`); console.log(`project root: ${ROOT}`); });
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/*
|
|
3
|
-
* The dashboard's own view of "which coding agents exist and is one actually installed" — a small,
|
|
4
|
-
* self-contained subset of this package's lib/adapters.js (the richer install-time registry with
|
|
5
|
-
* memory-file content). Duplicated rather than shared: .spectoflow/ must be self-contained (ships
|
|
6
|
-
* into every project), while lib/adapters.js does not ship there. test/agents-registry.test.js
|
|
7
|
-
* guards the two id/bin/runner sets from drifting apart.
|
|
8
|
-
*/
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
|
-
|
|
12
|
-
// headless:false = detectable and selectable as the active agent, but spectoflow never spawns it
|
|
13
|
-
// itself (no confirmed non-interactive one-shot mode) — Run/Orchestrate/Summarize stay disabled for
|
|
14
|
-
// it client-side; runner/summarize.js also refuse server-side (defense in depth). `docsUrl` is that
|
|
15
|
-
// agent's own official CLI docs, surfaced verbatim in the dashboard's Documentation tab. See the
|
|
16
|
-
// longer rationale above lib/adapters.js's REGISTRY, the richer install-time twin of this list —
|
|
17
|
-
// including why DeepSeek Harness isn't here at all, and why some runner strings order their flags
|
|
18
|
-
// the way they do (the trailing prompt must land right after whichever flag takes a value).
|
|
19
|
-
const KNOWN_AGENTS = [
|
|
20
|
-
{ id: 'claude', label: 'Claude Code', bin: 'claude', dirs: ['.claude'], runner: 'claude -p --permission-mode acceptEdits', headless: true, docsUrl: 'https://code.claude.com/docs/en/cli-reference' },
|
|
21
|
-
{ id: 'codex', label: 'Codex', bin: 'codex', dirs: ['.codex'], runner: 'codex exec', headless: true, docsUrl: 'https://developers.openai.com/codex/cli/reference' },
|
|
22
|
-
{ id: 'cursor', label: 'Cursor', bin: 'cursor-agent', dirs: ['.cursor'], runner: 'cursor-agent -p', headless: true, docsUrl: 'https://cursor.com/docs/cli/overview' },
|
|
23
|
-
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', dirs: ['.gemini'], runner: 'gemini -p', headless: true, docsUrl: 'https://github.com/google-gemini/gemini-cli' },
|
|
24
|
-
{ id: 'opencode', label: 'OpenCode', bin: 'opencode', dirs: ['.opencode'], runner: 'opencode run --quiet', headless: true, docsUrl: 'https://opencode.ai/docs/cli/' },
|
|
25
|
-
{ id: 'kiro', label: 'Kiro CLI', bin: 'kiro-cli', dirs: ['.kiro'], runner: 'kiro-cli chat --no-interactive --trust-all-tools', headless: true, docsUrl: 'https://kiro.dev/docs/cli/headless/' },
|
|
26
|
-
{ id: 'antigravity', label: 'Antigravity', bin: 'agy', dirs: [], runner: 'agy -p', headless: true, docsUrl: 'https://antigravity.google/docs/cli/headless/' },
|
|
27
|
-
{ id: 'kimi', label: 'Kimi CLI', bin: 'kimi', dirs: [], runner: null, headless: false, docsUrl: 'https://github.com/MoonshotAI/kimi-cli' },
|
|
28
|
-
{ id: 'copilot', label: 'GitHub Copilot CLI', bin: 'copilot', dirs: [], runner: 'copilot -s --allow-all-tools -p', headless: true, docsUrl: 'https://docs.github.com/copilot/concepts/agents/about-copilot-cli' },
|
|
29
|
-
{ id: 'amazon-q', label: 'Amazon Q Developer CLI', bin: 'q', dirs: ['.amazonq'], runner: 'q chat --no-interactive --trust-all-tools', headless: true, docsUrl: 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-chat.html' },
|
|
30
|
-
{ id: 'droid', label: 'Factory Droid CLI', bin: 'droid', dirs: ['.factory'], runner: 'droid exec', headless: true, docsUrl: 'https://docs.factory.ai/droid-exec/overview' },
|
|
31
|
-
{ id: 'auggie', label: 'Auggie CLI', bin: 'auggie', dirs: ['.augment'], runner: 'auggie --quiet --print', headless: true, docsUrl: 'https://docs.augmentcode.com/cli/overview' },
|
|
32
|
-
{ id: 'goose', label: 'Goose CLI', bin: 'goose', dirs: ['.goose'], runner: 'goose run -t', headless: true, docsUrl: 'https://block.github.io/goose/' },
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
// Is `bin` an executable resolvable on PATH? On win32, an extension from PATHEXT is required, so we
|
|
36
|
-
// try each; we also try the bare name (covers test fixtures and extensionless shims).
|
|
37
|
-
function binOnPath(bin, { env = process.env, platform = process.platform } = {}) {
|
|
38
|
-
const raw = env.PATH || env.Path || '';
|
|
39
|
-
const dirs = raw.split(path.delimiter).filter(Boolean);
|
|
40
|
-
const exts =
|
|
41
|
-
platform === 'win32' ? ['', ...(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] : [''];
|
|
42
|
-
for (const d of dirs) {
|
|
43
|
-
for (const e of exts) {
|
|
44
|
-
if (fs.existsSync(path.join(d, bin + e))) return true;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// True if `id` looks genuinely installed: its bin resolves on PATH, or the project already has its
|
|
51
|
-
// config dir (a project can be set up for an agent whose bin isn't on THIS machine's PATH, e.g. a
|
|
52
|
-
// remote/CI runner). Unknown ids are never "installed".
|
|
53
|
-
function isAgentInstalled(id, projectRoot, opts) {
|
|
54
|
-
const a = KNOWN_AGENTS.find((x) => x.id === id);
|
|
55
|
-
if (!a) return false;
|
|
56
|
-
if (a.bin && binOnPath(a.bin, opts)) return true;
|
|
57
|
-
return (a.dirs || []).some((d) => fs.existsSync(path.join(projectRoot, d)));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ids of every known agent actually installed for this project, in KNOWN_AGENTS (priority) order.
|
|
61
|
-
function installedAgents(projectRoot, opts) {
|
|
62
|
-
return KNOWN_AGENTS.filter((a) => isAgentInstalled(a.id, projectRoot, opts)).map((a) => a.id);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
module.exports = { KNOWN_AGENTS, binOnPath, isAgentInstalled, installedAgents };
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|