codehere 0.5.0-alpha.6 → 0.6.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 (39) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +89 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/context-handoff.js +143 -0
  17. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  18. package/dist/infrastructure/orchestration/failover-config.js +77 -0
  19. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  20. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  21. package/dist/infrastructure/orchestration/graphify-context.js +256 -0
  22. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  23. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  24. package/dist/infrastructure/orchestration/session-manager.js +515 -15
  25. package/dist/infrastructure/protocols/cloud-sync.js +208 -0
  26. package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
  27. package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
  28. package/dist/infrastructure/protocols/ui-page.js +2872 -220
  29. package/dist/infrastructure/security/permissions-config.js +234 -0
  30. package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
  31. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  32. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  33. package/dist/presentation/cli/commands/budget-command.js +79 -0
  34. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  35. package/dist/presentation/cli/commands/permission-command.js +116 -0
  36. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  37. package/dist/presentation/cli/repl.js +29 -0
  38. package/dist/utils/typo-guard.js +77 -17
  39. package/package.json +2 -1
@@ -23,17 +23,22 @@ import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from 'fs';
23
23
  import { join, relative, dirname } from 'path';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { collectFilesForMCP, scoreFileForMCP } from './mcp-helpers.js';
26
- import { scanPayload, calculateRiskScore, AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
26
+ import { scanPayload, calculateRiskScore, AUDIT_FILE, verifyAuditChain, } from '../hooks/claude-sdk-hook.js';
27
27
  import { getVersion } from '../../utils/version.js';
28
28
  import { TASK_HTML, HISTORY_HTML, THREAD_HTML } from './ui-page.js';
29
+ import { TASK_HTML_V2 } from './ui-page-v2.js';
29
30
  import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
30
31
  import { isAbsolute } from 'path';
31
32
  import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
32
33
  import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
33
- import { mirrorReplyToThread } from '../storage/project-thread-mirror.js';
34
+ import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
34
35
  import { randomUUID } from 'crypto';
35
36
  import { installAgent } from '../orchestration/agent-installer.js';
36
37
  import { detectReadiness } from '../orchestration/readiness.js';
38
+ import { getHookMode } from '../hooks/hook-mode.js';
39
+ import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
40
+ import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
41
+ import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
37
42
  // ── Tool definitions ─────────────────────────────────────────────────────────
38
43
  const TOOLS = [
39
44
  {
@@ -72,12 +77,20 @@ const TOOLS = [
72
77
  },
73
78
  {
74
79
  name: 'codehere_audit',
75
- description: 'Read recent hook audit log entries — shows which tool calls codehere blocked or allowed in Claude Code sessions.',
80
+ description: 'Read recent hook audit log entries — shows which tool calls codehere blocked, allowed, or overrode (fail-open paths) in Claude Code sessions. Pass verify:true to instead validate the SHA-256 hash chain and return any tampering detected.',
76
81
  inputSchema: {
77
82
  type: 'object',
78
83
  properties: {
79
84
  limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
80
85
  blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
86
+ overrideOnly: {
87
+ type: 'boolean',
88
+ description: 'Only return trust-override events — cases where the scanner was bypassed (stdin failure, parse failure, --log-only, scan exception). Per CC frontier Q1 (auditable permission trails) — surface every bypass for review.',
89
+ },
90
+ verify: {
91
+ type: 'boolean',
92
+ description: 'Verify the SHA-256 hash chain instead of returning entries. Returns { valid, totalEntries, chainedEntries, legacyEntries, errors[] } where errors lists every detected tampering point (modification / deletion / insertion / parse failure). Per CC frontier Q1 (cryptographic commitment + tamper evidence).',
93
+ },
81
94
  },
82
95
  },
83
96
  },
@@ -195,6 +208,14 @@ function handleReview(params) {
195
208
  function handleAudit(params) {
196
209
  const limit = Number(params.limit ?? 20);
197
210
  const blockedOnly = Boolean(params.blockedOnly);
211
+ const overrideOnly = Boolean(params.overrideOnly);
212
+ const verify = Boolean(params.verify);
213
+ if (verify) {
214
+ // Tamper-evidence verification — walks the file, recomputes every hash,
215
+ // checks the chain. Returns full report including ALL break points.
216
+ const result = verifyAuditChain(AUDIT_FILE_PATH);
217
+ return JSON.stringify(result, null, 2);
218
+ }
198
219
  if (!existsSync(AUDIT_FILE_PATH)) {
199
220
  return JSON.stringify({
200
221
  message: 'No audit log yet — install hooks first with: codehere hook install',
@@ -215,7 +236,13 @@ function handleAudit(params) {
215
236
  }
216
237
  })
217
238
  .filter(Boolean)
218
- .filter((e) => !blockedOnly || e.blocked)
239
+ .filter((e) => {
240
+ if (overrideOnly)
241
+ return Boolean(e.overrideReason);
242
+ if (blockedOnly)
243
+ return Boolean(e.blocked);
244
+ return true;
245
+ })
219
246
  .slice(-limit);
220
247
  return JSON.stringify({ entries, total: entries.length });
221
248
  }
@@ -293,8 +320,14 @@ export function startMCPServer(options = {}) {
293
320
  res.end(JSON.stringify({ status: 'ok', server: 'codehere-mcp', version: getVersion(), tools: TOOLS.length }));
294
321
  return;
295
322
  }
296
- // v0.3 primary UI: task intake + active sessions
323
+ // v2 Task-Centric PM UI (primary — served at /)
297
324
  if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
325
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
326
+ res.end(withDevElu(TASK_HTML_V2));
327
+ return;
328
+ }
329
+ // v1 dashboard (legacy — at /v1)
330
+ if (req.method === 'GET' && (req.url === '/v1' || req.url === '/v1/')) {
298
331
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
299
332
  res.end(withDevElu(TASK_HTML));
300
333
  return;
@@ -306,7 +339,8 @@ export function startMCPServer(options = {}) {
306
339
  return;
307
340
  }
308
341
  // v0.6.0 M1 — project thread view; consumes /api/projects + /api/projects/:slug
309
- if (req.method === 'GET' && (req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
342
+ if (req.method === 'GET' &&
343
+ (req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
310
344
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
311
345
  res.end(withDevElu(THREAD_HTML));
312
346
  return;
@@ -367,12 +401,181 @@ export function startMCPServer(options = {}) {
367
401
  });
368
402
  return;
369
403
  }
404
+ // ── P4 SSE — session lifecycle events (realtime /v2 updates) ────────────
405
+ // Tails sessions.jsonl and pushes "change" events so the /v2 UI can
406
+ // re-fetch task state instantly instead of waiting 8s for the next poll.
407
+ // Same pattern as /events (audit log tail) but for sessions.
408
+ const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
409
+ if (req.method === 'GET' && req.url === '/api/events/sessions') {
410
+ res.writeHead(200, {
411
+ 'Content-Type': 'text/event-stream',
412
+ 'Cache-Control': 'no-cache',
413
+ Connection: 'keep-alive',
414
+ });
415
+ // Send current session state on connect
416
+ try {
417
+ const manager = SessionManager.getInstance();
418
+ const sessions = manager.list().map(s => ({
419
+ sessionId: s.sessionId,
420
+ status: s.status,
421
+ agent: s.agent,
422
+ }));
423
+ res.write(`data: ${JSON.stringify({ type: 'init', sessions })}\n\n`);
424
+ }
425
+ catch {
426
+ res.write(`data: ${JSON.stringify({ type: 'init', sessions: [] })}\n\n`);
427
+ }
428
+ // Tail sessions.jsonl for changes
429
+ let lastSize = 0;
430
+ if (existsSync(SESSIONS_FILE)) {
431
+ try {
432
+ lastSize = statSync(SESSIONS_FILE).size;
433
+ }
434
+ catch { /* first time */ }
435
+ }
436
+ const onChange = () => {
437
+ try {
438
+ if (!existsSync(SESSIONS_FILE))
439
+ return;
440
+ const currentSize = statSync(SESSIONS_FILE).size;
441
+ if (currentSize <= lastSize)
442
+ return;
443
+ lastSize = currentSize;
444
+ res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
445
+ }
446
+ catch { /* transient */ }
447
+ };
448
+ watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
449
+ const ping = setInterval(() => res.write(': ping\n\n'), 15000);
450
+ req.on('close', () => {
451
+ clearInterval(ping);
452
+ unwatchFile(SESSIONS_FILE, onChange);
453
+ });
454
+ return;
455
+ }
370
456
  // ── F2 agent registry ─────────────────────────────────────────────────────
371
457
  if (req.method === 'GET' && req.url === '/api/agents') {
372
458
  res.writeHead(200, { 'Content-Type': 'application/json' });
373
459
  res.end(JSON.stringify({ agents: availableAgents() }));
374
460
  return;
375
461
  }
462
+ // P12 slice — tamper-evidence verification of the audit log.
463
+ // Calls verifyAuditChain() and returns a compact summary. /history
464
+ // polls this every minute and shows a red banner if valid=false.
465
+ if (req.method === 'GET' && req.url === '/api/audit/verify') {
466
+ try {
467
+ const r = verifyAuditChain();
468
+ res.writeHead(200, { 'Content-Type': 'application/json' });
469
+ res.end(JSON.stringify({
470
+ valid: r.valid,
471
+ totalEntries: r.totalEntries,
472
+ chainedEntries: r.chainedEntries,
473
+ legacyEntries: r.legacyEntries,
474
+ errors: r.errors.slice(0, 10),
475
+ }));
476
+ }
477
+ catch {
478
+ res.writeHead(200, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ valid: true, totalEntries: 0, chainedEntries: 0, legacyEntries: 0, errors: [] }));
480
+ }
481
+ return;
482
+ }
483
+ // P18 slice 3 — permissions read/write surface. Lets the future
484
+ // dashboard panel (when a hook block fires) call "Approve in this
485
+ // folder" without shelling out. The CLI command and this endpoint
486
+ // both call addPermissionEntry → single source of truth.
487
+ if (req.method === 'GET' && req.url?.startsWith('/api/permissions?')) {
488
+ const cwd = decodeURIComponent(req.url.split('cwd=')[1] || '');
489
+ if (!cwd || !isAbsolute(cwd)) {
490
+ res.writeHead(400, { 'Content-Type': 'application/json' });
491
+ res.end(JSON.stringify({ error: 'cwd query param required (absolute path)' }));
492
+ return;
493
+ }
494
+ try {
495
+ const cfg = loadPermissions(cwd);
496
+ res.writeHead(200, { 'Content-Type': 'application/json' });
497
+ res.end(JSON.stringify({ cwd, permissions: cfg }));
498
+ }
499
+ catch {
500
+ res.writeHead(200, { 'Content-Type': 'application/json' });
501
+ res.end(JSON.stringify({ cwd, permissions: { allow: [], deny: [] } }));
502
+ }
503
+ return;
504
+ }
505
+ if (req.method === 'POST' && req.url === '/api/permissions') {
506
+ let body = '';
507
+ req.on('data', (c) => { body += c; });
508
+ req.on('end', () => {
509
+ let parsed;
510
+ try {
511
+ parsed = JSON.parse(body || '{}');
512
+ }
513
+ catch (e) {
514
+ res.writeHead(400, { 'Content-Type': 'application/json' });
515
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
516
+ return;
517
+ }
518
+ const cwd = parsed.cwd;
519
+ const pattern = parsed.pattern;
520
+ const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
521
+ if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
522
+ res.writeHead(400, { 'Content-Type': 'application/json' });
523
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
524
+ return;
525
+ }
526
+ if (!pattern || typeof pattern !== 'string') {
527
+ res.writeHead(400, { 'Content-Type': 'application/json' });
528
+ res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
529
+ return;
530
+ }
531
+ try {
532
+ const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
533
+ res.writeHead(200, { 'Content-Type': 'application/json' });
534
+ res.end(JSON.stringify(result));
535
+ }
536
+ catch (e) {
537
+ res.writeHead(500, { 'Content-Type': 'application/json' });
538
+ res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
539
+ }
540
+ });
541
+ return;
542
+ }
543
+ if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
544
+ const params = new URLSearchParams(req.url.split('?')[1] || '');
545
+ const cwd = params.get('cwd') || '';
546
+ const pattern = params.get('pattern') || '';
547
+ if (!cwd || !isAbsolute(cwd) || !pattern) {
548
+ res.writeHead(400, { 'Content-Type': 'application/json' });
549
+ res.end(JSON.stringify({ error: 'cwd (absolute) + pattern query params required' }));
550
+ return;
551
+ }
552
+ try {
553
+ const result = removePermissionEntry(cwd, pattern);
554
+ res.writeHead(200, { 'Content-Type': 'application/json' });
555
+ res.end(JSON.stringify(result));
556
+ }
557
+ catch (e) {
558
+ res.writeHead(500, { 'Content-Type': 'application/json' });
559
+ res.end(JSON.stringify({ error: 'remove_failed', detail: String(e?.message || e) }));
560
+ }
561
+ return;
562
+ }
563
+ // Pattern 20 — hook-mode indicator. Read-only by design; mode
564
+ // switching goes through `codehere hook install/uninstall` so the
565
+ // safety surface re-validates. Cheap (one settings.json parse);
566
+ // safe to poll from the dashboard header at 30s cadence.
567
+ if (req.method === 'GET' && req.url === '/api/hook/mode') {
568
+ let mode;
569
+ try {
570
+ mode = getHookMode();
571
+ }
572
+ catch {
573
+ mode = 'off';
574
+ }
575
+ res.writeHead(200, { 'Content-Type': 'application/json' });
576
+ res.end(JSON.stringify({ mode }));
577
+ return;
578
+ }
376
579
  // ── Gate B3 · Audit summary (moat visible on main page) ───────────────────
377
580
  // Principle #3 status-at-a-glance + #7 agent-provenance. Reads the hook audit
378
581
  // log's last 24h, aggregates: how many actions fired across all agents, how
@@ -514,6 +717,34 @@ export function startMCPServer(options = {}) {
514
717
  }
515
718
  return;
516
719
  }
720
+ // ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
721
+ // GET /api/spend/today — read-only aggregate of today's estimated USD spend
722
+ // plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
723
+ // dashboard's "spent today" pill + budget warn/block colors. Per
724
+ // claim_market_position v3 capture-strategy edge "cost-bounded execution":
725
+ // this is the read surface; the actual block happens at /api/task spawn.
726
+ if (req.method === 'GET' && req.url === '/api/spend/today') {
727
+ try {
728
+ const spend = computeSpendToday();
729
+ const budget = loadBudget();
730
+ const pctUsed = budgetUsedPct(spend.totalUsd, budget);
731
+ res.writeHead(200, { 'Content-Type': 'application/json' });
732
+ res.end(JSON.stringify({
733
+ ...spend,
734
+ budget: {
735
+ dailyUsd: budget.dailyUsd,
736
+ warnAtPct: budget.warnAtPct,
737
+ pctUsed,
738
+ over: isOverBudget(spend.totalUsd, budget),
739
+ },
740
+ }));
741
+ }
742
+ catch (e) {
743
+ res.writeHead(500, { 'Content-Type': 'application/json' });
744
+ res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
745
+ }
746
+ return;
747
+ }
517
748
  // ── F1a readiness check ───────────────────────────────────────────────────
518
749
  if (req.method === 'GET' && req.url === '/api/readiness') {
519
750
  try {
@@ -570,6 +801,57 @@ export function startMCPServer(options = {}) {
570
801
  }
571
802
  return;
572
803
  }
804
+ // POST /api/projects/:slug/rename — body { title }
805
+ // User-initiated rename. Title agent generates a title from the first
806
+ // prompt; project intent drifts. Without rename, /agent dir gets stuck
807
+ // titled "Scan Auth File" forever even after 100+ unrelated tasks.
808
+ if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
809
+ const slug = req.url.split('/')[3];
810
+ let body = '';
811
+ req.on('data', (c) => {
812
+ body += c;
813
+ });
814
+ req.on('end', () => {
815
+ let parsed;
816
+ try {
817
+ const raw = JSON.parse(body);
818
+ // JSON.parse accepts the literal `null` and primitive bodies as
819
+ // valid JSON. Reject anything that isn't a plain object — otherwise
820
+ // the .title access below throws and crashes the request callback.
821
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
822
+ res.writeHead(400, { 'Content-Type': 'application/json' });
823
+ res.end(JSON.stringify({ error: 'invalid_body' }));
824
+ return;
825
+ }
826
+ parsed = raw;
827
+ }
828
+ catch {
829
+ res.writeHead(400, { 'Content-Type': 'application/json' });
830
+ res.end(JSON.stringify({ error: 'invalid_json' }));
831
+ return;
832
+ }
833
+ if (typeof parsed.title !== 'string') {
834
+ res.writeHead(400, { 'Content-Type': 'application/json' });
835
+ res.end(JSON.stringify({ error: 'title_required' }));
836
+ return;
837
+ }
838
+ const result = renameProject(slug, parsed.title);
839
+ if (!result.ok) {
840
+ // 404 = project missing; 500 = fs append failed; 400 = validation.
841
+ let status = 400;
842
+ if (result.error === 'Project not found')
843
+ status = 404;
844
+ else if (result.error === 'Write failed')
845
+ status = 500;
846
+ res.writeHead(status, { 'Content-Type': 'application/json' });
847
+ res.end(JSON.stringify({ error: result.error }));
848
+ return;
849
+ }
850
+ res.writeHead(200, { 'Content-Type': 'application/json' });
851
+ res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
852
+ });
853
+ return;
854
+ }
573
855
  // ── F1 task orchestration routes ─────────────────────────────────────────
574
856
  // POST /api/task — { prompt, cwd? } → { sessionId }
575
857
  // GET /api/tasks — list all sessions
@@ -577,14 +859,118 @@ export function startMCPServer(options = {}) {
577
859
  // POST /api/task/:id/kill — terminate
578
860
  if (req.url && req.url.startsWith('/api/task')) {
579
861
  const manager = SessionManager.getInstance();
862
+ // v0.6 M5 slice 1 — POST /api/task/batch.
863
+ // Spawns N tasks at once (independent files only, per W2 — same-file
864
+ // conflicts ship in v0.7). Each spawn flows through SessionManager
865
+ // so pre-prompt scan + audit still run per task. Cap of 5 concurrent
866
+ // to prevent runaway. Returns array of { sessionId, agent } in input
867
+ // order; per-task failures are surfaced as { error, agent } entries
868
+ // so a partial-failure batch still returns useful info.
869
+ if (req.method === 'POST' && req.url === '/api/task/batch') {
870
+ let body = '';
871
+ req.on('data', (c) => { body += c; });
872
+ req.on('end', () => {
873
+ let parsed;
874
+ try {
875
+ parsed = JSON.parse(body || '{}');
876
+ }
877
+ catch (e) {
878
+ res.writeHead(400, { 'Content-Type': 'application/json' });
879
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
880
+ return;
881
+ }
882
+ const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
883
+ if (tasks.length === 0) {
884
+ res.writeHead(400, { 'Content-Type': 'application/json' });
885
+ res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
886
+ return;
887
+ }
888
+ if (tasks.length > 5) {
889
+ res.writeHead(400, { 'Content-Type': 'application/json' });
890
+ res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
891
+ return;
892
+ }
893
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
894
+ // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
895
+ // is the most likely way to overshoot a daily budget (5 paid spawns
896
+ // at once); checking once at batch entry then re-checking per task
897
+ // is overkill, so we snapshot the budget + spend ONCE and let each
898
+ // paid spawn that would push us over fail with the same error shape
899
+ // /api/task uses. Free-tier spawns always pass.
900
+ const batchBudget = loadBudget();
901
+ const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
902
+ const results = [];
903
+ for (const t of tasks) {
904
+ const prompt = t.prompt;
905
+ if (!prompt || typeof prompt !== 'string') {
906
+ results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
907
+ continue;
908
+ }
909
+ const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
910
+ if (!validAgentIds.has(agentId)) {
911
+ results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
912
+ continue;
913
+ }
914
+ if (t.cwd !== undefined) {
915
+ if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
916
+ results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
917
+ continue;
918
+ }
919
+ }
920
+ // Per-task budget check (paid agents only).
921
+ if (batchBudget.dailyUsd !== null) {
922
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
923
+ if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
924
+ results.push({
925
+ error: 'daily_budget_exhausted',
926
+ agent: agentId,
927
+ spentTodayUsd: batchSpendNow,
928
+ dailyBudgetUsd: batchBudget.dailyUsd,
929
+ });
930
+ continue;
931
+ }
932
+ }
933
+ try {
934
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
935
+ results.push({
936
+ sessionId: session.sessionId,
937
+ agent: session.agent,
938
+ status: session.status,
939
+ startedAt: session.startedAt,
940
+ });
941
+ }
942
+ catch (e) {
943
+ const msg = String(e?.message || e);
944
+ const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
945
+ results.push({ error: code, reason: msg, agent: agentId });
946
+ }
947
+ }
948
+ res.writeHead(200, { 'Content-Type': 'application/json' });
949
+ res.end(JSON.stringify({ tasks: results, count: results.length }));
950
+ });
951
+ return;
952
+ }
580
953
  if (req.method === 'POST' && req.url === '/api/task') {
581
954
  let body = '';
582
955
  req.on('data', (c) => {
583
956
  body += c;
584
957
  });
585
958
  req.on('end', () => {
959
+ // Two-phase try: JSON parse failures get one error shape, spawn
960
+ // failures get a different one. Pre-fix the catch wrapped every
961
+ // failure as "Invalid JSON body" — including the pre-prompt scan
962
+ // throw — which read as a parse bug to the client.
963
+ let parsed;
586
964
  try {
587
- const { prompt, cwd, agent } = JSON.parse(body || '{}');
965
+ parsed = JSON.parse(body || '{}');
966
+ }
967
+ catch (e) {
968
+ res.writeHead(400, { 'Content-Type': 'application/json' });
969
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
970
+ return;
971
+ }
972
+ try {
973
+ const { prompt, cwd, agent } = parsed;
588
974
  if (!prompt || typeof prompt !== 'string') {
589
975
  res.writeHead(400, { 'Content-Type': 'application/json' });
590
976
  res.end(JSON.stringify({ error: 'prompt (string) required' }));
@@ -608,7 +994,7 @@ export function startMCPServer(options = {}) {
608
994
  return;
609
995
  }
610
996
  const validAgentIds = new Set(allAdapters().map((a) => a.id));
611
- const agentId = (typeof agent === 'string' && agent.length > 0) ? agent : 'conductor';
997
+ const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
612
998
  if (!validAgentIds.has(agentId)) {
613
999
  res.writeHead(400, { 'Content-Type': 'application/json' });
614
1000
  res.end(JSON.stringify({
@@ -626,6 +1012,31 @@ export function startMCPServer(options = {}) {
626
1012
  return;
627
1013
  }
628
1014
  }
1015
+ // v0.6 S3 slice 2 — pre-spawn budget gate. Blocks paid spawns
1016
+ // when today's estimated spend already meets the user's daily
1017
+ // ceiling. Free-tier specialists (gemini, opencode) always pass:
1018
+ // "don't let me spend more, but free is fine" matches user intent.
1019
+ // Conductor is treated as paid because it routes to whatever's
1020
+ // available (typically claude-code). Per
1021
+ // claim_market_position v3 cost-bounded-execution edge.
1022
+ const budgetForSpawn = loadBudget();
1023
+ if (budgetForSpawn.dailyUsd !== null) {
1024
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1025
+ const isPaidSpawn = probe > 0;
1026
+ if (isPaidSpawn) {
1027
+ const todaySpend = computeSpendToday();
1028
+ if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
1029
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1030
+ res.end(JSON.stringify({
1031
+ error: 'daily_budget_exhausted',
1032
+ message: `Today's estimated spend ($${todaySpend.totalUsd.toFixed(2)}) has reached the daily budget ($${budgetForSpawn.dailyUsd.toFixed(2)}). Switch to a free agent (gemini, opencode) or raise the budget with \`codehere budget set <amount>\`.`,
1033
+ spentTodayUsd: todaySpend.totalUsd,
1034
+ dailyBudgetUsd: budgetForSpawn.dailyUsd,
1035
+ }));
1036
+ return;
1037
+ }
1038
+ }
1039
+ }
629
1040
  const session = manager.spawn(prompt, agentId, { cwd });
630
1041
  res.writeHead(200, { 'Content-Type': 'application/json' });
631
1042
  res.end(JSON.stringify({
@@ -641,21 +1052,32 @@ export function startMCPServer(options = {}) {
641
1052
  }));
642
1053
  }
643
1054
  catch (e) {
644
- res.writeHead(400, { 'Content-Type': 'application/json' });
645
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1055
+ // Spawn-phase failure. Distinguish the cross-vendor pre-prompt
1056
+ // scan refusal (#110) — actionable safety block — from generic
1057
+ // adapter / install / SessionManager errors. The pre-prompt
1058
+ // case gets 422 (Unprocessable Entity, "we understood but
1059
+ // refused") + a structured 'pre_prompt_blocked' code so the
1060
+ // web UI can surface a recovery affordance.
1061
+ const msg = String(e?.message || e);
1062
+ if (msg.includes('pre-prompt scan blocked')) {
1063
+ res.writeHead(422, { 'Content-Type': 'application/json' });
1064
+ res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1065
+ return;
1066
+ }
1067
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1068
+ res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
646
1069
  }
647
1070
  });
648
1071
  return;
649
1072
  }
650
1073
  if (req.method === 'GET' && req.url === '/api/tasks') {
651
1074
  const spawned = manager.list().map((s) => {
652
- // Bug 2 sister-fix (May 2): include completedAt for in-memory exited
653
- // sessions so the UI can compute duration. TaskSession interface
654
- // intentionally lacks completedAt; SessionManager tracks it separately
655
- // in completedAtBySession map (populated when exit event fires).
656
1075
  const completedAt = manager.getCompletedAt(s.sessionId);
657
1076
  const finalReport = manager.getFinalReport(s.sessionId);
658
1077
  const cwd = manager.getCwd(s.sessionId);
1078
+ const continuedFrom = manager.getContinuedFrom(s.sessionId);
1079
+ const continuedAs = manager.getContinuedAs(s.sessionId);
1080
+ const failedReport = manager.hasFailedReport(s.sessionId);
659
1081
  return {
660
1082
  sessionId: s.sessionId,
661
1083
  prompt: s.prompt,
@@ -666,13 +1088,11 @@ export function startMCPServer(options = {}) {
666
1088
  artifacts: s.artifacts,
667
1089
  ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
668
1090
  ...(finalReport !== undefined ? { finalReport } : {}),
669
- // Iter 12 — surface canAppendMessage so the UI knows whether to
670
- // render the reply input or a disabled "follow-up unsupported" state.
1091
+ ...(failedReport ? { failedReport: true } : {}),
671
1092
  canAppendMessage: s.canAppendMessage === true,
672
- // v0.6.x Slack-shape spike — surface cwd so the dashboard's
673
- // "Continue thread" intake button can find the most recent
674
- // in-memory session for the current folder.
675
1093
  ...(cwd !== undefined ? { cwd } : {}),
1094
+ ...(continuedFrom !== undefined ? { continuedFrom } : {}),
1095
+ ...(continuedAs !== undefined ? { continuedAs } : {}),
676
1096
  provenance: 'spawned',
677
1097
  };
678
1098
  });
@@ -698,6 +1118,55 @@ export function startMCPServer(options = {}) {
698
1118
  }));
699
1119
  return;
700
1120
  }
1121
+ // P18 slice 4 — GET /api/task/:id/blocks → audit entries with
1122
+ // blocked:true for this session. Lets the session-card UI panel
1123
+ // surface "codehere noticed this pattern" prompts when the per-
1124
+ // tool hook fires mid-session. Reads from the tail of the audit
1125
+ // log; cheap because hook entries are short.
1126
+ const blocksMatch = req.url.match(/^\/api\/task\/([^/]+)\/blocks$/);
1127
+ if (blocksMatch && req.method === 'GET') {
1128
+ const sessId = blocksMatch[1];
1129
+ const blocks = [];
1130
+ try {
1131
+ if (existsSync(AUDIT_FILE)) {
1132
+ const content = readFileSync(AUDIT_FILE, 'utf-8');
1133
+ const lines = content.split('\n');
1134
+ // Walk backward — most recent first — collect up to 20.
1135
+ for (let i = lines.length - 1; i >= 0 && blocks.length < 20; i--) {
1136
+ const ln = lines[i].trim();
1137
+ if (!ln)
1138
+ continue;
1139
+ try {
1140
+ const e = JSON.parse(ln);
1141
+ if (e.sessionId === sessId && e.blocked === true) {
1142
+ blocks.unshift(e);
1143
+ }
1144
+ }
1145
+ catch {
1146
+ /* skip malformed */
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ catch {
1152
+ /* fail open — empty list */
1153
+ }
1154
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1155
+ res.end(JSON.stringify({ blocks }));
1156
+ return;
1157
+ }
1158
+ // v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
1159
+ // Read-only; SessionManager generates the narration in a background
1160
+ // timer and caches the latest. Frontend polls (~30s) while the
1161
+ // session is running.
1162
+ const narrMatch = req.url.match(/^\/api\/task\/([^/]+)\/narration$/);
1163
+ if (narrMatch && req.method === 'GET') {
1164
+ const sessId = narrMatch[1];
1165
+ const narration = manager.getNarration(sessId);
1166
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1167
+ res.end(JSON.stringify({ narration: narration ?? null }));
1168
+ return;
1169
+ }
701
1170
  // /api/task/:id/events or /api/task/:id/kill
702
1171
  const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
703
1172
  if (m) {
@@ -740,7 +1209,9 @@ export function startMCPServer(options = {}) {
740
1209
  return;
741
1210
  }
742
1211
  let body = '';
743
- req.on('data', (c) => { body += c.toString('utf-8'); });
1212
+ req.on('data', (c) => {
1213
+ body += c.toString('utf-8');
1214
+ });
744
1215
  req.on('end', async () => {
745
1216
  try {
746
1217
  const parsed = JSON.parse(body || '{}');
@@ -792,6 +1263,62 @@ export function startMCPServer(options = {}) {
792
1263
  res.end(JSON.stringify({ error: 'Method not allowed for this path' }));
793
1264
  return;
794
1265
  }
1266
+ // v0.6.x P0 — GET /api/failover-offers
1267
+ // Returns active failover offers so the dashboard can surface a
1268
+ // "Claude rate-limited — switch to Codex?" banner. Offers expire
1269
+ // after 60 s (handled by SessionManager).
1270
+ if (req.method === 'GET' && req.url === '/api/failover-offers') {
1271
+ const offers = SessionManager.getInstance().getFailoverOffers();
1272
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1273
+ res.end(JSON.stringify({ offers }));
1274
+ return;
1275
+ }
1276
+ // v0.6.x P0 — POST /api/failover-accept
1277
+ // User clicked "Switch" on the failoffer banner. Spawns a
1278
+ // replacement session on the target agent, preserving the
1279
+ // original prompt + cwd.
1280
+ if (req.method === 'POST' && req.url === '/api/failover-accept') {
1281
+ let body = '';
1282
+ req.on('data', (c) => { body += c; });
1283
+ req.on('end', () => {
1284
+ let parsed;
1285
+ try {
1286
+ const raw = JSON.parse(body);
1287
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1288
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1289
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1290
+ return;
1291
+ }
1292
+ parsed = raw;
1293
+ }
1294
+ catch {
1295
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1296
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1297
+ return;
1298
+ }
1299
+ if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
1300
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1301
+ res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
1302
+ return;
1303
+ }
1304
+ try {
1305
+ const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
1306
+ if (result) {
1307
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1308
+ res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
1309
+ }
1310
+ else {
1311
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1312
+ res.end(JSON.stringify({ ok: false, error: 'target_unavailable', detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.` }));
1313
+ }
1314
+ }
1315
+ catch (e) {
1316
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1317
+ res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1318
+ }
1319
+ });
1320
+ return;
1321
+ }
795
1322
  // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
796
1323
  // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
797
1324
  if (req.url === '/mcp') {
@@ -844,12 +1371,116 @@ export function startMCPServer(options = {}) {
844
1371
  }
845
1372
  return;
846
1373
  }
847
- // 404
848
- res.writeHead(404);
849
- res.end();
1374
+ // 404 — friendly HTML page with route list (Bug A6 fix May 6 2026).
1375
+ // Pre-fix: empty body, blank-page failure mode the dogfood walk caught.
1376
+ // Per claim_user_persona HIGH non-technical-first: a non-technical user
1377
+ // who typos a URL gets a useful page back, not a void.
1378
+ const requestedPath = (req.url ?? '/').split('?')[0];
1379
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
1380
+ res.end(`<!doctype html>
1381
+ <html lang="en">
1382
+ <head>
1383
+ <meta charset="utf-8"/>
1384
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
1385
+ <title>codehere · page not found</title>
1386
+ <style>
1387
+ :root {
1388
+ --bg: #0f0f12;
1389
+ --fg: #f5f5f7;
1390
+ --fg-50: #8b8b94;
1391
+ --fg-30: #5a5a62;
1392
+ --border: #2a2a32;
1393
+ --accent: #ff9a76;
1394
+ --mono: ui-monospace, 'SF Mono', Menlo, monospace;
1395
+ }
1396
+ * { box-sizing: border-box; }
1397
+ body {
1398
+ margin: 0; min-height: 100vh;
1399
+ background: var(--bg); color: var(--fg);
1400
+ font-family: Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
1401
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1402
+ padding: 48px 24px;
1403
+ }
1404
+ main { max-width: 480px; width: 100%; }
1405
+ .label {
1406
+ font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
1407
+ color: var(--fg-30); margin-bottom: 12px;
1408
+ }
1409
+ h1 { font-size: 22px; font-weight: 600; margin: 0 0 12px 0; }
1410
+ .path {
1411
+ font-family: var(--mono); font-size: 13px;
1412
+ color: var(--fg-50); background: rgba(255,255,255,0.04);
1413
+ border: 1px solid var(--border); border-radius: 4px;
1414
+ padding: 6px 10px; display: inline-block; margin-bottom: 28px;
1415
+ word-break: break-all;
1416
+ }
1417
+ .routes {
1418
+ border-top: 1px solid var(--border);
1419
+ padding-top: 24px; margin-top: 8px;
1420
+ }
1421
+ .routes-label {
1422
+ font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
1423
+ color: var(--fg-30); margin-bottom: 12px;
1424
+ }
1425
+ .route {
1426
+ display: flex; align-items: baseline; gap: 14px;
1427
+ padding: 8px 0; border-bottom: 1px solid var(--border);
1428
+ text-decoration: none; color: var(--fg);
1429
+ }
1430
+ .route:last-child { border-bottom: none; }
1431
+ .route:hover { background: rgba(255,255,255,0.02); }
1432
+ .route-name { font-weight: 500; }
1433
+ .route-path { font-family: var(--mono); font-size: 12px; color: var(--fg-50); margin-left: auto; }
1434
+ </style>
1435
+ </head>
1436
+ <body>
1437
+ <main>
1438
+ <div class="label">404 · NOT FOUND</div>
1439
+ <h1>That page doesn't exist</h1>
1440
+ <div class="path">${escapeHtmlText(requestedPath)}</div>
1441
+ <div class="routes">
1442
+ <div class="routes-label">VALID ROUTES</div>
1443
+ <a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
1444
+ <a class="route" href="/threads"><span class="route-name">Project threads</span><span class="route-path">/threads</span></a>
1445
+ <a class="route" href="/history"><span class="route-name">Audit history</span><span class="route-path">/history</span></a>
1446
+ </div>
1447
+ </main>
1448
+ </body>
1449
+ </html>`);
850
1450
  });
1451
+ function escapeHtmlText(s) {
1452
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
1453
+ }
851
1454
  server.listen(port, host, () => {
852
- // server started
1455
+ // Daemon-start data hygiene — runs once after listen so any failure
1456
+ // doesn't block the server coming up. Both are best-effort.
1457
+ void (async () => {
1458
+ try {
1459
+ const { backfillProjectTitles } = await import('../storage/project-thread-mirror.js');
1460
+ const r = await backfillProjectTitles();
1461
+ if (process.env.CODEHERE_DEBUG === '1' && r.rewritten > 0) {
1462
+ console.error(`[daemon-start] backfilled ${r.rewritten}/${r.scanned} project titles`);
1463
+ }
1464
+ }
1465
+ catch {
1466
+ /* best-effort */
1467
+ }
1468
+ // Stale-session reconciliation. Walks per-project thread.jsonl files
1469
+ // and marks any sessions stuck in 'running' for >60 min as crashed.
1470
+ // Closes the dogfood-found gap where 3-day-old tasks displayed as
1471
+ // RUNNING in the threads view because a daemon crash never wrote
1472
+ // their completion event.
1473
+ try {
1474
+ const { reconcileStaleProjectTasks } = await import('../storage/project-thread-mirror.js');
1475
+ const r = await reconcileStaleProjectTasks();
1476
+ if (process.env.CODEHERE_DEBUG === '1' && r.reconciled > 0) {
1477
+ console.error(`[daemon-start] reconciled ${r.reconciled} stale running tasks`);
1478
+ }
1479
+ }
1480
+ catch {
1481
+ /* best-effort */
1482
+ }
1483
+ })();
853
1484
  });
854
1485
  return server;
855
1486
  }