super-backlog 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,7 @@ After installation:
52
52
 
53
53
  ```bash
54
54
  npm run board # open the Backlog.md kanban board
55
+ sbl serve # dashboard server + Backlog browser with live reload
55
56
  sbl dashboard --serve # live Project Dashboard on http://localhost:6428
56
57
  ```
57
58
 
@@ -70,11 +71,10 @@ sbl dashboard --serve # live Project Dashboard on http://localhost:6
70
71
  | `.opencode/skill/<skill>/SKILL.md` (3 glue skills) | skill templates | fingerprint header line |
71
72
  | `.claude/skills/<skill>/SKILL.md` | same templates | fingerprint header line |
72
73
  | `package.json` scripts | `tasks` → `backlog task list`, `board` → `backlog board`, `browser` → `backlog browser`, `dashboard` → `super-backlog dashboard` (never overwrite existing values) | merged, add-only-if-absent |
73
- | `dashboard.html` | generated Project Dashboard | regenerated wholesale |
74
- | `.git/hooks/post-commit` | dashboard freshness block — regenerates `dashboard.html` after commits that touch `backlog/` (default; opt out with `--no-refresh-hook`) | appended marker block |
74
+ | `dashboard.html` | generated Project Dashboard | not installed in user projects; generated on demand by `sbl dashboard` |
75
75
  | `.git/hooks/pre-commit` | integrity guard hook — only with `--guard` (opt-in) | appended marker block |
76
76
 
77
- Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--serve] [--port <n>] [--no-open] [--out <file>]`. See `sbl help` for every flag.
77
+ Commands: `sbl init` · `sbl models` · `sbl doctor` · `sbl uninstall [--with-backlog]` · `sbl update` · `sbl dashboard [--port <n>] [--no-open]` · `sbl serve [--port <n>] [--no-open]`. See `sbl help` for every flag.
78
78
 
79
79
  ## Model router (opt-in)
80
80
 
@@ -98,11 +98,11 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
98
98
 
99
99
  ## Project Dashboard
100
100
 
101
- `sbl dashboard` generates a single self-contained `dashboard.html`: a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table with a click-in detail panel per task), Feature Cycle (pipeline stepper), Activity (30-day sparkline), and Decisions & Docs. A layered dependency graph maps task `depends-on` relations (cycle- and dangling-ref-tolerant): hover highlights edges, click opens the task. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fonts — works offline, diffs cleanly in git, hostable anywhere. Use `--serve` for live mode: it watches `backlog/`, regenerates on change, and serves on port 6428 by default.
101
+ `sbl dashboard` starts a local server that serves a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Activity (30-day sparkline), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fonts — works offline when served locally. The server watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. `sbl serve` is a deprecated alias that behaves identically. `sbl dashboard` also launches the Backlog browser alongside the server so you can edit tasks while the dashboard updates.
102
102
 
103
103
  ### Keeping it fresh
104
104
 
105
- By default `init` installs a `post-commit` hook block that regenerates `dashboard.html` whenever the commit touched `backlog/`. The block is marker-delimited, so it composes with the guard hook and foreign hook content, `uninstall` removes exactly that block, and `update` refreshes it. It never blocks a commit: failures print a one-line stderr note and the exit status stays 0. Opt out with `--no-refresh-hook`.
105
+ Run `sbl dashboard` whenever you want a live view of the board. The server regenerates the dashboard while it runs; stop it with `Ctrl+C`. There is no static `dashboard.html` installed in your project.
106
106
 
107
107
  ![Project Dashboard](docs/assets/dashboard.png)
108
108
 
package/dist/cli.js CHANGED
@@ -3,9 +3,11 @@
3
3
  import { parseArgs } from 'node:util';
4
4
  import process from 'node:process';
5
5
  import { runDashboard } from './commands/dashboard.js';
6
+ import { runBacklogSubcommand } from './commands/backlog-alias.js';
6
7
  import { runDoctor } from './commands/doctor.js';
7
8
  import { runInit } from './commands/init.js';
8
9
  import { runModels } from './commands/models.js';
10
+ import { runServe } from './commands/serve.js';
9
11
  import { runUninstall } from './commands/uninstall.js';
10
12
  import { runUpdate } from './commands/update.js';
11
13
  import { assertNode20, KIT_VERSION } from './lib/version.js';
@@ -17,18 +19,19 @@ Commands:
17
19
  init Install the kit into the current project
18
20
  uninstall Remove kit-managed files (project data kept unless --with-backlog)
19
21
  update Refresh kit-managed files and report upstream versions
20
- dashboard Generate the single-file project dashboard (--serve for live mode)
22
+ dashboard Start the project dashboard server (live-reload + Backlog browser)
23
+ serve Deprecated alias for 'sbl dashboard'
24
+ browser Open the Backlog.md browser (delegates to backlog browser)
25
+ board Show the Backlog.md board (delegates to backlog board)
21
26
  models Manage the model router (show, enable, disable, discover)
22
27
  doctor Check the environment (node, PowerShell policy, backlog CLI)
23
28
 
24
- init options:
29
+ init options:
25
30
  --pm <auto|npm|pnpm|bun|skip> Package manager to use (default: auto)
26
31
  --harness <opencode|claude> Target harness; repeatable or comma-separated (default: both)
27
32
  --guard Install the integrity pre-commit hook (opt-in)
28
33
  --models Install the model router config during init (opt-in)
29
34
  --no-models Explicitly opt out of the model router
30
- --no-dashboard Skip generating the project dashboard
31
- --no-refresh-hook Skip the post-commit dashboard freshness hook
32
35
  --fix-all Repair environment problems automatically (no prompts)
33
36
  --dry-run Show what would be done without writing anything
34
37
 
@@ -39,11 +42,13 @@ uninstall options:
39
42
  update options:
40
43
  (none) Refreshes injected files, skills, hook; prints upstream versions
41
44
 
42
- dashboard options:
43
- --serve Live mode: watch backlog/ and regenerate on changes
44
- --port <n> Port for --serve (default: 6428)
45
- --no-open With --serve: do not open the browser automatically
46
- --out <file> Output file name or path (default: dashboard.html)
45
+ dashboard options:
46
+ --port <n> Port for the dashboard server (default: 6428)
47
+ --no-open Do not open the dashboard browser automatically
48
+
49
+ serve options:
50
+ --port <n> Port for the dashboard server (default: 6428)
51
+ --no-open Do not open the dashboard browser automatically
47
52
 
48
53
  doctor options:
49
54
  (none) Prints one [ok]/[warn]/[skip] line per check; exit 4 on any warn
@@ -75,8 +80,6 @@ async function main(argv) {
75
80
  guard: { type: 'boolean' },
76
81
  models: { type: 'boolean' },
77
82
  'no-models': { type: 'boolean' },
78
- 'no-dashboard': { type: 'boolean' },
79
- 'no-refresh-hook': { type: 'boolean' },
80
83
  'fix-all': { type: 'boolean' },
81
84
  'dry-run': { type: 'boolean' },
82
85
  },
@@ -109,10 +112,8 @@ async function main(argv) {
109
112
  args: rest,
110
113
  allowPositionals: true,
111
114
  options: {
112
- serve: { type: 'boolean' },
113
115
  port: { type: 'string' },
114
116
  'no-open': { type: 'boolean' },
115
- out: { type: 'string' },
116
117
  },
117
118
  });
118
119
  return await runDashboard(process.cwd(), {
@@ -120,6 +121,24 @@ async function main(argv) {
120
121
  positionals: parsed.positionals,
121
122
  });
122
123
  }
124
+ case 'serve': {
125
+ const parsed = parseArgs({
126
+ args: rest,
127
+ allowPositionals: true,
128
+ options: {
129
+ port: { type: 'string' },
130
+ 'no-open': { type: 'boolean' },
131
+ },
132
+ });
133
+ return await runServe(process.cwd(), {
134
+ values: parsed.values,
135
+ positionals: parsed.positionals,
136
+ });
137
+ }
138
+ case 'browser':
139
+ return await runBacklogSubcommand(process.cwd(), 'browser', rest);
140
+ case 'board':
141
+ return await runBacklogSubcommand(process.cwd(), 'board', rest);
123
142
  case 'models': {
124
143
  const parsed = parseArgs({ args: rest, allowPositionals: true, options: {} });
125
144
  return await runModels(process.cwd(), {
@@ -0,0 +1,21 @@
1
+ // src/commands/backlog-alias.ts
2
+ import { spawn } from 'node:child_process';
3
+ import process from 'node:process';
4
+ import { resolveBacklogBin } from '../lib/run.js';
5
+ /** Run a backlog.md subcommand by delegating to the resolved backlog binary. */
6
+ export function runBacklogSubcommand(cwd, subcommand, args = []) {
7
+ const bin = resolveBacklogBin(cwd);
8
+ if (!bin) {
9
+ console.error('error: backlog CLI not found; is backlog.md installed?');
10
+ return Promise.resolve(1);
11
+ }
12
+ return new Promise((resolve) => {
13
+ const child = spawn(bin, [subcommand, ...args], {
14
+ cwd,
15
+ stdio: 'inherit',
16
+ shell: process.platform === 'win32',
17
+ });
18
+ child.on('error', () => resolve(1));
19
+ child.on('exit', (code) => resolve(code ?? 1));
20
+ });
21
+ }
@@ -1,38 +1,41 @@
1
1
  // src/commands/dashboard.ts
2
- import { isAbsolute, join, resolve } from 'node:path';
2
+ import { spawn } from 'node:child_process';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import process from 'node:process';
3
6
  import { collectDashboardData } from '../dashboard/data.js';
4
7
  import { renderDashboard } from '../dashboard/render.js';
5
8
  import { DASHBOARD_PORT, startServeServer } from '../dashboard/server.js';
6
9
  import { atomicWrite } from '../lib/atomic.js';
10
+ import { resolveBacklogBin } from '../lib/run.js';
7
11
  import { KIT_VERSION } from '../lib/version.js';
8
12
  async function regenerateInto(outPath, cwd) {
9
13
  const data = collectDashboardData(cwd, { kitVersion: KIT_VERSION });
10
14
  atomicWrite(outPath, renderDashboard(data));
11
15
  }
12
- /**
13
- * Generate dashboard.html for the project; with `{ serve: true }`, start the
14
- * live-reload server afterwards (the listening socket keeps the process up).
15
- * Called by `sbl init` with serve disabled.
16
- */
17
- export async function generateDashboard(cwd, o) {
18
- const outPath = join(cwd, 'dashboard.html');
19
- await regenerateInto(outPath, cwd);
20
- if (o.serve) {
21
- // Dynamic import keeps init resilient if the serve module is unavailable.
22
- const specifier = '../dashboard/server.js';
23
- const mod = (await import(specifier));
24
- if (typeof mod.startServeServer !== 'function') {
25
- throw new Error('serve module does not export startServeServer');
26
- }
27
- await mod.startServeServer(cwd, {
28
- port: DASHBOARD_PORT,
29
- regenerate: () => regenerateInto(outPath, cwd),
30
- openBrowser: true,
16
+ function spawnBacklogBrowser(cwd) {
17
+ const bin = resolveBacklogBin(cwd);
18
+ if (!bin) {
19
+ console.warn('warning: backlog CLI not found; dashboard will serve without the Backlog browser');
20
+ return;
21
+ }
22
+ try {
23
+ const child = spawn(bin, ['browser', '--no-open', '--non-interactive'], {
24
+ cwd,
25
+ detached: true,
26
+ stdio: 'ignore',
27
+ shell: process.platform === 'win32',
31
28
  });
29
+ child.on('error', () => { });
30
+ child.unref();
31
+ console.log('started Backlog browser (dashboard still serves if browser fails)');
32
+ }
33
+ catch {
34
+ console.warn('warning: failed to start Backlog browser; dashboard still serves');
32
35
  }
33
- return outPath;
34
36
  }
35
- /** CLI entry for `sbl dashboard [--serve] [--port N] [--no-open] [--out FILE]`. */
37
+ /** CLI entry for `sbl dashboard [--port N] [--no-open]`. Starts a local server
38
+ * that watches backlog/ and regenerates a temp dashboard file on changes. */
36
39
  export async function runDashboard(cwd, args) {
37
40
  const values = args.values;
38
41
  let port = DASHBOARD_PORT;
@@ -44,26 +47,24 @@ export async function runDashboard(cwd, args) {
44
47
  }
45
48
  port = parsed;
46
49
  }
47
- const serve = values['serve'] === true;
48
50
  const noOpen = values['no-open'] === true;
49
- const outFile = values['out'] === undefined ? 'dashboard.html' : String(values['out']);
50
- const outPath = isAbsolute(outFile) ? outFile : resolve(cwd, outFile);
51
+ const outPath = join(tmpdir(), `sbl-dashboard-${Date.now()}.html`);
51
52
  try {
52
53
  await regenerateInto(outPath, cwd);
53
54
  console.log(`dashboard written: ${outPath}`);
54
- if (serve) {
55
- console.log(`serving dashboard at http://127.0.0.1:${port}/ (press Ctrl+C to stop)`);
56
- await startServeServer(cwd, {
57
- port,
58
- file: outPath,
59
- regenerate: () => regenerateInto(outPath, cwd),
60
- openBrowser: !noOpen,
61
- });
62
- }
55
+ console.log(`serving dashboard at http://127.0.0.1:${port}/ (press Ctrl+C to stop)`);
56
+ // Start backlog browser in parallel; don't await so the dashboard server can listen immediately.
57
+ spawnBacklogBrowser(cwd);
58
+ await startServeServer(cwd, {
59
+ port,
60
+ file: outPath,
61
+ regenerate: () => regenerateInto(outPath, cwd),
62
+ openBrowser: !noOpen,
63
+ });
63
64
  return 0;
64
65
  }
65
66
  catch (err) {
66
- console.error(`error: dashboard generation failed (${err instanceof Error ? err.message : String(err)})`);
67
+ console.error(`error: dashboard serve failed (${err instanceof Error ? err.message : String(err)})`);
67
68
  return 1;
68
69
  }
69
70
  }
@@ -34,10 +34,6 @@ function describeAction(action) {
34
34
  return 'install-model-router .super-backlog/models.json';
35
35
  case 'install-guard-hook':
36
36
  return 'install-guard-hook .git/hooks/pre-commit';
37
- case 'install-refresh-hook':
38
- return 'install-refresh-hook .git/hooks/post-commit';
39
- case 'generate-dashboard':
40
- return 'generate-dashboard';
41
37
  case 'write':
42
38
  return `write ${action.path}`;
43
39
  }
@@ -96,8 +92,6 @@ export async function runInit(cwd, args, deps = {}) {
96
92
  }
97
93
  const pm = rawPm;
98
94
  const guard = args.values.guard === true; // opt-in per spec D8
99
- const dashboard = args.values['no-dashboard'] !== true;
100
- const refreshHook = args.values['no-refresh-hook'] !== true; // default on, opt-out flag
101
95
  const dryRun = args.values['dry-run'] === true;
102
96
  const models = args.values.models === true ? true : args.values['no-models'] === true ? false : undefined;
103
97
  const projectName = args.positionals[0] ?? basename(resolve(cwd));
@@ -121,7 +115,7 @@ export async function runInit(cwd, args, deps = {}) {
121
115
  opencodeConfig,
122
116
  pkgExists: existsSync(join(cwd, 'package.json')),
123
117
  };
124
- const opts = { projectName, harnesses, pm, guard, dashboard, refreshHook, skipInstall: false, models };
118
+ const opts = { projectName, harnesses, pm, guard, skipInstall: false, models };
125
119
  const plan = planInit(state, opts, KIT_VERSION);
126
120
  if (dryRun) {
127
121
  console.log(`dry-run for "${projectName}": ${plan.actions.length} action(s) planned, nothing written`);
@@ -0,0 +1,3 @@
1
+ // src/commands/serve.ts
2
+ // Deprecated alias: `sbl serve` behaves exactly like `sbl dashboard`.
3
+ export { runDashboard as runServe } from './dashboard.js';
@@ -4,7 +4,7 @@ import { basename, join, resolve } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { executeActions, findGitDir, InvalidJsonError, validateJsonFile, RefusalError, UpstreamError } from '../init/execute.js';
6
6
  import { planInit } from '../init/planner.js';
7
- import { GUARD_RE, REFRESH_RE } from '../lib/hooks.js';
7
+ import { GUARD_RE } from '../lib/hooks.js';
8
8
  import { detectPackageManager } from '../lib/pm.js';
9
9
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
10
10
  import { KIT_VERSION } from '../lib/version.js';
@@ -13,8 +13,6 @@ const REFRESH_KINDS = new Set([
13
13
  'write-claude-pointer',
14
14
  'copy-skills',
15
15
  'install-guard-hook',
16
- 'install-refresh-hook',
17
- 'generate-dashboard',
18
16
  ]);
19
17
  export function refreshActions(all) {
20
18
  return all.filter((action) => REFRESH_KINDS.has(action.kind));
@@ -31,15 +29,6 @@ function guardHookInstalled(cwd) {
31
29
  return false;
32
30
  return GUARD_RE.test(readFileSync(hookPath, 'utf8'));
33
31
  }
34
- function refreshHookInstalled(cwd) {
35
- const gitDir = findGitDir(cwd);
36
- if (!gitDir)
37
- return false;
38
- const hookPath = join(gitDir, 'hooks', 'post-commit');
39
- if (!existsSync(hookPath))
40
- return false;
41
- return REFRESH_RE.test(readFileSync(hookPath, 'utf8'));
42
- }
43
32
  export async function runUpdate(cwd, _args) {
44
33
  // Up-front detection-failure check (mirrors uninstall): refuse before mutating anything.
45
34
  for (const f of ['package.json', 'opencode.json']) {
@@ -72,8 +61,6 @@ export async function runUpdate(cwd, _args) {
72
61
  harnesses: ['opencode', 'claude'],
73
62
  pm: 'auto',
74
63
  guard: guardHookInstalled(cwd),
75
- refreshHook: refreshHookInstalled(cwd),
76
- dashboard: existsSync(join(cwd, 'dashboard.html')),
77
64
  skipInstall: false,
78
65
  };
79
66
  const plan = planInit(state, opts, KIT_VERSION);
@@ -1,5 +1,5 @@
1
1
  // src/dashboard/data.ts
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
4
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
5
5
  import { readSimpleKeys } from '../lib/yamlmini.js';
@@ -159,7 +159,7 @@ export const BUILT_IN_GLOSSARY = [
159
159
  { term: 'Backlog.md', definition: 'File-based task management CLI owning specs, statuses and history under backlog/.' },
160
160
  { term: 'Superpowers', definition: 'The methodology skill set that decides HOW the work is done.' },
161
161
  { term: 'Pipeline', definition: 'The nine workflow phases from Idea to Merge & archive.' },
162
- { term: 'Freshness Hook', definition: 'A post-commit git hook that regenerates dashboard.html when commits touch backlog/.' },
162
+ { term: 'Freshness Hook', definition: 'Run `sbl dashboard` to serve a live dashboard that reloads automatically while the server is running.' },
163
163
  ];
164
164
  /** Split `## Term` headings plus their following non-heading block into entries; empty sections are skipped. */
165
165
  export function parseGlossaryMarkdown(content) {
@@ -216,6 +216,29 @@ function readProjectGlossary(cwd) {
216
216
  return [];
217
217
  }
218
218
  }
219
+ function readDraftFile(path) {
220
+ const keys = readSimpleKeys(path, ['id', 'title', 'status']);
221
+ const id = asString(keys.id);
222
+ const title = asString(keys.title);
223
+ const status = asString(keys.status);
224
+ if (!id || !title || !status)
225
+ return null;
226
+ return { id, title, status };
227
+ }
228
+ export function readDrafts(cwd) {
229
+ const draftsDir = join(cwd, 'backlog', 'drafts');
230
+ if (!existsSync(draftsDir))
231
+ return [];
232
+ const out = [];
233
+ for (const entry of readdirSync(draftsDir, { withFileTypes: true })) {
234
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
235
+ continue;
236
+ const draft = readDraftFile(join(draftsDir, entry.name));
237
+ if (draft)
238
+ out.push(draft);
239
+ }
240
+ return out.sort((a, b) => a.id.localeCompare(b.id));
241
+ }
219
242
  function readProjectIdentity(cwd) {
220
243
  const cfg = readSimpleKeys(join(cwd, 'backlog', 'config.yml'), [
221
244
  'project_name',
@@ -252,6 +275,7 @@ export function collectDashboardData(cwd, opts) {
252
275
  milestones: [],
253
276
  tasks: [],
254
277
  deps: [],
278
+ drafts: readDrafts(cwd),
255
279
  activity: computeActivity([], today),
256
280
  glossary: mergeGlossary(readProjectGlossary(cwd)),
257
281
  source: 'fallback-empty',
@@ -6,7 +6,182 @@ import { createServer } from 'node:http';
6
6
  import { isAbsolute, join } from 'node:path';
7
7
  import process from 'node:process';
8
8
  import { createModelApiHandler } from '../models/dashboard-api.js';
9
+ import { resolveBacklogBin } from '../lib/run.js';
9
10
  export const DASHBOARD_PORT = 6428;
11
+ const WHITELIST = new Map([
12
+ ['browser', ['browser']],
13
+ ['board', ['board']],
14
+ ]);
15
+ function isRecord(v) {
16
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
17
+ }
18
+ async function readBody(req) {
19
+ const chunks = [];
20
+ for await (const chunk of req) {
21
+ chunks.push(Buffer.from(chunk));
22
+ }
23
+ return Buffer.concat(chunks).toString('utf8');
24
+ }
25
+ /** Safe /api/run handler: only whitelisted backlog subcommands may be spawned. */
26
+ export function createRunApiHandler(cwd) {
27
+ return async (req, res) => {
28
+ if (req.url !== '/api/run') {
29
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
30
+ res.end('not found');
31
+ return;
32
+ }
33
+ if (req.method !== 'POST') {
34
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
35
+ res.end('not found');
36
+ return;
37
+ }
38
+ let body;
39
+ try {
40
+ body = await readBody(req);
41
+ }
42
+ catch {
43
+ res.writeHead(400, { 'content-type': 'application/json' });
44
+ res.end(JSON.stringify({ error: 'failed to read body' }));
45
+ return;
46
+ }
47
+ let payload;
48
+ try {
49
+ payload = JSON.parse(body);
50
+ }
51
+ catch {
52
+ res.writeHead(400, { 'content-type': 'application/json' });
53
+ res.end(JSON.stringify({ error: 'invalid json' }));
54
+ return;
55
+ }
56
+ if (!isRecord(payload) || typeof payload.command !== 'string' || !WHITELIST.has(payload.command)) {
57
+ res.writeHead(400, { 'content-type': 'application/json' });
58
+ res.end(JSON.stringify({ error: 'unknown command' }));
59
+ return;
60
+ }
61
+ const bin = resolveBacklogBin(cwd);
62
+ if (!bin) {
63
+ res.writeHead(503, { 'content-type': 'application/json' });
64
+ res.end(JSON.stringify({ error: 'backlog cli not found' }));
65
+ return;
66
+ }
67
+ const args = WHITELIST.get(payload.command);
68
+ try {
69
+ const child = spawn(bin, args, {
70
+ cwd,
71
+ detached: true,
72
+ stdio: 'ignore',
73
+ shell: process.platform === 'win32',
74
+ });
75
+ child.on('error', () => { });
76
+ child.unref();
77
+ res.writeHead(200, { 'content-type': 'application/json' });
78
+ res.end(JSON.stringify({ ok: true }));
79
+ }
80
+ catch (err) {
81
+ res.writeHead(500, { 'content-type': 'application/json' });
82
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
83
+ }
84
+ };
85
+ }
86
+ /** SSE broker: keeps a set of response objects and broadcasts named events. */
87
+ export function createReloadBroker() {
88
+ const clients = new Set();
89
+ let closed = false;
90
+ function handler(req, res) {
91
+ if (req.url !== '/api/events' || req.method !== 'GET')
92
+ return false;
93
+ res.writeHead(200, {
94
+ 'content-type': 'text/event-stream',
95
+ 'cache-control': 'no-cache',
96
+ connection: 'keep-alive',
97
+ });
98
+ res.write(':ok\n\n');
99
+ clients.add(res);
100
+ const cleanup = () => {
101
+ clients.delete(res);
102
+ };
103
+ req.on('close', cleanup);
104
+ req.on('error', cleanup);
105
+ res.on('close', cleanup);
106
+ res.on('error', cleanup);
107
+ return true;
108
+ }
109
+ function broadcast(event) {
110
+ if (closed)
111
+ return;
112
+ // A data line is mandatory: per the HTML standard, EventSource never
113
+ // dispatches an event whose data buffer is empty, so `event: x\n\n`
114
+ // alone would silently never reach addEventListener('x', ...) clients.
115
+ const message = `event: ${event}\ndata: {}\n\n`;
116
+ for (const client of clients) {
117
+ try {
118
+ client.write(message);
119
+ }
120
+ catch {
121
+ clients.delete(client);
122
+ }
123
+ }
124
+ }
125
+ function clientCount() {
126
+ return clients.size;
127
+ }
128
+ function close() {
129
+ if (closed)
130
+ return;
131
+ closed = true;
132
+ for (const client of clients) {
133
+ try {
134
+ client.end();
135
+ }
136
+ catch {
137
+ // ignore
138
+ }
139
+ }
140
+ clients.clear();
141
+ }
142
+ return { handler, broadcast, clientCount, close };
143
+ }
144
+ /** Debounced wrapper around a regenerate callback; on success invokes onReload. */
145
+ export function createDebouncedReloader(regenerate, onReload, delayMs) {
146
+ let timer = null;
147
+ function trigger() {
148
+ if (!regenerate)
149
+ return;
150
+ if (timer !== null)
151
+ clearTimeout(timer);
152
+ timer = setTimeout(() => {
153
+ timer = null;
154
+ void Promise.resolve()
155
+ .then(regenerate)
156
+ .then(() => {
157
+ onReload();
158
+ })
159
+ .catch(() => { });
160
+ }, delayMs);
161
+ }
162
+ function cancel() {
163
+ if (timer !== null) {
164
+ clearTimeout(timer);
165
+ timer = null;
166
+ }
167
+ }
168
+ return { trigger, cancel };
169
+ }
170
+ function createApiHandler(cwd, broker) {
171
+ const modelApi = createModelApiHandler();
172
+ const runApi = createRunApiHandler(cwd);
173
+ return async (req, res) => {
174
+ const url = req.url ?? '/';
175
+ if (url === '/api/run') {
176
+ await runApi(req, res);
177
+ return;
178
+ }
179
+ if (broker.handler(req, res)) {
180
+ return;
181
+ }
182
+ await modelApi(req, res);
183
+ };
184
+ }
10
185
  export function recursiveWatchSupported(platform, nodeVersion) {
11
186
  // Node 24 on Windows triggers a libuv assertion in recursive fs.watch:
12
187
  // https://github.com/nodejs/node/issues/xxx (fs-event.c line 72)
@@ -43,25 +218,14 @@ export async function startServeServer(cwd, opts = {}) {
43
218
  const file = opts.file ?? 'dashboard.html';
44
219
  const filePath = isAbsolute(file) ? file : join(cwd, file);
45
220
  const regenerate = opts.regenerate;
46
- let timer = null;
47
- const debouncedRegenerate = () => {
48
- if (!regenerate)
49
- return;
50
- if (timer !== null)
51
- clearTimeout(timer);
52
- timer = setTimeout(() => {
53
- timer = null;
54
- void Promise.resolve()
55
- .then(regenerate)
56
- .catch(() => { }); // regeneration failures never kill the server
57
- }, 300);
58
- };
221
+ const broker = createReloadBroker();
222
+ const reloader = createDebouncedReloader(regenerate, () => broker.broadcast('reload'), 300);
59
223
  let watcher = null;
60
224
  const backlogDir = join(cwd, 'backlog');
61
225
  if (recursiveWatchSupported(process.platform, process.versions.node)) {
62
226
  try {
63
227
  // recursive so subdirectory writes (e.g. backlog/tasks/*.md) fire on every platform
64
- watcher = watch(backlogDir, { persistent: true, recursive: true }, debouncedRegenerate);
228
+ watcher = watch(backlogDir, { persistent: true, recursive: true }, () => reloader.trigger());
65
229
  watcher.on('error', () => { }); // e.g. watched dir removed mid-session
66
230
  }
67
231
  catch {
@@ -71,10 +235,10 @@ export async function startServeServer(cwd, opts = {}) {
71
235
  else {
72
236
  console.warn('warning: live reload is disabled because Node 24+ on Windows cannot reliably watch directories recursively (libuv fs-event bug); use Node 22 or Linux/macOS for --serve');
73
237
  }
74
- const modelApi = createModelApiHandler();
238
+ const apiHandler = createApiHandler(cwd, broker);
75
239
  const server = createServer((req, res) => {
76
240
  if (req.url?.startsWith('/api/')) {
77
- void modelApi(req, res);
241
+ void apiHandler(req, res);
78
242
  return;
79
243
  }
80
244
  const url = req.url ?? '/';
@@ -114,9 +278,8 @@ export async function startServeServer(cwd, opts = {}) {
114
278
  server,
115
279
  port,
116
280
  close() {
117
- if (timer !== null)
118
- clearTimeout(timer);
119
- timer = null;
281
+ reloader.cancel();
282
+ broker.close();
120
283
  watcher?.close();
121
284
  watcher = null;
122
285
  return new Promise((resolveClose) => {