spectoflow 0.22.3 → 0.22.5
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/bin/spectoflow.js +29 -0
- package/lib/registry.js +98 -0
- package/package.json +1 -1
- package/templates/dashboard/handlers.js +241 -0
- package/templates/dashboard/public/app.js +124 -12
- package/templates/dashboard/public/i18n.js +6 -6
- package/templates/dashboard/public/index.html +2 -0
- package/templates/dashboard/public/styles.css +21 -1
- package/templates/dashboard/server.js +13 -226
package/bin/spectoflow.js
CHANGED
|
@@ -9,6 +9,7 @@ const adapters = require('../lib/adapters');
|
|
|
9
9
|
const detect = require('../lib/detect');
|
|
10
10
|
const ownership = require('../lib/ownership');
|
|
11
11
|
const manifest = require('../lib/manifest');
|
|
12
|
+
const registry = require('../lib/registry');
|
|
12
13
|
const mcp = require('../lib/mcp');
|
|
13
14
|
const { startRun } = require('../templates/dashboard/runner');
|
|
14
15
|
const { buildCustomizePrompt } = require('../templates/lib/customize-prompts');
|
|
@@ -283,6 +284,28 @@ async function dashboard() {
|
|
|
283
284
|
return startDashboard();
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
// ---- projects: the multi-project registry's CLI surface (~/.spectoflow/projects.json) ----
|
|
288
|
+
function projectsCmd() {
|
|
289
|
+
const sub = argv[1];
|
|
290
|
+
if (sub === 'remove') return projectsRemove(argv[2]);
|
|
291
|
+
return projectsList();
|
|
292
|
+
}
|
|
293
|
+
function projectsList() {
|
|
294
|
+
console.log(wordmark());
|
|
295
|
+
const rows = registry.listProjects();
|
|
296
|
+
if (!rows.length) {
|
|
297
|
+
console.log(c.dim(' no projects registered yet — run `spectoflow dashboard` inside one'));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const w = Math.max(4, ...rows.map((r) => r.name.length));
|
|
301
|
+
rows.forEach((r) => console.log(` ${c.g(r.id)} ${r.name.padEnd(w)} ${c.dim(r.path)}`));
|
|
302
|
+
}
|
|
303
|
+
function projectsRemove(id) {
|
|
304
|
+
if (!id) { console.log('Usage: spectoflow projects remove <id>'); return; }
|
|
305
|
+
const ok = registry.removeProject(id);
|
|
306
|
+
console.log(ok ? `${c.g('✓')} removed ${id}` : `${c.y('!')} no project registered with id ${id}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
286
309
|
// ---- Customize: `spectoflow skill/agent/dashboard create` — the CLI mirror of the dashboard's
|
|
287
310
|
// Settings → Customize UI. Both surfaces build the same natural-language prompt (customize-prompts.js)
|
|
288
311
|
// and post it through the same pipeline (runner.js's startRun — the function /api/run itself calls),
|
|
@@ -479,6 +502,7 @@ ${c.bold('Dashboard')}
|
|
|
479
502
|
${c.g('dashboard status')} is it running? (url + pid)
|
|
480
503
|
${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
|
|
481
504
|
${c.g('dashboard restart')} stop then start
|
|
505
|
+
${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far (~/.spectoflow/projects.json)
|
|
482
506
|
|
|
483
507
|
${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
|
|
484
508
|
${c.g('skill create')} ${c.dim('"<description>" | --auto')} generate a project skill
|
|
@@ -522,6 +546,10 @@ const HELP = {
|
|
|
522
546
|
${c.g('stop')} stop it ${c.dim('(alias: spectoflow stop)')}
|
|
523
547
|
${c.g('restart')} stop then start
|
|
524
548
|
${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}`,
|
|
549
|
+
projects: `${c.bold('spectoflow projects')} ${c.dim('[remove <id>]')}\n
|
|
550
|
+
List every registered project in the global registry at ${c.dim('~/.spectoflow/projects.json')} (stored by
|
|
551
|
+
${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
|
|
552
|
+
or was deleted) from this list only; it never touches that project's own files.`,
|
|
525
553
|
skill: `${c.bold('spectoflow skill create')} ${c.dim('"<description>" [--agent=name]')}\n${c.bold('spectoflow skill create')} ${c.dim('--auto [--agent=name]')}\n
|
|
526
554
|
Generate a project-specific skill — the CLI mirror of Settings → Customize → ${c.bold('Skills')} →
|
|
527
555
|
${c.bold('Add skill')} in the dashboard. Describe what it should do, or pass ${c.g('--auto')} to have
|
|
@@ -547,6 +575,7 @@ const showHelp = (name) => console.log('\n' + HELP[name].trim() + '\n');
|
|
|
547
575
|
// ---- dispatch ---------------------------------------------------------------
|
|
548
576
|
const fns = {
|
|
549
577
|
init, update, dashboard, stop: stopDashboard, status, list: listAll, help, version,
|
|
578
|
+
projects: projectsCmd,
|
|
550
579
|
agents: () => { console.log(wordmark()); printAgents(false); },
|
|
551
580
|
skills: () => { console.log(wordmark()); printSkills(false); },
|
|
552
581
|
workflow: () => { console.log(wordmark()); printWorkflow(false); },
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The project registry — ~/.spectoflow/projects.json. Tracks every project spectoflow has seen (via
|
|
4
|
+
* `spectoflow dashboard`, wired in a later sub-project), so the multi-project hub knows what to list
|
|
5
|
+
* and switch between. This module owns only the registry file itself; it has no opinion about
|
|
6
|
+
* dashboards, ports, or servers, and nothing else in the codebase calls it yet.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
const REGISTRY_FILE = 'projects.json';
|
|
14
|
+
|
|
15
|
+
// Resolution order: an explicit baseDir (unit tests) > SPECTOFLOW_HOME (CLI-level test isolation,
|
|
16
|
+
// same convention as SPECTOFLOW_ROOT/SPECTOFLOW_PORT elsewhere in this codebase) > the real home dir.
|
|
17
|
+
function registryDir(baseDir) {
|
|
18
|
+
return baseDir || process.env.SPECTOFLOW_HOME || path.join(os.homedir(), '.spectoflow');
|
|
19
|
+
}
|
|
20
|
+
function registryPath(baseDir) {
|
|
21
|
+
return path.join(registryDir(baseDir), REGISTRY_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readRegistry(baseDir) {
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(registryPath(baseDir), 'utf8')); }
|
|
26
|
+
catch { return { projects: [] }; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeRegistry(baseDir, data) {
|
|
30
|
+
const dir = registryDir(baseDir);
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(registryPath(baseDir), JSON.stringify(data, null, 2) + '\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 6 hex chars; regenerated on the rare collision against ids already in the registry. `randomFn` is
|
|
36
|
+
// injectable (defaults to crypto.randomBytes) so collision handling is testable without depending on
|
|
37
|
+
// genuine randomness to ever actually collide.
|
|
38
|
+
function genId(existingIds, randomFn) {
|
|
39
|
+
const rand = randomFn || ((n) => crypto.randomBytes(n));
|
|
40
|
+
let id;
|
|
41
|
+
do { id = rand(3).toString('hex'); } while (existingIds.includes(id));
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findByPath(projectPath, baseDir) {
|
|
46
|
+
const target = path.resolve(projectPath);
|
|
47
|
+
return readRegistry(baseDir).projects.find((p) => path.resolve(p.path) === target) || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Registers `projectPath` if it isn't already known (matched by normalized path); either way stamps
|
|
51
|
+
// lastOpened to now and returns the entry. Never duplicates the same folder under a second id.
|
|
52
|
+
function addProject(projectPath, baseDir) {
|
|
53
|
+
const reg = readRegistry(baseDir);
|
|
54
|
+
const target = path.resolve(projectPath);
|
|
55
|
+
let entry = reg.projects.find((p) => path.resolve(p.path) === target);
|
|
56
|
+
if (!entry) {
|
|
57
|
+
entry = {
|
|
58
|
+
id: genId(reg.projects.map((p) => p.id)),
|
|
59
|
+
path: target,
|
|
60
|
+
name: path.basename(target),
|
|
61
|
+
lastOpened: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
reg.projects.push(entry);
|
|
64
|
+
} else {
|
|
65
|
+
entry.lastOpened = new Date().toISOString();
|
|
66
|
+
}
|
|
67
|
+
writeRegistry(baseDir, reg);
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function removeProject(id, baseDir) {
|
|
72
|
+
const reg = readRegistry(baseDir);
|
|
73
|
+
const before = reg.projects.length;
|
|
74
|
+
reg.projects = reg.projects.filter((p) => p.id !== id);
|
|
75
|
+
writeRegistry(baseDir, reg);
|
|
76
|
+
return reg.projects.length < before;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function touchProject(id, baseDir) {
|
|
80
|
+
const reg = readRegistry(baseDir);
|
|
81
|
+
const entry = reg.projects.find((p) => p.id === id);
|
|
82
|
+
if (!entry) return false;
|
|
83
|
+
entry.lastOpened = new Date().toISOString();
|
|
84
|
+
writeRegistry(baseDir, reg);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Newest-first — the natural "what did I touch most recently" order for both `spectoflow projects
|
|
89
|
+
// list` and (in a later sub-project) the hub landing page.
|
|
90
|
+
function listProjects(baseDir) {
|
|
91
|
+
return readRegistry(baseDir).projects.slice()
|
|
92
|
+
.sort((a, b) => (b.lastOpened || '').localeCompare(a.lastOpened || ''));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
readRegistry, writeRegistry, genId, addProject, removeProject, touchProject,
|
|
97
|
+
findByPath, listProjects, registryPath,
|
|
98
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectoflow",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.5",
|
|
4
4
|
"description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -0,0 +1,241 @@
|
|
|
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
|
+
for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
|
|
139
|
+
if (m && m[4].replace(/\s*\(optional\)\s*$/i, '').trim() === name) lines[i] = m[1] + (m[2].trim() ? ' ' : 'x') + m[3] + m[4]; }
|
|
140
|
+
fs.writeFileSync(wf, lines.join('\n')); emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (p === '/api/run' && req.method === 'POST') {
|
|
144
|
+
const { prompt, agent } = await body(req);
|
|
145
|
+
if (!prompt || !String(prompt).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
|
|
146
|
+
const r = startRun(root, { prompt, agent }, emit);
|
|
147
|
+
if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
|
|
148
|
+
sendJSON(res, 200, { runId: r.runId }); return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (p === '/api/chat/summarize' && req.method === 'POST') {
|
|
152
|
+
const { agent } = await body(req);
|
|
153
|
+
const r = runSummarize(root, { agent }, emit);
|
|
154
|
+
if (r.error) { sendJSON(res, 400, { error: r.error }); return true; }
|
|
155
|
+
sendJSON(res, 200, { ok: true }); return true;
|
|
156
|
+
}
|
|
157
|
+
if (p === '/api/chat/clear' && req.method === 'POST') {
|
|
158
|
+
const rt = store.readRuntime(root); rt.messages = []; store.writeRuntime(root, rt);
|
|
159
|
+
emit({ type: 'change' }); sendJSON(res, 200, { ok: true }); return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (p === '/api/orchestrate' && req.method === 'POST') {
|
|
163
|
+
const { request } = await body(req);
|
|
164
|
+
if (!request || !String(request).trim()) { sendJSON(res, 400, { error: 'Empty request.' }); return true; }
|
|
165
|
+
const active = store.readRuntime(root).orchestration;
|
|
166
|
+
if (active && ['running', 'awaiting_approval'].includes(active.status))
|
|
167
|
+
{ sendJSON(res, 409, { error: 'An orchestration is already active.' }); return true; }
|
|
168
|
+
const mode = store.readConfig(root).mode || 'semi';
|
|
169
|
+
orchestrator.runOrchestration({ root, request: String(request).trim(), mode,
|
|
170
|
+
runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
|
|
171
|
+
.catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
|
|
172
|
+
const o = store.readRuntime(root).orchestration;
|
|
173
|
+
sendJSON(res, 200, { orchestrationId: o && o.id }); return true;
|
|
174
|
+
}
|
|
175
|
+
if (p === '/api/orchestrate/approve' && req.method === 'POST') {
|
|
176
|
+
const { decision, note } = await body(req);
|
|
177
|
+
const ok = orchestrator.submitDecision(decision, note);
|
|
178
|
+
sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' }); return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (p === '/api/settings' && req.method === 'POST') {
|
|
182
|
+
const patch = await body(req);
|
|
183
|
+
try { const cfg = writeConfig(patch); emit({ type: 'change' }); sendJSON(res, 200, { config: cfg }); }
|
|
184
|
+
catch (e) { sendJSON(res, 400, { error: String(e && e.message || e) }); }
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (p === '/api/attention' && req.method === 'POST') {
|
|
189
|
+
const { text } = await body(req);
|
|
190
|
+
if (!text || !String(text).trim()) { sendJSON(res, 400, { error: 'Empty note.' }); return true; }
|
|
191
|
+
const rt = store.readRuntime(root); rt.attention = rt.attention || [];
|
|
192
|
+
const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
|
|
193
|
+
rt.attention.unshift(item); store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
194
|
+
sendJSON(res, 200, { item }); return true;
|
|
195
|
+
}
|
|
196
|
+
if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
|
|
197
|
+
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
198
|
+
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
199
|
+
if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
|
|
200
|
+
const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
|
|
201
|
+
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
202
|
+
sendJSON(res, 200, { task: t }); return true;
|
|
203
|
+
}
|
|
204
|
+
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
|
|
205
|
+
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
206
|
+
const patch = await body(req);
|
|
207
|
+
const rt = store.readRuntime(root); const it = (rt.attention || []).find((x) => x.id === id);
|
|
208
|
+
if (!it) { sendJSON(res, 404, { error: 'Note not found.' }); return true; }
|
|
209
|
+
if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
|
|
210
|
+
if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
|
|
211
|
+
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
212
|
+
sendJSON(res, 200, { item: it }); return true;
|
|
213
|
+
}
|
|
214
|
+
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
215
|
+
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
216
|
+
const rt = store.readRuntime(root); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
|
|
217
|
+
store.writeRuntime(root, rt); emit({ type: 'change' });
|
|
218
|
+
sendJSON(res, 200, { ok: true }); return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function onBoot() {
|
|
225
|
+
// A project that hasn't used Customize yet won't have this dir on disk, and `spectoflow init` on
|
|
226
|
+
// an older install won't have created it either.
|
|
227
|
+
try { fs.mkdirSync(path.join(root, '.spectoflow', 'dashboard', 'custom'), { recursive: true }); } catch (_) {}
|
|
228
|
+
// A process restart loses any in-flight orchestration; without this, a stale 'running' or
|
|
229
|
+
// 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real resume —
|
|
230
|
+
// just clears the wedge so a fresh orchestration can start.
|
|
231
|
+
try { orchestrator.reconcileOnBoot(root); } catch (_) {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
handleApi,
|
|
236
|
+
watchDirs: ['plans', 'specs', '.spectoflow', '.spectoflow/dashboard/custom'],
|
|
237
|
+
onBoot,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { createHandlers };
|
|
@@ -1321,6 +1321,7 @@ function renderDocs(){
|
|
|
1321
1321
|
// event refreshes the TREE listing but never overwrites an open file's editor buffer, so an
|
|
1322
1322
|
// unrelated agent write elsewhere can't clobber unsaved work here. ----
|
|
1323
1323
|
let filesTreeData=null, filesOpenPath=null, filesOpenDirty=false;
|
|
1324
|
+
let filesSelectedDir=''; // '' = project root — the folder + File/+ Folder create inside
|
|
1324
1325
|
const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
|
|
1325
1326
|
async function loadFilesTree(){
|
|
1326
1327
|
try{
|
|
@@ -1338,7 +1339,8 @@ function renderFiles(){
|
|
|
1338
1339
|
loadFilesTree();
|
|
1339
1340
|
}
|
|
1340
1341
|
function fNode(entry){
|
|
1341
|
-
const
|
|
1342
|
+
const isTarget=entry.type==='dir'&&entry.path===filesSelectedDir;
|
|
1343
|
+
const row=el('div','f-row'+(entry.type==='dir'&&filesOpenDirs.has(entry.path)?' is-open':'')+(entry.path===filesOpenPath?' is-active':'')+(isTarget?' is-target':''));
|
|
1342
1344
|
row.tabIndex=0;
|
|
1343
1345
|
if(entry.type==='dir'){
|
|
1344
1346
|
const chev=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
|
@@ -1353,10 +1355,17 @@ function fNode(entry){
|
|
|
1353
1355
|
const kids=el('div','f-children'); kids.hidden=!filesOpenDirs.has(entry.path);
|
|
1354
1356
|
(entry.children||[]).forEach(c=> kids.append(fNode(c)));
|
|
1355
1357
|
wrap.append(kids);
|
|
1358
|
+
// a click both toggles expand/collapse AND marks this folder as where +File/+Folder create —
|
|
1359
|
+
// the two are independent ideas but sharing the click keeps the tree from needing a second,
|
|
1360
|
+
// easy-to-miss gesture just to pick a target folder.
|
|
1356
1361
|
row.addEventListener('click',()=>{
|
|
1357
1362
|
const open=filesOpenDirs.has(entry.path);
|
|
1358
1363
|
if(open) filesOpenDirs.delete(entry.path); else filesOpenDirs.add(entry.path);
|
|
1359
1364
|
row.classList.toggle('is-open',!open); kids.hidden=open;
|
|
1365
|
+
filesSelectedDir=entry.path;
|
|
1366
|
+
const root=$('#filesRootRow'); if(root) root.classList.remove('is-target');
|
|
1367
|
+
$$('#filesTree .f-row.is-target').forEach(r=> r!==row && r.classList.remove('is-target'));
|
|
1368
|
+
row.classList.add('is-target');
|
|
1360
1369
|
});
|
|
1361
1370
|
} else {
|
|
1362
1371
|
row.addEventListener('click',()=> openFilesFile(entry.path));
|
|
@@ -1366,10 +1375,104 @@ function fNode(entry){
|
|
|
1366
1375
|
function renderFilesTree(){
|
|
1367
1376
|
const box=$('#filesTree'); if(!box) return;
|
|
1368
1377
|
box.innerHTML='';
|
|
1378
|
+
const root=$('#filesRootRow'); if(root) root.classList.toggle('is-target',filesSelectedDir==='');
|
|
1369
1379
|
if(!filesTreeData || !filesTreeData.length){ box.append(el('div','empty',t('files.empty'))); return; }
|
|
1370
1380
|
filesTreeData.forEach(e=> box.append(fNode(e)));
|
|
1371
1381
|
}
|
|
1372
1382
|
function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
|
|
1383
|
+
|
|
1384
|
+
// ---- lightweight syntax highlighting — zero-dependency (no CodeMirror/Monaco, per the explicit
|
|
1385
|
+
// call made when this tab was designed): a single char-scanner tokenizer good enough to make code
|
|
1386
|
+
// readable at a glance in a file browser, not a language-correct parser. Anything not recognized
|
|
1387
|
+
// (or with no lang mapping) just renders as plain, unstyled text — never a rendering error. ----
|
|
1388
|
+
const FILES_HL_LANG = {
|
|
1389
|
+
js: { comments:[['//','\n'],['/*','*/']], strings:['"',"'",'`'],
|
|
1390
|
+
keywords:'const let var function return if else for while do switch case break continue new class extends super this typeof instanceof in of try catch finally throw async await yield import export default from as null undefined true false void delete'.split(' ') },
|
|
1391
|
+
json: { comments:[], strings:['"'], keywords:'true false null'.split(' ') },
|
|
1392
|
+
css: { comments:[['/*','*/']], strings:['"',"'"], keywords:[] },
|
|
1393
|
+
html: { comments:[['<!--','-->']], strings:['"',"'"], keywords:[], tags:true },
|
|
1394
|
+
py: { comments:[['#','\n']], strings:['"',"'"],
|
|
1395
|
+
keywords:'def class return if elif else for while break continue pass import from as try except finally raise with lambda yield async await None True False and or not in is del global nonlocal'.split(' ') },
|
|
1396
|
+
sh: { comments:[['#','\n']], strings:['"',"'"],
|
|
1397
|
+
keywords:'if then else elif fi for while do done case esac function return exit export local readonly'.split(' ') },
|
|
1398
|
+
yml: { comments:[['#','\n']], strings:['"',"'"], keywords:'true false null'.split(' ') },
|
|
1399
|
+
};
|
|
1400
|
+
function filesHlLang(ext){
|
|
1401
|
+
if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'js';
|
|
1402
|
+
if(ext==='json') return 'json';
|
|
1403
|
+
if(ext==='css') return 'css';
|
|
1404
|
+
if(['html','htm'].includes(ext)) return 'html';
|
|
1405
|
+
if(ext==='py') return 'py';
|
|
1406
|
+
if(['sh','bash'].includes(ext)) return 'sh';
|
|
1407
|
+
if(['yml','yaml'].includes(ext)) return 'yml';
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
// Scans `src` one character at a time, classifying spans as it goes; returns an HTML string with
|
|
1411
|
+
// each span wrapped in a colored <span> (escaped — this is the only place raw file content becomes
|
|
1412
|
+
// markup, so nothing here may skip escHtml).
|
|
1413
|
+
function filesHighlight(src,langKey){
|
|
1414
|
+
const lang=FILES_HL_LANG[langKey];
|
|
1415
|
+
if(!lang) return escHtml(src);
|
|
1416
|
+
const n=src.length;
|
|
1417
|
+
let i=0,html='',plain='';
|
|
1418
|
+
const flushPlain=()=>{ if(plain){ html+=escHtml(plain); plain=''; } };
|
|
1419
|
+
const isWordChar=c=>/[A-Za-z0-9_$]/.test(c);
|
|
1420
|
+
while(i<n){
|
|
1421
|
+
let matched=false;
|
|
1422
|
+
// comments
|
|
1423
|
+
for(const [open,close] of lang.comments){
|
|
1424
|
+
if(src.startsWith(open,i)){
|
|
1425
|
+
const end = close==='\n' ? (src.indexOf('\n',i)===-1?n:src.indexOf('\n',i)) : (src.indexOf(close,i+open.length)===-1?n:src.indexOf(close,i+open.length)+close.length);
|
|
1426
|
+
flushPlain(); html+='<span class="hl-comment">'+escHtml(src.slice(i,end))+'</span>'; i=end; matched=true; break;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
if(matched) continue;
|
|
1430
|
+
// strings
|
|
1431
|
+
if(lang.strings.includes(src[i])){
|
|
1432
|
+
const q=src[i]; let j=i+1;
|
|
1433
|
+
while(j<n && src[j]!==q){ if(src[j]==='\\') j++; j++; }
|
|
1434
|
+
j=Math.min(j+1,n);
|
|
1435
|
+
flushPlain(); html+='<span class="hl-string">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1436
|
+
}
|
|
1437
|
+
// html tags (bonus: <tag ...> / </tag>) — a light touch, not full attribute-vs-value parsing
|
|
1438
|
+
if(lang.tags && src[i]==='<' && /[a-zA-Z/!]/.test(src[i+1]||'')){
|
|
1439
|
+
const end=src.indexOf('>',i); const j=end===-1?n:end+1;
|
|
1440
|
+
flushPlain(); html+='<span class="hl-tag">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1441
|
+
}
|
|
1442
|
+
// numbers
|
|
1443
|
+
if(/[0-9]/.test(src[i]) && !isWordChar(src[i-1]||'')){
|
|
1444
|
+
let j=i; while(j<n && /[0-9.]/.test(src[j])) j++;
|
|
1445
|
+
flushPlain(); html+='<span class="hl-number">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
|
|
1446
|
+
}
|
|
1447
|
+
// keywords
|
|
1448
|
+
if(isWordChar(src[i]) && !isWordChar(src[i-1]||'')){
|
|
1449
|
+
let j=i; while(j<n && isWordChar(src[j])) j++;
|
|
1450
|
+
const word=src.slice(i,j);
|
|
1451
|
+
if(lang.keywords.includes(word)){ flushPlain(); html+='<span class="hl-keyword">'+escHtml(word)+'</span>'; i=j; continue; }
|
|
1452
|
+
plain+=word; i=j; continue;
|
|
1453
|
+
}
|
|
1454
|
+
plain+=src[i]; i++;
|
|
1455
|
+
}
|
|
1456
|
+
flushPlain();
|
|
1457
|
+
return html;
|
|
1458
|
+
}
|
|
1459
|
+
// A textarea can't render colored text itself, so this overlays one, transparent, on top of a
|
|
1460
|
+
// highlighted <pre><code> "backdrop" showing through it (the standard technique for a highlighted
|
|
1461
|
+
// plain-text editor without a full editor component) — same font metrics on both, scroll positions
|
|
1462
|
+
// kept in lockstep, backdrop re-rendered on every keystroke.
|
|
1463
|
+
function filesCodeEditor(content,langKey,onInput){
|
|
1464
|
+
const wrap=el('div','files-code-wrap');
|
|
1465
|
+
const pre=document.createElement('pre'); pre.className='files-code-backdrop'; pre.setAttribute('aria-hidden','true');
|
|
1466
|
+
const code=document.createElement('code'); pre.append(code);
|
|
1467
|
+
const ta=el('textarea','files-editor files-code-input'); ta.spellcheck=false; ta.value=content;
|
|
1468
|
+
const paint=()=>{ code.innerHTML=filesHighlight(ta.value,langKey)+'\n'; }; // trailing \n: a final blank line still gets backdrop height
|
|
1469
|
+
const syncScroll=()=>{ pre.scrollTop=ta.scrollTop; pre.scrollLeft=ta.scrollLeft; };
|
|
1470
|
+
ta.addEventListener('input',()=>{ paint(); if(onInput) onInput(ta.value); });
|
|
1471
|
+
ta.addEventListener('scroll',syncScroll);
|
|
1472
|
+
paint();
|
|
1473
|
+
wrap.append(pre,ta);
|
|
1474
|
+
return { wrap, textarea:ta };
|
|
1475
|
+
}
|
|
1373
1476
|
async function openFilesFile(relPath){
|
|
1374
1477
|
// no native confirm() dialog (it blocks the whole tab, including our own SSE/automation) — a
|
|
1375
1478
|
// dirty editor just refuses to switch until the user explicitly saves or discards.
|
|
@@ -1432,15 +1535,19 @@ function renderFilesMd(box,actions,relPath,content){
|
|
|
1432
1535
|
const showEditor=()=>{
|
|
1433
1536
|
body.innerHTML='';
|
|
1434
1537
|
const tb=el('div','files-md-toolbar');
|
|
1435
|
-
const ta=
|
|
1436
|
-
const wrapSel=(before,after)=>{
|
|
1538
|
+
const {wrap,textarea:ta}=filesCodeEditor(content,null,(v)=>{ content=v; filesOpenDirty=true; });
|
|
1539
|
+
const wrapSel=(before,after)=>{
|
|
1540
|
+
const s=ta.selectionStart,e=ta.selectionEnd; const v=ta.value;
|
|
1541
|
+
ta.value=v.slice(0,s)+before+v.slice(s,e)+after+v.slice(e);
|
|
1542
|
+
ta.dispatchEvent(new Event('input')); // repaints the backdrop and marks dirty via the same path as typing
|
|
1543
|
+
ta.focus(); ta.selectionStart=s+before.length; ta.selectionEnd=e+before.length;
|
|
1544
|
+
};
|
|
1437
1545
|
[['B','**','**'],['I','_','_'],['H','## ',''],['Link','[','](url)']].forEach(([label,a,b])=>{
|
|
1438
1546
|
const bt=el('button',null,label); bt.type='button'; bt.addEventListener('click',()=>wrapSel(a,b)); tb.append(bt);
|
|
1439
1547
|
});
|
|
1440
1548
|
const saveBtn=el('button',null,t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>{ content=ta.value; filesSave(relPath,content,actions); });
|
|
1441
1549
|
tb.append(saveBtn,filesDiscardBtn(relPath));
|
|
1442
|
-
|
|
1443
|
-
body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1550
|
+
body.append(tb,wrap); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1444
1551
|
};
|
|
1445
1552
|
editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
|
|
1446
1553
|
showPreview();
|
|
@@ -1461,23 +1568,21 @@ function renderFilesHtml(box,actions,relPath,content){
|
|
|
1461
1568
|
};
|
|
1462
1569
|
const showEditor=()=>{
|
|
1463
1570
|
body.innerHTML='';
|
|
1464
|
-
const ta=
|
|
1465
|
-
ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
|
|
1571
|
+
const {wrap,textarea:ta}=filesCodeEditor(content,'html',(v)=>{ content=v; filesOpenDirty=true; });
|
|
1466
1572
|
const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>filesSave(relPath,content,actions));
|
|
1467
1573
|
const tb=el('div','files-md-toolbar'); tb.append(saveBtn,filesDiscardBtn(relPath));
|
|
1468
|
-
body.append(tb,
|
|
1574
|
+
body.append(tb,wrap); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1469
1575
|
};
|
|
1470
1576
|
editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
|
|
1471
1577
|
showPreview();
|
|
1472
1578
|
}
|
|
1473
1579
|
// Anything else that reads as text: a plain monospace editor, editable straight away.
|
|
1474
1580
|
function renderFilesText(box,actions,relPath,content){
|
|
1475
|
-
const ta=
|
|
1476
|
-
ta.addEventListener('input',()=>{ filesOpenDirty=true; });
|
|
1581
|
+
const {wrap,textarea:ta}=filesCodeEditor(content,filesHlLang(filesExt(relPath)),()=>{ filesOpenDirty=true; });
|
|
1477
1582
|
const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button';
|
|
1478
1583
|
saveBtn.addEventListener('click',()=>filesSave(relPath,ta.value,actions));
|
|
1479
1584
|
actions.append(saveBtn,filesDiscardBtn(relPath));
|
|
1480
|
-
box.append(
|
|
1585
|
+
box.append(wrap); ta.focus();
|
|
1481
1586
|
}
|
|
1482
1587
|
// "+ File"/"+ Folder" open the same inline form (never a native prompt()/alert() — those block the
|
|
1483
1588
|
// whole tab, including our own SSE connection, until dismissed).
|
|
@@ -1488,6 +1593,7 @@ function openFilesCreateForm(kind){
|
|
|
1488
1593
|
const input=$('#filesCreateInput');
|
|
1489
1594
|
input.placeholder = kind==='dir' ? t('files.newFolderPrompt') : t('files.newFilePrompt');
|
|
1490
1595
|
input.value='';
|
|
1596
|
+
const target=$('#filesCreateTarget'); if(target) target.textContent=t('files.creatingIn',{path:filesSelectedDir||t('files.projectRoot')});
|
|
1491
1597
|
const err=$('#filesCreateError'); if(err) err.hidden=true;
|
|
1492
1598
|
form.hidden=false; form.classList.add('is-open');
|
|
1493
1599
|
input.focus();
|
|
@@ -1500,7 +1606,7 @@ async function submitFilesCreate(){
|
|
|
1500
1606
|
const input=$('#filesCreateInput'); const err=$('#filesCreateError');
|
|
1501
1607
|
const name=(input.value||'').trim();
|
|
1502
1608
|
if(!name){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } return; }
|
|
1503
|
-
const rel=name.replace(/^\/+/,'');
|
|
1609
|
+
const rel=(filesSelectedDir?filesSelectedDir+'/':'')+name.replace(/^\/+/,'');
|
|
1504
1610
|
const kind=filesCreateKind;
|
|
1505
1611
|
const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
|
|
1506
1612
|
const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
|
|
@@ -1638,6 +1744,12 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
|
|
|
1638
1744
|
const filesNewFileBtn=$('#filesNewFile'); if(filesNewFileBtn) filesNewFileBtn.addEventListener('click',()=>openFilesCreateForm('file'));
|
|
1639
1745
|
const filesNewFolderBtn=$('#filesNewFolder'); if(filesNewFolderBtn) filesNewFolderBtn.addEventListener('click',()=>openFilesCreateForm('dir'));
|
|
1640
1746
|
const filesRefreshBtn=$('#filesRefresh'); if(filesRefreshBtn) filesRefreshBtn.addEventListener('click',loadFilesTree);
|
|
1747
|
+
const filesRootRowBtn=$('#filesRootRow');
|
|
1748
|
+
if(filesRootRowBtn) filesRootRowBtn.addEventListener('click',()=>{
|
|
1749
|
+
filesSelectedDir='';
|
|
1750
|
+
$$('#filesTree .f-row.is-target').forEach(r=> r.classList.remove('is-target'));
|
|
1751
|
+
filesRootRowBtn.classList.add('is-target');
|
|
1752
|
+
});
|
|
1641
1753
|
const filesCreateGoBtn=$('#filesCreateGo'); if(filesCreateGoBtn) filesCreateGoBtn.addEventListener('click',submitFilesCreate);
|
|
1642
1754
|
const filesCreateCancelBtn=$('#filesCreateCancel'); if(filesCreateCancelBtn) filesCreateCancelBtn.addEventListener('click',closeFilesCreateForm);
|
|
1643
1755
|
const filesCreateInputEl=$('#filesCreateInput'); if(filesCreateInputEl) filesCreateInputEl.addEventListener('keydown',e=>{ if(e.key==='Enter') submitFilesCreate(); });
|
|
@@ -85,7 +85,7 @@ en: {
|
|
|
85
85
|
'team.skillsTitle':'Skills','team.skillsSub':'Evolving procedures — the <em>how</em>.',
|
|
86
86
|
'team.standardsLabel':'standards','team.usesLabel':'uses','team.standardLabel':'standard',
|
|
87
87
|
'drawer.agent':'Agent','drawer.skill':'Skill','drawer.file':'File · {rel}',
|
|
88
|
-
'drawer.loading':'Loading…','drawer.loadError':'Could not load this file.','files.title':'Files','files.sub':'Browse the project\'s files — view Markdown & HTML, edit any text file, create new ones.','files.newFile':'+ File','files.newFolder':'+ Folder','files.pickFile':'Select a file to view it.','files.empty':'No files yet.','files.edit':'Edit','files.preview':'Preview','files.save':'Save','files.saved':'✓ saved','files.saveError':'Could not save this file.','files.loadError':'Could not load this file.','files.binary':'This file can\'t be previewed here (not text).','files.discardConfirm':'Discard unsaved changes?','files.newFilePrompt':'New file
|
|
88
|
+
'drawer.loading':'Loading…','drawer.loadError':'Could not load this file.','files.title':'Files','files.sub':'Browse the project\'s files — view Markdown & HTML, edit any text file, create new ones.','files.newFile':'+ File','files.newFolder':'+ Folder','files.pickFile':'Select a file to view it.','files.empty':'No files yet.','files.edit':'Edit','files.preview':'Preview','files.save':'Save','files.saved':'✓ saved','files.saveError':'Could not save this file.','files.loadError':'Could not load this file.','files.binary':'This file can\'t be previewed here (not text).','files.discardConfirm':'Discard unsaved changes?','files.newFilePrompt':'New file name (e.g. todo.md):','files.newFolderPrompt':'New folder name:','files.projectRoot':'project root','files.creatingIn':'Creating in: {path}','files.refresh':'Refresh','files.discard':'Discard','files.create':'Create',
|
|
89
89
|
'chat.widgetTitle':'Run an agent','chat.widgetSub':'Quick access · full view in the Chat tab',
|
|
90
90
|
'chat.tabSub':'Full conversation with the runner — the same run as the widget, more room to read it.',
|
|
91
91
|
'chat.idle':'Type a request — the agent runs headless in this project with full memory (<code>CLAUDE.md → AGENTS.md</code>) and updates the board live.',
|
|
@@ -197,7 +197,7 @@ fr: {
|
|
|
197
197
|
'team.skillsTitle':'Compétences','team.skillsSub':'Procédures évolutives — le <em>comment</em>.',
|
|
198
198
|
'team.standardsLabel':'standards','team.usesLabel':'utilise','team.standardLabel':'standard',
|
|
199
199
|
'drawer.agent':'Agent','drawer.skill':'Compétence','drawer.file':'Fichier · {rel}',
|
|
200
|
-
'drawer.loading':'Chargement…','drawer.loadError':'Impossible de charger ce fichier.','files.title':'Fichiers','files.sub':'Parcourez les fichiers du projet — visualisez Markdown et HTML, modifiez tout fichier texte, créez-en de nouveaux.','files.newFile':'+ Fichier','files.newFolder':'+ Dossier','files.pickFile':'Sélectionnez un fichier pour l’afficher.','files.empty':'Aucun fichier pour l’instant.','files.edit':'Modifier','files.preview':'Aperçu','files.save':'Enregistrer','files.saved':'✓ enregistré','files.saveError':'Impossible d’enregistrer ce fichier.','files.loadError':'Impossible de charger ce fichier.','files.binary':'Ce fichier ne peut pas être prévisualisé ici (non textuel).','files.discardConfirm':'Abandonner les modifications non enregistrées ?','files.newFilePrompt':'
|
|
200
|
+
'drawer.loading':'Chargement…','drawer.loadError':'Impossible de charger ce fichier.','files.title':'Fichiers','files.sub':'Parcourez les fichiers du projet — visualisez Markdown et HTML, modifiez tout fichier texte, créez-en de nouveaux.','files.newFile':'+ Fichier','files.newFolder':'+ Dossier','files.pickFile':'Sélectionnez un fichier pour l’afficher.','files.empty':'Aucun fichier pour l’instant.','files.edit':'Modifier','files.preview':'Aperçu','files.save':'Enregistrer','files.saved':'✓ enregistré','files.saveError':'Impossible d’enregistrer ce fichier.','files.loadError':'Impossible de charger ce fichier.','files.binary':'Ce fichier ne peut pas être prévisualisé ici (non textuel).','files.discardConfirm':'Abandonner les modifications non enregistrées ?','files.newFilePrompt':'Nom du nouveau fichier (ex. todo.md) :','files.newFolderPrompt':'Nom du nouveau dossier :','files.projectRoot':'racine du projet','files.creatingIn':'Création dans : {path}','files.refresh':'Actualiser','files.discard':'Annuler','files.create':'Créer',
|
|
201
201
|
'chat.widgetTitle':'Lancer un agent','chat.widgetSub':'Accès rapide · vue complète dans l’onglet Chat',
|
|
202
202
|
'chat.tabSub':'Conversation complète avec l’exécuteur — la même exécution que le widget, avec plus de place pour la lire.',
|
|
203
203
|
'chat.idle':'Tapez une demande — l’agent s’exécute sans supervision dans ce projet avec toute sa mémoire (<code>CLAUDE.md → AGENTS.md</code>) et met le tableau à jour en direct.',
|
|
@@ -309,7 +309,7 @@ es: {
|
|
|
309
309
|
'team.skillsTitle':'Habilidades','team.skillsSub':'Procedimientos en evolución — el <em>cómo</em>.',
|
|
310
310
|
'team.standardsLabel':'estándares','team.usesLabel':'usa','team.standardLabel':'estándar',
|
|
311
311
|
'drawer.agent':'Agente','drawer.skill':'Habilidad','drawer.file':'Archivo · {rel}',
|
|
312
|
-
'drawer.loading':'Cargando…','drawer.loadError':'No se pudo cargar este archivo.','files.title':'Archivos','files.sub':'Explora los archivos del proyecto — visualiza Markdown y HTML, edita cualquier archivo de texto, crea otros nuevos.','files.newFile':'+ Archivo','files.newFolder':'+ Carpeta','files.pickFile':'Selecciona un archivo para verlo.','files.empty':'Aún no hay archivos.','files.edit':'Editar','files.preview':'Vista previa','files.save':'Guardar','files.saved':'✓ guardado','files.saveError':'No se pudo guardar este archivo.','files.loadError':'No se pudo cargar este archivo.','files.binary':'Este archivo no se puede previsualizar aquí (no es texto).','files.discardConfirm':'¿Descartar los cambios sin guardar?','files.newFilePrompt':'
|
|
312
|
+
'drawer.loading':'Cargando…','drawer.loadError':'No se pudo cargar este archivo.','files.title':'Archivos','files.sub':'Explora los archivos del proyecto — visualiza Markdown y HTML, edita cualquier archivo de texto, crea otros nuevos.','files.newFile':'+ Archivo','files.newFolder':'+ Carpeta','files.pickFile':'Selecciona un archivo para verlo.','files.empty':'Aún no hay archivos.','files.edit':'Editar','files.preview':'Vista previa','files.save':'Guardar','files.saved':'✓ guardado','files.saveError':'No se pudo guardar este archivo.','files.loadError':'No se pudo cargar este archivo.','files.binary':'Este archivo no se puede previsualizar aquí (no es texto).','files.discardConfirm':'¿Descartar los cambios sin guardar?','files.newFilePrompt':'Nombre del nuevo archivo (p. ej. todo.md):','files.newFolderPrompt':'Nombre de la nueva carpeta:','files.projectRoot':'raíz del proyecto','files.creatingIn':'Creando en: {path}','files.refresh':'Actualizar','files.discard':'Descartar','files.create':'Crear',
|
|
313
313
|
'chat.widgetTitle':'Ejecutar un agente','chat.widgetSub':'Acceso rápido · vista completa en la pestaña Chat',
|
|
314
314
|
'chat.tabSub':'Conversación completa con el ejecutor — la misma ejecución que el widget, con más espacio para leerla.',
|
|
315
315
|
'chat.idle':'Escribe una solicitud — el agente se ejecuta sin supervisión en este proyecto con toda su memoria (<code>CLAUDE.md → AGENTS.md</code>) y actualiza el tablero en vivo.',
|
|
@@ -421,7 +421,7 @@ de: {
|
|
|
421
421
|
'team.skillsTitle':'Skills','team.skillsSub':'Sich weiterentwickelnde Vorgehensweisen — das <em>Wie</em>.',
|
|
422
422
|
'team.standardsLabel':'Standards','team.usesLabel':'nutzt','team.standardLabel':'Standard',
|
|
423
423
|
'drawer.agent':'Agent','drawer.skill':'Skill','drawer.file':'Datei · {rel}',
|
|
424
|
-
'drawer.loading':'Lädt…','drawer.loadError':'Diese Datei konnte nicht geladen werden.','files.title':'Dateien','files.sub':'Durchsuche die Projektdateien — Markdown & HTML ansehen, jede Textdatei bearbeiten, neue erstellen.','files.newFile':'+ Datei','files.newFolder':'+ Ordner','files.pickFile':'Wähle eine Datei aus, um sie anzuzeigen.','files.empty':'Noch keine Dateien.','files.edit':'Bearbeiten','files.preview':'Vorschau','files.save':'Speichern','files.saved':'✓ gespeichert','files.saveError':'Diese Datei konnte nicht gespeichert werden.','files.loadError':'Diese Datei konnte nicht geladen werden.','files.binary':'Diese Datei kann hier nicht angezeigt werden (kein Text).','files.discardConfirm':'Nicht gespeicherte Änderungen verwerfen?','files.newFilePrompt':'
|
|
424
|
+
'drawer.loading':'Lädt…','drawer.loadError':'Diese Datei konnte nicht geladen werden.','files.title':'Dateien','files.sub':'Durchsuche die Projektdateien — Markdown & HTML ansehen, jede Textdatei bearbeiten, neue erstellen.','files.newFile':'+ Datei','files.newFolder':'+ Ordner','files.pickFile':'Wähle eine Datei aus, um sie anzuzeigen.','files.empty':'Noch keine Dateien.','files.edit':'Bearbeiten','files.preview':'Vorschau','files.save':'Speichern','files.saved':'✓ gespeichert','files.saveError':'Diese Datei konnte nicht gespeichert werden.','files.loadError':'Diese Datei konnte nicht geladen werden.','files.binary':'Diese Datei kann hier nicht angezeigt werden (kein Text).','files.discardConfirm':'Nicht gespeicherte Änderungen verwerfen?','files.newFilePrompt':'Name der neuen Datei (z. B. todo.md):','files.newFolderPrompt':'Name des neuen Ordners:','files.projectRoot':'Projektstamm','files.creatingIn':'Erstellen in: {path}','files.refresh':'Aktualisieren','files.discard':'Verwerfen','files.create':'Erstellen',
|
|
425
425
|
'chat.widgetTitle':'Agenten ausführen','chat.widgetSub':'Schnellzugriff · vollständige Ansicht im Chat-Tab',
|
|
426
426
|
'chat.tabSub':'Vollständiges Gespräch mit dem Runner — derselbe Lauf wie im Widget, mit mehr Platz zum Lesen.',
|
|
427
427
|
'chat.idle':'Geben Sie eine Anfrage ein — der Agent läuft eigenständig in diesem Projekt mit vollem Gedächtnis (<code>CLAUDE.md → AGENTS.md</code>) und aktualisiert das Board live.',
|
|
@@ -533,7 +533,7 @@ pt: {
|
|
|
533
533
|
'team.skillsTitle':'Habilidades','team.skillsSub':'Procedimentos em evolução — o <em>como</em>.',
|
|
534
534
|
'team.standardsLabel':'padrões','team.usesLabel':'usa','team.standardLabel':'padrão',
|
|
535
535
|
'drawer.agent':'Agente','drawer.skill':'Habilidade','drawer.file':'Ficheiro · {rel}',
|
|
536
|
-
'drawer.loading':'A carregar…','drawer.loadError':'Não foi possível carregar este ficheiro.','files.title':'Ficheiros','files.sub':'Percorra os ficheiros do projeto — veja Markdown e HTML, edite qualquer ficheiro de texto, crie novos.','files.newFile':'+ Ficheiro','files.newFolder':'+ Pasta','files.pickFile':'Selecione um ficheiro para o ver.','files.empty':'Ainda não há ficheiros.','files.edit':'Editar','files.preview':'Pré-visualizar','files.save':'Guardar','files.saved':'✓ guardado','files.saveError':'Não foi possível guardar este ficheiro.','files.loadError':'Não foi possível carregar este ficheiro.','files.binary':'Este ficheiro não pode ser pré-visualizado aqui (não é texto).','files.discardConfirm':'Descartar alterações não guardadas?','files.newFilePrompt':'
|
|
536
|
+
'drawer.loading':'A carregar…','drawer.loadError':'Não foi possível carregar este ficheiro.','files.title':'Ficheiros','files.sub':'Percorra os ficheiros do projeto — veja Markdown e HTML, edite qualquer ficheiro de texto, crie novos.','files.newFile':'+ Ficheiro','files.newFolder':'+ Pasta','files.pickFile':'Selecione um ficheiro para o ver.','files.empty':'Ainda não há ficheiros.','files.edit':'Editar','files.preview':'Pré-visualizar','files.save':'Guardar','files.saved':'✓ guardado','files.saveError':'Não foi possível guardar este ficheiro.','files.loadError':'Não foi possível carregar este ficheiro.','files.binary':'Este ficheiro não pode ser pré-visualizado aqui (não é texto).','files.discardConfirm':'Descartar alterações não guardadas?','files.newFilePrompt':'Nome do novo ficheiro (ex. todo.md):','files.newFolderPrompt':'Nome da nova pasta:','files.projectRoot':'raiz do projeto','files.creatingIn':'A criar em: {path}','files.refresh':'Atualizar','files.discard':'Descartar','files.create':'Criar',
|
|
537
537
|
'chat.widgetTitle':'Executar um agente','chat.widgetSub':'Acesso rápido · vista completa no separador Chat',
|
|
538
538
|
'chat.tabSub':'Conversa completa com o executor — a mesma execução do widget, com mais espaço para ler.',
|
|
539
539
|
'chat.idle':'Escreva um pedido — o agente corre sem supervisão neste projeto com toda a sua memória (<code>CLAUDE.md → AGENTS.md</code>) e atualiza o painel em direto.',
|
|
@@ -645,7 +645,7 @@ it: {
|
|
|
645
645
|
'team.skillsTitle':'Skill','team.skillsSub':'Procedure in evoluzione — il <em>come</em>.',
|
|
646
646
|
'team.standardsLabel':'standard','team.usesLabel':'usa','team.standardLabel':'standard',
|
|
647
647
|
'drawer.agent':'Agente','drawer.skill':'Skill','drawer.file':'File · {rel}',
|
|
648
|
-
'drawer.loading':'Caricamento…','drawer.loadError':'Impossibile caricare questo file.','files.title':'File','files.sub':'Sfoglia i file del progetto — visualizza Markdown e HTML, modifica qualsiasi file di testo, creane di nuovi.','files.newFile':'+ File','files.newFolder':'+ Cartella','files.pickFile':'Seleziona un file per visualizzarlo.','files.empty':'Nessun file ancora.','files.edit':'Modifica','files.preview':'Anteprima','files.save':'Salva','files.saved':'✓ salvato','files.saveError':'Impossibile salvare questo file.','files.loadError':'Impossibile caricare questo file.','files.binary':'Questo file non può essere visualizzato qui (non è testo).','files.discardConfirm':'Scartare le modifiche non salvate?','files.newFilePrompt':'
|
|
648
|
+
'drawer.loading':'Caricamento…','drawer.loadError':'Impossibile caricare questo file.','files.title':'File','files.sub':'Sfoglia i file del progetto — visualizza Markdown e HTML, modifica qualsiasi file di testo, creane di nuovi.','files.newFile':'+ File','files.newFolder':'+ Cartella','files.pickFile':'Seleziona un file per visualizzarlo.','files.empty':'Nessun file ancora.','files.edit':'Modifica','files.preview':'Anteprima','files.save':'Salva','files.saved':'✓ salvato','files.saveError':'Impossibile salvare questo file.','files.loadError':'Impossibile caricare questo file.','files.binary':'Questo file non può essere visualizzato qui (non è testo).','files.discardConfirm':'Scartare le modifiche non salvate?','files.newFilePrompt':'Nome del nuovo file (es. todo.md):','files.newFolderPrompt':'Nome della nuova cartella:','files.projectRoot':'radice del progetto','files.creatingIn':'Creazione in: {path}','files.refresh':'Aggiorna','files.discard':'Scarta','files.create':'Crea',
|
|
649
649
|
'chat.widgetTitle':'Avvia un agente','chat.widgetSub':'Accesso rapido · vista completa nella scheda Chat',
|
|
650
650
|
'chat.tabSub':'Conversazione completa con l’esecutore — la stessa esecuzione del widget, con più spazio per leggerla.',
|
|
651
651
|
'chat.idle':'Digita una richiesta — l’agente viene eseguito senza supervisione in questo progetto con tutta la sua memoria (<code>CLAUDE.md → AGENTS.md</code>) e aggiorna la bacheca in diretta.',
|
|
@@ -226,6 +226,7 @@
|
|
|
226
226
|
<button id="filesRefresh" class="btn" type="button" title="Refresh" data-i18n-title="files.refresh">⟳</button>
|
|
227
227
|
</div>
|
|
228
228
|
<div class="files-create-form" id="filesCreateForm" hidden>
|
|
229
|
+
<div class="files-create-target" id="filesCreateTarget"></div>
|
|
229
230
|
<input type="text" id="filesCreateInput" class="bl-add-input" autocomplete="off" />
|
|
230
231
|
<div class="files-create-actions">
|
|
231
232
|
<button id="filesCreateGo" class="btn primary" type="button" data-i18n="files.create">Create</button>
|
|
@@ -233,6 +234,7 @@
|
|
|
233
234
|
</div>
|
|
234
235
|
<span class="files-error-tip" id="filesCreateError" hidden></span>
|
|
235
236
|
</div>
|
|
237
|
+
<div class="f-row f-root" id="filesRootRow" tabindex="0" data-i18n="files.projectRoot">project root</div>
|
|
236
238
|
<div class="files-tree" id="filesTree"></div>
|
|
237
239
|
</div>
|
|
238
240
|
<div class="files-content-col" id="filesContent">
|
|
@@ -294,7 +294,7 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
294
294
|
.team-wrap { max-width:1500px; } /* the agent/skill .cards grid fills more columns instead of sitting capped */
|
|
295
295
|
|
|
296
296
|
/* Files tab — a two-pane explorer: a collapsible tree, and the selected file's preview/editor */
|
|
297
|
-
.files-wrap { padding:26px 28px
|
|
297
|
+
.files-wrap { padding:26px 28px 16px; max-width:1600px; height:calc(100vh - 90px); display:flex; flex-direction:column; }
|
|
298
298
|
.files-body { flex:1; min-height:0; display:flex; gap:16px; margin-top:8px; }
|
|
299
299
|
.files-tree-col { width:280px; flex-shrink:0; display:flex; flex-direction:column; gap:8px; min-height:0; }
|
|
300
300
|
.files-toolbar { display:flex; gap:8px; }
|
|
@@ -313,6 +313,9 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
313
313
|
.f-row { display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:6px; cursor:pointer; font-size:12.5px; white-space:nowrap; color:var(--muted); }
|
|
314
314
|
.f-row:hover { background:var(--surface-2); color:var(--ink); }
|
|
315
315
|
.f-row.is-active { background:var(--surface-2); color:var(--signal); font-weight:600; }
|
|
316
|
+
.f-row.is-target { background:color-mix(in srgb,var(--signal) 14%,transparent); color:var(--ink); } /* the folder new files/folders will be created in */
|
|
317
|
+
.f-root { font-weight:600; margin-bottom:4px; padding-bottom:6px; border-bottom:1px solid var(--line); border-radius:0; }
|
|
318
|
+
.files-create-target { font-size:11px; color:var(--muted); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
316
319
|
.f-row .f-chevron { width:11px; height:11px; flex-shrink:0; transition:transform .15s; color:var(--faint); }
|
|
317
320
|
.f-row.is-open .f-chevron { transform:rotate(90deg); }
|
|
318
321
|
.f-row .f-name { overflow:hidden; text-overflow:ellipsis; }
|
|
@@ -329,6 +332,23 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
329
332
|
.files-view::-webkit-scrollbar-track,.files-editor::-webkit-scrollbar-track { background:transparent; }
|
|
330
333
|
.files-view { flex:1; min-height:0; overflow:auto; padding:16px 20px; }
|
|
331
334
|
.files-editor { flex:1; min-height:0; width:100%; border:0; resize:none; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; background:var(--surface); color:var(--ink); outline:none; }
|
|
335
|
+
/* syntax-highlighted editor: a transparent textarea (real caret, real selection, real typing) sits
|
|
336
|
+
exactly on top of a highlighted <pre><code> backdrop showing through it — the standard technique
|
|
337
|
+
for highlighting without a full editor component. Both share identical font metrics so characters
|
|
338
|
+
line up; the backdrop never scrolls on its own (overflow:hidden), its scroll position is just
|
|
339
|
+
copied from the textarea on every scroll event. */
|
|
340
|
+
.files-code-wrap { position:relative; flex:1; min-height:0; }
|
|
341
|
+
/* Ligatures (calt/liga — e.g. "===", "!==", "=>" fused into one glyph, common in Cascadia Code and
|
|
342
|
+
JetBrains Mono) must be off on BOTH layers: this overlay depends on the backdrop and the textarea
|
|
343
|
+
laying out every character at an identical pixel width, and a fused ligature glyph breaks that. */
|
|
344
|
+
.files-code-backdrop { position:absolute; inset:0; margin:0; overflow:hidden; pointer-events:none; white-space:pre-wrap; word-break:break-word; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; color:var(--ink); background:transparent; font-variant-ligatures:none; font-feature-settings:"liga" 0,"calt" 0; }
|
|
345
|
+
.files-code-backdrop code { font:inherit; background:none; }
|
|
346
|
+
.files-code-wrap .files-code-input { position:absolute; inset:0; background:transparent; color:transparent; caret-color:var(--ink); white-space:pre-wrap; word-break:break-word; font-variant-ligatures:none; font-feature-settings:"liga" 0,"calt" 0; }
|
|
347
|
+
.hl-comment { color:var(--faint); font-style:italic; }
|
|
348
|
+
.hl-string { color:var(--s-done); }
|
|
349
|
+
.hl-number { color:var(--cool); }
|
|
350
|
+
.hl-keyword { color:var(--signal); font-weight:600; }
|
|
351
|
+
.hl-tag { color:var(--s-in_progress); }
|
|
332
352
|
.files-iframe { flex:1; min-height:0; width:100%; border:0; background:#fff; }
|
|
333
353
|
.files-md-toolbar { display:flex; gap:6px; padding:8px 14px; border-bottom:1px solid var(--line); }
|
|
334
354
|
.files-md-toolbar button { font-family:var(--mono); font-size:12px; padding:4px 9px; border:1px solid var(--line); border-radius:6px; background:var(--surface-2); color:var(--ink); cursor:pointer; }
|
|
@@ -1,256 +1,43 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/*
|
|
3
|
-
* spectoflow dashboard — ZERO-DEPENDENCY server, real-time (SSE + fs.watch).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
9
|
*/
|
|
8
10
|
const http = require('http');
|
|
9
11
|
const fs = require('fs');
|
|
10
12
|
const path = require('path');
|
|
11
|
-
const
|
|
12
|
-
const { startRun } = require('./runner');
|
|
13
|
-
const { runSummarize } = require('./summarize');
|
|
14
|
-
const orchestrator = require('./orchestrator');
|
|
15
|
-
const agentsRegistry = require('../lib/agents-registry');
|
|
16
|
-
const files = require('./files');
|
|
13
|
+
const { createHandlers } = require('./handlers');
|
|
17
14
|
|
|
18
15
|
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
|
|
19
16
|
const PUBLIC = path.join(__dirname, 'public');
|
|
20
17
|
const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
|
|
21
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' };
|
|
22
19
|
const clients = new Set();
|
|
23
|
-
|
|
24
|
-
// Installed framework version: the manifest records it at init/update time. Fallback to the kit's
|
|
25
|
-
// own package.json — only reachable (and only used) when the server is run straight from templates/
|
|
26
|
-
// (dev/preview), never from an installed project whose sibling package.json belongs to the user.
|
|
27
|
-
function frameworkVersion(){
|
|
28
|
-
try { return JSON.parse(fs.readFileSync(path.join(ROOT, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
|
|
29
|
-
try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
function project(){
|
|
33
|
-
const p = store.readProject(ROOT);
|
|
34
|
-
const v = frameworkVersion(); if (v) p.version = v;
|
|
35
|
-
p.projectName = path.basename(ROOT); // the actual project folder, always shown in the topbar
|
|
36
|
-
// Known vs. actually-installed agents — the topbar switcher needs both: the full list to offer,
|
|
37
|
-
// and which ones are real (bin on PATH, or the project already has that agent's config dir) so it
|
|
38
|
-
// can refuse to activate one that isn't there.
|
|
39
|
-
p.knownAgents = agentsRegistry.KNOWN_AGENTS.map((a) => ({ id: a.id, label: a.label, headless: a.headless, docsUrl: a.docsUrl }));
|
|
40
|
-
p.installedAgents = agentsRegistry.installedAgents(ROOT);
|
|
41
|
-
return p;
|
|
42
|
-
}
|
|
43
20
|
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
44
|
-
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({});} }); }); }
|
|
45
21
|
function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
|
|
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
22
|
|
|
48
|
-
|
|
49
|
-
const configPath = () => path.join(ROOT, '.spectoflow', 'config.json');
|
|
50
|
-
function writeConfig(patch){
|
|
51
|
-
const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
52
|
-
if (patch.mode && ['autopilot','semi','manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
53
|
-
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
54
|
-
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
55
|
-
if (typeof patch.agent === 'string' && patch.agent.trim()) {
|
|
56
|
-
const id = patch.agent.trim();
|
|
57
|
-
// Never activate an agent whose CLI isn't actually there — a picked-but-absent agent would just
|
|
58
|
-
// fail silently the next time something tries to run it.
|
|
59
|
-
if (!agentsRegistry.isAgentInstalled(id, ROOT)) {
|
|
60
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
61
|
-
const label = known ? known.label : id;
|
|
62
|
-
throw new Error(`${label} isn't installed here (its command wasn't found on PATH). Install it, then try again.`);
|
|
63
|
-
}
|
|
64
|
-
cfg.agent = id;
|
|
65
|
-
// Seed a default runner if this agent was never configured (e.g. installed after init/update).
|
|
66
|
-
// A headless:false agent (e.g. kimi) has no runner to seed — it can still be the active agent,
|
|
67
|
-
// it just can't be spawned by Run/Orchestrate/Summarize (disabled client-side; runner.js and
|
|
68
|
-
// summarize.js also refuse server-side either way).
|
|
69
|
-
const known = agentsRegistry.KNOWN_AGENTS.find((a) => a.id === id);
|
|
70
|
-
if (known && known.runner) { cfg.runners = cfg.runners || {}; if (!cfg.runners[id]) cfg.runners[id] = known.runner; }
|
|
71
|
-
}
|
|
72
|
-
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
73
|
-
return cfg;
|
|
74
|
-
}
|
|
75
|
-
// Promote an attention item into a real checkbox task under an `## Attention` phase.
|
|
76
|
-
function promoteAttention(item){
|
|
77
|
-
return store.addTask(ROOT, { phase: 'Attention', title: item.text, owner: 'user' });
|
|
78
|
-
}
|
|
23
|
+
const handlers = createHandlers(ROOT);
|
|
79
24
|
|
|
80
25
|
function watch(dir){ try{ fs.watch(dir,{recursive:false},()=>emit({type:'change'})); }catch(_){} }
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// (a recursive watch on the whole .spectoflow tree would also fire on every runtime.json write).
|
|
84
|
-
// Ensure the directory exists before watching it: a project that hasn't used Customize yet won't
|
|
85
|
-
// have it on disk, and `spectoflow init` on an older install won't have created it either.
|
|
86
|
-
try { fs.mkdirSync(path.join(ROOT,'.spectoflow','dashboard','custom'), { recursive: true }); } catch (_) {}
|
|
87
|
-
['plans','specs','.spectoflow','.spectoflow/dashboard/custom'].forEach(d=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
|
|
88
|
-
|
|
89
|
-
// A process restart loses any in-flight orchestration; without this, a stale 'running' or
|
|
90
|
-
// 'awaiting_approval' status wedges the /api/orchestrate 409 guard forever. Not a real
|
|
91
|
-
// resume — just clears the wedge so a fresh orchestration can start.
|
|
92
|
-
try { orchestrator.reconcileOnBoot(ROOT); } catch {}
|
|
26
|
+
handlers.onBoot();
|
|
27
|
+
handlers.watchDirs.forEach((d)=>{ const p=path.join(ROOT,d); if(fs.existsSync(p)) watch(p); });
|
|
93
28
|
|
|
94
29
|
const server = http.createServer(async (req,res)=>{
|
|
95
30
|
const u=new URL(req.url,`http://localhost:${PORT}`); const p=u.pathname;
|
|
96
31
|
try{
|
|
97
|
-
if(p==='/api/project') return sendJSON(res,200,project());
|
|
98
|
-
|
|
99
|
-
// ---- read-only agent/skill file viewer (scoped to .spectoflow/{agents,skills}/**) ----
|
|
100
|
-
if (p === '/api/agentfile' && req.method === 'GET') {
|
|
101
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
102
|
-
const base = path.join(ROOT, '.spectoflow');
|
|
103
|
-
const aDir = path.join(base, 'agents'), sDir = path.join(base, 'skills');
|
|
104
|
-
const abs = path.resolve(base, rel);
|
|
105
|
-
const okDir = abs.startsWith(aDir + path.sep) || abs.startsWith(sDir + path.sep);
|
|
106
|
-
if (!okDir || !abs.endsWith('.md') || !fs.existsSync(abs) || fs.statSync(abs).isDirectory())
|
|
107
|
-
return sendJSON(res, 400, { error: 'not an agent/skill file' });
|
|
108
|
-
// Symlink guard: the resolved real path must stay within the (real) scope dirs.
|
|
109
|
-
let real; try { real = fs.realpathSync(abs); } catch { real = null; }
|
|
110
|
-
const realA = (() => { try { return fs.realpathSync(aDir); } catch { return aDir; } })();
|
|
111
|
-
const realS = (() => { try { return fs.realpathSync(sDir); } catch { return sDir; } })();
|
|
112
|
-
const okReal = real && (real.startsWith(realA + path.sep) || real.startsWith(realS + path.sep));
|
|
113
|
-
if (!okReal || !real.endsWith('.md') || fs.statSync(real).isDirectory())
|
|
114
|
-
return sendJSON(res, 400, { error: 'not an agent/skill file' });
|
|
115
|
-
return sendJSON(res, 200, { content: fs.readFileSync(real, 'utf8') });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ---- File Explorer: browse/read/write/create anywhere under the project root ----
|
|
119
|
-
if (p === '/api/files/tree' && req.method === 'GET') return sendJSON(res, 200, { tree: files.tree(ROOT) });
|
|
120
|
-
if (p === '/api/files/read' && req.method === 'GET') {
|
|
121
|
-
const rel = new URL(req.url, 'http://x').searchParams.get('path') || '';
|
|
122
|
-
const r = files.readFile(ROOT, rel);
|
|
123
|
-
return sendJSON(res, r.error ? 400 : 200, r);
|
|
124
|
-
}
|
|
125
|
-
if (p === '/api/files/write' && req.method === 'POST') {
|
|
126
|
-
const { path: rel, content } = await body(req);
|
|
127
|
-
const r = files.writeFile(ROOT, rel, content);
|
|
128
|
-
if (r.error) return sendJSON(res, 400, r);
|
|
129
|
-
emit({ type: 'change' }); return sendJSON(res, 200, r);
|
|
130
|
-
}
|
|
131
|
-
if (p === '/api/files/mkdir' && req.method === 'POST') {
|
|
132
|
-
const { path: rel } = await body(req);
|
|
133
|
-
const r = files.mkdir(ROOT, rel);
|
|
134
|
-
if (r.error) return sendJSON(res, 400, r);
|
|
135
|
-
emit({ type: 'change' }); return sendJSON(res, 200, r);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
32
|
if(p==='/api/events'){
|
|
139
33
|
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
|
|
140
34
|
res.write('data: '+JSON.stringify({type:'hello'})+'\n\n');
|
|
141
35
|
clients.add(res); req.on('close',()=>clients.delete(res)); return;
|
|
142
36
|
}
|
|
143
37
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if(!title||!String(title).trim()) return sendJSON(res,400,{error:'A title is required.'});
|
|
148
|
-
const t = store.addTask(ROOT,{ title:String(title).trim(), phase, file, owner, level });
|
|
149
|
-
emit({type:'change'}); return sendJSON(res,200,{task:t});
|
|
150
|
-
}
|
|
151
|
-
if(p.startsWith('/api/task/')&&req.method==='PATCH'){
|
|
152
|
-
const id=decodeURIComponent(p.split('/')[3]||''); const patch=await body(req);
|
|
153
|
-
const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
|
|
154
|
-
store.updateTaskLine(ROOT,file,id,patch); emit({type:'change'}); return sendJSON(res,200,{ok:true});
|
|
155
|
-
}
|
|
156
|
-
if(/^\/api\/task\/[^/]+\/comment$/.test(p)&&req.method==='POST'){
|
|
157
|
-
const id=decodeURIComponent(p.split('/')[3]||''); const {text,action}=await body(req);
|
|
158
|
-
if(!text||!String(text).trim()) return sendJSON(res,400,{error:'Empty comment.'});
|
|
159
|
-
const file=findPlanFileForTask(id); if(!file) return sendJSON(res,404,{error:`Task ${id} not found.`});
|
|
160
|
-
store.addTaskComment(ROOT,file,id,String(text).trim(),'me');
|
|
161
|
-
if(action==='analyze') store.updateTaskLine(ROOT,file,id,{status:'to_analyze'});
|
|
162
|
-
emit({type:'change'}); return sendJSON(res,200,{ok:true});
|
|
163
|
-
}
|
|
164
|
-
if(p==='/api/workflow/toggle'&&req.method==='POST'){
|
|
165
|
-
const {name}=await body(req); const wf=path.join(ROOT,'.spectoflow','workflow.md');
|
|
166
|
-
const lines=fs.readFileSync(wf,'utf8').split('\n');
|
|
167
|
-
for(let i=0;i<lines.length;i++){ const m=lines[i].match(/^(\s*- \[)( |x|X)(\]\s+)(.*)$/);
|
|
168
|
-
if(m&&m[4].replace(/\s*\(optional\)\s*$/i,'').trim()===name) lines[i]=m[1]+(m[2].trim()?' ':'x')+m[3]+m[4]; }
|
|
169
|
-
fs.writeFileSync(wf,lines.join('\n')); emit({type:'change'}); return sendJSON(res,200,{ok:true});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ---- agent launcher (pipeline lives in runner.js; posts to the group-chat log) ----
|
|
173
|
-
if(p==='/api/run'&&req.method==='POST'){
|
|
174
|
-
const {prompt,agent}=await body(req);
|
|
175
|
-
if(!prompt||!String(prompt).trim()) return sendJSON(res,400,{error:'Empty request.'});
|
|
176
|
-
const r=startRun(ROOT,{prompt,agent},emit);
|
|
177
|
-
if(r.error) return sendJSON(res,400,{error:r.error});
|
|
178
|
-
return sendJSON(res,200,{runId:r.runId});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ---- chat context management: condense the log via the agent, or wipe it ----
|
|
182
|
-
if (p === '/api/chat/summarize' && req.method === 'POST') {
|
|
183
|
-
const { agent } = await body(req);
|
|
184
|
-
const r = runSummarize(ROOT, { agent }, emit);
|
|
185
|
-
if (r.error) return sendJSON(res, 400, { error: r.error });
|
|
186
|
-
return sendJSON(res, 200, { ok: true });
|
|
187
|
-
}
|
|
188
|
-
if (p === '/api/chat/clear' && req.method === 'POST') {
|
|
189
|
-
const rt = store.readRuntime(ROOT); rt.messages = []; store.writeRuntime(ROOT, rt);
|
|
190
|
-
emit({ type: 'change' });
|
|
191
|
-
return sendJSON(res, 200, { ok: true });
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ---- orchestrator ----
|
|
195
|
-
if (p === '/api/orchestrate' && req.method === 'POST') {
|
|
196
|
-
const { request } = await body(req);
|
|
197
|
-
if (!request || !String(request).trim()) return sendJSON(res, 400, { error: 'Empty request.' });
|
|
198
|
-
const active = store.readRuntime(ROOT).orchestration;
|
|
199
|
-
if (active && ['running', 'awaiting_approval'].includes(active.status))
|
|
200
|
-
return sendJSON(res, 409, { error: 'An orchestration is already active.' });
|
|
201
|
-
const mode = store.readConfig(ROOT).mode || 'semi';
|
|
202
|
-
// fire and forget; state + messages stream over SSE
|
|
203
|
-
orchestrator.runOrchestration({ root: ROOT, request: String(request).trim(), mode,
|
|
204
|
-
runStep: orchestrator.defaultRunStep, confirm: orchestrator.defaultConfirm }, emit)
|
|
205
|
-
.catch((e) => emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: 'orchestration error: ' + e.message } }));
|
|
206
|
-
const o = store.readRuntime(ROOT).orchestration;
|
|
207
|
-
return sendJSON(res, 200, { orchestrationId: o && o.id });
|
|
208
|
-
}
|
|
209
|
-
if (p === '/api/orchestrate/approve' && req.method === 'POST') {
|
|
210
|
-
const { decision, note } = await body(req);
|
|
211
|
-
const ok = orchestrator.submitDecision(decision, note);
|
|
212
|
-
return sendJSON(res, ok ? 200 : 409, ok ? { ok: true } : { error: 'No pending approval.' });
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ---- settings: change autonomy mode + output language (writes config.json) ----
|
|
216
|
-
if (p === '/api/settings' && req.method === 'POST') {
|
|
217
|
-
const patch = await body(req);
|
|
218
|
-
try { const cfg = writeConfig(patch); emit({ type: 'change' }); return sendJSON(res, 200, { config: cfg }); }
|
|
219
|
-
catch (e) { return sendJSON(res, 400, { error: String(e && e.message || e) }); }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ---- attention points: agent- or user-raised notes; validate → real task ----
|
|
223
|
-
if (p === '/api/attention' && req.method === 'POST') {
|
|
224
|
-
const { text } = await body(req);
|
|
225
|
-
if (!text || !String(text).trim()) return sendJSON(res, 400, { error: 'Empty note.' });
|
|
226
|
-
const rt = store.readRuntime(ROOT); rt.attention = rt.attention || [];
|
|
227
|
-
const item = { id: 'att' + Date.now().toString(36), at: new Date().toISOString(), by: 'me', source: 'user', status: 'open', text: String(text).trim() };
|
|
228
|
-
rt.attention.unshift(item); store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
229
|
-
return sendJSON(res, 200, { item });
|
|
230
|
-
}
|
|
231
|
-
if (/^\/api\/attention\/[^/]+\/promote$/.test(p) && req.method === 'POST') {
|
|
232
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
233
|
-
const rt = store.readRuntime(ROOT); const it = (rt.attention || []).find((x) => x.id === id);
|
|
234
|
-
if (!it) return sendJSON(res, 404, { error: 'Note not found.' });
|
|
235
|
-
const t = promoteAttention(it); it.status = 'resolved'; it.promotedTo = t.id;
|
|
236
|
-
store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
237
|
-
return sendJSON(res, 200, { task: t });
|
|
238
|
-
}
|
|
239
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'PATCH') {
|
|
240
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
241
|
-
const patch = await body(req);
|
|
242
|
-
const rt = store.readRuntime(ROOT); const it = (rt.attention || []).find((x) => x.id === id);
|
|
243
|
-
if (!it) return sendJSON(res, 404, { error: 'Note not found.' });
|
|
244
|
-
if (typeof patch.text === 'string' && patch.text.trim()) it.text = patch.text.trim();
|
|
245
|
-
if (patch.status && ['open', 'resolved'].includes(patch.status)) it.status = patch.status;
|
|
246
|
-
store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
247
|
-
return sendJSON(res, 200, { item: it });
|
|
248
|
-
}
|
|
249
|
-
if (/^\/api\/attention\/[^/]+$/.test(p) && req.method === 'DELETE') {
|
|
250
|
-
const id = decodeURIComponent(p.split('/')[3] || '');
|
|
251
|
-
const rt = store.readRuntime(ROOT); rt.attention = (rt.attention || []).filter((x) => x.id !== id);
|
|
252
|
-
store.writeRuntime(ROOT, rt); emit({ type: 'change' });
|
|
253
|
-
return sendJSON(res, 200, { ok: true });
|
|
38
|
+
if (p.startsWith('/api/')) {
|
|
39
|
+
const handled = await handlers.handleApi(req, res, u, emit);
|
|
40
|
+
if (handled) return;
|
|
254
41
|
}
|
|
255
42
|
|
|
256
43
|
// ---- static files, with SPA fallback: a route like /backlog (no file extension)
|