spectoflow 0.24.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +28 -6
  2. package/bin/spectoflow.js +88 -6
  3. package/lib/dashboard/connector.js +193 -0
  4. package/lib/dashboard/handlers.js +2 -27
  5. package/lib/dashboard/hub-server.js +103 -11
  6. package/lib/dashboard/inject-design.js +41 -0
  7. package/lib/dashboard/meeting.js +116 -0
  8. package/lib/dashboard/ops.js +83 -2
  9. package/lib/dashboard/public/app.js +738 -118
  10. package/lib/dashboard/public/charts.js +7 -4
  11. package/lib/dashboard/public/commands.js +77 -0
  12. package/lib/dashboard/public/designs/console.css +7 -19
  13. package/lib/dashboard/public/designs/orbit.css +3 -4
  14. package/lib/dashboard/public/designs.js +2 -2
  15. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  16. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  17. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  18. package/lib/dashboard/public/hub.html +1 -1
  19. package/lib/dashboard/public/hub.js +27 -4
  20. package/lib/dashboard/public/i18n.js +60 -18
  21. package/lib/dashboard/public/icons.js +2 -0
  22. package/lib/dashboard/public/index.html +106 -13
  23. package/lib/dashboard/public/styles.css +161 -26
  24. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  25. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  26. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  27. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  28. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  29. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  46. package/lib/dashboard/routes.js +38 -0
  47. package/lib/dashboard/runner.js +7 -2
  48. package/lib/workspace.js +36 -1
  49. package/package.json +3 -3
  50. package/templates/config.json +23 -0
  51. package/lib/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  52. package/lib/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  53. package/lib/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/spectoflow"><img src="https://img.shields.io/npm/v/spectoflow.svg?color=e6a54b" alt="npm version"></a>
14
- <img src="https://img.shields.io/badge/node-%3E%3D18-5fb2cc" alt="node >= 18">
14
+ <img src="https://img.shields.io/badge/node-%3E%3D22-5fb2cc" alt="node >= 22">
15
15
  <img src="https://img.shields.io/badge/dependencies-0-4caf72" alt="zero dependencies">
16
16
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
17
17
  </p>
@@ -186,24 +186,36 @@ spectoflow dashboard # → http://localhost:4319 (or --port=
186
186
  dashboard already running on the port). `spectoflow status` tells you whether one is up. Zero
187
187
  dependencies, updates live via SSE + file watching.
188
188
 
189
+ Want your own hosted, online dashboard instead of (or alongside) the local one? `server/` is a
190
+ separate, self-hostable relay with a ready-made Docker deployment path — see
191
+ [`server/docs/deploy-vps.md`](server/docs/deploy-vps.md).
192
+
189
193
  The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
190
- meter, a sync dot, and a **Run** quick-action. Ten tabs:
194
+ meter, a sync dot, and a **Run** quick-action. Thirteen tabs — and **which ones you see, and in what
195
+ order, is up to you** (Personalize → *Navigation tabs*: enable / disable / reorder; two of them ship
196
+ off by default):
191
197
 
192
198
  - **Board** — the control-room Overview (compact KPI cards, a status donut, a **scope-vs-delivered
193
199
  area curve**, a workflow-at-a-glance strip, per-phase progress bars, filter chips + search) plus the
194
- phase board.
195
- - **Chat** — a full-height group-chat panel with **Summarize** / **Clear**.
200
+ phase board, as a **List** or a **Kanban** whose columns you can show/hide and that pages long
201
+ columns instead of scrolling them.
202
+ - **Chat** — a full-height group-chat panel with **Summarize** / **Clear** and **slash commands** (see
203
+ below).
196
204
  - **Requests** — tasks awaiting you (`to_validate` / `to_analyze`).
197
205
  - **Attention** — points the agent raised (a `::spectoflow attention msg=…` sentinel) or that you
198
206
  noted yourself — edit / resolve / delete, or **validate → task**.
199
207
  - **Backlog** — a flat sortable/filterable, paginated table of every task, defaulting to open work.
200
208
  - **Workflow** — the pipeline as step cards; click one to enable/disable it, which edits `workflow.md`.
201
209
  - **Agents & Skills** — enriched cards that open a full-body markdown drawer.
210
+ - **Files** — a project file tree with read / write / create, syntax-highlighted (self-hosted Prism.js).
211
+ - **Bloc note** *(off by default)* — a per-project post-it Markdown scratchpad.
212
+ - **Daily meeting** *(off by default)* — dated notes (`.spectoflow/meetings/<date>.md`), written by
213
+ hand or generated by the active agent.
202
214
  - **Info** — a project-at-a-glance summary.
203
215
  - **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
204
216
  command reference.
205
- - **Personalize** — autonomy mode, language, design, the active agent, and **Extend spectoflow** (see
206
- *Customize* below).
217
+ - **Personalize** — autonomy mode, language, design, the active agent, **navigation tabs**, **slash
218
+ commands**, and **Extend spectoflow** (see *Customize* below).
207
219
 
208
220
  URLs are real routes (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in
209
221
  `lib/dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
@@ -232,6 +244,16 @@ drawer is served by the one read-only endpoint, `GET /api/agentfile?path=` (scop
232
244
  `.spectoflow/agents/**` + `.spectoflow/skills/**`, path-traversal-safe) — the framework's only other
233
245
  server surface is unchanged.
234
246
 
247
+ ### Slash commands
248
+
249
+ Type `/` in either chat surface to open an autocomplete of reusable **prompt macros** — pick one,
250
+ type your text, send. Five ship built-in (`/spec`, `/plan`, `/revue`, `/resume`, `/rapport_jour`);
251
+ add your own from **Personalize → Commands** (a trigger, a short description, and the instruction the
252
+ agent receives — put `{{input}}` where your text should land, or it's appended). Expansion happens
253
+ **client-side**, so it's fully agent-agnostic: the agent never sees the `/`, it gets the full
254
+ instruction, while the chat log keeps the short `/rapport_jour focus bugs` you typed. Commands persist
255
+ in `config.json` (the DB online), so they survive a refresh and travel with the project.
256
+
235
257
  ### Customize
236
258
 
237
259
  Personalize → **Extend spectoflow** lets you extend the project's own spectoflow install: add a
package/bin/spectoflow.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const http = require('http');
6
+ const os = require('os');
6
7
  const readline = require('readline');
7
8
  const { spawn } = require('child_process');
8
9
  const store = require('../lib/store');
@@ -212,7 +213,6 @@ function dashboardInit() {
212
213
  } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
213
214
  }
214
215
 
215
- const REMOTE_NOTE = 'This version manages local dashboards. Remote dashboards (login with a token) come in a later release — continuing with your local dashboard.';
216
216
  function isLocalUrl(u) { try { return ['localhost', '127.0.0.1', '::1'].includes(new URL(u).hostname); } catch { return true; } }
217
217
  // The one-time question every dashboard-starting command answers before doing anything else: which
218
218
  // dashboard should this project talk to? `--url` answers it without prompting. Otherwise, only when
@@ -241,7 +241,6 @@ async function resolveDashboardUrl() {
241
241
  globalConfig.set('dashboard.url', answer || globalConfig.get('dashboard.url').value);
242
242
  }
243
243
  const url = globalConfig.get('dashboard.url').value;
244
- if (!isLocalUrl(url)) console.log(`${c.y('!')} ${REMOTE_NOTE}`);
245
244
  return url;
246
245
  }
247
246
 
@@ -250,7 +249,10 @@ async function resolveDashboardUrl() {
250
249
  async function dashboard() {
251
250
  const sub = argv[1];
252
251
  if (sub === 'init') return dashboardInit();
253
- if (sub === 'login') { console.log(REMOTE_NOTE); return; }
252
+ if (sub === 'login') return dashboardLogin();
253
+ if (sub === 'logout') return dashboardLogout();
254
+ if (sub === 'publish') return dashboardPublish(true);
255
+ if (sub === 'unpublish') return dashboardPublish(false);
254
256
  if (sub === 'stop') return stopDashboard();
255
257
  if (sub === 'status') return dashboardStatus();
256
258
  if (sub === 'restart') return restartDashboard();
@@ -259,6 +261,80 @@ async function dashboard() {
259
261
  return startDashboard();
260
262
  }
261
263
 
264
+ // ---- online dashboard (C1): login / logout / publish / unpublish ----
265
+ // The machine token authenticates this machine to a hosted dashboard (server/). `login` checks it
266
+ // against POST /connector/whoami, stores it in the workspace's remote.json (0600) and points
267
+ // dashboard.url at the server; the hub — running or next started — opens the outbound connection.
268
+ async function fetchJSON(url, init = {}, timeoutMs = 10000) {
269
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
270
+ let data = {}; try { data = await res.json(); } catch {}
271
+ return { status: res.status, ok: res.ok, data };
272
+ }
273
+ // Best-effort call on the running local hub; null when no hub is up (the caller says "next start will").
274
+ async function hubCall(pathname, payload) {
275
+ const info = workspace.readLock();
276
+ if (!info || !info.port || !(await probeDashboard(info.port))) return null;
277
+ try { return await fetchJSON(`http://localhost:${info.port}${pathname}`, payload === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }
278
+ catch { return null; }
279
+ }
280
+ function fail(msg) { console.log(`${c.y('!')} ${msg}`); process.exitCode = 1; }
281
+ async function dashboardLogin() {
282
+ const url = flag('url'), token = flag('token'), transport = flag('transport') || 'ws';
283
+ if (!url || !token) { console.log(`Usage: spectoflow dashboard login --url=<https://…> --token=<spf_…> ${c.dim('[--name=<machine name>] [--transport=ws|http]')}`); process.exitCode = 1; return; }
284
+ let base;
285
+ try { base = new URL(url); if (!/^https?:$/.test(base.protocol)) throw new Error(); } catch { return fail('--url must be an http(s) URL, e.g. https://dashboard.example.com'); }
286
+ if (!['ws', 'http'].includes(transport)) return fail('--transport must be ws or http');
287
+ const clean = base.toString().replace(/\/+$/, '');
288
+ let who;
289
+ try { who = await fetchJSON(`${clean}/connector/whoami`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); }
290
+ catch (e) { return fail(`could not reach ${clean} (${e.cause && e.cause.code ? e.cause.code : e.name === 'TimeoutError' ? 'timeout' : e.message})`); }
291
+ if (who.status === 401) return fail(`${clean} rejected this token — create one on the server: node cli.js token create --name="${os.hostname()}"`);
292
+ if (!who.ok) return fail(`${clean} answered HTTP ${who.status} — is that the spectoflow server?`);
293
+ const machineName = flag('name') || os.hostname();
294
+ workspace.migrateLegacyHome(); if (!workspace.exists()) workspace.init({});
295
+ workspace.writeRemote({ url: clean, token, machineName, transport });
296
+ globalConfig.set('dashboard.url', clean);
297
+ console.log(`${c.g('✓')} logged in to ${c.bold(clean)} as ${c.bold(machineName)} ${c.dim(`(machine ${who.data.machineId || '?'}, ${transport})`)}`);
298
+ const r = await hubCall('/api/hub/remote/reconnect', {});
299
+ console.log(` ${c.dim(r ? 'the running hub is connecting now' : 'the next `spectoflow dashboard` will connect')}`);
300
+ console.log(` publish a project from inside it: ${c.g('spectoflow dashboard publish')}`);
301
+ }
302
+ async function dashboardLogout() {
303
+ const had = workspace.clearRemote();
304
+ globalConfig.set('dashboard.url', `http://localhost:${resolvePort(argv)}`);
305
+ await hubCall('/api/hub/remote/reconnect', {});
306
+ console.log(had ? `${c.g('✓')} logged out — this machine no longer connects to an online dashboard` : `${c.dim('○')} not logged in to any online dashboard`);
307
+ }
308
+ async function dashboardPublish(published) {
309
+ let id = flag('id');
310
+ if (!id) {
311
+ const entry = registry.findByPath(process.cwd());
312
+ if (!entry) return fail(`this folder isn't registered in the dashboard yet — run ${c.g('spectoflow dashboard')} here first, or pass --id=<id> (see ${c.g('spectoflow projects')})`);
313
+ id = entry.id;
314
+ }
315
+ const meta = workspace.setPublished(id, published);
316
+ if (!meta) return fail(`no project registered with id ${id}`);
317
+ await hubCall(`/api/hub/projects/${encodeURIComponent(id)}/publish`, { published });
318
+ if (!published) return console.log(`${c.g('✓')} ${id} is no longer published`);
319
+ console.log(`${c.g('✓')} ${id} is published`);
320
+ const remote = workspace.readRemote();
321
+ if (remote) console.log(` online: ${c.bold(remote.url)}`);
322
+ else console.log(` ${c.dim('not logged in yet —')} ${c.g('spectoflow dashboard login --url=… --token=…')}`);
323
+ }
324
+ // The one-line "online" status under the local one — used by `dashboard` and `dashboard status`.
325
+ async function printOnlineLine(port, running) {
326
+ const remote = workspace.readRemote();
327
+ if (remote) {
328
+ let s = null;
329
+ if (running) { try { s = (await fetchJSON(`http://localhost:${port}/api/hub/remote`)).data; } catch {} }
330
+ if (s && s.connected) return console.log(`${c.g('●')} online → ${c.bold(remote.url)} ${c.dim(`(connected via ${s.transport}, machine "${remote.machineName}")`)}`);
331
+ const why = !running ? 'hub not running' : s && s.lastError ? `not connected: ${s.lastError}` : 'connecting…';
332
+ return console.log(`${c.dim('○')} online → ${c.bold(remote.url)} ${c.dim(`(${why})`)}`);
333
+ }
334
+ const url = globalConfig.get('dashboard.url').value;
335
+ if (!isLocalUrl(url)) console.log(`${c.dim('○')} online → ${url} ${c.dim('— not logged in:')} ${c.g('spectoflow dashboard login --url=… --token=…')}`);
336
+ }
337
+
262
338
  // ---- projects: the multi-project registry's CLI surface (the workspace's projects.json) ----
263
339
  function projectsCmd() {
264
340
  const sub = argv[1];
@@ -361,6 +437,7 @@ async function startDashboard() {
361
437
  const info = workspace.readLock();
362
438
  if (info && info.port && await probeDashboard(info.port)) {
363
439
  console.log(`${c.g('●')} hub already running → ${c.bold(boardUrl(info.port))}`);
440
+ await printOnlineLine(info.port, true);
364
441
  return printDashboardCommands();
365
442
  }
366
443
  const port = resolvePort(argv);
@@ -374,6 +451,7 @@ async function startDashboard() {
374
451
  for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
375
452
  if (up) console.log(`${c.g('✓')} hub started → ${c.bold(boardUrl(port))} ${c.dim('(pid ' + child.pid + ')')}`);
376
453
  else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on http://localhost:${port} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
454
+ await printOnlineLine(port, up);
377
455
  printDashboardCommands();
378
456
  }
379
457
 
@@ -392,6 +470,7 @@ async function dashboardStatus() {
392
470
  const running = await probeDashboard(port);
393
471
  if (running) console.log(`${c.g('●')} hub running → ${c.bold('http://localhost:' + port)}${info && info.pid ? c.dim(' (pid ' + info.pid + ')') : ''}`);
394
472
  else console.log(`${c.dim('○')} hub not running`);
473
+ await printOnlineLine(port, running);
395
474
  }
396
475
 
397
476
  async function restartDashboard() {
@@ -504,7 +583,7 @@ ${c.bold('Dashboard')}
504
583
  ${c.g('dashboard status')} is it running? (url + pid)
505
584
  ${c.g('dashboard stop')} stop it ${c.dim('(alias: stop)')}
506
585
  ${c.g('dashboard restart')} stop then start
507
- ${c.g('dashboard login')} connect to a remote dashboard ${c.dim('(coming in a later release)')}
586
+ ${c.g('dashboard login')} ${c.dim('--url=<u> --token=<t>')} connect this machine to an online dashboard ${c.dim('(logout · publish · unpublish)')}
508
587
  ${c.g('projects')} ${c.dim('[remove <id>]')} list every project seen so far
509
588
 
510
589
  ${c.bold('Customize')} ${c.dim('— same as Settings → Customize, from the terminal')}
@@ -542,7 +621,7 @@ const HELP = {
542
621
  ${c.g('--force')} (${c.g('-f')}) overwrites a diverged file in place instead of dropping a ${c.dim('*.new')}
543
622
  — use it when you know you have no local edits worth keeping (e.g. a file stuck diverged from an
544
623
  earlier update). It never touches config.json, workflow.md, specs/ or plans/.`,
545
- dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [--url=<u>] [init|status|stop|restart|create|validate|login]')}\n
624
+ dashboard: `${c.bold('spectoflow dashboard')} ${c.dim('[--port=NNNN] [--url=<u>] [init|status|stop|restart|create|validate|login|logout|publish|unpublish]')}\n
546
625
  Start the local control plane in the ${c.bold('background')} (default ${c.dim('4319')} or
547
626
  ${c.dim('$SPECTOFLOW_PORT')}) and hand the prompt back. ${c.g('--url=<u>')} sets which dashboard this
548
627
  project talks to (${c.dim('~/.spectoflow/config.json → dashboard.url')}) — asked once, interactively,
@@ -553,7 +632,10 @@ const HELP = {
553
632
  ${c.g('restart')} stop then start
554
633
  ${c.g('create')} generate a custom dashboard, e.g. ${c.dim('spectoflow dashboard create "..." --auto')}
555
634
  ${c.g('validate <file>')} check a custom-view JSON against the block schema
556
- ${c.g('login')} connect to a remote dashboard ${c.dim('(coming in a later release)')}`,
635
+ ${c.g('login')} connect this machine to an online dashboard: ${c.dim('--url=<https://…> --token=<spf_…> [--name=<machine>] [--transport=ws|http]')}
636
+ ${c.g('logout')} forget it (remote.json removed, dashboard.url back to local)
637
+ ${c.g('publish')} make the current project (or ${c.dim('--id=<id>')}) visible online — nothing is published by default
638
+ ${c.g('unpublish')} take it back offline`,
557
639
  projects: `${c.bold('spectoflow projects')} ${c.dim('[remove <id>]')}\n
558
640
  List every registered project in the global registry (the dashboard workspace's projects.json) (stored by
559
641
  ${c.g('spectoflow dashboard')}) — id, name, path. ${c.g('remove <id>')} drops one (e.g. a project that moved
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+ /*
3
+ * The local hub's outbound connector to an online dashboard (docs/online-dashboard-connector-design.md §2).
4
+ * One connection per machine; JSON frames, identical on both transports:
5
+ * up: auth {token,version} · hello {machineName,projects} · event {p,event} · snapshot {p,project} · reply {reqId,result|error} · pong
6
+ * down: auth-ok {machineId} · op {reqId,p,op,args} · ping
7
+ * Transport 1 is Node's native WebSocket client (zero-dep, Node ≥ 22); transport 2 is HTTP long-poll
8
+ * (POST batches + GET held ≤ 25 s) for hosts that don't pass WebSockets (cPanel/Passenger). The hub
9
+ * decides nothing here: it hands in listPublished/readSnapshot/execOp and tees its emit into
10
+ * pushEvent/pushSnapshot. Reconnection: exponential backoff 1 s → 30 s with jitter; after an upgrade
11
+ * failure or 3 drops in 60 s the connector falls back to HTTP and retries WebSocket every 10 min.
12
+ * The token travels in-band (auth frame / Authorization header) — never in a URL, never logged.
13
+ */
14
+ const DEFAULT_TIMING = { backoffMin: 1000, backoffMax: 30000, dropWindow: 60000, dropLimit: 3, wsRetryEvery: 600000, pollTimeout: 35000, wsOpenTimeout: 15000 };
15
+ const REJECTED = 'token rejected by the server — run `spectoflow dashboard login` again';
16
+
17
+ function createConnector(opts) {
18
+ const { token, machineName, listPublished, readSnapshot, execOp } = opts;
19
+ const version = opts.version || '0.0.0';
20
+ const base = String(opts.url || '').replace(/\/+$/, '');
21
+ const preferred = opts.transport === 'http' ? 'http' : 'ws';
22
+ const timing = { ...DEFAULT_TIMING, ...(opts.timing || {}) };
23
+ const log = opts.log || (() => {});
24
+
25
+ const state = { connected: false, lastError: null, machineId: null, attempts: 0, since: null };
26
+ let mode = preferred; // transport in use ('ws' | 'http'); may fall back to 'http'
27
+ let stopped = true;
28
+ let active = null; // the live transport: { send(frame), close() }
29
+ let generation = 0; // bumps on every connect/stop so a stale transport's callbacks are ignored
30
+ const drops = [];
31
+ let reconnectTimer = null, wsRetryTimer = null;
32
+
33
+ function status() {
34
+ return { url: base, machineName, connected: state.connected, transport: mode, lastError: state.lastError, machineId: state.machineId, since: state.since };
35
+ }
36
+
37
+ // ---- frames ----
38
+ async function handleDownward(frame, send) {
39
+ if (!frame || typeof frame !== 'object') return;
40
+ if (frame.type === 'auth-ok') { state.machineId = frame.machineId || null; onConnected(); return; }
41
+ if (frame.type === 'ping') { send({ type: 'pong' }); return; }
42
+ if (frame.type === 'op') {
43
+ let reply;
44
+ try {
45
+ const result = await execOp(frame.p, frame.op, frame.args || {});
46
+ reply = { type: 'reply', reqId: frame.reqId, result: result === undefined ? {} : result };
47
+ } catch (e) {
48
+ reply = { type: 'reply', reqId: frame.reqId, error: { status: Number(e && e.status) || 500, message: String(e && e.message || e) } };
49
+ }
50
+ send(reply);
51
+ }
52
+ }
53
+ function onConnected() {
54
+ state.connected = true; state.lastError = null; state.attempts = 0; state.since = new Date().toISOString();
55
+ log(`online dashboard: connected to ${base} (${mode})`);
56
+ announce();
57
+ }
58
+ async function announce() {
59
+ const t = active;
60
+ if (!t || !state.connected) return;
61
+ const projects = listPublished();
62
+ t.send({ type: 'hello', machineName, projects });
63
+ for (const p of projects) {
64
+ try { const project = await readSnapshot(p.localId); if (project && t === active) t.send({ type: 'snapshot', p: p.localId, project }); }
65
+ catch (e) { log(`online dashboard: snapshot of ${p.localId} failed: ${e.message}`); }
66
+ }
67
+ }
68
+ function pushEvent(localId, event) { if (active && state.connected) active.send({ type: 'event', p: localId, event }); }
69
+ function pushSnapshot(localId, project) { if (active && state.connected) active.send({ type: 'snapshot', p: localId, project }); }
70
+
71
+ // ---- lifecycle ----
72
+ function onDisconnected(gen, err, { upgradeFailed = false } = {}) {
73
+ if (gen !== generation) return; // a transport we already replaced
74
+ const was = state.connected;
75
+ state.connected = false; active = null;
76
+ if (err) state.lastError = String(err.message || err);
77
+ if (was) log(`online dashboard: disconnected from ${base}${err ? ' — ' + state.lastError : ''}`);
78
+ if (stopped) return;
79
+ if (mode === 'ws' && preferred === 'ws') {
80
+ const now = Date.now();
81
+ drops.push(now); while (drops.length && now - drops[0] > timing.dropWindow) drops.shift();
82
+ if (upgradeFailed || drops.length >= timing.dropLimit) {
83
+ mode = 'http'; drops.length = 0;
84
+ log('online dashboard: WebSocket unavailable, falling back to HTTP long-poll');
85
+ scheduleWsRetry();
86
+ }
87
+ }
88
+ scheduleReconnect();
89
+ }
90
+ function backoff() {
91
+ const exp = Math.min(timing.backoffMax, timing.backoffMin * 2 ** Math.min(state.attempts, 10));
92
+ state.attempts++;
93
+ return Math.round(exp * (0.75 + Math.random() * 0.5));
94
+ }
95
+ function scheduleReconnect() { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, backoff()); }
96
+ function scheduleWsRetry() {
97
+ clearTimeout(wsRetryTimer);
98
+ wsRetryTimer = setTimeout(() => { if (!stopped && mode === 'http') { mode = 'ws'; forceReconnect(); } }, timing.wsRetryEvery);
99
+ }
100
+ function connect() {
101
+ if (stopped) return;
102
+ clearTimeout(reconnectTimer);
103
+ const gen = ++generation;
104
+ active = mode === 'http' ? httpTransport(gen) : wsTransport(gen);
105
+ }
106
+ function start() { if (!stopped) return; stopped = false; mode = preferred; state.attempts = 0; connect(); }
107
+ function stop() {
108
+ stopped = true; generation++;
109
+ clearTimeout(reconnectTimer); clearTimeout(wsRetryTimer);
110
+ const t = active; active = null; state.connected = false;
111
+ if (t) t.close();
112
+ }
113
+ function forceReconnect() {
114
+ if (stopped) return;
115
+ const t = active; generation++; active = null; state.connected = false; state.attempts = 0;
116
+ if (t) t.close();
117
+ connect();
118
+ }
119
+
120
+ // ---- transport 1: native WebSocket ----
121
+ // A handshake that never resolves (no open/error/close at all — observed on some platforms when
122
+ // the server rejects the upgrade with a plain HTTP response) must still fail: wsOpenTimeout bounds
123
+ // it so the connector always falls back to HTTP instead of hanging forever in 'ws' mode.
124
+ function wsTransport(gen) {
125
+ let ws, opened = false, settled = false, lastErr = null;
126
+ const settle = (err, opts) => { if (settled) return; settled = true; clearTimeout(openTimer); onDisconnected(gen, err, opts); };
127
+ try { ws = new WebSocket(base.replace(/^http/, 'ws') + '/connector/ws'); }
128
+ catch (e) { setImmediate(() => settle(e, { upgradeFailed: true })); return { send() {}, close() {} }; }
129
+ const openTimer = setTimeout(() => {
130
+ try { ws.close(); } catch (_) {}
131
+ settle(new Error('WebSocket handshake timed out'), { upgradeFailed: true });
132
+ }, timing.wsOpenTimeout);
133
+ const send = (frame) => { if (ws.readyState === 1) ws.send(JSON.stringify(frame)); };
134
+ ws.addEventListener('open', () => { opened = true; clearTimeout(openTimer); send({ type: 'auth', token, version }); });
135
+ ws.addEventListener('message', (ev) => { let f; try { f = JSON.parse(String(ev.data)); } catch { return; } handleDownward(f, send); });
136
+ ws.addEventListener('error', (ev) => { lastErr = new Error((ev && ev.message) || 'websocket error'); });
137
+ ws.addEventListener('close', (ev) => {
138
+ const code = ev && ev.code;
139
+ const err = code === 4401 ? new Error(REJECTED) : lastErr || (code && code !== 1000 && code !== 1005 ? new Error(`connection closed (${code}${ev.reason ? ' ' + ev.reason : ''})`) : null);
140
+ settle(err, { upgradeFailed: !opened });
141
+ });
142
+ return { send, close: () => { clearTimeout(openTimer); try { ws.close(1000); } catch (_) {} } };
143
+ }
144
+
145
+ // ---- transport 2: HTTP long-poll ----
146
+ // Upward frames are POSTed in batches, strictly one batch in flight at a time (ordering). Downward
147
+ // frames arrive in the POST responses and through a GET loop the server holds open ≤ 25 s. The first
148
+ // batch carries `auth` so the frame sequence is the same as on WebSocket; the Authorization header is
149
+ // what actually authenticates every request.
150
+ function httpTransport(gen) {
151
+ const ac = new AbortController();
152
+ const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
153
+ let queue = [{ type: 'auth', token, version }];
154
+ let flushing = false, closed = false;
155
+ const alive = () => !closed && gen === generation;
156
+ const signal = () => AbortSignal.any([ac.signal, AbortSignal.timeout(timing.pollTimeout)]);
157
+ const fail = (e) => { if (!alive()) return; closed = true; ac.abort(); onDisconnected(gen, e); };
158
+ const check = (res, what) => {
159
+ if (res.status === 401) throw new Error(REJECTED);
160
+ if (!res.ok) throw new Error(`${what} /connector/frames → HTTP ${res.status}`);
161
+ return res.json();
162
+ };
163
+ async function flush() {
164
+ if (flushing) return;
165
+ flushing = true;
166
+ try {
167
+ while (alive() && queue.length) {
168
+ const batch = queue; queue = [];
169
+ const res = await fetch(base + '/connector/frames', { method: 'POST', headers, body: JSON.stringify(batch), signal: signal() });
170
+ const down = await check(res, 'POST');
171
+ for (const f of down) handleDownward(f, send); // not awaited: a slow op must not stall the batch loop
172
+ }
173
+ } catch (e) { fail(e); } finally { flushing = false; }
174
+ }
175
+ async function poll() {
176
+ try {
177
+ while (alive()) {
178
+ const res = await fetch(base + '/connector/frames', { headers: { Authorization: headers.Authorization }, signal: signal() });
179
+ if (!alive()) return;
180
+ const down = await check(res, 'GET');
181
+ for (const f of down) handleDownward(f, send); // not awaited: a slow op must not stall the batch loop
182
+ }
183
+ } catch (e) { fail(e); }
184
+ }
185
+ function send(frame) { if (!alive()) return; queue.push(frame); flush(); }
186
+ flush(); poll();
187
+ return { send, close: () => { closed = true; ac.abort(); } };
188
+ }
189
+
190
+ return { start, stop, status, announce, pushEvent, pushSnapshot, forceReconnect };
191
+ }
192
+
193
+ module.exports = { createConnector, DEFAULT_TIMING };
@@ -18,37 +18,12 @@ const orchestrator = require('./orchestrator');
18
18
  function sendJSON(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); }
19
19
  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({}); } }); }); }
20
20
 
21
- const seg = (p, i) => decodeURIComponent(p.split('/')[i] || '');
22
- const q = (u, k) => u.searchParams.get(k) || '';
23
- // [method, matcher, op, args(u, body, pathname)]
24
- const ROUTES = [
25
- ['GET', '/api/project', 'project.read', () => ({})],
26
- ['GET', '/api/agentfile', 'agentfile.read', (u) => ({ path: q(u, 'path') })],
27
- ['GET', '/api/files/tree', 'files.tree', () => ({})],
28
- ['GET', '/api/files/read', 'files.read', (u) => ({ path: q(u, 'path') })],
29
- ['POST', '/api/files/write', 'files.write', (_u, b) => b],
30
- ['POST', '/api/files/mkdir', 'files.mkdir', (_u, b) => b],
31
- ['POST', '/api/task', 'task.add', (_u, b) => b],
32
- ['PATCH', /^\/api\/task\/[^/]+$/, 'task.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
33
- ['POST', /^\/api\/task\/[^/]+\/comment$/, 'task.comment', (_u, b, p) => ({ id: seg(p, 3), text: b.text, action: b.action })],
34
- ['POST', '/api/workflow/toggle', 'workflow.toggle', (_u, b) => b],
35
- ['POST', '/api/run', 'run.start', (_u, b) => b],
36
- ['POST', '/api/chat/summarize', 'chat.summarize', (_u, b) => b],
37
- ['POST', '/api/chat/clear', 'chat.clear', () => ({})],
38
- ['POST', '/api/orchestrate', 'orchestrate.start', (_u, b) => b],
39
- ['POST', '/api/orchestrate/approve', 'orchestrate.approve', (_u, b) => b],
40
- ['POST', '/api/settings', 'settings.save', (_u, b) => b],
41
- ['POST', '/api/attention', 'attention.add', (_u, b) => b],
42
- ['POST', /^\/api\/attention\/[^/]+\/promote$/, 'attention.promote', (_u, _b, p) => ({ id: seg(p, 3) })],
43
- ['PATCH', /^\/api\/attention\/[^/]+$/, 'attention.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
44
- ['DELETE', /^\/api\/attention\/[^/]+$/, 'attention.remove', (_u, _b, p) => ({ id: seg(p, 3) })],
45
- ];
46
- const matches = (m, p) => (typeof m === 'string' ? m === p : m.test(p));
21
+ const { ROUTES, findRoute } = require('./routes');
47
22
 
48
23
  function createHandlers(root) {
49
24
  async function handleApi(req, res, u, emit) {
50
25
  const p = u.pathname;
51
- const route = ROUTES.find(([method, m]) => method === req.method && matches(m, p));
26
+ const route = findRoute(req.method, p);
52
27
  if (!route) return false;
53
28
  const [, , opName, args] = route;
54
29
  const b = req.method === 'GET' ? {} : await body(req);