smol-symphony 0.1.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/dist/http.js ADDED
@@ -0,0 +1,1189 @@
1
+ // HTTP server extension (SPEC §13.7) plus the local-tracker UI for creating issues and
2
+ // watching status. The UI polls `/api/v1/state` so no SSE/WebSocket infrastructure is
3
+ // needed.
4
+ import { createServer } from 'node:http';
5
+ import { mkdir, readdir, readFile, writeFile, stat } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { sanitizeWorkspaceKey } from './workspace.js';
8
+ import { log } from './logging.js';
9
+ function jsonResponse(res, status, body) {
10
+ const text = JSON.stringify(body);
11
+ res.statusCode = status;
12
+ res.setHeader('content-type', 'application/json; charset=utf-8');
13
+ res.setHeader('content-length', Buffer.byteLength(text));
14
+ res.end(text);
15
+ }
16
+ function methodNotAllowed(res) {
17
+ jsonResponse(res, 405, { error: { code: 'method_not_allowed', message: 'method not allowed' } });
18
+ }
19
+ function notFound(res, code = 'not_found', message = 'not found') {
20
+ jsonResponse(res, 404, { error: { code, message } });
21
+ }
22
+ function badRequest(res, message) {
23
+ jsonResponse(res, 400, { error: { code: 'bad_request', message } });
24
+ }
25
+ async function readJsonBody(req, maxBytes = 1_000_000) {
26
+ return new Promise((resolve, reject) => {
27
+ const chunks = [];
28
+ let size = 0;
29
+ req.on('data', (chunk) => {
30
+ size += chunk.length;
31
+ if (size > maxBytes) {
32
+ req.destroy();
33
+ reject(new Error('request body too large'));
34
+ return;
35
+ }
36
+ chunks.push(chunk);
37
+ });
38
+ req.on('end', () => {
39
+ const text = Buffer.concat(chunks).toString('utf8');
40
+ if (text.length === 0)
41
+ return resolve({});
42
+ try {
43
+ resolve(JSON.parse(text));
44
+ }
45
+ catch (err) {
46
+ reject(new Error(`invalid JSON: ${err.message}`));
47
+ }
48
+ });
49
+ req.on('error', reject);
50
+ });
51
+ }
52
+ // Cross-site form POSTs are "simple" CORS requests that bypass preflight, so an
53
+ // unauthenticated endpoint that accepts form-encoded bodies is CSRFable from any
54
+ // origin. Steering reply uses this check (in addition to requiring HX-Request) to
55
+ // reject form bodies whose Origin doesn't match the Host the request hit.
56
+ //
57
+ // We treat the request as same-origin if either:
58
+ // • An Origin header is present and its host:port equals the Host header (the
59
+ // normal browser case), or
60
+ // • There is no Origin header at all AND no Referer header (curl, internal calls,
61
+ // non-browser tools). Browsers always send Origin on cross-origin form POSTs.
62
+ function isSameOriginRequest(req) {
63
+ const host = (req.headers['host'] ?? '').toString().trim();
64
+ const origin = (req.headers['origin'] ?? '').toString().trim();
65
+ if (origin) {
66
+ try {
67
+ const parsed = new URL(origin);
68
+ return parsed.host === host;
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ // No Origin header — only trust if there's no Referer either (a real browser
75
+ // form POST sets at least one).
76
+ return !(req.headers['referer'] && req.headers['referer'].length > 0);
77
+ }
78
+ // HTMX's default response handling does not swap content on 4xx/5xx responses, so
79
+ // returning a 4xx with an HTML partial silently leaves the panel stale. For HTMX
80
+ // callers we therefore return 200 with the attention partial plus a one-line error
81
+ // banner; the operator's textarea text survives via hx-preserve. JSON callers still
82
+ // get the appropriate status code and a structured error.
83
+ async function htmxOrJsonError(res, isHtmx, orch, view, jsonStatus, code, message) {
84
+ if (isHtmx) {
85
+ const p = await gatherPartialInputs(orch, view);
86
+ res.statusCode = 200;
87
+ res.setHeader('content-type', 'text/html; charset=utf-8');
88
+ res.setHeader('cache-control', 'no-store');
89
+ res.end(renderAttentionPartial(p, { errorMessage: message }));
90
+ return;
91
+ }
92
+ jsonResponse(res, jsonStatus, { error: { code, message } });
93
+ }
94
+ async function readTextBody(req, maxBytes = 1_000_000) {
95
+ return new Promise((resolve, reject) => {
96
+ const chunks = [];
97
+ let size = 0;
98
+ req.on('data', (chunk) => {
99
+ size += chunk.length;
100
+ if (size > maxBytes) {
101
+ req.destroy();
102
+ reject(new Error('request body too large'));
103
+ return;
104
+ }
105
+ chunks.push(chunk);
106
+ });
107
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
108
+ req.on('error', reject);
109
+ });
110
+ }
111
+ // Used by the dashboard form. The local tracker stores issues at:
112
+ // <tracker.root>/<state>/<identifier>.md
113
+ // with YAML front matter. Identifier sanitization re-uses the workspace key rules so the
114
+ // file name is always safe across the rest of the orchestrator.
115
+ async function writeIssueFile(input) {
116
+ const ident = sanitizeWorkspaceKey(input.identifier);
117
+ if (!ident)
118
+ throw new Error('identifier must contain at least one allowed character');
119
+ const stateDir = path.join(input.trackerRoot, input.state);
120
+ await mkdir(stateDir, { recursive: true });
121
+ const filePath = path.join(stateDir, `${ident}.md`);
122
+ try {
123
+ await stat(filePath);
124
+ throw new Error(`issue ${ident} already exists at ${filePath}`);
125
+ }
126
+ catch (err) {
127
+ if (err.code !== 'ENOENT')
128
+ throw err;
129
+ }
130
+ const now = new Date().toISOString();
131
+ const fm = {
132
+ id: ident,
133
+ identifier: ident,
134
+ title: input.title,
135
+ created_at: now,
136
+ updated_at: now,
137
+ };
138
+ if (typeof input.priority === 'number')
139
+ fm.priority = input.priority;
140
+ if (input.labels && input.labels.length > 0)
141
+ fm.labels = input.labels;
142
+ if (input.blocked_by && input.blocked_by.length > 0)
143
+ fm.blocked_by = input.blocked_by;
144
+ const yamlLines = ['---'];
145
+ for (const [k, v] of Object.entries(fm)) {
146
+ if (Array.isArray(v)) {
147
+ yamlLines.push(`${k}: [${v.map((x) => JSON.stringify(x)).join(', ')}]`);
148
+ }
149
+ else if (typeof v === 'string') {
150
+ yamlLines.push(`${k}: ${JSON.stringify(v)}`);
151
+ }
152
+ else {
153
+ yamlLines.push(`${k}: ${String(v)}`);
154
+ }
155
+ }
156
+ yamlLines.push('---', '');
157
+ const body = (input.description ?? '').trim();
158
+ const content = yamlLines.join('\n') + (body.length > 0 ? body + '\n' : '');
159
+ await writeFile(filePath, content, 'utf8');
160
+ return { path: filePath, identifier: ident, state: input.state };
161
+ }
162
+ // Browse current issues directly from disk so the UI can show items that are neither
163
+ // currently running nor in the retry queue.
164
+ async function listIssuesFromDisk(trackerRoot) {
165
+ const out = [];
166
+ let entries;
167
+ try {
168
+ entries = await readdir(trackerRoot);
169
+ }
170
+ catch {
171
+ return out;
172
+ }
173
+ for (const stateDir of entries) {
174
+ const dirPath = path.join(trackerRoot, stateDir);
175
+ let st;
176
+ try {
177
+ st = await stat(dirPath);
178
+ }
179
+ catch {
180
+ continue;
181
+ }
182
+ if (!st.isDirectory())
183
+ continue;
184
+ let files;
185
+ try {
186
+ files = await readdir(dirPath);
187
+ }
188
+ catch {
189
+ continue;
190
+ }
191
+ for (const f of files) {
192
+ if (!f.endsWith('.md'))
193
+ continue;
194
+ const filePath = path.join(dirPath, f);
195
+ let text;
196
+ try {
197
+ text = await readFile(filePath, 'utf8');
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ let title = f.slice(0, -3);
203
+ const titleMatch = /^---[\s\S]*?\ntitle:\s*(.+)\n[\s\S]*?---/m.exec(text);
204
+ if (titleMatch) {
205
+ title = titleMatch[1].trim().replace(/^["'](.*)["']$/, '$1');
206
+ }
207
+ out.push({ identifier: f.slice(0, -3), state: stateDir, title });
208
+ }
209
+ }
210
+ out.sort((a, b) => a.identifier.localeCompare(b.identifier));
211
+ return out;
212
+ }
213
+ async function gatherPartialInputs(orch, view) {
214
+ const trackerRoot = view.trackerRoot ?? '(unset)';
215
+ let diskIssues = [];
216
+ if (view.trackerRoot) {
217
+ try {
218
+ diskIssues = await listIssuesFromDisk(view.trackerRoot);
219
+ }
220
+ catch {
221
+ diskIssues = [];
222
+ }
223
+ }
224
+ return {
225
+ workflowName: path.basename(view.workflowPath || 'workflow.md'),
226
+ workflowPath: view.workflowPath || '',
227
+ trackerRoot,
228
+ activeStates: view.activeStates,
229
+ terminalStates: view.terminalStates,
230
+ snapshot: orch.snapshot(),
231
+ diskIssues,
232
+ };
233
+ }
234
+ function trackerStatus(snap) {
235
+ const awaiting = snap.running.some((r) => r.steering_requested);
236
+ if (awaiting || snap.retrying.length > 0)
237
+ return 'attention';
238
+ if (snap.running.length > 0)
239
+ return 'working';
240
+ return 'idle';
241
+ }
242
+ function formatTokens(n) {
243
+ if (n < 1000)
244
+ return String(n);
245
+ if (n < 1_000_000)
246
+ return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
247
+ return `${(n / 1_000_000).toFixed(1)}M`;
248
+ }
249
+ function formatRuntime(seconds) {
250
+ const s = Math.max(0, Math.round(seconds));
251
+ if (s < 60)
252
+ return `${s}s`;
253
+ const m = Math.floor(s / 60);
254
+ if (m < 60)
255
+ return `${m}m`;
256
+ const h = Math.floor(m / 60);
257
+ const rem = m % 60;
258
+ return rem === 0 ? `${h}h` : `${h}h${rem}m`;
259
+ }
260
+ function formatTimeShort(iso) {
261
+ if (!iso)
262
+ return '';
263
+ const d = new Date(iso);
264
+ if (Number.isNaN(d.getTime()))
265
+ return '';
266
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
267
+ }
268
+ function truncate(text, max) {
269
+ if (!text)
270
+ return '';
271
+ const trimmed = text.replace(/\s+/g, ' ').trim();
272
+ return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
273
+ }
274
+ // The header partial returns ONLY the tracker-state badge content. Brand, workflow name,
275
+ // tracker root, and the refresh button live in the static shell and never repoll, so the
276
+ // 2s heartbeat doesn't flash the whole strip.
277
+ function renderHeaderPartial(p) {
278
+ const status = trackerStatus(p.snapshot);
279
+ const statusLabel = status === 'attention' ? 'attention' : status === 'working' ? 'working' : 'idle';
280
+ return `<span class="badge badge-${status}" aria-label="tracker state: ${statusLabel}">${statusLabel}</span>`;
281
+ }
282
+ function renderAttentionPartial(p, opts) {
283
+ const awaiting = p.snapshot.running.filter((r) => r.steering_requested);
284
+ const retrying = p.snapshot.retrying;
285
+ const errorMessage = opts?.errorMessage?.trim() ?? '';
286
+ if (awaiting.length === 0 && retrying.length === 0 && !errorMessage)
287
+ return '';
288
+ const errorBanner = errorMessage
289
+ ? `<div class="steering-error" role="alert">${escapeHtml(errorMessage)}</div>`
290
+ : '';
291
+ const steeringBlocks = awaiting.map((r) => renderSteeringBlock(r)).join('');
292
+ const retryBlocks = retrying.length > 0 ? renderRetryBlock(retrying) : '';
293
+ return `<h2 class="attention-title">attention</h2>
294
+ ${errorBanner}${steeringBlocks}${retryBlocks}`;
295
+ }
296
+ function renderSteeringBlock(r) {
297
+ const question = (r.steering_question ?? '').trim() || '(no question text)';
298
+ const context = (r.steering_context ?? '').trim();
299
+ const issueTitle = (r.issue_title ?? '').trim();
300
+ const issueBody = (r.issue_body ?? '').trim();
301
+ const hasOriginalTask = issueTitle.length > 0 || issueBody.length > 0;
302
+ const hasAnyExtra = hasOriginalTask || context.length > 0;
303
+ // The textarea is given a stable id and hx-preserve="true" so the every-2s repoll of
304
+ // the attention zone doesn't wipe the operator's in-progress reply.
305
+ const textareaId = `reply-${r.issue_identifier}`;
306
+ const summaryLabel = hasOriginalTask && context ? 'original task & agent’s context'
307
+ : hasOriginalTask ? 'original task'
308
+ : 'agent’s context';
309
+ return `<article class="steering" data-identifier="${escapeHtml(r.issue_identifier)}">
310
+ <header class="steering-head">
311
+ <strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
312
+ <span class="pill awaiting">awaiting</span>
313
+ <span class="turn"><span class="dim">turn</span> ${r.turn_count}</span>
314
+ </header>
315
+ <p class="question-primary">${escapeHtml(question)}</p>
316
+ ${hasAnyExtra ? `<details class="steering-task">
317
+ <summary>${escapeHtml(summaryLabel)}</summary>
318
+ <div class="steering-task-body">
319
+ ${hasOriginalTask ? `<div class="steering-task-label">issue</div>
320
+ ${issueTitle ? `<h3 class="issue-title">${escapeHtml(issueTitle)}</h3>` : ''}
321
+ ${issueBody ? `<p class="issue-body">${escapeHtml(issueBody)}</p>` : ''}` : ''}
322
+ ${context ? `<div class="steering-task-label">agent’s context</div>
323
+ <pre class="context">${escapeHtml(context)}</pre>` : ''}
324
+ </div>
325
+ </details>` : ''}
326
+ <form class="reply"
327
+ hx-post="/api/v1/issues/${encodeURIComponent(r.issue_identifier)}/steering-reply"
328
+ hx-target="#attention" hx-swap="morph:innerHTML">
329
+ <textarea id="${escapeHtml(textareaId)}" name="text" required
330
+ placeholder="your reply…"
331
+ aria-label="reply to ${escapeHtml(r.issue_identifier)}"
332
+ hx-preserve="true"></textarea>
333
+ <div class="reply-row">
334
+ <span class="hint dim">enter to send · shift+enter for newline</span>
335
+ <button type="submit" class="ghost">send reply</button>
336
+ </div>
337
+ </form>
338
+ </article>`;
339
+ }
340
+ function renderRetryBlock(rows) {
341
+ const items = rows.map((r) => `<li>
342
+ <strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
343
+ <span class="pill retrying">retrying</span>
344
+ <span class="dim">attempt ${r.attempt}</span>
345
+ <span class="dim">due ${escapeHtml(formatTimeShort(r.due_at) || '—')}</span>
346
+ ${r.error ? `<div class="retry-err">${escapeHtml(truncate(r.error, 200))}</div>` : ''}
347
+ </li>`).join('');
348
+ return `<ul class="retry-list" aria-label="retry queue">${items}</ul>`;
349
+ }
350
+ function renderSessionsPartial(p) {
351
+ const rows = p.snapshot.running;
352
+ if (rows.length === 0) {
353
+ return `<h2>sessions</h2>
354
+ <p class="empty dim">no sessions running. agents wake when an issue lands in <code>${escapeHtml(p.activeStates[0] ?? 'Todo')}/</code>.</p>`;
355
+ }
356
+ const sessionItems = rows.map((r) => renderSessionRow(r)).join('');
357
+ return `<h2>sessions <span class="count dim">(${rows.length})</span></h2>
358
+ <ul class="sessions">${sessionItems}</ul>`;
359
+ }
360
+ function renderSessionRow(r) {
361
+ const awaiting = r.steering_requested;
362
+ const pill = awaiting
363
+ ? '<span class="pill awaiting">awaiting</span>'
364
+ : r.marked_done
365
+ ? '<span class="pill done">done</span>'
366
+ : '<span class="pill running">running</span>';
367
+ const tokens = r.tokens.total_tokens || 0;
368
+ const sessionId = r.session_id ? r.session_id.slice(0, 8) : '—';
369
+ const lastMsg = truncate(r.last_message ?? r.last_event ?? '', 140);
370
+ // The session row is two lines max: identifier + pill + turn + tokens + time on top,
371
+ // last-message on bottom. Session id / state / last-event detail is surfaced via the
372
+ // row's title attr so it's available on hover without adding a third line of noise.
373
+ const rowTitle = [
374
+ `session ${sessionId}`,
375
+ r.state,
376
+ r.last_event ?? null,
377
+ `started ${formatTimeShort(r.started_at) || '—'}`,
378
+ ]
379
+ .filter(Boolean)
380
+ .join(' · ');
381
+ return `<li class="session" title="${escapeHtml(rowTitle)}">
382
+ <div class="session-row">
383
+ <strong class="ident">${escapeHtml(r.issue_identifier)}</strong>
384
+ ${pill}
385
+ <span class="turn"><span class="dim">turn</span> ${r.turn_count}</span>
386
+ <span class="grow"></span>
387
+ <span class="tokens dim">${formatTokens(tokens)} tok</span>
388
+ </div>
389
+ ${lastMsg ? `<div class="session-msg dim">${escapeHtml(lastMsg)}</div>` : ''}
390
+ </li>`;
391
+ }
392
+ function renderDiskPartial(p) {
393
+ const runIds = new Set(p.snapshot.running.map((r) => r.issue_identifier));
394
+ const retryIds = new Set(p.snapshot.retrying.map((r) => r.issue_identifier));
395
+ const active = new Set(p.activeStates);
396
+ const filtered = p.diskIssues.filter((i) => active.has(i.state) && !runIds.has(i.identifier) && !retryIds.has(i.identifier));
397
+ if (filtered.length === 0) {
398
+ return `<h2>on disk</h2>
399
+ <p class="empty dim">tracker is clean. drop a markdown file into <code>${escapeHtml(p.activeStates[0] ?? 'Todo')}/</code> or open <em>new issue</em> below.</p>`;
400
+ }
401
+ const items = filtered.map((i) => `<li>
402
+ <span class="ident">${escapeHtml(i.identifier)}</span>
403
+ <span class="state dim">${escapeHtml(i.state)}</span>
404
+ <span class="title">${escapeHtml(i.title)}</span>
405
+ </li>`).join('');
406
+ return `<h2>on disk <span class="count dim">(${filtered.length})</span></h2>
407
+ <ul class="disk">${items}</ul>`;
408
+ }
409
+ function renderTotalsPartial(p) {
410
+ const t = p.snapshot.codex_totals;
411
+ if (!t || (t.input_tokens === 0 && t.output_tokens === 0 && t.seconds_running === 0))
412
+ return '';
413
+ return `${formatTokens(t.input_tokens)} in · ${formatTokens(t.output_tokens)} out · ${formatTokens(t.total_tokens)} total · ${formatRuntime(t.seconds_running)} runtime`;
414
+ }
415
+ function renderDashboardHtml(p) {
416
+ const allStates = Array.from(new Set([...p.activeStates, ...p.terminalStates]));
417
+ const stateOptions = allStates
418
+ .map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`)
419
+ .join('');
420
+ const diskCount = p.diskIssues.filter((i) => p.activeStates.includes(i.state)).length;
421
+ const formOpen = diskCount === 0 && p.snapshot.running.length === 0 && p.snapshot.retrying.length === 0;
422
+ return `<!doctype html>
423
+ <html lang="en"><head>
424
+ <meta charset="utf-8">
425
+ <meta name="viewport" content="width=device-width, initial-scale=1">
426
+ <title>symphony · ${escapeHtml(p.workflowName)}</title>
427
+ <style>
428
+ :root {
429
+ color-scheme: dark;
430
+ --inset: #0c0f15; --bench: #0f1115; --raised: #161a22; --chip: #20242c;
431
+ --rule-soft: #1c2029; --rule-firm: #2a2e36;
432
+ --dim: #6b7280; --muted: #9aa4b2; --base: #dfe2e7; --strong: #e6ebf2;
433
+ --accent: #2a6df4;
434
+ --run-bg: #1f3a26; --run-fg: #58d68d;
435
+ --retry-bg: #3a2f1f; --retry-fg: #f0c060;
436
+ --idle-bg: #20242c; --idle-fg: #9aa4b2;
437
+ --await-bg: #1f2a36; --await-fg: #7fb5d4;
438
+ --done-bg: #1c2a1f; --done-fg: #82c896;
439
+ --err: #ff7676;
440
+ }
441
+ * { box-sizing: border-box; }
442
+ html, body { background: var(--bench); color: var(--base); }
443
+ body {
444
+ font: 14px/1.45 ui-sans-serif, system-ui, sans-serif;
445
+ margin: 0; padding: 0;
446
+ display: flex; flex-direction: column; min-height: 100vh;
447
+ }
448
+ main {
449
+ width: 100%; max-width: 1080px; margin: 0 auto;
450
+ padding: 1rem 1.5rem 2rem; flex: 1;
451
+ }
452
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; color: var(--strong); }
453
+ .dim { color: var(--dim); }
454
+ .grow { flex: 1; }
455
+ h2 {
456
+ font-size: 1rem; font-weight: 500; margin: 1.6rem 0 0.5rem;
457
+ padding-bottom: 0.35rem; border-bottom: 1px solid var(--rule-firm);
458
+ letter-spacing: 0.01em;
459
+ }
460
+ h2:first-child { margin-top: 0.4rem; }
461
+
462
+ /* ── header strip ─────────────────────────────────────────────────────── */
463
+ #header {
464
+ display: flex; align-items: center; gap: 0.5rem;
465
+ padding: 0.65rem 1.5rem;
466
+ background: var(--bench); border-bottom: 1px solid var(--rule-firm);
467
+ font-size: 13px;
468
+ position: sticky; top: 0; z-index: 10;
469
+ }
470
+ #header .brand { font-weight: 600; color: var(--strong); letter-spacing: 0.01em; }
471
+ #header .rule { color: var(--dim); }
472
+ #header .workflow { color: var(--base); }
473
+ #header .tracker-root {
474
+ color: var(--dim); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
475
+ font-size: 12px;
476
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
477
+ margin-left: 0.75rem;
478
+ flex: 1 1 auto; min-width: 0;
479
+ }
480
+ #header .badge {
481
+ display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
482
+ font-size: 0.78em; letter-spacing: 0.04em; text-transform: uppercase;
483
+ background: var(--idle-bg); color: var(--idle-fg);
484
+ }
485
+ #header .badge-working { background: var(--run-bg); color: var(--run-fg); }
486
+ #header .badge-attention { background: var(--await-bg); color: var(--await-fg); }
487
+ #header .badge-idle { background: var(--idle-bg); color: var(--idle-fg); }
488
+ #header .refresh {
489
+ background: var(--chip); color: var(--base);
490
+ border: 1px solid var(--rule-firm); border-radius: 4px;
491
+ width: 28px; height: 28px;
492
+ display: inline-flex; align-items: center; justify-content: center;
493
+ cursor: pointer; font: inherit; font-size: 14px;
494
+ transition: border-color 180ms cubic-bezier(.22,1,.36,1);
495
+ }
496
+ #header .refresh:hover { border-color: var(--muted); }
497
+ #header .refresh:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
498
+
499
+ /* ── pills ────────────────────────────────────────────────────────────── */
500
+ .pill {
501
+ display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px;
502
+ font-size: 0.82em; line-height: 1.4;
503
+ font-variant-numeric: tabular-nums;
504
+ }
505
+ .pill.running { background: var(--run-bg); color: var(--run-fg); }
506
+ .pill.retrying { background: var(--retry-bg); color: var(--retry-fg); }
507
+ .pill.idle { background: var(--idle-bg); color: var(--idle-fg); }
508
+ .pill.awaiting { background: var(--await-bg); color: var(--await-fg); }
509
+ .pill.done { background: var(--done-bg); color: var(--done-fg); }
510
+
511
+ /* ── attention zone ───────────────────────────────────────────────────── */
512
+ /* Animated open/close: max-height transitions between 0 (empty) and a generous cap
513
+ (populated) so the section eases in and out rather than snapping the page up/down
514
+ when steering arrives or is resolved. */
515
+ #attention {
516
+ display: block;
517
+ overflow: hidden;
518
+ max-height: 0;
519
+ margin-bottom: 0;
520
+ transition: max-height 280ms cubic-bezier(.22, 1, .36, 1),
521
+ margin-bottom 280ms cubic-bezier(.22, 1, .36, 1);
522
+ }
523
+ #attention:not(:empty) {
524
+ max-height: 1500px;
525
+ margin-bottom: 0.4rem;
526
+ }
527
+ .attention-title { color: var(--await-fg); border-bottom-color: var(--await-bg); }
528
+ .steering-error {
529
+ margin: 0.4rem 0; padding: 0.5rem 0.7rem;
530
+ background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
531
+ color: var(--err); font-size: 0.9em; line-height: 1.4;
532
+ }
533
+ /* The steering panel: question-first layout. Selected via /impeccable live (variant 3,
534
+ question-scale=1.1, density=snug). The agent's prompt sits proud and unscaled, and the
535
+ original issue + agent context tuck into a disclosure so the panel reads small until
536
+ the operator wants depth. */
537
+ .steering {
538
+ background: var(--raised); padding: 0.55rem 0.8rem; margin: 0.5rem 0;
539
+ border-radius: 6px;
540
+ display: grid; gap: 0.4rem;
541
+ }
542
+ .steering-head {
543
+ display: flex; align-items: center; gap: 0.6rem;
544
+ }
545
+ .steering .ident { color: var(--strong); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
546
+ .steering .turn { font-size: 0.88em; }
547
+ .steering .question-primary {
548
+ margin: 0;
549
+ font-size: calc(14px * 1.1);
550
+ line-height: 1.4; color: var(--strong); font-weight: 400;
551
+ }
552
+ .steering details.steering-task { font-size: 0.92em; }
553
+ .steering details.steering-task > summary {
554
+ cursor: pointer; list-style: none;
555
+ color: var(--muted); padding: 0.3rem 0; user-select: none;
556
+ font-size: 0.88em;
557
+ }
558
+ .steering details.steering-task > summary::-webkit-details-marker { display: none; }
559
+ .steering details.steering-task > summary::before {
560
+ content: "▸"; padding-right: 0.4rem;
561
+ transition: transform 180ms cubic-bezier(.22,1,.36,1);
562
+ display: inline-block; color: var(--dim);
563
+ }
564
+ .steering details.steering-task[open] > summary::before { transform: rotate(90deg); }
565
+ .steering .steering-task-body {
566
+ display: grid; gap: 0.45rem;
567
+ padding: 0.35rem 0 0 0.85rem;
568
+ border-left: 1px solid var(--rule-soft);
569
+ }
570
+ .steering .steering-task-label {
571
+ font-size: 0.72em; color: var(--dim);
572
+ letter-spacing: 0.09em; text-transform: uppercase;
573
+ }
574
+ .steering .steering-task-body .issue-title {
575
+ margin: 0; font-size: 0.95em; font-weight: 500; color: var(--base);
576
+ }
577
+ .steering .steering-task-body .issue-body {
578
+ margin: 0; color: var(--muted); font-size: 0.92em; line-height: 1.5;
579
+ }
580
+ .steering .context {
581
+ margin: 0; padding: 0.5rem 0.65rem;
582
+ background: var(--inset); border: 1px solid var(--rule-firm); border-radius: 4px;
583
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
584
+ color: var(--muted); white-space: pre-wrap; word-break: break-word;
585
+ max-height: 12em; overflow: auto;
586
+ }
587
+ .steering form.reply { display: grid; gap: 0.45rem; }
588
+ .steering textarea {
589
+ background: var(--inset); color: var(--strong);
590
+ border: 1px solid var(--rule-firm); border-radius: 4px;
591
+ padding: 0.5rem 0.65rem;
592
+ font: 14px/1.5 ui-sans-serif, system-ui, sans-serif;
593
+ width: 100%; min-height: 64px; resize: vertical;
594
+ }
595
+ .steering textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent); }
596
+ .steering .reply-row { display: flex; align-items: center; gap: 0.75rem; }
597
+ .steering .hint { font-size: 0.82em; }
598
+ .ghost {
599
+ background: var(--chip); color: var(--base);
600
+ border: 1px solid var(--rule-firm); border-radius: 4px;
601
+ padding: 0.35rem 0.85rem; font: inherit; cursor: pointer;
602
+ transition: border-color 180ms cubic-bezier(.22,1,.36,1);
603
+ }
604
+ .ghost:hover { border-color: var(--muted); }
605
+ .ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
606
+ .ghost:disabled { color: var(--dim); cursor: not-allowed; }
607
+
608
+ .retry-list { list-style: none; padding: 0; margin: 0.4rem 0 0; }
609
+ .retry-list li {
610
+ display: grid;
611
+ grid-template-columns: max-content max-content max-content max-content;
612
+ gap: 0.55rem; align-items: center;
613
+ padding: 0.4rem 0; border-bottom: 1px solid var(--rule-soft);
614
+ font-variant-numeric: tabular-nums;
615
+ }
616
+ .retry-list li:last-child { border-bottom: 0; }
617
+ .retry-list .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
618
+ .retry-list .retry-err {
619
+ grid-column: 1 / -1; color: var(--err); font-size: 0.88em;
620
+ padding-top: 0.15rem;
621
+ word-break: break-word;
622
+ }
623
+
624
+ /* ── sessions ─────────────────────────────────────────────────────────── */
625
+ .sessions { list-style: none; padding: 0; margin: 0; }
626
+ .sessions li.session {
627
+ padding: 0.5rem 0; border-bottom: 1px solid var(--rule-soft);
628
+ }
629
+ .sessions li.session:last-child { border-bottom: 0; }
630
+ .session-row {
631
+ display: flex; align-items: baseline; gap: 0.65rem;
632
+ font-variant-numeric: tabular-nums;
633
+ }
634
+ .session-row .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--strong); }
635
+ .session-row .turn { font-size: 0.88em; color: var(--muted); }
636
+ .session-row .tokens { font-size: 0.88em; }
637
+ .session-msg {
638
+ margin-top: 0.2rem; padding-left: 0.05rem;
639
+ font-size: 0.9em; line-height: 1.45;
640
+ overflow-wrap: anywhere;
641
+ max-height: 2.9em; overflow: hidden;
642
+ }
643
+
644
+ /* ── on disk ──────────────────────────────────────────────────────────── */
645
+ .disk { list-style: none; padding: 0; margin: 0; }
646
+ .disk li {
647
+ display: grid;
648
+ grid-template-columns: 10rem 5.5rem 1fr;
649
+ gap: 0.5rem; align-items: baseline;
650
+ padding: 0.35rem 0; border-bottom: 1px solid var(--rule-soft);
651
+ font-variant-numeric: tabular-nums;
652
+ }
653
+ .disk li:last-child { border-bottom: 0; }
654
+ .disk .ident { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--strong); }
655
+ .disk .state { font-size: 0.85em; }
656
+ .disk .title { color: var(--base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
657
+ .count { font-weight: 400; font-size: 0.88em; margin-left: 0.4rem; }
658
+
659
+ /* ── new issue (collapsed) ────────────────────────────────────────────── */
660
+ details.new-issue {
661
+ margin-top: 1.4rem;
662
+ }
663
+ details.new-issue > summary {
664
+ cursor: pointer; list-style: none; padding: 0.55rem 0;
665
+ border-bottom: 1px solid var(--rule-firm);
666
+ font-size: 1rem; font-weight: 500;
667
+ display: flex; align-items: center; gap: 0.5rem;
668
+ user-select: none;
669
+ }
670
+ details.new-issue > summary::-webkit-details-marker { display: none; }
671
+ details.new-issue > summary::before {
672
+ content: "▸"; color: var(--dim); font-size: 0.85em;
673
+ transition: transform 180ms cubic-bezier(.22,1,.36,1);
674
+ display: inline-block;
675
+ }
676
+ details.new-issue[open] > summary::before { transform: rotate(90deg); }
677
+ details.new-issue[open] > summary { border-bottom-color: var(--rule-firm); }
678
+ form.create {
679
+ display: grid; grid-template-columns: max-content 1fr; gap: 0.5rem 1rem;
680
+ align-items: center; padding: 1rem 0 0; margin-top: 0.4rem;
681
+ }
682
+ form.create label { color: var(--muted); }
683
+ form.create input, form.create select, form.create textarea {
684
+ background: var(--inset); color: var(--strong);
685
+ border: 1px solid var(--rule-firm); border-radius: 4px;
686
+ padding: 0.4rem 0.6rem; font: inherit; width: 100%;
687
+ }
688
+ form.create input:focus-visible, form.create select:focus-visible, form.create textarea:focus-visible {
689
+ outline: 1px solid var(--accent); outline-offset: 0; border-color: var(--accent);
690
+ }
691
+ form.create textarea { min-height: 80px; resize: vertical; }
692
+ form.create .submit-row {
693
+ grid-column: 2; display: flex; align-items: center; gap: 0.85rem;
694
+ }
695
+ form.create button {
696
+ background: var(--accent); color: #f4f6fb; border: 0;
697
+ padding: 0.5rem 1rem; border-radius: 4px; font: inherit; cursor: pointer;
698
+ transition: filter 180ms cubic-bezier(.22,1,.36,1);
699
+ flex: 0 0 auto;
700
+ }
701
+ form.create button:hover { filter: brightness(1.08); }
702
+ form.create button:focus-visible { outline: 2px solid var(--strong); outline-offset: 2px; }
703
+ form.create .msg { min-height: 1.2em; color: var(--muted); font-size: 0.9em; line-height: 1.2; }
704
+ form.create .msg.err { color: var(--err); }
705
+ form.create .msg.ok { color: var(--run-fg); }
706
+
707
+ /* ── totals footer ────────────────────────────────────────────────────── */
708
+ footer.totals {
709
+ margin-top: 2.5rem; padding-top: 0.85rem;
710
+ border-top: 1px solid var(--rule-soft);
711
+ color: var(--dim); font-size: 0.88em;
712
+ font-variant-numeric: tabular-nums;
713
+ display: flex; gap: 0.65rem; flex-wrap: wrap;
714
+ }
715
+ footer.totals:empty { display: none; }
716
+
717
+ .empty { padding: 0.5rem 0; }
718
+
719
+ /* htmx ergonomics */
720
+ .htmx-request .refresh { opacity: 0.6; }
721
+ .htmx-settling .session-msg { opacity: 0.85; }
722
+ </style>
723
+ <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
724
+ <script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph-ext.min.js"></script>
725
+ </head><body hx-ext="morph">
726
+
727
+ <header id="header">
728
+ <span class="brand">symphony</span>
729
+ <span class="rule" aria-hidden="true">·</span>
730
+ <span class="workflow" title="${escapeHtml(p.workflowPath)}">${escapeHtml(p.workflowName)}</span>
731
+ <span class="tracker-root" title="${escapeHtml(p.trackerRoot)}">${escapeHtml(p.trackerRoot)}</span>
732
+ <span id="tracker-state"
733
+ hx-get="/api/v1/partials/header" hx-trigger="every 2s, refreshed from:body"
734
+ hx-swap="morph:innerHTML">${renderHeaderPartial(p)}</span>
735
+ <button type="button" class="refresh"
736
+ hx-post="/api/v1/refresh" hx-swap="none"
737
+ aria-label="refresh now" title="poll &amp; reconcile">⟳</button>
738
+ </header>
739
+
740
+ <main>
741
+
742
+ <section id="attention"
743
+ hx-get="/api/v1/partials/attention" hx-trigger="every 2s, refreshed from:body"
744
+ hx-swap="morph:innerHTML">${renderAttentionPartial(p)}</section>
745
+
746
+ <section id="sessions"
747
+ hx-get="/api/v1/partials/sessions" hx-trigger="every 2s, refreshed from:body"
748
+ hx-swap="morph:innerHTML">${renderSessionsPartial(p)}</section>
749
+
750
+ <section id="disk"
751
+ hx-get="/api/v1/partials/disk" hx-trigger="every 2s, refreshed from:body"
752
+ hx-swap="morph:innerHTML">${renderDiskPartial(p)}</section>
753
+
754
+ <details class="new-issue"${formOpen ? ' open' : ''}>
755
+ <summary>new issue</summary>
756
+ <form class="create" id="create-form">
757
+ <label for="identifier">identifier</label>
758
+ <input id="identifier" name="identifier" required placeholder="ABC-42" autocomplete="off" />
759
+ <label for="title">title</label>
760
+ <input id="title" name="title" required autocomplete="off" />
761
+ <label for="state">state</label>
762
+ <select id="state" name="state">${stateOptions}</select>
763
+ <label for="priority">priority</label>
764
+ <input id="priority" name="priority" type="number" min="0" max="10" placeholder="2" />
765
+ <label for="labels">labels</label>
766
+ <input id="labels" name="labels" placeholder="bug, urgent" autocomplete="off" />
767
+ <label for="description">description</label>
768
+ <textarea id="description" name="description" placeholder="what needs to be done?"></textarea>
769
+ <div class="submit-row">
770
+ <button type="submit">create issue</button>
771
+ <span class="msg" id="create-msg" role="status" aria-live="polite"></span>
772
+ </div>
773
+ </form>
774
+ </details>
775
+
776
+ <footer class="totals"
777
+ hx-get="/api/v1/partials/totals" hx-trigger="every 2s, refreshed from:body"
778
+ hx-swap="morph:innerHTML">${renderTotalsPartial(p)}</footer>
779
+
780
+ </main>
781
+
782
+ <script>
783
+ const $ = (id) => document.getElementById(id);
784
+
785
+ // Enter-to-send on steering textareas. HTMX submits the form; shift+enter still inserts a newline.
786
+ document.addEventListener('keydown', (ev) => {
787
+ const t = ev.target;
788
+ if (!(t instanceof HTMLTextAreaElement)) return;
789
+ if (!t.closest('form.reply')) return;
790
+ if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
791
+ ev.preventDefault();
792
+ t.form && t.form.requestSubmit();
793
+ }
794
+ });
795
+
796
+ // Create-issue form: stays on fetch+JSON (the create POST takes structured arrays). After
797
+ // success we fire a custom event the polling regions listen to via hx-trigger.
798
+ $('create-form').addEventListener('submit', async (ev) => {
799
+ ev.preventDefault();
800
+ const msg = $('create-msg');
801
+ msg.className = 'msg'; msg.textContent = 'creating…';
802
+ const labels = $('labels').value.split(',').map((s) => s.trim()).filter(Boolean);
803
+ const priorityRaw = $('priority').value.trim();
804
+ const body = {
805
+ identifier: $('identifier').value.trim(),
806
+ title: $('title').value.trim(),
807
+ state: $('state').value,
808
+ description: $('description').value,
809
+ labels,
810
+ priority: priorityRaw === '' ? null : Number(priorityRaw),
811
+ };
812
+ try {
813
+ const res = await fetch('/api/v1/issues', {
814
+ method: 'POST',
815
+ headers: { 'content-type': 'application/json' },
816
+ body: JSON.stringify(body),
817
+ });
818
+ const data = await res.json();
819
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status));
820
+ msg.className = 'msg ok';
821
+ msg.textContent = 'created ' + data.identifier;
822
+ $('create-form').reset();
823
+ fetch('/api/v1/refresh', { method: 'POST' }).catch(() => {});
824
+ document.body.dispatchEvent(new CustomEvent('refreshed', { bubbles: true }));
825
+ } catch (err) {
826
+ msg.className = 'msg err';
827
+ msg.textContent = err.message;
828
+ }
829
+ });
830
+ </script>
831
+
832
+ </body></html>`;
833
+ }
834
+ function escapeHtml(s) {
835
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
836
+ }
837
+ // Resolves once the server has either bound the requested port or rejected with the bind
838
+ // error so CLI startup can surface EADDRINUSE / EACCES instead of an unhandled rejection.
839
+ // Returns the *actually bound* port (relevant for --port 0, where the kernel picks an
840
+ // ephemeral port) so callers can advertise the live address.
841
+ export async function startHttpServer(orch, opts) {
842
+ const server = createServer((req, res) => {
843
+ void handleRequest(req, res, orch, opts).catch((err) => {
844
+ log.error('http handler error', { error: err.message });
845
+ try {
846
+ jsonResponse(res, 500, {
847
+ error: { code: 'internal_error', message: err.message },
848
+ });
849
+ }
850
+ catch {
851
+ /* response already started */
852
+ }
853
+ });
854
+ });
855
+ server.on('clientError', (err, socket) => {
856
+ log.debug('http client error', { error: err.message });
857
+ try {
858
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
859
+ }
860
+ catch {
861
+ /* socket may already be closed */
862
+ }
863
+ });
864
+ let boundPort = opts.port;
865
+ await new Promise((resolve, reject) => {
866
+ const onError = (err) => {
867
+ server.removeListener('listening', onListening);
868
+ reject(err);
869
+ };
870
+ const onListening = () => {
871
+ server.removeListener('error', onError);
872
+ const addr = server.address();
873
+ const port = typeof addr === 'object' && addr ? addr.port : opts.port;
874
+ boundPort = port;
875
+ log.info('http server listening', { host: opts.host, port });
876
+ resolve();
877
+ };
878
+ server.once('error', onError);
879
+ server.once('listening', onListening);
880
+ server.listen(opts.port, opts.host);
881
+ });
882
+ // After bind succeeds, install a permanent error handler so later runtime errors
883
+ // (sockets resetting, ENOTCONN, etc.) are logged rather than crashing the process.
884
+ server.on('error', (err) => {
885
+ log.warn('http server error', { error: err.message });
886
+ });
887
+ return {
888
+ port: boundPort,
889
+ close: () => new Promise((resolve) => {
890
+ server.close(() => resolve());
891
+ }),
892
+ };
893
+ }
894
+ async function handleRequest(req, res, orch, opts) {
895
+ // URL parsing inside the handler so a malformed Host header doesn't crash the listener.
896
+ let pathname;
897
+ try {
898
+ const url = new URL(req.url ?? '/', 'http://symphony.local');
899
+ pathname = url.pathname;
900
+ }
901
+ catch {
902
+ return badRequest(res, 'invalid request URL');
903
+ }
904
+ const method = (req.method ?? 'GET').toUpperCase();
905
+ const view = opts.getTrackerView();
906
+ if (pathname === '/') {
907
+ if (method !== 'GET')
908
+ return methodNotAllowed(res);
909
+ const p = await gatherPartialInputs(orch, view);
910
+ const html = renderDashboardHtml(p);
911
+ res.statusCode = 200;
912
+ res.setHeader('content-type', 'text/html; charset=utf-8');
913
+ res.end(html);
914
+ return;
915
+ }
916
+ // Static preview for impeccable live mode. The file under .impeccable/preview/ is a
917
+ // captured snapshot of the dashboard with polling disabled, used as a variant playground.
918
+ // Read on every request so live-wrap edits land immediately.
919
+ if (pathname === '/preview' || pathname === '/preview/') {
920
+ if (method !== 'GET')
921
+ return methodNotAllowed(res);
922
+ try {
923
+ const html = await readFile('.impeccable/preview/dashboard.html', 'utf8');
924
+ res.statusCode = 200;
925
+ res.setHeader('content-type', 'text/html; charset=utf-8');
926
+ res.setHeader('cache-control', 'no-store');
927
+ res.end(html);
928
+ }
929
+ catch (err) {
930
+ return notFound(res, 'preview_missing', `preview not available: ${err.message}`);
931
+ }
932
+ return;
933
+ }
934
+ // HTMX partials. Each region polls its own endpoint at 2s; this is what the dashboard
935
+ // <section hx-get=...> elements consume. They return only the inner HTML; the outer
936
+ // wrapper is in the dashboard shell.
937
+ if (pathname.startsWith('/api/v1/partials/')) {
938
+ if (method !== 'GET')
939
+ return methodNotAllowed(res);
940
+ const p = await gatherPartialInputs(orch, view);
941
+ const slug = pathname.slice('/api/v1/partials/'.length);
942
+ let body = null;
943
+ if (slug === 'header')
944
+ body = renderHeaderPartial(p);
945
+ else if (slug === 'attention')
946
+ body = renderAttentionPartial(p);
947
+ else if (slug === 'sessions')
948
+ body = renderSessionsPartial(p);
949
+ else if (slug === 'disk')
950
+ body = renderDiskPartial(p);
951
+ else if (slug === 'totals')
952
+ body = renderTotalsPartial(p);
953
+ if (body === null)
954
+ return notFound(res, 'partial_not_found', `partial ${slug} does not exist`);
955
+ res.statusCode = 200;
956
+ res.setHeader('content-type', 'text/html; charset=utf-8');
957
+ res.setHeader('cache-control', 'no-store');
958
+ res.end(body);
959
+ return;
960
+ }
961
+ if (pathname === '/api/v1/state') {
962
+ if (method !== 'GET')
963
+ return methodNotAllowed(res);
964
+ return jsonResponse(res, 200, orch.snapshot());
965
+ }
966
+ if (pathname === '/api/v1/refresh') {
967
+ if (method !== 'POST')
968
+ return methodNotAllowed(res);
969
+ const status = orch.triggerRefresh();
970
+ return jsonResponse(res, 202, {
971
+ ...status,
972
+ requested_at: new Date().toISOString(),
973
+ operations: ['poll', 'reconcile'],
974
+ });
975
+ }
976
+ if (pathname === '/api/v1/issues') {
977
+ if (method === 'GET') {
978
+ const root = view.trackerRoot;
979
+ if (!root)
980
+ return jsonResponse(res, 200, { issues: [] });
981
+ const issues = await listIssuesFromDisk(root);
982
+ return jsonResponse(res, 200, { issues });
983
+ }
984
+ if (method === 'POST') {
985
+ const root = view.trackerRoot;
986
+ if (!root)
987
+ return badRequest(res, 'tracker.root not configured');
988
+ let body;
989
+ try {
990
+ body = await readJsonBody(req);
991
+ }
992
+ catch (err) {
993
+ return badRequest(res, err.message);
994
+ }
995
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
996
+ return badRequest(res, 'body must be a JSON object');
997
+ }
998
+ const b = body;
999
+ const identifier = typeof b.identifier === 'string' ? b.identifier.trim() : '';
1000
+ const title = typeof b.title === 'string' ? b.title.trim() : '';
1001
+ const state = typeof b.state === 'string' ? b.state.trim() : '';
1002
+ if (!identifier)
1003
+ return badRequest(res, 'identifier is required');
1004
+ if (!title)
1005
+ return badRequest(res, 'title is required');
1006
+ if (!state)
1007
+ return badRequest(res, 'state is required');
1008
+ // Restrict `state` to one of the configured active/terminal states. Anything else
1009
+ // (or values containing path separators / `..`) is rejected so the request cannot
1010
+ // escape the tracker root via `path.join`.
1011
+ const allowedStates = new Set([...view.activeStates, ...view.terminalStates]);
1012
+ if (!allowedStates.has(state)) {
1013
+ return badRequest(res, `state must be one of: ${[...allowedStates].join(', ') || '<none configured>'}`);
1014
+ }
1015
+ const description = typeof b.description === 'string' ? b.description : undefined;
1016
+ const priority = typeof b.priority === 'number' && Number.isFinite(b.priority) ? b.priority : null;
1017
+ const labels = Array.isArray(b.labels)
1018
+ ? b.labels.filter((x) => typeof x === 'string')
1019
+ : [];
1020
+ const blockedBy = Array.isArray(b.blocked_by)
1021
+ ? b.blocked_by.filter((x) => typeof x === 'string')
1022
+ : [];
1023
+ try {
1024
+ const created = await writeIssueFile({
1025
+ trackerRoot: root,
1026
+ identifier,
1027
+ title,
1028
+ state,
1029
+ description,
1030
+ priority,
1031
+ labels,
1032
+ blocked_by: blockedBy,
1033
+ });
1034
+ log.info('issue created via http', { identifier: created.identifier, state: created.state });
1035
+ return jsonResponse(res, 201, created);
1036
+ }
1037
+ catch (err) {
1038
+ return jsonResponse(res, 409, {
1039
+ error: { code: 'create_failed', message: err.message },
1040
+ });
1041
+ }
1042
+ }
1043
+ return methodNotAllowed(res);
1044
+ }
1045
+ // MCP JSON-RPC endpoint: agent (inside the smolvm) POSTs JSON-RPC envelopes here. The
1046
+ // URL is per-issue (the agent only knows its own /<id>/mcp), backed by a bearer token
1047
+ // generated at dispatch. Both layers are belt-and-braces against the no-auth 8787 socket.
1048
+ const mcpMatch = /^\/api\/v1\/issues\/([^/]+)\/mcp$/.exec(pathname);
1049
+ if (mcpMatch) {
1050
+ if (method !== 'POST')
1051
+ return methodNotAllowed(res);
1052
+ const mcp = opts.mcp;
1053
+ if (!mcp)
1054
+ return notFound(res, 'mcp_disabled', 'mcp endpoint not enabled');
1055
+ const identifier = decodeURIComponent(mcpMatch[1]);
1056
+ const auth = (req.headers['authorization'] ?? req.headers['Authorization']);
1057
+ const token = auth && auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : '';
1058
+ if (!token) {
1059
+ res.statusCode = 401;
1060
+ res.setHeader('content-type', 'application/json; charset=utf-8');
1061
+ res.end(JSON.stringify({ error: { code: 'unauthorized', message: 'bearer token required' } }));
1062
+ return;
1063
+ }
1064
+ if (!mcp.isActive(identifier, token)) {
1065
+ res.statusCode = 404;
1066
+ res.setHeader('content-type', 'application/json; charset=utf-8');
1067
+ res.end(JSON.stringify({ error: { code: 'not_found', message: 'issue not active or token mismatch' } }));
1068
+ return;
1069
+ }
1070
+ let body;
1071
+ try {
1072
+ body = await readJsonBody(req);
1073
+ }
1074
+ catch (err) {
1075
+ return badRequest(res, err.message);
1076
+ }
1077
+ const reply = await mcp.handleJsonRpc(identifier, token, body);
1078
+ if (reply === null) {
1079
+ // JSON-RPC notification (no id) → 204 No Content
1080
+ res.statusCode = 204;
1081
+ res.end();
1082
+ return;
1083
+ }
1084
+ return jsonResponse(res, 200, reply);
1085
+ }
1086
+ // Steering reply: the dashboard (or any operator with access) submits the human's
1087
+ // response to a queued request_human_steering call. The orchestrator-side runner is
1088
+ // awaiting on the registry; this POST unblocks it.
1089
+ //
1090
+ // Two callers:
1091
+ // • Dashboard via HTMX (form-encoded body, `HX-Request: true`, same-origin). We accept
1092
+ // only this combination for form bodies and reply with an HTML partial that swaps
1093
+ // into #attention.
1094
+ // • Direct API client (JSON body). Replies with a structured JSON acknowledgement.
1095
+ //
1096
+ // The form-encoded branch is gated on `HX-Request: true` and a same-origin check so a
1097
+ // simple cross-site form POST cannot inject a steering reply: form-encoded is a "simple"
1098
+ // CORS request that bypasses preflight, and the steering endpoint is unauthenticated.
1099
+ // HTMX errors land at 200 OK with an inline `.steering-error` message because HTMX's
1100
+ // default response-handling does not swap on 4xx/5xx; returning 200 keeps the operator's
1101
+ // form in sync with reality (their textarea text is preserved by hx-preserve regardless).
1102
+ const steeringMatch = /^\/api\/v1\/issues\/([^/]+)\/steering-reply$/.exec(pathname);
1103
+ if (steeringMatch) {
1104
+ if (method !== 'POST')
1105
+ return methodNotAllowed(res);
1106
+ const mcp = opts.mcp;
1107
+ if (!mcp)
1108
+ return notFound(res, 'mcp_disabled', 'steering endpoint not enabled');
1109
+ const identifier = decodeURIComponent(steeringMatch[1]);
1110
+ const isHtmx = req.headers['hx-request'] === 'true';
1111
+ const ctype = (req.headers['content-type'] ?? '').toLowerCase();
1112
+ const baseCtype = ctype.split(';', 1)[0].trim();
1113
+ const isFormBody = baseCtype === 'application/x-www-form-urlencoded';
1114
+ const isJsonBody = baseCtype === 'application/json';
1115
+ // Content-type gates against CSRF: form-urlencoded, text/plain, and multipart/form-data
1116
+ // are "simple" CORS requests and bypass preflight. Only application/json (non-simple,
1117
+ // triggers preflight) is accepted on the JSON path; the form path is additionally
1118
+ // gated on HX-Request + same-origin. Anything else is rejected outright.
1119
+ if (!isFormBody && !isJsonBody) {
1120
+ return jsonResponse(res, 415, {
1121
+ error: {
1122
+ code: 'unsupported_media_type',
1123
+ message: 'content-type must be application/json or application/x-www-form-urlencoded',
1124
+ },
1125
+ });
1126
+ }
1127
+ if (isFormBody) {
1128
+ if (!isHtmx || !isSameOriginRequest(req)) {
1129
+ return jsonResponse(res, 403, {
1130
+ error: {
1131
+ code: 'forbidden',
1132
+ message: 'form-encoded steering replies require an HTMX same-origin request',
1133
+ },
1134
+ });
1135
+ }
1136
+ }
1137
+ let text = '';
1138
+ if (isFormBody) {
1139
+ try {
1140
+ const raw = await readTextBody(req);
1141
+ const params = new URLSearchParams(raw);
1142
+ text = (params.get('text') ?? '').trim();
1143
+ }
1144
+ catch (err) {
1145
+ return htmxOrJsonError(res, isHtmx, orch, view, 400, 'bad_request', err.message);
1146
+ }
1147
+ }
1148
+ else {
1149
+ let body;
1150
+ try {
1151
+ body = await readJsonBody(req);
1152
+ }
1153
+ catch (err) {
1154
+ return badRequest(res, err.message);
1155
+ }
1156
+ text =
1157
+ body && typeof body === 'object' && !Array.isArray(body) && typeof body.text === 'string'
1158
+ ? body.text.trim()
1159
+ : '';
1160
+ }
1161
+ if (!text) {
1162
+ return htmxOrJsonError(res, isHtmx, orch, view, 400, 'bad_request', 'text is required and must be a non-empty string');
1163
+ }
1164
+ const ok = mcp.submitSteeringReply(identifier, text);
1165
+ if (!ok) {
1166
+ return htmxOrJsonError(res, isHtmx, orch, view, 409, 'no_pending_steering', 'no agent is awaiting steering for this issue');
1167
+ }
1168
+ if (isHtmx) {
1169
+ const p = await gatherPartialInputs(orch, view);
1170
+ res.statusCode = 200;
1171
+ res.setHeader('content-type', 'text/html; charset=utf-8');
1172
+ res.end(renderAttentionPartial(p));
1173
+ return;
1174
+ }
1175
+ return jsonResponse(res, 202, { identifier, accepted_at: new Date().toISOString() });
1176
+ }
1177
+ const m = /^\/api\/v1\/([^/]+)$/.exec(pathname);
1178
+ if (m) {
1179
+ if (method !== 'GET')
1180
+ return methodNotAllowed(res);
1181
+ const identifier = decodeURIComponent(m[1]);
1182
+ const detail = orch.detailByIdentifier(identifier);
1183
+ if (!detail)
1184
+ return notFound(res, 'issue_not_found', `issue ${identifier} is not tracked`);
1185
+ return jsonResponse(res, 200, detail);
1186
+ }
1187
+ notFound(res);
1188
+ }
1189
+ //# sourceMappingURL=http.js.map