@seanmars/tospec 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +17 -3
  3. package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
  4. package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
  5. package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
  6. package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
  7. package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
  8. package/assets/dashboard/app.js +218 -1180
  9. package/assets/dashboard/index.html +31 -22
  10. package/assets/dashboard/lib/charts.js +355 -0
  11. package/assets/dashboard/lib/derive.js +369 -0
  12. package/assets/dashboard/lib/dom.js +121 -0
  13. package/assets/dashboard/lib/routes.js +52 -0
  14. package/assets/dashboard/lib/store.js +324 -0
  15. package/assets/dashboard/style.css +3103 -664
  16. package/assets/dashboard/views/alerts.js +170 -0
  17. package/assets/dashboard/views/change.js +232 -0
  18. package/assets/dashboard/views/docs.js +214 -0
  19. package/assets/dashboard/views/home.js +357 -0
  20. package/assets/dashboard/views/project.js +168 -0
  21. package/assets/dashboard/views/session.js +270 -0
  22. package/assets/dashboard/views/sidebar.js +131 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +74 -95
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/commands/dashboard-relay.d.ts +11 -0
  27. package/dist/commands/dashboard-relay.d.ts.map +1 -0
  28. package/dist/commands/dashboard-relay.js +88 -0
  29. package/dist/commands/dashboard-relay.js.map +1 -0
  30. package/dist/commands/dashboard.d.ts +62 -102
  31. package/dist/commands/dashboard.d.ts.map +1 -1
  32. package/dist/commands/dashboard.js +375 -609
  33. package/dist/commands/dashboard.js.map +1 -1
  34. package/dist/core/dashboard-hub.d.ts +258 -0
  35. package/dist/core/dashboard-hub.d.ts.map +1 -0
  36. package/dist/core/dashboard-hub.js +643 -0
  37. package/dist/core/dashboard-hub.js.map +1 -0
  38. package/dist/core/dashboard-routes.d.ts +56 -0
  39. package/dist/core/dashboard-routes.d.ts.map +1 -0
  40. package/dist/core/dashboard-routes.js +329 -0
  41. package/dist/core/dashboard-routes.js.map +1 -0
  42. package/dist/core/init.d.ts.map +1 -1
  43. package/dist/core/init.js +4 -1
  44. package/dist/core/init.js.map +1 -1
  45. package/dist/core/local-server.d.ts +1 -1
  46. package/dist/core/local-server.js +1 -1
  47. package/dist/core/shared/dashboard-mod.d.ts +24 -0
  48. package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
  49. package/dist/core/shared/dashboard-mod.js +44 -0
  50. package/dist/core/shared/dashboard-mod.js.map +1 -0
  51. package/dist/core/shared/index.d.ts +1 -0
  52. package/dist/core/shared/index.d.ts.map +1 -1
  53. package/dist/core/shared/index.js +1 -0
  54. package/dist/core/shared/index.js.map +1 -1
  55. package/dist/core/update.d.ts.map +1 -1
  56. package/dist/core/update.js +4 -1
  57. package/dist/core/update.js.map +1 -1
  58. package/package.json +3 -2
  59. package/assets/dashboard/chart.umd.js +0 -14
@@ -1,172 +1,47 @@
1
1
  /**
2
- * `tospec dashboard` — a `node:http` server over the aggregators in
3
- * `core/dashboard-data.ts`. Reads are GET-only; the sole write is
4
- * `POST /api/task`. `/api/render` and `/api/task` are trust boundaries: the
5
- * path must resolve under the tospec root (D5).
2
+ * `tospec dashboard start|stop|status` — one local hub for every tospec
3
+ * project. It reads no project files: Claude Code sessions push snapshots and
4
+ * activity through the `tospec-dashboard` mod (see `core/dashboard-hub.ts`),
5
+ * and the browser reads those. A task checkbox is relayed to a live session,
6
+ * which applies it with the checks in `core/dashboard-routes.ts`. Beyond that
7
+ * the page can only answer a question a session asked (an AskUserQuestion, a
8
+ * permission), and only from a hub bound to loopback; it never types a prompt.
6
9
  */
7
10
  import * as http from 'node:http';
8
- import { promises as fs, readdirSync, readFileSync, rmSync, unlinkSync, watch, } from 'node:fs';
11
+ import { promises as fs, readdirSync, rmSync } from 'node:fs';
9
12
  import * as path from 'node:path';
10
- import * as os from 'node:os';
11
- import * as crypto from 'node:crypto';
12
13
  import { spawn } from 'node:child_process';
13
- import { MarkdownParser } from '../core/parsers/markdown-parser.js';
14
- import { renderMarkdownSafe } from '../core/markdown-render.js';
15
- import { collectOverview, collectChangeDetail, collectArchive, collectDecisions, readSyncConclusion, } from '../core/dashboard-data.js';
16
- import { collectActivity } from '../core/dashboard-activity.js';
14
+ import { DashboardHub, HubRequestError, } from '../core/dashboard-hub.js';
15
+ import { DASHBOARD_PROTOCOL } from '../core/dashboard-routes.js';
17
16
  import { getGlobalConfigDir } from '../core/global-config.js';
18
- import { acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenFrom, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
19
- import { resolveTaskFiles, setTaskDone } from '../utils/task-progress.js';
20
- const DEFAULT_PORT = 5620;
17
+ import { LOOPBACK_HOSTS, acceptsAnyHostHeader, assertBindableHost, connectableUrl, isAllowedHostHeader, listenOnce, normalizeHost, openBrowser, packageAssetsDir, sendJson, serveAsset, } from '../core/local-server.js';
18
+ export const DEFAULT_PORT = 5620;
21
19
  const DEFAULT_HOST = '127.0.0.1';
22
20
  // Readiness signal the detached child writes to stderr once bound; emitted only
23
- // when TOSPEC_DASHBOARD_PIDFILE marks it as a `--detach` child.
21
+ // when TOSPEC_DASHBOARD_DETACHED marks it as the child `dashboard start` spawned.
24
22
  const LISTENING_PREFIX = 'LISTENING ';
25
- /** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
26
- function stripErrorPrefix(text) {
27
- return text.replace(/^Error:\s*/, '');
28
- }
23
+ const DETACHED_ENV = 'TOSPEC_DASHBOARD_DETACHED';
29
24
  // The child signals right after binding, so slower than a cold start plus a
30
25
  // bind means it is stuck, not busy.
31
26
  const READINESS_TIMEOUT_MS = 10_000;
27
+ // `status` asks a hub on this machine; one that takes longer is not answering.
28
+ const STATUS_TIMEOUT_MS = 3_000;
29
+ // How often online/offline transitions are checked and broadcast.
30
+ const SWEEP_INTERVAL_MS = 5_000;
31
+ // A browser POST carries one task tick or one answer (up to four questions of
32
+ // CJK text); a mod push carries a whole project.
33
+ const BROWSER_BODY_LIMIT = 64 * 1024;
34
+ const SNAPSHOT_BODY_LIMIT = 64 * 1024 * 1024;
35
+ const REPLY_BODY_LIMIT = 16 * 1024 * 1024;
36
+ const ACTIVITY_BODY_LIMIT = 1024 * 1024;
32
37
  /** This command's shipped frontend; `serveAsset` is confined to it. */
33
38
  const ASSETS_DIR = packageAssetsDir('dashboard');
34
- /**
35
- * Decodes one URL path segment and confines it to a single directory name, or
36
- * answers the request and returns null.
37
- *
38
- * WHATWG URL normalizes a literal `..` out of the pathname but does not
39
- * percent-decode, so `%2e%2e%2f` survives until `decodeURIComponent` turns it
40
- * back into `../../` — a traversal. Malformed escapes (`%zz`) are the client's
41
- * mistake, so 400 rather than a 500 from the router.
42
- */
43
- function decodePathSegment(raw, label, res) {
44
- let decoded;
45
- try {
46
- decoded = decodeURIComponent(raw);
47
- }
48
- catch {
49
- sendJson(res, 400, { error: `malformed ${label}` });
50
- return null;
51
- }
52
- if (!decoded || decoded.includes('/') || decoded.includes('\\') || decoded.includes('..')) {
53
- sendJson(res, 403, { error: 'forbidden' });
54
- return null;
55
- }
56
- return decoded;
57
- }
58
- async function handleChangeDetail(projectRoot, rawName, res) {
59
- const name = decodePathSegment(rawName, 'change name', res);
60
- if (name === null)
61
- return;
62
- try {
63
- sendJson(res, 200, await collectChangeDetail(projectRoot, name));
64
- }
65
- catch (err) {
66
- const message = err instanceof Error ? err.message : String(err);
67
- sendJson(res, /not found/i.test(message) ? 404 : 500, { error: message });
68
- }
69
- }
70
- async function handleSpec(projectRoot, rawId, res) {
71
- const id = decodePathSegment(rawId, 'spec id', res);
72
- if (id === null)
73
- return;
74
- const specPath = path.join(projectRoot, 'tospec', 'specs', id, 'spec.md');
75
- let content;
76
- try {
77
- content = await fs.readFile(specPath, 'utf-8');
78
- }
79
- catch {
80
- return sendJson(res, 404, { error: 'not found' });
81
- }
82
- const spec = new MarkdownParser(content).parseSpec(id);
83
- sendJson(res, 200, { id, requirements: spec.requirements });
84
- }
85
- /**
86
- * Git activity + health signals (ADR 001). `change` feeds a `git log` path
87
- * filter, so reject anything that could escape `tospec/changes/` before it
88
- * reaches git. `days` is capped at 3650 to bound the zero-filled series.
89
- */
90
- async function handleActivity(projectRoot, changeParam, daysParam, res) {
91
- const change = changeParam ?? undefined;
92
- if (change && (change.includes('/') || change.includes('\\') || change.includes('..'))) {
93
- return sendJson(res, 403, { error: 'forbidden' });
94
- }
95
- // Absent or garbage stays undefined → collectActivity's default.
96
- let window;
97
- if (daysParam === 'all') {
98
- window = 'all';
99
- }
100
- else {
101
- const parsed = Number(daysParam);
102
- if (Number.isInteger(parsed) && parsed >= 1)
103
- window = Math.min(parsed, 3650);
104
- }
105
- sendJson(res, 200, await collectActivity(projectRoot, change, window));
106
- }
107
- /**
108
- * Trust boundary (D5): resolves a client-supplied path to an absolute `.md` file
109
- * under `<root>/<prefix>`, or null when it escapes.
110
- */
111
- function resolveTospecFile(projectRoot, file, prefix) {
112
- const resolved = path.resolve(projectRoot, file.replace(/\\/g, '/'));
113
- if (!resolved.startsWith(path.resolve(projectRoot) + path.sep))
114
- return null;
115
- const posixRel = path.relative(projectRoot, resolved).replace(/\\/g, '/');
116
- if (!posixRel.startsWith(prefix) || !posixRel.endsWith('.md'))
117
- return null;
118
- return resolved;
119
- }
120
- const ARCHIVE_SEGMENT = 'archive';
121
- /**
122
- * True for a path inside `tospec/changes/archive/`. Not folded into
123
- * `resolveTospecFile`: `/api/render` shares that helper and legitimately renders
124
- * archived documents, so the rejection belongs on the write path.
125
- */
126
- function isArchivedChangePath(projectRoot, resolvedAbsPath) {
127
- const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
128
- const parts = posixRel.split('/');
129
- return parts[0] === 'tospec' && parts[1] === 'changes' && parts[2] === ARCHIVE_SEGMENT;
130
- }
131
- /**
132
- * Maps a resolved task-file path (`tospec/changes/<name>/...`) back to its
133
- * change name, or null when it doesn't match. An archived change is one level
134
- * deeper, so `parts[2]` is the literal `archive`, never a change name.
135
- */
136
- function changeNameFromTaskFile(projectRoot, resolvedAbsPath) {
137
- const posixRel = path.relative(projectRoot, resolvedAbsPath).replace(/\\/g, '/');
138
- const parts = posixRel.split('/');
139
- if (parts.length < 4 || parts[0] !== 'tospec' || parts[1] !== 'changes')
140
- return null;
141
- if (parts[2] === ARCHIVE_SEGMENT)
142
- return null;
143
- return parts[2] || null;
144
- }
145
- /**
146
- * Renders through `renderMarkdownSafe`, not `marked.parse`: the client assigns
147
- * this with `innerHTML`, so anything the renderer passes through runs inside
148
- * the CSRF boundary `handleTaskUpdate` relies on.
149
- */
150
- async function handleRender(projectRoot, fileParam, res) {
151
- if (!fileParam) {
152
- return sendJson(res, 400, { error: 'missing file parameter' });
153
- }
154
- const resolved = resolveTospecFile(projectRoot, fileParam, 'tospec/');
155
- if (!resolved) {
156
- return sendJson(res, 403, { error: 'forbidden' });
157
- }
158
- let content;
159
- try {
160
- content = await fs.readFile(resolved, 'utf-8');
161
- }
162
- catch {
163
- return sendJson(res, 404, { error: 'not found' });
164
- }
165
- const html = await renderMarkdownSafe(content);
166
- sendJson(res, 200, { html });
39
+ /** Drops one leading `Error: ` the child added, so wrapping doesn't stack prefixes. */
40
+ function stripErrorPrefix(text) {
41
+ return text.replace(/^Error:\s*/, '');
167
42
  }
168
43
  /** Reads a JSON request body, capped so a stray client can't exhaust memory. */
169
- async function readJsonBody(req, limit = 8192) {
44
+ async function readJsonBody(req, limit) {
170
45
  // Bytes first, decoded once: `string += chunk` decodes each chunk alone, so a
171
46
  // multi-byte character split across two chunks (a CJK filename) came out as
172
47
  // U+FFFD and the request addressed a file that does not exist.
@@ -176,7 +51,7 @@ async function readJsonBody(req, limit = 8192) {
176
51
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
177
52
  size += buffer.length;
178
53
  if (size > limit)
179
- throw new Error('body too large');
54
+ throw new HubRequestError('body too large', 413);
180
55
  chunks.push(buffer);
181
56
  }
182
57
  const raw = Buffer.concat(chunks).toString('utf-8');
@@ -184,103 +59,9 @@ async function readJsonBody(req, limit = 8192) {
184
59
  return JSON.parse(raw);
185
60
  }
186
61
  catch {
187
- throw new Error('invalid json');
62
+ throw new HubRequestError('invalid json', 400);
188
63
  }
189
64
  }
190
- /**
191
- * Ticks a task checkbox: `{ file, line, done }`, `file` root-relative and `line`
192
- * 1-based. Writes are confined to `<root>/tospec/changes/`, so the dashboard can
193
- * never edit specs.
194
- *
195
- * Requiring `content-type: application/json` is load-bearing: it pushes any
196
- * cross-origin POST into a CORS preflight this server does not answer.
197
- */
198
- async function handleTaskUpdate(projectRoot, req, res) {
199
- if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
200
- return sendJson(res, 415, { error: 'expected application/json' });
201
- }
202
- let body;
203
- try {
204
- body = await readJsonBody(req);
205
- }
206
- catch (err) {
207
- return sendJson(res, 400, { error: err instanceof Error ? err.message : 'bad request' });
208
- }
209
- const { file, line, done } = (body ?? {});
210
- if (typeof file !== 'string' || !Number.isInteger(line) || line < 1) {
211
- return sendJson(res, 400, { error: 'expected { file: string, line: int >= 1, done: bool }' });
212
- }
213
- if (typeof done !== 'boolean') {
214
- return sendJson(res, 400, { error: 'expected { file: string, line: int >= 1, done: bool }' });
215
- }
216
- const resolved = resolveTospecFile(projectRoot, file, 'tospec/changes/');
217
- if (!resolved) {
218
- return sendJson(res, 403, { error: 'forbidden' });
219
- }
220
- // Before the sync gate: that gate asks whether a change is *becoming*
221
- // immutable; an archived one already is.
222
- if (isArchivedChangePath(projectRoot, resolved)) {
223
- return sendJson(res, 403, { error: 'change is archived; tasks are immutable' });
224
- }
225
- // The checks above are lexical. A task file that is itself a symlink out of
226
- // the project passes both, and the write would land outside the root, so the
227
- // fence is re-checked on the real path. Before the tracked-file check below:
228
- // that check canonicalizes its own side, so the symlink used to fail there
229
- // as "not the tracked file" — refused, but for the wrong reason. A file that
230
- // is gone by now is the 404 the write would have reported.
231
- let realFile;
232
- try {
233
- const realRoot = await fs.realpath(projectRoot);
234
- realFile = await fs.realpath(resolved);
235
- if (!realFile.startsWith(realRoot + path.sep)) {
236
- return sendJson(res, 403, { error: 'forbidden' });
237
- }
238
- }
239
- catch (err) {
240
- if (err.code === 'ENOENT')
241
- return sendJson(res, 404, { error: 'not found' });
242
- throw err;
243
- }
244
- // A file directly under tospec/changes/ belongs to no change, so neither the
245
- // sync gate nor the tracked-file check below can apply to it. Skipping both
246
- // left any markdown there writable by a raw POST.
247
- const changeName = changeNameFromTaskFile(projectRoot, resolved);
248
- if (!changeName) {
249
- return sendJson(res, 403, { error: 'forbidden' });
250
- }
251
- // Freeze task edits once a change is sync certified. The frontend disable is
252
- // only UX; this gate is what protects certified content from a raw POST. A
253
- // read error reads as not certified.
254
- const changeDir = path.join(projectRoot, 'tospec', 'changes', changeName);
255
- if ((await readSyncConclusion(changeDir)) === 'PASS') {
256
- return sendJson(res, 409, { error: 'change is archiving; tasks are frozen' });
257
- }
258
- // Confined to the schema's tracked task file, via the resolver the read
259
- // side uses. The path check above only proves the file sits inside a change
260
- // directory, so any other markdown there with a checkbox on the addressed
261
- // line was writable too, invisibly. The resolver canonicalizes the files it
262
- // finds and leaves its `tasks.md` fallback as joined, so both spellings of
263
- // the request path are compared.
264
- const tracked = resolveTaskFiles(changeDir, projectRoot).map((file) => path.resolve(file));
265
- if (!tracked.includes(realFile) && !tracked.includes(path.resolve(resolved))) {
266
- return sendJson(res, 409, {
267
- error: `${path.basename(resolved)} is not this change's tracked task file`,
268
- });
269
- }
270
- try {
271
- await setTaskDone(resolved, line, done);
272
- }
273
- catch (err) {
274
- const message = err instanceof Error ? err.message : String(err);
275
- // Stale client view or a deleted file — neither is a server fault, so no 500.
276
- if (/not a task/.test(message))
277
- return sendJson(res, 409, { error: message });
278
- if (/ENOENT/.test(message))
279
- return sendJson(res, 404, { error: 'not found' });
280
- throw err;
281
- }
282
- sendJson(res, 200, { file, line, done });
283
- }
284
65
  function handleEvents(req, res, clients) {
285
66
  res.writeHead(200, {
286
67
  'content-type': 'text/event-stream',
@@ -291,18 +72,22 @@ function handleEvents(req, res, clients) {
291
72
  clients.add(res);
292
73
  req.on('close', () => clients.delete(res));
293
74
  }
294
- function broadcastChange(clients) {
295
- for (const client of clients) {
296
- client.write('event: change\ndata: {}\n\n');
297
- }
75
+ function broadcast(clients, event) {
76
+ const { type, ...data } = event;
77
+ const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
78
+ for (const client of clients)
79
+ client.write(line);
298
80
  }
299
81
  /**
300
82
  * Second layer behind `renderMarkdownSafe`, applied to every response.
301
83
  *
302
84
  * `script-src 'self'` is the load-bearing directive: it refuses the inline
303
- * handlers and `javascript:` URLs a markdown file would need to reach
304
- * `POST /api/task` from inside this origin. `style-src` keeps `'unsafe-inline'`
305
- * for Chart.js — a style injection cannot call the write endpoint.
85
+ * handlers and `javascript:` URLs a markdown file would need to reach the task
86
+ * relay from inside this origin. `style-src` still carries `'unsafe-inline'`,
87
+ * added for Chart.js, which the dashboard no longer loads (its charts are SVG
88
+ * styled from the stylesheet, and the app sets styles through the CSSOM).
89
+ * Dropping it is a separate hardening step; a style injection cannot call the
90
+ * write endpoint either way.
306
91
  */
307
92
  function applyContentSecurityPolicy(res) {
308
93
  res.setHeader('content-security-policy', [
@@ -317,7 +102,104 @@ function applyContentSecurityPolicy(res) {
317
102
  "frame-ancestors 'none'",
318
103
  ].join('; '));
319
104
  }
320
- async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAnyHost) {
105
+ /** `/api/p/<key>/<rest>` → the project key and the per-project path `/api/<rest>?…`. */
106
+ function matchProjectPath(url) {
107
+ const match = /^\/api\/p\/([0-9a-f]{12})(\/.*)$/.exec(url.pathname);
108
+ if (!match)
109
+ return null;
110
+ return { key: match[1], inner: `/api${match[2]}${url.search}` };
111
+ }
112
+ /**
113
+ * `/api/p/<key>/sessions/<id>/<action>`: the hub's own answers about one
114
+ * session, matched before the per-project relay, which would otherwise carry
115
+ * them to the project as a file read.
116
+ */
117
+ function matchSessionPath(url) {
118
+ const match = /^\/api\/p\/([0-9a-f]{12})\/sessions\/([^/]+)\/(log|answer)$/.exec(url.pathname);
119
+ if (!match)
120
+ return null;
121
+ try {
122
+ return { key: match[1], session: decodeURIComponent(match[2]), action: match[3] };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ function fieldsOf(body) {
129
+ return typeof body === 'object' && body !== null ? body : {};
130
+ }
131
+ /**
132
+ * Every POST must be JSON. That is the CSRF fence for the whole write surface:
133
+ * a cross-origin page can only send `application/json` through a CORS preflight
134
+ * this server never answers, so no web page can push a snapshot, take a queued
135
+ * command, tick a task, or answer for a session.
136
+ */
137
+ async function handlePost(hub, req, res, url) {
138
+ if (!/^application\/json\b/.test(req.headers['content-type'] ?? '')) {
139
+ return sendJson(res, 415, { error: 'expected application/json' });
140
+ }
141
+ const pathname = url.pathname;
142
+ if (pathname === '/api/mod/snapshot') {
143
+ const body = fieldsOf(await readJsonBody(req, SNAPSHOT_BODY_LIMIT));
144
+ return sendJson(res, 200, { project: hub.acceptSnapshot(body.session, body.snapshot) });
145
+ }
146
+ if (pathname === '/api/mod/poll') {
147
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
148
+ const hangUp = new AbortController();
149
+ res.on('close', () => hangUp.abort());
150
+ const commands = await hub.poll(body.project, body.session, hangUp.signal);
151
+ if (commands === null)
152
+ return sendJson(res, 404, { error: 'unknown project' });
153
+ return sendJson(res, 200, { commands });
154
+ }
155
+ if (pathname === '/api/mod/reply') {
156
+ const body = fieldsOf(await readJsonBody(req, REPLY_BODY_LIMIT));
157
+ const settled = hub.reply(body.id, { status: body.status, body: body.body });
158
+ return sendJson(res, settled ? 200 : 404, settled ? { ok: true } : { error: 'no such request' });
159
+ }
160
+ if (pathname === '/api/mod/leave') {
161
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
162
+ hub.leave(body.project, body.session);
163
+ return sendJson(res, 200, { ok: true });
164
+ }
165
+ if (pathname === '/api/mod/activity') {
166
+ const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
167
+ const known = hub.reportActivity(body.project, body.session, body);
168
+ return sendJson(res, known ? 200 : 404, known ? { ok: true } : { error: 'unknown project' });
169
+ }
170
+ if (pathname === '/api/mod/ask') {
171
+ const body = fieldsOf(await readJsonBody(req, ACTIVITY_BODY_LIMIT));
172
+ return sendJson(res, 200, hub.openAsk(body.project, body.session, body.ask));
173
+ }
174
+ if (pathname === '/api/mod/ask/wait') {
175
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
176
+ const hangUp = new AbortController();
177
+ res.on('close', () => hangUp.abort());
178
+ return sendJson(res, 200, await hub.waitAsk(body.id, hangUp.signal));
179
+ }
180
+ if (pathname === '/api/mod/ask/close') {
181
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
182
+ hub.closeAsk(body.id);
183
+ return sendJson(res, 200, { ok: true });
184
+ }
185
+ const session = matchSessionPath(url);
186
+ if (session && session.action === 'answer') {
187
+ const body = fieldsOf(await readJsonBody(req, BROWSER_BODY_LIMIT));
188
+ const answer = hub.answerAsk(session.key, session.session, body.id, body.answer);
189
+ return sendJson(res, answer.status, answer.body);
190
+ }
191
+ const scoped = matchProjectPath(url);
192
+ if (scoped && scoped.inner === '/api/task') {
193
+ const body = await readJsonBody(req, BROWSER_BODY_LIMIT);
194
+ const answer = await hub.write(scoped.key, { method: 'POST', path: '/api/task', body });
195
+ return sendJson(res, answer.status, answer.body);
196
+ }
197
+ if (scoped && scoped.inner === '/api/refresh') {
198
+ return sendJson(res, 202, { queued: hub.requestRefresh(scoped.key) });
199
+ }
200
+ sendJson(res, 404, { error: 'not found' });
201
+ }
202
+ async function handleRequest(hub, req, res, clients, boundHost, acceptAnyHost) {
321
203
  try {
322
204
  // Host allowlist runs before any route (DNS-rebinding guard).
323
205
  if (!isAllowedHostHeader(req.headers.host, boundHost, acceptAnyHost)) {
@@ -326,14 +208,29 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
326
208
  applyContentSecurityPolicy(res);
327
209
  const url = new URL(req.url ?? '/', 'http://localhost');
328
210
  const pathname = url.pathname;
329
- if (req.method === 'POST' && pathname === '/api/task') {
330
- return await handleTaskUpdate(projectRoot, req, res);
211
+ if (req.method === 'POST') {
212
+ return await handlePost(hub, req, res, url);
331
213
  }
332
214
  if (req.method !== 'GET') {
333
215
  return sendJson(res, 405, { error: 'method not allowed' });
334
216
  }
335
217
  if (pathname === '/api/events')
336
218
  return handleEvents(req, res, clients);
219
+ // The mod's cheap reachability probe, so a session with no hub to talk to
220
+ // does not run the tospec CLI just to find that out.
221
+ if (pathname === '/api/health')
222
+ return sendJson(res, 200, { protocol: DASHBOARD_PROTOCOL });
223
+ // `interactive` tells the page whether to offer the answer controls.
224
+ if (pathname === '/api/projects') {
225
+ return sendJson(res, 200, { projects: hub.listProjects(), interactive: hub.interactive });
226
+ }
227
+ const session = matchSessionPath(url);
228
+ if (session) {
229
+ if (session.action !== 'log')
230
+ return sendJson(res, 405, { error: 'method not allowed' });
231
+ const log = hub.sessionLog(session.key, session.session);
232
+ return log ? sendJson(res, 200, log) : sendJson(res, 404, { error: 'That session is not connected to this dashboard.' });
233
+ }
337
234
  // `/index.html` alongside `/`: nothing links to it, but typing it 404'd.
338
235
  if (pathname === '/' || pathname === '/index.html') {
339
236
  return await serveAsset(res, ASSETS_DIR, 'index.html');
@@ -341,89 +238,76 @@ async function handleRequest(projectRoot, req, res, clients, boundHost, acceptAn
341
238
  if (pathname.startsWith('/assets/')) {
342
239
  return await serveAsset(res, ASSETS_DIR, pathname.slice('/assets/'.length));
343
240
  }
344
- if (pathname === '/api/overview') {
345
- return sendJson(res, 200, await collectOverview(projectRoot));
346
- }
347
- if (pathname === '/api/archive') {
348
- return sendJson(res, 200, await collectArchive(projectRoot));
349
- }
350
- if (pathname === '/api/decisions') {
351
- return sendJson(res, 200, await collectDecisions(projectRoot));
352
- }
353
- if (pathname === '/api/activity') {
354
- return await handleActivity(projectRoot, url.searchParams.get('change'), url.searchParams.get('days'), res);
355
- }
356
- if (pathname.startsWith('/api/changes/')) {
357
- return await handleChangeDetail(projectRoot, pathname.slice('/api/changes/'.length), res);
358
- }
359
- if (pathname.startsWith('/api/specs/')) {
360
- return await handleSpec(projectRoot, pathname.slice('/api/specs/'.length), res);
361
- }
362
- if (pathname === '/api/render') {
363
- return await handleRender(projectRoot, url.searchParams.get('file'), res);
241
+ const scoped = matchProjectPath(url);
242
+ if (scoped) {
243
+ const answer = await hub.read(scoped.key, scoped.inner);
244
+ return sendJson(res, answer.status, answer.body);
364
245
  }
365
246
  sendJson(res, 404, { error: 'not found' });
366
247
  }
367
248
  catch (err) {
249
+ if (err instanceof HubRequestError)
250
+ return sendJson(res, err.status, { error: err.message });
368
251
  // A single handler failure must never crash the server.
369
252
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
370
253
  }
371
254
  }
372
255
  /**
373
- * Starts the server, advancing past `EADDRINUSE` until a port is free (D6) so
374
- * dashboards for different projects coexist. Side-effect free (no logging,
375
- * signal handlers or registry) so tests can drive it directly.
256
+ * Starts the hub on exactly the requested port. Unlike the per-project servers
257
+ * this replaced, it never advances past `EADDRINUSE`: mods find the hub at a
258
+ * fixed address, so a hub that moved would be one no session could reach.
259
+ * Side-effect free (no logging, signal handlers or registry) so tests can drive
260
+ * it directly.
376
261
  */
377
- export async function startDashboardServer(projectRoot, opts = {}) {
262
+ export async function startDashboardServer(opts = {}) {
378
263
  const host = opts.host ?? DEFAULT_HOST;
379
264
  assertBindableHost(host, 'dashboard', opts.allowRemote, 'Pass --allow-remote to accept that risk.');
380
265
  const acceptAnyHost = acceptsAnyHostHeader(host, opts.allowRemote);
381
266
  const clients = new Set();
267
+ const hub = new DashboardHub({
268
+ // Driving a session from the page runs tools in it, so only a hub that
269
+ // nobody else can reach may do so; `--allow-remote` leaves it read-only.
270
+ interactive: LOOPBACK_HOSTS.has(normalizeHost(host)),
271
+ ...opts.hub,
272
+ });
273
+ const unsubscribe = hub.subscribe((event) => broadcast(clients, event));
382
274
  const server = http.createServer((req, res) => {
383
- void handleRequest(projectRoot, req, res, clients, host, acceptAnyHost);
275
+ void handleRequest(hub, req, res, clients, host, acceptAnyHost);
384
276
  });
385
- const actualPort = await listenFrom(server, opts.port ?? DEFAULT_PORT, host);
386
- const watcher = watchTospec(projectRoot, () => broadcastChange(clients));
387
- server.once('close', () => watcher?.close());
388
- return { server, url: connectableUrl(host, actualPort), port: actualPort };
389
- }
390
- /**
391
- * Watches `<root>/tospec` recursively and calls `onChange` (debounced), or null
392
- * if the directory can't be watched. `fs.watch` uses OS change notifications,
393
- * so it never locks the watched files — agents and users stay free to edit or
394
- * delete them (D2). Recursive watch is native on win32/macOS, Node 20+ on Linux.
395
- */
396
- function watchTospec(projectRoot, onChange) {
397
- const dir = path.join(projectRoot, 'tospec');
398
- let timer = null;
277
+ const requestedPort = opts.port ?? DEFAULT_PORT;
399
278
  try {
400
- const watcher = watch(dir, { recursive: true }, () => {
401
- if (timer)
402
- clearTimeout(timer);
403
- timer = setTimeout(onChange, 200);
404
- });
405
- watcher.on('close', () => timer && clearTimeout(timer));
406
- watcher.on('error', () => { }); // transient watch errors must not crash the server
407
- return watcher;
279
+ await listenOnce(server, requestedPort, host);
408
280
  }
409
- catch {
410
- return null;
281
+ catch (err) {
282
+ if (err.code === 'EADDRINUSE') {
283
+ throw new Error(`Port ${requestedPort} is already in use. Another dashboard may be running: see tospec dashboard status.`);
284
+ }
285
+ throw err;
411
286
  }
287
+ const address = server.address();
288
+ const port = typeof address === 'object' && address ? address.port : requestedPort;
289
+ const sweep = setInterval(() => hub.sweep(), SWEEP_INTERVAL_MS);
290
+ sweep.unref();
291
+ const close = async () => {
292
+ clearInterval(sweep);
293
+ unsubscribe();
294
+ hub.close();
295
+ // Held polls and SSE streams would otherwise keep close() from draining.
296
+ server.closeAllConnections?.();
297
+ await new Promise((resolve) => server.close(() => resolve()));
298
+ };
299
+ return { server, url: connectableUrl(host, port), port, hub, close };
412
300
  }
413
- /**
414
- * PID-file location, keyed by project root so `--detach` and `--stop` derive the
415
- * same path. In the OS temp dir, not the repo, so it never gets committed.
416
- */
417
- export function pidFilePath(projectRoot) {
418
- const hash = crypto.createHash('sha1').update(path.resolve(projectRoot)).digest('hex').slice(0, 12);
419
- return path.join(os.tmpdir(), `tospec-dashboard-${hash}.json`);
301
+ function registryDir() {
302
+ // Derived from the one config resolver, so relocating the config directory
303
+ // moves the registry with it.
304
+ return path.join(getGlobalConfigDir(), 'dashboards');
420
305
  }
421
306
  /** True if a process with this PID exists (signal 0 probes without killing). */
422
307
  function isProcessAlive(pid) {
423
- // Every record's pid arrives here, so the sign check guards the module: on
424
- // POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
425
- // named one, so a corrupt record in the shared temp dir would make `--stop`
426
- // terminate the user's own shell.
308
+ // On POSIX `kill(0, sig)` signals our own process group and `kill(-n, sig)` a
309
+ // named one, so a corrupt marker would make `stop` terminate the user's own
310
+ // shell.
427
311
  if (!Number.isInteger(pid) || pid <= 0)
428
312
  return false;
429
313
  try {
@@ -435,67 +319,6 @@ function isProcessAlive(pid) {
435
319
  return err.code === 'EPERM';
436
320
  }
437
321
  }
438
- /**
439
- * This project's dashboard PID record, or null if absent or corrupt. `pid` is
440
- * the only required field — a truncated record still names a process to reap.
441
- */
442
- async function readPidRecord(pidFile) {
443
- try {
444
- const rec = JSON.parse(await fs.readFile(pidFile, 'utf-8'));
445
- return typeof rec?.pid === 'number' ? rec : null;
446
- }
447
- catch {
448
- return null;
449
- }
450
- }
451
- /**
452
- * Records this dashboard so `--stop` can find it. Reads tolerate the missing
453
- * optional fields of pre-0.13 records; writes do not, since the root is what
454
- * attributes a scanned record to its project.
455
- */
456
- async function writePidRecord(pidFile, rec) {
457
- await fs.writeFile(pidFile, JSON.stringify(rec), 'utf-8');
458
- }
459
- /**
460
- * Removes a pid record only if it still names `pid`: the path is keyed by
461
- * project, not process, so two sibling dashboards share this file and either
462
- * clearer would otherwise delete the other's record.
463
- *
464
- * Limitation: one record per project, so only the later writer is tracked by
465
- * pid file; both stay reachable through their registry markers.
466
- *
467
- * Sync so it completes before the `process.exit` its signal-handler caller
468
- * races — hence not reusing the async `readPidRecord`.
469
- */
470
- export function clearPidRecordOwnedBy(pidFile, pid) {
471
- try {
472
- const rec = JSON.parse(readFileSync(pidFile, 'utf-8'));
473
- if (rec?.pid === pid)
474
- unlinkSync(pidFile);
475
- }
476
- catch {
477
- // Absent: nothing to do. Corrupt: left alone — it names no process, and a
478
- // half-written record may belong to one that is starting up.
479
- }
480
- }
481
- /** Every project's pid file, not just this one's — they all live in the temp dir. */
482
- async function pidFilePaths() {
483
- const dir = os.tmpdir();
484
- try {
485
- return (await fs.readdir(dir))
486
- // Exactly the shape pidFilePath() generates — never wider than we write.
487
- .filter((name) => /^tospec-dashboard-[0-9a-f]{12}\.json$/.test(name))
488
- .map((name) => path.join(dir, name));
489
- }
490
- catch {
491
- return [];
492
- }
493
- }
494
- function registryDir() {
495
- // Derived from the one config resolver, so relocating the config directory
496
- // moves the registry with it.
497
- return path.join(getGlobalConfigDir(), 'dashboards');
498
- }
499
322
  /**
500
323
  * Lists live dashboards sorted by port, reaping any marker whose process has
501
324
  * died. A missing or unreadable dir means no dashboards are running.
@@ -515,6 +338,7 @@ export async function listDashboards() {
515
338
  if (!m)
516
339
  continue;
517
340
  const pid = Number(m[1]);
341
+ const port = Number(m[2]);
518
342
  const file = path.join(dir, name);
519
343
  if (!isProcessAlive(pid)) {
520
344
  // Best-effort: failing to unlink one stale marker must not take down a
@@ -522,112 +346,27 @@ export async function listDashboards() {
522
346
  await fs.rm(file, { force: true }).catch(() => { });
523
347
  continue;
524
348
  }
525
- let projectRoot = '';
349
+ let url = '';
526
350
  try {
527
- projectRoot = (await fs.readFile(file, 'utf-8')).trim();
351
+ url = (await fs.readFile(file, 'utf-8')).trim();
528
352
  }
529
353
  catch {
530
- /* marker without content (pre-upgrade) → leave path blank */
354
+ /* unreadable marker → fall back to loopback below */
531
355
  }
532
- running.push({ pid, port: Number(m[2]), projectRoot });
356
+ // A marker from before the hub held a project root, not a URL.
357
+ if (!/^https?:\/\//.test(url))
358
+ url = connectableUrl(DEFAULT_HOST, port);
359
+ running.push({ pid, port, url });
533
360
  }
534
361
  return running.sort((a, b) => a.port - b.port);
535
362
  }
536
- /**
537
- * One line describing a running dashboard, for `--list` and `--stop`'s picker.
538
- * The URL assumes loopback — `RunningDashboard` doesn't record the bound host.
539
- */
540
- export function formatRunningDashboard(d, sep = '\t') {
541
- const url = `http://${DEFAULT_HOST}:${d.port}`;
542
- return `pid ${d.pid}${sep}${url}${sep}${d.projectRoot || '(unknown project)'}`;
543
- }
544
- /**
545
- * `--stop` found dashboards but cannot ask which to stop (no terminal). Carries
546
- * its own diagnostic so the shared failure sink renders it.
547
- */
548
- export class DashboardStopError extends Error {
549
- diagnostic;
550
- constructor(message, fix) {
551
- super(message);
552
- this.name = 'DashboardStopError';
553
- this.diagnostic = { severity: 'error', code: 'dashboard_stop_needs_selection', message, fix };
554
- }
363
+ /** One line describing a running dashboard, for `start`'s refusal. */
364
+ export function formatRunningDashboard(d) {
365
+ return `pid ${d.pid} ${d.url}`;
555
366
  }
556
- /**
557
- * The detached child neither reported itself listening nor exited. It may still
558
- * be coming up, so the message reports what was not observed rather than
559
- * claiming failure.
560
- */
561
- export class DashboardReadinessTimeoutError extends Error {
562
- diagnostic;
563
- constructor(timeoutMs) {
564
- const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
565
- 'It was started and has not exited, so it may still be coming up.';
566
- super(message);
567
- this.name = 'DashboardReadinessTimeoutError';
568
- this.diagnostic = {
569
- severity: 'error',
570
- code: 'dashboard_detach_readiness_timeout',
571
- message,
572
- fix: 'Check whether it came up with: tospec dashboard --list (then tospec dashboard --stop to stop it).',
573
- };
574
- }
575
- }
576
- /**
577
- * Every dashboard `--stop` can reach: the registry unioned with every project's
578
- * pid file, keyed by pid, reaping dead records along the way.
579
- *
580
- * The registry alone is not enough — `listDashboards()` reaps any marker whose
581
- * pid looks dead, so a live server can lose its marker and become unstoppable
582
- * while its pid file still names it.
583
- *
584
- * `ownRootHint` names records written before pid files carried their own root;
585
- * it never restricts the result.
586
- */
587
- export async function collectStoppableDashboards(ownRootHint) {
588
- const byPid = new Map();
589
- for (const d of await listDashboards())
590
- byPid.set(d.pid, d);
591
- const ownPidFile = ownRootHint ? pidFilePath(ownRootHint) : null;
592
- for (const file of await pidFilePaths()) {
593
- const rec = await readPidRecord(file);
594
- if (!rec)
595
- continue;
596
- // Reap like the marker reaper does: a crashed dashboard's record would
597
- // otherwise linger until the OS recycled its pid, then read as alive again —
598
- // a phantom `--stop` would kill an unrelated process for. Unlink is
599
- // best-effort: the POSIX temp dir is shared with other users.
600
- if (!isProcessAlive(rec.pid)) {
601
- await fs.rm(file, { force: true }).catch(() => { });
602
- continue;
603
- }
604
- // Live, but we cannot name a port to report or reach it by.
605
- if (typeof rec.port !== 'number')
606
- continue;
607
- const root = rec.projectRoot ?? (file === ownPidFile && ownRootHint ? path.resolve(ownRootHint) : '');
608
- const known = byPid.get(rec.pid);
609
- if (!known) {
610
- byPid.set(rec.pid, { pid: rec.pid, port: rec.port, projectRoot: root });
611
- continue;
612
- }
613
- // A content-less registry marker never matches its project; the pid file may
614
- // still know the root.
615
- if (!known.projectRoot && root)
616
- byPid.set(rec.pid, { ...known, projectRoot: root });
617
- }
618
- return [...byPid.values()].sort((a, b) => a.port - b.port);
619
- }
620
- /**
621
- * Ports held by live dashboards, reaping dead markers as a side effect. Runs at
622
- * every startup so a crashed dashboard's port frees up.
623
- */
624
- async function reapAndCollectPorts() {
625
- return new Set((await listDashboards()).map((d) => d.port));
626
- }
627
- /** Creates the `{pid}_{port}` marker for this dashboard, holding its project root. */
628
- async function registerDashboard(pid, port, projectRoot) {
367
+ async function registerDashboard(pid, port, url) {
629
368
  await fs.mkdir(registryDir(), { recursive: true });
630
- await fs.writeFile(path.join(registryDir(), `${pid}_${port}`), projectRoot, 'utf-8');
369
+ await fs.writeFile(path.join(registryDir(), `${pid}_${port}`), url, 'utf-8');
631
370
  }
632
371
  /** Removes any marker for this pid (port-agnostic, so callers needn't know it). */
633
372
  async function unregisterDashboard(pid) {
@@ -666,42 +405,45 @@ function unregisterDashboardSync(pid) {
666
405
  }
667
406
  }
668
407
  /**
669
- * Launches the dashboard as a detached background process: re-runs this CLI
670
- * without `--detach` (the child is the actual server), then waits for the
671
- * child's `LISTENING` report, so the URL printed is one the child actually
672
- * bound — including after an EADDRINUSE fallback moved it off the predicted
673
- * port. Order: spawn → wait for the report → write the PID record → print.
674
- *
675
- * Refuses if any live dashboard already serves this project, asking the shared
676
- * candidate set because either record can be the only one left (a foreground
677
- * dashboard writes no pid file; a background one can lose its registry marker).
678
- *
679
- * This process and the child both write the PID record, with identical fields.
680
- * The child's is all that remains if this process dies mid-handshake; this one
681
- * makes a record exist by the time the call returns, since the child writes only
682
- * after signalling.
408
+ * The detached child neither reported itself listening nor exited. It may still
409
+ * be coming up, so the message reports what was not observed rather than
410
+ * claiming failure.
411
+ */
412
+ export class DashboardReadinessTimeoutError extends Error {
413
+ diagnostic;
414
+ constructor(timeoutMs) {
415
+ const message = `The background dashboard process did not report that it is listening within ${Math.round(timeoutMs / 1000)}s. ` +
416
+ 'It was started and has not exited, so it may still be coming up.';
417
+ super(message);
418
+ this.name = 'DashboardReadinessTimeoutError';
419
+ this.diagnostic = {
420
+ severity: 'error',
421
+ code: 'dashboard_detach_readiness_timeout',
422
+ message,
423
+ fix: 'Check whether it came up with: tospec dashboard status (then tospec dashboard stop to stop it).',
424
+ };
425
+ }
426
+ }
427
+ /**
428
+ * `tospec dashboard start`: launches the hub as a detached background process
429
+ * by re-running this CLI as the hidden `dashboard serve` (the child is the
430
+ * actual server), then waits for the child's `LISTENING` report, so the URL
431
+ * printed is one the child actually bound.
683
432
  *
684
- * This is the only guarded start path: nothing stops a second foreground
685
- * `tospec dashboard`, so "one dashboard per project" is not machine-wide.
433
+ * Refuses while any hub is running: one hub serves every project, and a second
434
+ * one could only bind another port that no mod looks at. Limitation: no
435
+ * cross-process lock, so two simultaneous `start` could both pass the guard;
436
+ * the loser then fails on the port.
686
437
  */
687
- export async function detachDashboard(projectRoot, cliEntry, opts) {
688
- const pidFile = pidFilePath(projectRoot);
689
- const running = shouldRefuseDetach(await collectStoppableDashboards(projectRoot), projectRoot);
438
+ export async function detachDashboard(cliEntry, opts) {
439
+ const running = (await listDashboards())[0];
690
440
  if (running) {
691
- // Same fields and separator as `--list` and `--stop`'s picker.
692
- console.log('Dashboard already running for this project.');
693
- console.log(` ${formatRunningDashboard(running, ' ')}`);
694
- console.log('Stop it with: tospec dashboard --stop');
441
+ console.log('Dashboard already running.');
442
+ console.log(` ${formatRunningDashboard(running)}`);
443
+ console.log('Stop it with: tospec dashboard stop');
695
444
  return;
696
445
  }
697
- // Predict the port to hand the child, skipping ones live dashboards hold. The
698
- // child re-checks and binds, so a race for this port just shifts it upward;
699
- // what gets reported is the URL the child confirms, never this prediction.
700
- const used = await reapAndCollectPorts();
701
- let port = opts.port;
702
- while (used.has(port))
703
- port++;
704
- const args = [cliEntry, 'dashboard', opts.targetPath, '--port', String(port), '--host', opts.host];
446
+ const args = [cliEntry, 'dashboard', 'serve', '--port', String(opts.port), '--host', opts.host];
705
447
  if (opts.open)
706
448
  args.push('--open');
707
449
  if (opts.allowRemote)
@@ -713,15 +455,13 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
713
455
  stdio: ['ignore', 'ignore', 'pipe'],
714
456
  // On Windows, detached: true otherwise pops a new console window.
715
457
  windowsHide: true,
716
- env: { ...process.env, TOSPEC_DASHBOARD_PIDFILE: pidFile },
458
+ env: { ...process.env, [DETACHED_ENV]: '1' },
717
459
  });
718
- // No pid means the spawn itself failed; reporting success would leave a record
719
- // `--stop` could never use.
460
+ // No pid means the spawn itself failed; reporting success would point at a
461
+ // process `--stop` could never reach.
720
462
  if (child.pid === undefined) {
721
463
  throw new Error('Could not start the background dashboard process.');
722
464
  }
723
- // Everything that can go wrong after a successful spawn happens inside the
724
- // child and is only observable through this handshake.
725
465
  let confirmedUrl;
726
466
  try {
727
467
  confirmedUrl = await waitForListening(child);
@@ -731,31 +471,14 @@ export async function detachDashboard(projectRoot, cliEntry, opts) {
731
471
  // un-unref'd handle would keep this process's event loop alive.
732
472
  child.unref();
733
473
  }
734
- // Derived from the URL the child reported, so this record and the child's own
735
- // agree whichever lands last. Limitation: no cross-process lock, so two
736
- // simultaneous --detach could both pass the guard.
737
- await writePidRecord(pidFile, {
738
- pid: child.pid,
739
- port: portFromUrl(confirmedUrl) ?? port,
740
- url: confirmedUrl,
741
- projectRoot: path.resolve(projectRoot),
742
- });
743
474
  console.log(`Dashboard starting in background at ${confirmedUrl}`);
744
- console.log('Stop it with: tospec dashboard --stop');
745
- }
746
- /** `http://host:5621` -> `5621`, or null if the URL carries no parseable port. */
747
- function portFromUrl(url) {
748
- const match = /:(\d+)$/.exec(url);
749
- return match ? Number(match[1]) : null;
475
+ console.log('Stop it with: tospec dashboard stop');
750
476
  }
751
477
  /**
752
478
  * Races the child's outcome: resolves with the confirmed URL on
753
479
  * `LISTENING <url>`, rejects with its captured stderr if it exits first, and
754
480
  * rejects on timeout if it does neither. Closes the stderr pipe once settled so
755
481
  * our end cannot keep this process's event loop alive.
756
- *
757
- * The timeout is a real third outcome: a child that binds then wedges emits
758
- * neither event, and `--detach` would hang with no output.
759
482
  */
760
483
  function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
761
484
  return new Promise((resolve, reject) => {
@@ -804,44 +527,13 @@ function waitForListening(child, timeoutMs = READINESS_TIMEOUT_MS) {
804
527
  }, timeoutMs);
805
528
  });
806
529
  }
807
- /** Case-correct path equality (the Windows filesystem is case-insensitive). */
808
- export function samePath(a, b) {
809
- const na = path.resolve(a);
810
- const nb = path.resolve(b);
811
- return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
812
- }
813
- /**
814
- * The dashboards `--stop` may terminate without asking. Empty is the caller's
815
- * cue to offer the pick-from-all list. A candidate with no recorded project
816
- * never matches: `path.resolve('')` is the cwd, so a blank root would claim it.
817
- */
818
- export function selectStopTargets(candidates, projectRoot) {
819
- if (!projectRoot)
820
- return [];
821
- return candidates.filter((d) => d.projectRoot && samePath(d.projectRoot, projectRoot));
822
- }
823
- /**
824
- * The dashboard already serving this project, or null to go ahead and start one.
825
- * One dashboard per project, foreground or background — see
826
- * `tospec/decisions/20260727_232950-one-dashboard-per-project.md`.
827
- *
828
- * Deliberately the same question `--stop` asks, over the same candidate set, so
829
- * the two can never disagree about what is running.
830
- */
831
- export function shouldRefuseDetach(candidates, projectRoot) {
832
- // First match only: one is enough to refuse and point at.
833
- return selectStopTargets(candidates, projectRoot)[0] ?? null;
834
- }
835
530
  /**
836
- * Terminates a running dashboard and clears its registry marker, plus the
837
- * per-project pid file if that still names this pid. A dead pid counts as
838
- * already stopped.
839
- *
840
- * Returns null on success, or the reason the signal was refused — the POSIX temp
841
- * dir is shared, so a candidate can belong to another user.
531
+ * Terminates a running dashboard and clears its registry marker. A dead pid
532
+ * counts as already stopped. Returns null on success, or the reason the signal
533
+ * was refused.
842
534
  *
843
535
  * Limitation: SIGTERM; Windows maps it to TerminateProcess (hard kill), so the
844
- * target's own cleanup won't run — we clear its files here.
536
+ * target's own cleanup won't run — we clear its marker here.
845
537
  */
846
538
  export async function stopRunningDashboard(d) {
847
539
  try {
@@ -849,71 +541,145 @@ export async function stopRunningDashboard(d) {
849
541
  }
850
542
  catch (err) {
851
543
  // ESRCH means it exited between listing and now: already stopped, so still
852
- // clear its records. Anything else (EPERM) is a real refusal.
544
+ // clear its marker. Anything else (EPERM) is a real refusal.
853
545
  const { code, message } = err;
854
546
  if (code !== 'ESRCH')
855
547
  return code ?? message;
856
548
  }
857
549
  await unregisterDashboard(d.pid);
858
- // Only this dashboard's record: the path is per-project, so stopping one of
859
- // two siblings must not clear the other's.
860
- if (d.projectRoot)
861
- clearPidRecordOwnedBy(pidFilePath(d.projectRoot), d.pid);
862
550
  return null;
863
551
  }
864
552
  /**
865
- * CLI entry: allocates a free port, starts the server, prints the URL, and stays
866
- * up until terminated. Port allocation reaps dead registry markers and skips
867
- * ports live dashboards hold; the bind then advances past any the registry
868
- * didn't know about.
553
+ * `tospec dashboard stop`: stops every hub, not a pick. One serves all
554
+ * projects, so there is no "this project's dashboard" left to choose.
555
+ */
556
+ export async function dashboardStopCommand() {
557
+ const running = await listDashboards();
558
+ if (running.length === 0) {
559
+ console.log('No dashboards are running.');
560
+ return;
561
+ }
562
+ for (const d of running) {
563
+ const refusal = await stopRunningDashboard(d);
564
+ if (refusal === null) {
565
+ console.log(`Stopped dashboard (pid ${d.pid}) at ${d.url}.`);
566
+ }
567
+ else {
568
+ console.error(`Could not stop dashboard (pid ${d.pid}) at ${d.url}: ${refusal}.`);
569
+ process.exitCode = 1;
570
+ }
571
+ }
572
+ }
573
+ /**
574
+ * Asks a running hub which projects it serves. Sessions live only in the hub's
575
+ * memory, so the registry marker can say a hub is running but not whom it
576
+ * serves.
577
+ */
578
+ export async function fetchHubProjects(d, timeoutMs = STATUS_TIMEOUT_MS) {
579
+ const res = await fetch(`${d.url}/api/projects`, { signal: AbortSignal.timeout(timeoutMs) });
580
+ if (!res.ok)
581
+ throw new Error(`it answered HTTP ${res.status}`);
582
+ const body = (await res.json());
583
+ if (!Array.isArray(body.projects))
584
+ throw new Error('its answer had no project list');
585
+ return body.projects;
586
+ }
587
+ function plural(n, noun) {
588
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
589
+ }
590
+ /**
591
+ * The `status` block for one hub. Projects with no live session stay listed as
592
+ * offline: the hub still serves their last snapshot.
593
+ */
594
+ export function formatDashboardStatus(d, projects) {
595
+ const sessionCount = projects.reduce((sum, p) => sum + p.sessions.length, 0);
596
+ const lines = [`Dashboard running at ${d.url} (pid ${d.pid}).`, `Sessions: ${sessionCount}`];
597
+ if (projects.length === 0) {
598
+ lines.push('Projects: none');
599
+ return lines;
600
+ }
601
+ lines.push(`Projects: ${projects.length}`);
602
+ const counts = projects.map((p) => (p.sessions.length === 0 ? 'offline' : plural(p.sessions.length, 'session')));
603
+ const nameWidth = Math.max(...projects.map((p) => p.name.length));
604
+ const countWidth = Math.max(...counts.map((c) => c.length));
605
+ projects.forEach((p, i) => {
606
+ // Root last: two projects can share a directory name, and the root is what
607
+ // tells them apart.
608
+ lines.push(` ${p.name.padEnd(nameWidth)} ${counts[i].padEnd(countWidth)} ${p.root}`);
609
+ });
610
+ return lines;
611
+ }
612
+ /** `tospec dashboard status`: whether a hub runs, and the sessions it serves. */
613
+ export async function dashboardStatusCommand() {
614
+ const running = await listDashboards();
615
+ if (running.length === 0) {
616
+ console.log('Dashboard is not running.');
617
+ console.log('Start it with: tospec dashboard start');
618
+ return;
619
+ }
620
+ for (const [i, d] of running.entries()) {
621
+ if (i > 0)
622
+ console.log('');
623
+ let projects;
624
+ try {
625
+ projects = await fetchHubProjects(d);
626
+ }
627
+ catch (err) {
628
+ // The process is alive but not answering: still binding, wedged, or a
629
+ // reused pid that is not a hub at all. Saying "running" would claim the
630
+ // one thing this could not confirm.
631
+ const reason = err instanceof Error ? err.message : String(err);
632
+ console.error(`Error: Dashboard process ${d.pid} is registered at ${d.url} but did not answer: ${reason}.`);
633
+ console.error('Fix: Retry in a moment; if it still does not answer, run tospec dashboard stop, then tospec dashboard start.');
634
+ process.exitCode = 1;
635
+ continue;
636
+ }
637
+ for (const line of formatDashboardStatus(d, projects))
638
+ console.log(line);
639
+ }
640
+ }
641
+ /**
642
+ * `tospec dashboard serve` (hidden), the process `start` spawns: starts the
643
+ * hub, records it, prints the URL, and stays up until terminated.
869
644
  */
870
- export async function runDashboard(projectRoot, opts = {}) {
871
- const used = await reapAndCollectPorts();
872
- let start = opts.port ?? DEFAULT_PORT;
873
- while (used.has(start))
874
- start++;
875
- const { server, url, port } = await startDashboardServer(projectRoot, { ...opts, port: start });
645
+ export async function runDashboard(opts = {}) {
646
+ const started = await startDashboardServer(opts);
647
+ const { url, port } = started;
648
+ // Recorded before the readiness line, so a `status` right after `start`
649
+ // returns already sees it.
650
+ try {
651
+ await registerDashboard(process.pid, port, url);
652
+ }
653
+ catch (err) {
654
+ await started.close();
655
+ throw err;
656
+ }
876
657
  console.log(`Dashboard running at ${url} (Ctrl+C to stop)`);
658
+ console.log('Claude Code sessions in tospec projects register through the tospec-dashboard mod.');
877
659
  // Detached child only: `detachDashboard` waits on stderr for exactly this line
878
- // before it reports anything. A foreground run has no parent watching.
879
- if (process.env.TOSPEC_DASHBOARD_PIDFILE) {
660
+ // before it reports anything. A `serve` run by hand has no parent watching.
661
+ if (process.env[DETACHED_ENV]) {
880
662
  // The parent closes its end of this pipe once it reads the line below and
881
- // then exits, so every later stderr write (a config warning on
882
- // /api/activity, a failed --open) hits EPIPE. Unhandled, that error killed
883
- // the background dashboard a few writes in; handled, the writes are dropped.
663
+ // then exits, so every later stderr write hits EPIPE. Unhandled, that error
664
+ // killed the background dashboard a few writes in; handled, the writes are
665
+ // dropped.
884
666
  process.stderr.on('error', () => { });
885
667
  console.error(`${LISTENING_PREFIX}${url}`);
886
668
  }
887
- await registerDashboard(process.pid, port, path.resolve(projectRoot));
888
- // Every dashboard records itself, foreground included: `--detach` can only
889
- // refuse for one that left a record, and a registry marker alone can be
890
- // reaped out from under a live server. The child is handed its parent's path
891
- // so the two cannot disagree about where the file lives.
892
- const pidFile = process.env.TOSPEC_DASHBOARD_PIDFILE ?? pidFilePath(projectRoot);
893
- await writePidRecord(pidFile, {
894
- pid: process.pid,
895
- port,
896
- url,
897
- projectRoot: path.resolve(projectRoot),
898
- });
899
669
  if (opts.open) {
900
670
  openBrowser(url);
901
671
  }
902
- // Normal termination removes this dashboard's own records. Windows hard-kills
903
- // on `--stop`, so there `stopRunningDashboard` clears them instead.
672
+ // Normal termination removes this dashboard's marker. Windows hard-kills on
673
+ // `stop`, so there `stopRunningDashboard` clears it instead.
904
674
  const cleanup = () => {
905
- // SSE keep-alive sockets would otherwise block close() from ever draining.
906
- server.closeAllConnections?.();
907
- // Sync, like clearPidRecordOwnedBy: fired and forgotten, the async
908
- // readdir + rm could lose the race with process.exit, and a marker
909
- // outliving its process reads as alive again once the OS reuses the pid —
910
- // which `--stop` would then kill.
675
+ // Sync: fired and forgotten, the async readdir + rm could lose the race
676
+ // with process.exit, and a marker outliving its process reads as alive
677
+ // again once the OS reuses the pid — which `stop` would then kill.
911
678
  unregisterDashboardSync(process.pid);
912
- clearPidRecordOwnedBy(pidFile, process.pid);
913
- server.close(() => process.exit(0));
679
+ void started.close().then(() => process.exit(0));
914
680
  };
915
681
  process.on('SIGINT', cleanup);
916
682
  process.on('SIGTERM', cleanup);
917
- return server;
683
+ return started;
918
684
  }
919
685
  //# sourceMappingURL=dashboard.js.map