codehere 0.5.0-alpha.6 → 0.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) 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 +75 -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/failover-classifier.js +151 -0
  17. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  18. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  19. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  20. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  21. package/dist/infrastructure/orchestration/session-manager.js +95 -9
  22. package/dist/infrastructure/protocols/mcp-server-impl.js +533 -14
  23. package/dist/infrastructure/protocols/ui-page.js +2598 -212
  24. package/dist/infrastructure/security/permissions-config.js +234 -0
  25. package/dist/infrastructure/storage/project-thread-mirror.js +346 -24
  26. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  27. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  28. package/dist/presentation/cli/commands/budget-command.js +79 -0
  29. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  30. package/dist/presentation/cli/commands/permission-command.js +116 -0
  31. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  32. package/dist/utils/typo-guard.js +77 -17
  33. package/package.json +1 -1
@@ -23,17 +23,21 @@ 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
29
  import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
30
30
  import { isAbsolute } from 'path';
31
31
  import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
32
32
  import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
33
- import { mirrorReplyToThread } from '../storage/project-thread-mirror.js';
33
+ import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
34
34
  import { randomUUID } from 'crypto';
35
35
  import { installAgent } from '../orchestration/agent-installer.js';
36
36
  import { detectReadiness } from '../orchestration/readiness.js';
37
+ import { getHookMode } from '../hooks/hook-mode.js';
38
+ import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
39
+ import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
40
+ import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
37
41
  // ── Tool definitions ─────────────────────────────────────────────────────────
38
42
  const TOOLS = [
39
43
  {
@@ -72,12 +76,20 @@ const TOOLS = [
72
76
  },
73
77
  {
74
78
  name: 'codehere_audit',
75
- description: 'Read recent hook audit log entries — shows which tool calls codehere blocked or allowed in Claude Code sessions.',
79
+ 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
80
  inputSchema: {
77
81
  type: 'object',
78
82
  properties: {
79
83
  limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
80
84
  blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
85
+ overrideOnly: {
86
+ type: 'boolean',
87
+ 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.',
88
+ },
89
+ verify: {
90
+ type: 'boolean',
91
+ 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).',
92
+ },
81
93
  },
82
94
  },
83
95
  },
@@ -195,6 +207,14 @@ function handleReview(params) {
195
207
  function handleAudit(params) {
196
208
  const limit = Number(params.limit ?? 20);
197
209
  const blockedOnly = Boolean(params.blockedOnly);
210
+ const overrideOnly = Boolean(params.overrideOnly);
211
+ const verify = Boolean(params.verify);
212
+ if (verify) {
213
+ // Tamper-evidence verification — walks the file, recomputes every hash,
214
+ // checks the chain. Returns full report including ALL break points.
215
+ const result = verifyAuditChain(AUDIT_FILE_PATH);
216
+ return JSON.stringify(result, null, 2);
217
+ }
198
218
  if (!existsSync(AUDIT_FILE_PATH)) {
199
219
  return JSON.stringify({
200
220
  message: 'No audit log yet — install hooks first with: codehere hook install',
@@ -215,7 +235,13 @@ function handleAudit(params) {
215
235
  }
216
236
  })
217
237
  .filter(Boolean)
218
- .filter((e) => !blockedOnly || e.blocked)
238
+ .filter((e) => {
239
+ if (overrideOnly)
240
+ return Boolean(e.overrideReason);
241
+ if (blockedOnly)
242
+ return Boolean(e.blocked);
243
+ return true;
244
+ })
219
245
  .slice(-limit);
220
246
  return JSON.stringify({ entries, total: entries.length });
221
247
  }
@@ -306,7 +332,8 @@ export function startMCPServer(options = {}) {
306
332
  return;
307
333
  }
308
334
  // 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?'))) {
335
+ if (req.method === 'GET' &&
336
+ (req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
310
337
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
311
338
  res.end(withDevElu(THREAD_HTML));
312
339
  return;
@@ -373,6 +400,123 @@ export function startMCPServer(options = {}) {
373
400
  res.end(JSON.stringify({ agents: availableAgents() }));
374
401
  return;
375
402
  }
403
+ // P12 slice — tamper-evidence verification of the audit log.
404
+ // Calls verifyAuditChain() and returns a compact summary. /history
405
+ // polls this every minute and shows a red banner if valid=false.
406
+ if (req.method === 'GET' && req.url === '/api/audit/verify') {
407
+ try {
408
+ const r = verifyAuditChain();
409
+ res.writeHead(200, { 'Content-Type': 'application/json' });
410
+ res.end(JSON.stringify({
411
+ valid: r.valid,
412
+ totalEntries: r.totalEntries,
413
+ chainedEntries: r.chainedEntries,
414
+ legacyEntries: r.legacyEntries,
415
+ errors: r.errors.slice(0, 10),
416
+ }));
417
+ }
418
+ catch {
419
+ res.writeHead(200, { 'Content-Type': 'application/json' });
420
+ res.end(JSON.stringify({ valid: true, totalEntries: 0, chainedEntries: 0, legacyEntries: 0, errors: [] }));
421
+ }
422
+ return;
423
+ }
424
+ // P18 slice 3 — permissions read/write surface. Lets the future
425
+ // dashboard panel (when a hook block fires) call "Approve in this
426
+ // folder" without shelling out. The CLI command and this endpoint
427
+ // both call addPermissionEntry → single source of truth.
428
+ if (req.method === 'GET' && req.url?.startsWith('/api/permissions?')) {
429
+ const cwd = decodeURIComponent(req.url.split('cwd=')[1] || '');
430
+ if (!cwd || !isAbsolute(cwd)) {
431
+ res.writeHead(400, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify({ error: 'cwd query param required (absolute path)' }));
433
+ return;
434
+ }
435
+ try {
436
+ const cfg = loadPermissions(cwd);
437
+ res.writeHead(200, { 'Content-Type': 'application/json' });
438
+ res.end(JSON.stringify({ cwd, permissions: cfg }));
439
+ }
440
+ catch {
441
+ res.writeHead(200, { 'Content-Type': 'application/json' });
442
+ res.end(JSON.stringify({ cwd, permissions: { allow: [], deny: [] } }));
443
+ }
444
+ return;
445
+ }
446
+ if (req.method === 'POST' && req.url === '/api/permissions') {
447
+ let body = '';
448
+ req.on('data', (c) => { body += c; });
449
+ req.on('end', () => {
450
+ let parsed;
451
+ try {
452
+ parsed = JSON.parse(body || '{}');
453
+ }
454
+ catch (e) {
455
+ res.writeHead(400, { 'Content-Type': 'application/json' });
456
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
457
+ return;
458
+ }
459
+ const cwd = parsed.cwd;
460
+ const pattern = parsed.pattern;
461
+ const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
462
+ if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
463
+ res.writeHead(400, { 'Content-Type': 'application/json' });
464
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
465
+ return;
466
+ }
467
+ if (!pattern || typeof pattern !== 'string') {
468
+ res.writeHead(400, { 'Content-Type': 'application/json' });
469
+ res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
470
+ return;
471
+ }
472
+ try {
473
+ const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
474
+ res.writeHead(200, { 'Content-Type': 'application/json' });
475
+ res.end(JSON.stringify(result));
476
+ }
477
+ catch (e) {
478
+ res.writeHead(500, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
480
+ }
481
+ });
482
+ return;
483
+ }
484
+ if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
485
+ const params = new URLSearchParams(req.url.split('?')[1] || '');
486
+ const cwd = params.get('cwd') || '';
487
+ const pattern = params.get('pattern') || '';
488
+ if (!cwd || !isAbsolute(cwd) || !pattern) {
489
+ res.writeHead(400, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ error: 'cwd (absolute) + pattern query params required' }));
491
+ return;
492
+ }
493
+ try {
494
+ const result = removePermissionEntry(cwd, pattern);
495
+ res.writeHead(200, { 'Content-Type': 'application/json' });
496
+ res.end(JSON.stringify(result));
497
+ }
498
+ catch (e) {
499
+ res.writeHead(500, { 'Content-Type': 'application/json' });
500
+ res.end(JSON.stringify({ error: 'remove_failed', detail: String(e?.message || e) }));
501
+ }
502
+ return;
503
+ }
504
+ // Pattern 20 — hook-mode indicator. Read-only by design; mode
505
+ // switching goes through `codehere hook install/uninstall` so the
506
+ // safety surface re-validates. Cheap (one settings.json parse);
507
+ // safe to poll from the dashboard header at 30s cadence.
508
+ if (req.method === 'GET' && req.url === '/api/hook/mode') {
509
+ let mode;
510
+ try {
511
+ mode = getHookMode();
512
+ }
513
+ catch {
514
+ mode = 'off';
515
+ }
516
+ res.writeHead(200, { 'Content-Type': 'application/json' });
517
+ res.end(JSON.stringify({ mode }));
518
+ return;
519
+ }
376
520
  // ── Gate B3 · Audit summary (moat visible on main page) ───────────────────
377
521
  // Principle #3 status-at-a-glance + #7 agent-provenance. Reads the hook audit
378
522
  // log's last 24h, aggregates: how many actions fired across all agents, how
@@ -514,6 +658,34 @@ export function startMCPServer(options = {}) {
514
658
  }
515
659
  return;
516
660
  }
661
+ // ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
662
+ // GET /api/spend/today — read-only aggregate of today's estimated USD spend
663
+ // plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
664
+ // dashboard's "spent today" pill + budget warn/block colors. Per
665
+ // claim_market_position v3 capture-strategy edge "cost-bounded execution":
666
+ // this is the read surface; the actual block happens at /api/task spawn.
667
+ if (req.method === 'GET' && req.url === '/api/spend/today') {
668
+ try {
669
+ const spend = computeSpendToday();
670
+ const budget = loadBudget();
671
+ const pctUsed = budgetUsedPct(spend.totalUsd, budget);
672
+ res.writeHead(200, { 'Content-Type': 'application/json' });
673
+ res.end(JSON.stringify({
674
+ ...spend,
675
+ budget: {
676
+ dailyUsd: budget.dailyUsd,
677
+ warnAtPct: budget.warnAtPct,
678
+ pctUsed,
679
+ over: isOverBudget(spend.totalUsd, budget),
680
+ },
681
+ }));
682
+ }
683
+ catch (e) {
684
+ res.writeHead(500, { 'Content-Type': 'application/json' });
685
+ res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
686
+ }
687
+ return;
688
+ }
517
689
  // ── F1a readiness check ───────────────────────────────────────────────────
518
690
  if (req.method === 'GET' && req.url === '/api/readiness') {
519
691
  try {
@@ -570,6 +742,57 @@ export function startMCPServer(options = {}) {
570
742
  }
571
743
  return;
572
744
  }
745
+ // POST /api/projects/:slug/rename — body { title }
746
+ // User-initiated rename. Title agent generates a title from the first
747
+ // prompt; project intent drifts. Without rename, /agent dir gets stuck
748
+ // titled "Scan Auth File" forever even after 100+ unrelated tasks.
749
+ if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
750
+ const slug = req.url.split('/')[3];
751
+ let body = '';
752
+ req.on('data', (c) => {
753
+ body += c;
754
+ });
755
+ req.on('end', () => {
756
+ let parsed;
757
+ try {
758
+ const raw = JSON.parse(body);
759
+ // JSON.parse accepts the literal `null` and primitive bodies as
760
+ // valid JSON. Reject anything that isn't a plain object — otherwise
761
+ // the .title access below throws and crashes the request callback.
762
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
763
+ res.writeHead(400, { 'Content-Type': 'application/json' });
764
+ res.end(JSON.stringify({ error: 'invalid_body' }));
765
+ return;
766
+ }
767
+ parsed = raw;
768
+ }
769
+ catch {
770
+ res.writeHead(400, { 'Content-Type': 'application/json' });
771
+ res.end(JSON.stringify({ error: 'invalid_json' }));
772
+ return;
773
+ }
774
+ if (typeof parsed.title !== 'string') {
775
+ res.writeHead(400, { 'Content-Type': 'application/json' });
776
+ res.end(JSON.stringify({ error: 'title_required' }));
777
+ return;
778
+ }
779
+ const result = renameProject(slug, parsed.title);
780
+ if (!result.ok) {
781
+ // 404 = project missing; 500 = fs append failed; 400 = validation.
782
+ let status = 400;
783
+ if (result.error === 'Project not found')
784
+ status = 404;
785
+ else if (result.error === 'Write failed')
786
+ status = 500;
787
+ res.writeHead(status, { 'Content-Type': 'application/json' });
788
+ res.end(JSON.stringify({ error: result.error }));
789
+ return;
790
+ }
791
+ res.writeHead(200, { 'Content-Type': 'application/json' });
792
+ res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
793
+ });
794
+ return;
795
+ }
573
796
  // ── F1 task orchestration routes ─────────────────────────────────────────
574
797
  // POST /api/task — { prompt, cwd? } → { sessionId }
575
798
  // GET /api/tasks — list all sessions
@@ -577,14 +800,118 @@ export function startMCPServer(options = {}) {
577
800
  // POST /api/task/:id/kill — terminate
578
801
  if (req.url && req.url.startsWith('/api/task')) {
579
802
  const manager = SessionManager.getInstance();
803
+ // v0.6 M5 slice 1 — POST /api/task/batch.
804
+ // Spawns N tasks at once (independent files only, per W2 — same-file
805
+ // conflicts ship in v0.7). Each spawn flows through SessionManager
806
+ // so pre-prompt scan + audit still run per task. Cap of 5 concurrent
807
+ // to prevent runaway. Returns array of { sessionId, agent } in input
808
+ // order; per-task failures are surfaced as { error, agent } entries
809
+ // so a partial-failure batch still returns useful info.
810
+ if (req.method === 'POST' && req.url === '/api/task/batch') {
811
+ let body = '';
812
+ req.on('data', (c) => { body += c; });
813
+ req.on('end', () => {
814
+ let parsed;
815
+ try {
816
+ parsed = JSON.parse(body || '{}');
817
+ }
818
+ catch (e) {
819
+ res.writeHead(400, { 'Content-Type': 'application/json' });
820
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
821
+ return;
822
+ }
823
+ const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
824
+ if (tasks.length === 0) {
825
+ res.writeHead(400, { 'Content-Type': 'application/json' });
826
+ res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
827
+ return;
828
+ }
829
+ if (tasks.length > 5) {
830
+ res.writeHead(400, { 'Content-Type': 'application/json' });
831
+ res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
832
+ return;
833
+ }
834
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
835
+ // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
836
+ // is the most likely way to overshoot a daily budget (5 paid spawns
837
+ // at once); checking once at batch entry then re-checking per task
838
+ // is overkill, so we snapshot the budget + spend ONCE and let each
839
+ // paid spawn that would push us over fail with the same error shape
840
+ // /api/task uses. Free-tier spawns always pass.
841
+ const batchBudget = loadBudget();
842
+ const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
843
+ const results = [];
844
+ for (const t of tasks) {
845
+ const prompt = t.prompt;
846
+ if (!prompt || typeof prompt !== 'string') {
847
+ results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
848
+ continue;
849
+ }
850
+ const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
851
+ if (!validAgentIds.has(agentId)) {
852
+ results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
853
+ continue;
854
+ }
855
+ if (t.cwd !== undefined) {
856
+ if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
857
+ results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
858
+ continue;
859
+ }
860
+ }
861
+ // Per-task budget check (paid agents only).
862
+ if (batchBudget.dailyUsd !== null) {
863
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
864
+ if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
865
+ results.push({
866
+ error: 'daily_budget_exhausted',
867
+ agent: agentId,
868
+ spentTodayUsd: batchSpendNow,
869
+ dailyBudgetUsd: batchBudget.dailyUsd,
870
+ });
871
+ continue;
872
+ }
873
+ }
874
+ try {
875
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
876
+ results.push({
877
+ sessionId: session.sessionId,
878
+ agent: session.agent,
879
+ status: session.status,
880
+ startedAt: session.startedAt,
881
+ });
882
+ }
883
+ catch (e) {
884
+ const msg = String(e?.message || e);
885
+ const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
886
+ results.push({ error: code, reason: msg, agent: agentId });
887
+ }
888
+ }
889
+ res.writeHead(200, { 'Content-Type': 'application/json' });
890
+ res.end(JSON.stringify({ tasks: results, count: results.length }));
891
+ });
892
+ return;
893
+ }
580
894
  if (req.method === 'POST' && req.url === '/api/task') {
581
895
  let body = '';
582
896
  req.on('data', (c) => {
583
897
  body += c;
584
898
  });
585
899
  req.on('end', () => {
900
+ // Two-phase try: JSON parse failures get one error shape, spawn
901
+ // failures get a different one. Pre-fix the catch wrapped every
902
+ // failure as "Invalid JSON body" — including the pre-prompt scan
903
+ // throw — which read as a parse bug to the client.
904
+ let parsed;
905
+ try {
906
+ parsed = JSON.parse(body || '{}');
907
+ }
908
+ catch (e) {
909
+ res.writeHead(400, { 'Content-Type': 'application/json' });
910
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
911
+ return;
912
+ }
586
913
  try {
587
- const { prompt, cwd, agent } = JSON.parse(body || '{}');
914
+ const { prompt, cwd, agent } = parsed;
588
915
  if (!prompt || typeof prompt !== 'string') {
589
916
  res.writeHead(400, { 'Content-Type': 'application/json' });
590
917
  res.end(JSON.stringify({ error: 'prompt (string) required' }));
@@ -608,7 +935,7 @@ export function startMCPServer(options = {}) {
608
935
  return;
609
936
  }
610
937
  const validAgentIds = new Set(allAdapters().map((a) => a.id));
611
- const agentId = (typeof agent === 'string' && agent.length > 0) ? agent : 'conductor';
938
+ const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
612
939
  if (!validAgentIds.has(agentId)) {
613
940
  res.writeHead(400, { 'Content-Type': 'application/json' });
614
941
  res.end(JSON.stringify({
@@ -626,6 +953,31 @@ export function startMCPServer(options = {}) {
626
953
  return;
627
954
  }
628
955
  }
956
+ // v0.6 S3 slice 2 — pre-spawn budget gate. Blocks paid spawns
957
+ // when today's estimated spend already meets the user's daily
958
+ // ceiling. Free-tier specialists (gemini, opencode) always pass:
959
+ // "don't let me spend more, but free is fine" matches user intent.
960
+ // Conductor is treated as paid because it routes to whatever's
961
+ // available (typically claude-code). Per
962
+ // claim_market_position v3 cost-bounded-execution edge.
963
+ const budgetForSpawn = loadBudget();
964
+ if (budgetForSpawn.dailyUsd !== null) {
965
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
966
+ const isPaidSpawn = probe > 0;
967
+ if (isPaidSpawn) {
968
+ const todaySpend = computeSpendToday();
969
+ if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
970
+ res.writeHead(429, { 'Content-Type': 'application/json' });
971
+ res.end(JSON.stringify({
972
+ error: 'daily_budget_exhausted',
973
+ 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>\`.`,
974
+ spentTodayUsd: todaySpend.totalUsd,
975
+ dailyBudgetUsd: budgetForSpawn.dailyUsd,
976
+ }));
977
+ return;
978
+ }
979
+ }
980
+ }
629
981
  const session = manager.spawn(prompt, agentId, { cwd });
630
982
  res.writeHead(200, { 'Content-Type': 'application/json' });
631
983
  res.end(JSON.stringify({
@@ -641,8 +993,20 @@ export function startMCPServer(options = {}) {
641
993
  }));
642
994
  }
643
995
  catch (e) {
644
- res.writeHead(400, { 'Content-Type': 'application/json' });
645
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
996
+ // Spawn-phase failure. Distinguish the cross-vendor pre-prompt
997
+ // scan refusal (#110) — actionable safety block — from generic
998
+ // adapter / install / SessionManager errors. The pre-prompt
999
+ // case gets 422 (Unprocessable Entity, "we understood but
1000
+ // refused") + a structured 'pre_prompt_blocked' code so the
1001
+ // web UI can surface a recovery affordance.
1002
+ const msg = String(e?.message || e);
1003
+ if (msg.includes('pre-prompt scan blocked')) {
1004
+ res.writeHead(422, { 'Content-Type': 'application/json' });
1005
+ res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1006
+ return;
1007
+ }
1008
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1009
+ res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
646
1010
  }
647
1011
  });
648
1012
  return;
@@ -698,6 +1062,55 @@ export function startMCPServer(options = {}) {
698
1062
  }));
699
1063
  return;
700
1064
  }
1065
+ // P18 slice 4 — GET /api/task/:id/blocks → audit entries with
1066
+ // blocked:true for this session. Lets the session-card UI panel
1067
+ // surface "codehere noticed this pattern" prompts when the per-
1068
+ // tool hook fires mid-session. Reads from the tail of the audit
1069
+ // log; cheap because hook entries are short.
1070
+ const blocksMatch = req.url.match(/^\/api\/task\/([^/]+)\/blocks$/);
1071
+ if (blocksMatch && req.method === 'GET') {
1072
+ const sessId = blocksMatch[1];
1073
+ const blocks = [];
1074
+ try {
1075
+ if (existsSync(AUDIT_FILE)) {
1076
+ const content = readFileSync(AUDIT_FILE, 'utf-8');
1077
+ const lines = content.split('\n');
1078
+ // Walk backward — most recent first — collect up to 20.
1079
+ for (let i = lines.length - 1; i >= 0 && blocks.length < 20; i--) {
1080
+ const ln = lines[i].trim();
1081
+ if (!ln)
1082
+ continue;
1083
+ try {
1084
+ const e = JSON.parse(ln);
1085
+ if (e.sessionId === sessId && e.blocked === true) {
1086
+ blocks.unshift(e);
1087
+ }
1088
+ }
1089
+ catch {
1090
+ /* skip malformed */
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ catch {
1096
+ /* fail open — empty list */
1097
+ }
1098
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1099
+ res.end(JSON.stringify({ blocks }));
1100
+ return;
1101
+ }
1102
+ // v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
1103
+ // Read-only; SessionManager generates the narration in a background
1104
+ // timer and caches the latest. Frontend polls (~30s) while the
1105
+ // session is running.
1106
+ const narrMatch = req.url.match(/^\/api\/task\/([^/]+)\/narration$/);
1107
+ if (narrMatch && req.method === 'GET') {
1108
+ const sessId = narrMatch[1];
1109
+ const narration = manager.getNarration(sessId);
1110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1111
+ res.end(JSON.stringify({ narration: narration ?? null }));
1112
+ return;
1113
+ }
701
1114
  // /api/task/:id/events or /api/task/:id/kill
702
1115
  const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
703
1116
  if (m) {
@@ -740,7 +1153,9 @@ export function startMCPServer(options = {}) {
740
1153
  return;
741
1154
  }
742
1155
  let body = '';
743
- req.on('data', (c) => { body += c.toString('utf-8'); });
1156
+ req.on('data', (c) => {
1157
+ body += c.toString('utf-8');
1158
+ });
744
1159
  req.on('end', async () => {
745
1160
  try {
746
1161
  const parsed = JSON.parse(body || '{}');
@@ -844,12 +1259,116 @@ export function startMCPServer(options = {}) {
844
1259
  }
845
1260
  return;
846
1261
  }
847
- // 404
848
- res.writeHead(404);
849
- res.end();
1262
+ // 404 — friendly HTML page with route list (Bug A6 fix May 6 2026).
1263
+ // Pre-fix: empty body, blank-page failure mode the dogfood walk caught.
1264
+ // Per claim_user_persona HIGH non-technical-first: a non-technical user
1265
+ // who typos a URL gets a useful page back, not a void.
1266
+ const requestedPath = (req.url ?? '/').split('?')[0];
1267
+ res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
1268
+ res.end(`<!doctype html>
1269
+ <html lang="en">
1270
+ <head>
1271
+ <meta charset="utf-8"/>
1272
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
1273
+ <title>codehere · page not found</title>
1274
+ <style>
1275
+ :root {
1276
+ --bg: #0f0f12;
1277
+ --fg: #f5f5f7;
1278
+ --fg-50: #8b8b94;
1279
+ --fg-30: #5a5a62;
1280
+ --border: #2a2a32;
1281
+ --accent: #ff9a76;
1282
+ --mono: ui-monospace, 'SF Mono', Menlo, monospace;
1283
+ }
1284
+ * { box-sizing: border-box; }
1285
+ body {
1286
+ margin: 0; min-height: 100vh;
1287
+ background: var(--bg); color: var(--fg);
1288
+ font-family: Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
1289
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1290
+ padding: 48px 24px;
1291
+ }
1292
+ main { max-width: 480px; width: 100%; }
1293
+ .label {
1294
+ font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
1295
+ color: var(--fg-30); margin-bottom: 12px;
1296
+ }
1297
+ h1 { font-size: 22px; font-weight: 600; margin: 0 0 12px 0; }
1298
+ .path {
1299
+ font-family: var(--mono); font-size: 13px;
1300
+ color: var(--fg-50); background: rgba(255,255,255,0.04);
1301
+ border: 1px solid var(--border); border-radius: 4px;
1302
+ padding: 6px 10px; display: inline-block; margin-bottom: 28px;
1303
+ word-break: break-all;
1304
+ }
1305
+ .routes {
1306
+ border-top: 1px solid var(--border);
1307
+ padding-top: 24px; margin-top: 8px;
1308
+ }
1309
+ .routes-label {
1310
+ font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
1311
+ color: var(--fg-30); margin-bottom: 12px;
1312
+ }
1313
+ .route {
1314
+ display: flex; align-items: baseline; gap: 14px;
1315
+ padding: 8px 0; border-bottom: 1px solid var(--border);
1316
+ text-decoration: none; color: var(--fg);
1317
+ }
1318
+ .route:last-child { border-bottom: none; }
1319
+ .route:hover { background: rgba(255,255,255,0.02); }
1320
+ .route-name { font-weight: 500; }
1321
+ .route-path { font-family: var(--mono); font-size: 12px; color: var(--fg-50); margin-left: auto; }
1322
+ </style>
1323
+ </head>
1324
+ <body>
1325
+ <main>
1326
+ <div class="label">404 · NOT FOUND</div>
1327
+ <h1>That page doesn't exist</h1>
1328
+ <div class="path">${escapeHtmlText(requestedPath)}</div>
1329
+ <div class="routes">
1330
+ <div class="routes-label">VALID ROUTES</div>
1331
+ <a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
1332
+ <a class="route" href="/threads"><span class="route-name">Project threads</span><span class="route-path">/threads</span></a>
1333
+ <a class="route" href="/history"><span class="route-name">Audit history</span><span class="route-path">/history</span></a>
1334
+ </div>
1335
+ </main>
1336
+ </body>
1337
+ </html>`);
850
1338
  });
1339
+ function escapeHtmlText(s) {
1340
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
1341
+ }
851
1342
  server.listen(port, host, () => {
852
- // server started
1343
+ // Daemon-start data hygiene — runs once after listen so any failure
1344
+ // doesn't block the server coming up. Both are best-effort.
1345
+ void (async () => {
1346
+ try {
1347
+ const { backfillProjectTitles } = await import('../storage/project-thread-mirror.js');
1348
+ const r = await backfillProjectTitles();
1349
+ if (process.env.CODEHERE_DEBUG === '1' && r.rewritten > 0) {
1350
+ console.error(`[daemon-start] backfilled ${r.rewritten}/${r.scanned} project titles`);
1351
+ }
1352
+ }
1353
+ catch {
1354
+ /* best-effort */
1355
+ }
1356
+ // Stale-session reconciliation. Walks per-project thread.jsonl files
1357
+ // and marks any sessions stuck in 'running' for >60 min as crashed.
1358
+ // Closes the dogfood-found gap where 3-day-old tasks displayed as
1359
+ // RUNNING in the threads view because a daemon crash never wrote
1360
+ // their completion event.
1361
+ try {
1362
+ const { reconcileStaleProjectTasks } = await import('../storage/project-thread-mirror.js');
1363
+ const r = await reconcileStaleProjectTasks();
1364
+ if (process.env.CODEHERE_DEBUG === '1' && r.reconciled > 0) {
1365
+ console.error(`[daemon-start] reconciled ${r.reconciled} stale running tasks`);
1366
+ }
1367
+ }
1368
+ catch {
1369
+ /* best-effort */
1370
+ }
1371
+ })();
853
1372
  });
854
1373
  return server;
855
1374
  }