codehere 0.6.0-alpha.1 → 0.6.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.
@@ -26,12 +26,13 @@ import { collectFilesForMCP, scoreFileForMCP } from './mcp-helpers.js';
26
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
34
  import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
34
- import { randomUUID } from 'crypto';
35
+ import { randomUUID, timingSafeEqual } from 'crypto';
35
36
  import { installAgent } from '../orchestration/agent-installer.js';
36
37
  import { detectReadiness } from '../orchestration/readiness.js';
37
38
  import { getHookMode } from '../hooks/hook-mode.js';
@@ -299,15 +300,39 @@ function dispatch(req) {
299
300
  return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
300
301
  }
301
302
  }
303
+ const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
304
+ function readBody(req, res) {
305
+ return new Promise((resolve, reject) => {
306
+ let body = '';
307
+ req.on('data', (c) => {
308
+ body += c;
309
+ if (body.length > MAX_BODY_SIZE) {
310
+ req.pause();
311
+ res.writeHead(413, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: 'Request body too large' }));
313
+ reject(new Error('oversized'));
314
+ }
315
+ });
316
+ req.on('end', () => resolve(body));
317
+ req.on('error', () => reject(new Error('request aborted')));
318
+ });
319
+ }
302
320
  export function startMCPServer(options = {}) {
303
321
  const port = options.port ?? 3773;
304
322
  const host = options.host ?? '127.0.0.1'; // localhost only by default
305
323
  const token = options.token;
306
324
  const server = http.createServer((req, res) => {
307
- // Auth check
325
+ // Auth check — timing-safe comparison
308
326
  if (token) {
309
327
  const authHeader = req.headers.authorization ?? '';
310
- if (authHeader !== `Bearer ${token}`) {
328
+ const expected = `Bearer ${token}`;
329
+ const cmpLen = Math.max(expected.length, authHeader.length);
330
+ const bufExpected = Buffer.alloc(cmpLen);
331
+ const bufActual = Buffer.alloc(cmpLen);
332
+ bufExpected.write(expected);
333
+ bufActual.write(authHeader);
334
+ const ok = timingSafeEqual(bufExpected, bufActual);
335
+ if (!ok) {
311
336
  res.writeHead(401, { 'Content-Type': 'application/json' });
312
337
  res.end(JSON.stringify({ error: 'Unauthorized' }));
313
338
  return;
@@ -319,8 +344,14 @@ export function startMCPServer(options = {}) {
319
344
  res.end(JSON.stringify({ status: 'ok', server: 'codehere-mcp', version: getVersion(), tools: TOOLS.length }));
320
345
  return;
321
346
  }
322
- // v0.3 primary UI: task intake + active sessions
347
+ // v2 Task-Centric PM UI (primary — served at /)
323
348
  if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
349
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
350
+ res.end(withDevElu(TASK_HTML_V2));
351
+ return;
352
+ }
353
+ // v1 dashboard (legacy — at /v1)
354
+ if (req.method === 'GET' && (req.url === '/v1' || req.url === '/v1/')) {
324
355
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
325
356
  res.end(withDevElu(TASK_HTML));
326
357
  return;
@@ -394,6 +425,58 @@ export function startMCPServer(options = {}) {
394
425
  });
395
426
  return;
396
427
  }
428
+ // ── P4 SSE — session lifecycle events (realtime /v2 updates) ────────────
429
+ // Tails sessions.jsonl and pushes "change" events so the /v2 UI can
430
+ // re-fetch task state instantly instead of waiting 8s for the next poll.
431
+ // Same pattern as /events (audit log tail) but for sessions.
432
+ const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
433
+ if (req.method === 'GET' && req.url === '/api/events/sessions') {
434
+ res.writeHead(200, {
435
+ 'Content-Type': 'text/event-stream',
436
+ 'Cache-Control': 'no-cache',
437
+ Connection: 'keep-alive',
438
+ });
439
+ // Send current session state on connect
440
+ try {
441
+ const manager = SessionManager.getInstance();
442
+ const sessions = manager.list().map(s => ({
443
+ sessionId: s.sessionId,
444
+ status: s.status,
445
+ agent: s.agent,
446
+ }));
447
+ res.write(`data: ${JSON.stringify({ type: 'init', sessions })}\n\n`);
448
+ }
449
+ catch {
450
+ res.write(`data: ${JSON.stringify({ type: 'init', sessions: [] })}\n\n`);
451
+ }
452
+ // Tail sessions.jsonl for changes
453
+ let lastSize = 0;
454
+ if (existsSync(SESSIONS_FILE)) {
455
+ try {
456
+ lastSize = statSync(SESSIONS_FILE).size;
457
+ }
458
+ catch { /* first time */ }
459
+ }
460
+ const onChange = () => {
461
+ try {
462
+ if (!existsSync(SESSIONS_FILE))
463
+ return;
464
+ const currentSize = statSync(SESSIONS_FILE).size;
465
+ if (currentSize <= lastSize)
466
+ return;
467
+ lastSize = currentSize;
468
+ res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
469
+ }
470
+ catch { /* transient */ }
471
+ };
472
+ watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
473
+ const ping = setInterval(() => res.write(': ping\n\n'), 15000);
474
+ req.on('close', () => {
475
+ clearInterval(ping);
476
+ unwatchFile(SESSIONS_FILE, onChange);
477
+ });
478
+ return;
479
+ }
397
480
  // ── F2 agent registry ─────────────────────────────────────────────────────
398
481
  if (req.method === 'GET' && req.url === '/api/agents') {
399
482
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -444,41 +527,46 @@ export function startMCPServer(options = {}) {
444
527
  return;
445
528
  }
446
529
  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
- }
530
+ (async () => {
472
531
  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));
532
+ const body = await readBody(req, res);
533
+ let parsed;
534
+ try {
535
+ parsed = JSON.parse(body || '{}');
536
+ }
537
+ catch (e) {
538
+ res.writeHead(400, { 'Content-Type': 'application/json' });
539
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
540
+ return;
541
+ }
542
+ const cwd = parsed.cwd;
543
+ const pattern = parsed.pattern;
544
+ const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
545
+ if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
546
+ res.writeHead(400, { 'Content-Type': 'application/json' });
547
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
548
+ return;
549
+ }
550
+ if (!pattern || typeof pattern !== 'string') {
551
+ res.writeHead(400, { 'Content-Type': 'application/json' });
552
+ res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
553
+ return;
554
+ }
555
+ try {
556
+ const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
557
+ res.writeHead(200, { 'Content-Type': 'application/json' });
558
+ res.end(JSON.stringify(result));
559
+ }
560
+ catch (e) {
561
+ res.writeHead(500, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
563
+ }
476
564
  }
477
- catch (e) {
478
- res.writeHead(500, { 'Content-Type': 'application/json' });
479
- res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
565
+ catch {
566
+ if (process.env.CODEHERE_DEBUG === '1')
567
+ console.error('[codehere] readBody rejected (oversized or aborted)');
480
568
  }
481
- });
569
+ })();
482
570
  return;
483
571
  }
484
572
  if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
@@ -748,49 +836,48 @@ export function startMCPServer(options = {}) {
748
836
  // titled "Scan Auth File" forever even after 100+ unrelated tasks.
749
837
  if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
750
838
  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;
839
+ (async () => {
757
840
  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)) {
841
+ const body = await readBody(req, res);
842
+ let parsed;
843
+ try {
844
+ const raw = JSON.parse(body);
845
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
846
+ res.writeHead(400, { 'Content-Type': 'application/json' });
847
+ res.end(JSON.stringify({ error: 'invalid_body' }));
848
+ return;
849
+ }
850
+ parsed = raw;
851
+ }
852
+ catch {
763
853
  res.writeHead(400, { 'Content-Type': 'application/json' });
764
- res.end(JSON.stringify({ error: 'invalid_body' }));
854
+ res.end(JSON.stringify({ error: 'invalid_json' }));
765
855
  return;
766
856
  }
767
- parsed = raw;
857
+ if (typeof parsed.title !== 'string') {
858
+ res.writeHead(400, { 'Content-Type': 'application/json' });
859
+ res.end(JSON.stringify({ error: 'title_required' }));
860
+ return;
861
+ }
862
+ const result = renameProject(slug, parsed.title);
863
+ if (!result.ok) {
864
+ let status = 400;
865
+ if (result.error === 'Project not found')
866
+ status = 404;
867
+ else if (result.error === 'Write failed')
868
+ status = 500;
869
+ res.writeHead(status, { 'Content-Type': 'application/json' });
870
+ res.end(JSON.stringify({ error: result.error }));
871
+ return;
872
+ }
873
+ res.writeHead(200, { 'Content-Type': 'application/json' });
874
+ res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
768
875
  }
769
876
  catch {
770
- res.writeHead(400, { 'Content-Type': 'application/json' });
771
- res.end(JSON.stringify({ error: 'invalid_json' }));
772
- return;
877
+ if (process.env.CODEHERE_DEBUG === '1')
878
+ console.error('[codehere] readBody rejected (oversized or aborted)');
773
879
  }
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
- });
880
+ })();
794
881
  return;
795
882
  }
796
883
  // ── F1 task orchestration routes ─────────────────────────────────────────
@@ -808,218 +895,208 @@ export function startMCPServer(options = {}) {
808
895
  // order; per-task failures are surfaced as { error, agent } entries
809
896
  // so a partial-failure batch still returns useful info.
810
897
  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;
898
+ (async () => {
815
899
  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;
900
+ const body = await readBody(req, res);
901
+ let parsed;
902
+ try {
903
+ parsed = JSON.parse(body || '{}');
849
904
  }
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;
905
+ catch (e) {
906
+ res.writeHead(400, { 'Content-Type': 'application/json' });
907
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
908
+ return;
854
909
  }
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 });
910
+ const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
911
+ if (tasks.length === 0) {
912
+ res.writeHead(400, { 'Content-Type': 'application/json' });
913
+ res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
914
+ return;
915
+ }
916
+ if (tasks.length > 5) {
917
+ res.writeHead(400, { 'Content-Type': 'application/json' });
918
+ res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
919
+ return;
920
+ }
921
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
922
+ // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
923
+ // is the most likely way to overshoot a daily budget (5 paid spawns
924
+ // at once); checking once at batch entry then re-checking per task
925
+ // is overkill, so we snapshot the budget + spend ONCE and let each
926
+ // paid spawn that would push us over fail with the same error shape
927
+ // /api/task uses. Free-tier spawns always pass.
928
+ const batchBudget = loadBudget();
929
+ const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
930
+ const results = [];
931
+ for (const t of tasks) {
932
+ const prompt = t.prompt;
933
+ if (!prompt || typeof prompt !== 'string') {
934
+ results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
858
935
  continue;
859
936
  }
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)) {
937
+ const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
938
+ if (!validAgentIds.has(agentId)) {
939
+ results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
940
+ continue;
941
+ }
942
+ if (t.cwd !== undefined) {
943
+ if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
944
+ results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
945
+ continue;
946
+ }
947
+ }
948
+ // Per-task budget check (paid agents only).
949
+ if (batchBudget.dailyUsd !== null) {
950
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
951
+ if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
952
+ results.push({
953
+ error: 'daily_budget_exhausted',
954
+ agent: agentId,
955
+ spentTodayUsd: batchSpendNow,
956
+ dailyBudgetUsd: batchBudget.dailyUsd,
957
+ });
958
+ continue;
959
+ }
960
+ }
961
+ try {
962
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
865
963
  results.push({
866
- error: 'daily_budget_exhausted',
867
- agent: agentId,
868
- spentTodayUsd: batchSpendNow,
869
- dailyBudgetUsd: batchBudget.dailyUsd,
964
+ sessionId: session.sessionId,
965
+ agent: session.agent,
966
+ status: session.status,
967
+ startedAt: session.startedAt,
870
968
  });
871
- continue;
969
+ }
970
+ catch (e) {
971
+ const msg = String(e?.message || e);
972
+ const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
973
+ results.push({ error: code, reason: msg, agent: agentId });
872
974
  }
873
975
  }
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
- }
976
+ res.writeHead(200, { 'Content-Type': 'application/json' });
977
+ res.end(JSON.stringify({ tasks: results, count: results.length }));
888
978
  }
889
- res.writeHead(200, { 'Content-Type': 'application/json' });
890
- res.end(JSON.stringify({ tasks: results, count: results.length }));
891
- });
979
+ catch {
980
+ if (process.env.CODEHERE_DEBUG === '1')
981
+ console.error('[codehere] readBody rejected (oversized or aborted)');
982
+ }
983
+ })();
892
984
  return;
893
985
  }
894
986
  if (req.method === 'POST' && req.url === '/api/task') {
895
- let body = '';
896
- req.on('data', (c) => {
897
- body += c;
898
- });
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
- }
987
+ (async () => {
913
988
  try {
914
- const { prompt, cwd, agent } = parsed;
915
- if (!prompt || typeof prompt !== 'string') {
916
- res.writeHead(400, { 'Content-Type': 'application/json' });
917
- res.end(JSON.stringify({ error: 'prompt (string) required' }));
918
- return;
919
- }
920
- // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
921
- // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
922
- // future-proofs against partial-match exploits, and fails fast
923
- // with a clear 400 instead of letting SessionManager.spawn throw.
924
- //
925
- // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
926
- // UI is the Conductor surface; tasks submitted without an explicit
927
- // agent route through the supervisor (rule-based routing today,
928
- // LLM classifier in v0.6). Empty string is treated like undefined
929
- // so the form submitting `agent: ""` (Auto option) hits the same
930
- // path. Specialists remain explicit overrides via the dropdown.
931
- // Type-check is preserved: non-string non-undefined values still 400.
932
- if (agent !== undefined && agent !== null && typeof agent !== 'string') {
933
- res.writeHead(400, { 'Content-Type': 'application/json' });
934
- res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
935
- return;
989
+ const body = await readBody(req, res);
990
+ // Two-phase try: JSON parse failures get one error shape, spawn
991
+ // failures get a different one. Pre-fix the catch wrapped every
992
+ // failure as "Invalid JSON body" — including the pre-prompt scan
993
+ // throw — which read as a parse bug to the client.
994
+ let parsed;
995
+ try {
996
+ parsed = JSON.parse(body || '{}');
936
997
  }
937
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
938
- const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
939
- if (!validAgentIds.has(agentId)) {
998
+ catch (e) {
940
999
  res.writeHead(400, { 'Content-Type': 'application/json' });
941
- res.end(JSON.stringify({
942
- error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
943
- valid: [...validAgentIds],
944
- }));
1000
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
945
1001
  return;
946
1002
  }
947
- // Path-traversal + null-byte defense · cwd must be absolute and
948
- // free of NUL bytes (some filesystems treat \0 as terminator).
949
- if (cwd !== undefined) {
950
- if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
1003
+ try {
1004
+ const { prompt, cwd, agent } = parsed;
1005
+ if (!prompt || typeof prompt !== 'string') {
951
1006
  res.writeHead(400, { 'Content-Type': 'application/json' });
952
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1007
+ res.end(JSON.stringify({ error: 'prompt (string) required' }));
953
1008
  return;
954
1009
  }
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
- }));
1010
+ // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
1011
+ // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
1012
+ // future-proofs against partial-match exploits, and fails fast
1013
+ // with a clear 400 instead of letting SessionManager.spawn throw.
1014
+ //
1015
+ // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
1016
+ // UI is the Conductor surface; tasks submitted without an explicit
1017
+ // agent route through the supervisor (rule-based routing today,
1018
+ // LLM classifier in v0.6). Empty string is treated like undefined
1019
+ // so the form submitting `agent: ""` (Auto option) hits the same
1020
+ // path. Specialists remain explicit overrides via the dropdown.
1021
+ // Type-check is preserved: non-string non-undefined values still 400.
1022
+ if (agent !== undefined && agent !== null && typeof agent !== 'string') {
1023
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1024
+ res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
1025
+ return;
1026
+ }
1027
+ const validAgentIds = new Set(allAdapters().map((a) => a.id));
1028
+ const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
1029
+ if (!validAgentIds.has(agentId)) {
1030
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({
1032
+ error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
1033
+ valid: [...validAgentIds],
1034
+ }));
1035
+ return;
1036
+ }
1037
+ // Path-traversal + null-byte defense · cwd must be absolute and
1038
+ // free of NUL bytes (some filesystems treat \0 as terminator).
1039
+ if (cwd !== undefined) {
1040
+ if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
1041
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1042
+ res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
977
1043
  return;
978
1044
  }
979
1045
  }
1046
+ const budgetForSpawn = loadBudget();
1047
+ if (budgetForSpawn.dailyUsd !== null) {
1048
+ const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1049
+ const isPaidSpawn = probe > 0;
1050
+ if (isPaidSpawn) {
1051
+ const todaySpend = computeSpendToday();
1052
+ if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
1053
+ res.writeHead(429, { 'Content-Type': 'application/json' });
1054
+ res.end(JSON.stringify({
1055
+ error: 'daily_budget_exhausted',
1056
+ 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>\`.`,
1057
+ spentTodayUsd: todaySpend.totalUsd,
1058
+ dailyBudgetUsd: budgetForSpawn.dailyUsd,
1059
+ }));
1060
+ return;
1061
+ }
1062
+ }
1063
+ }
1064
+ const session = manager.spawn(prompt, agentId, { cwd });
1065
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1066
+ res.end(JSON.stringify({
1067
+ sessionId: session.sessionId,
1068
+ startedAt: session.startedAt,
1069
+ status: session.status,
1070
+ agent: session.agent,
1071
+ canAppendMessage: session.canAppendMessage === true,
1072
+ }));
980
1073
  }
981
- const session = manager.spawn(prompt, agentId, { cwd });
982
- res.writeHead(200, { 'Content-Type': 'application/json' });
983
- res.end(JSON.stringify({
984
- sessionId: session.sessionId,
985
- startedAt: session.startedAt,
986
- status: session.status,
987
- agent: session.agent,
988
- // Iter 13 — include canAppendMessage so the freshly-attached
989
- // session card can correctly enable/disable reply input on the
990
- // first render. Without this, ALL just-spawned sessions render
991
- // with reply disabled even when the adapter supports it.
992
- canAppendMessage: session.canAppendMessage === true,
993
- }));
994
- }
995
- catch (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;
1074
+ catch (e) {
1075
+ const msg = String(e?.message || e);
1076
+ if (msg.includes('pre-prompt scan blocked')) {
1077
+ res.writeHead(422, { 'Content-Type': 'application/json' });
1078
+ res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
1079
+ return;
1080
+ }
1081
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1082
+ res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1007
1083
  }
1008
- res.writeHead(500, { 'Content-Type': 'application/json' });
1009
- res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
1010
1084
  }
1011
- });
1085
+ catch {
1086
+ if (process.env.CODEHERE_DEBUG === '1')
1087
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1088
+ }
1089
+ })();
1012
1090
  return;
1013
1091
  }
1014
1092
  if (req.method === 'GET' && req.url === '/api/tasks') {
1015
1093
  const spawned = manager.list().map((s) => {
1016
- // Bug 2 sister-fix (May 2): include completedAt for in-memory exited
1017
- // sessions so the UI can compute duration. TaskSession interface
1018
- // intentionally lacks completedAt; SessionManager tracks it separately
1019
- // in completedAtBySession map (populated when exit event fires).
1020
1094
  const completedAt = manager.getCompletedAt(s.sessionId);
1021
1095
  const finalReport = manager.getFinalReport(s.sessionId);
1022
1096
  const cwd = manager.getCwd(s.sessionId);
1097
+ const continuedFrom = manager.getContinuedFrom(s.sessionId);
1098
+ const continuedAs = manager.getContinuedAs(s.sessionId);
1099
+ const failedReport = manager.hasFailedReport(s.sessionId);
1023
1100
  return {
1024
1101
  sessionId: s.sessionId,
1025
1102
  prompt: s.prompt,
@@ -1030,13 +1107,11 @@ export function startMCPServer(options = {}) {
1030
1107
  artifacts: s.artifacts,
1031
1108
  ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
1032
1109
  ...(finalReport !== undefined ? { finalReport } : {}),
1033
- // Iter 12 — surface canAppendMessage so the UI knows whether to
1034
- // render the reply input or a disabled "follow-up unsupported" state.
1110
+ ...(failedReport ? { failedReport: true } : {}),
1035
1111
  canAppendMessage: s.canAppendMessage === true,
1036
- // v0.6.x Slack-shape spike — surface cwd so the dashboard's
1037
- // "Continue thread" intake button can find the most recent
1038
- // in-memory session for the current folder.
1039
1112
  ...(cwd !== undefined ? { cwd } : {}),
1113
+ ...(continuedFrom !== undefined ? { continuedFrom } : {}),
1114
+ ...(continuedAs !== undefined ? { continuedAs } : {}),
1040
1115
  provenance: 'spawned',
1041
1116
  };
1042
1117
  });
@@ -1152,54 +1227,51 @@ export function startMCPServer(options = {}) {
1152
1227
  res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
1153
1228
  return;
1154
1229
  }
1155
- let body = '';
1156
- req.on('data', (c) => {
1157
- body += c.toString('utf-8');
1158
- });
1159
- req.on('end', async () => {
1230
+ (async () => {
1160
1231
  try {
1161
- const parsed = JSON.parse(body || '{}');
1162
- const text = (parsed.text ?? '').trim();
1163
- if (!text) {
1164
- res.writeHead(400, { 'Content-Type': 'application/json' });
1165
- res.end(JSON.stringify({ error: 'text is required' }));
1166
- return;
1167
- }
1168
- const replyStartedAt = Date.now();
1169
- const out = await session.appendMessage(text);
1170
- const replyCompletedAt = Date.now();
1171
- // v0.6.x — forward-mirror the reply turn into the project thread
1172
- // so /threads sees follow-ups as visible chronological turns
1173
- // (not just the original spawn). Best-effort: mirror failures
1174
- // never break the reply path.
1232
+ const body = await readBody(req, res);
1175
1233
  try {
1176
- const replyCwd = manager.getCwd(sessionId);
1177
- if (replyCwd) {
1178
- mirrorReplyToThread({
1179
- parentSessionId: sessionId,
1180
- replySessionId: randomUUID(),
1181
- agent: session.agent,
1182
- prompt: text,
1183
- cwd: replyCwd,
1184
- startedAt: replyStartedAt,
1185
- completedAt: replyCompletedAt,
1186
- durationMs: out.durationMs,
1187
- result: out.result,
1188
- });
1234
+ const parsed = JSON.parse(body || '{}');
1235
+ const text = (parsed.text ?? '').trim();
1236
+ if (!text) {
1237
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1238
+ res.end(JSON.stringify({ error: 'text is required' }));
1239
+ return;
1240
+ }
1241
+ const replyStartedAt = Date.now();
1242
+ const out = await session.appendMessage(text);
1243
+ const replyCompletedAt = Date.now();
1244
+ try {
1245
+ const replyCwd = manager.getCwd(sessionId);
1246
+ if (replyCwd) {
1247
+ mirrorReplyToThread({
1248
+ parentSessionId: sessionId,
1249
+ replySessionId: randomUUID(),
1250
+ agent: session.agent,
1251
+ prompt: text,
1252
+ cwd: replyCwd,
1253
+ startedAt: replyStartedAt,
1254
+ completedAt: replyCompletedAt,
1255
+ durationMs: out.durationMs,
1256
+ result: out.result,
1257
+ });
1258
+ }
1189
1259
  }
1260
+ catch { /* mirror is best-effort */ }
1261
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1262
+ res.end(JSON.stringify({ sessionId, ...out }));
1190
1263
  }
1191
- catch {
1192
- /* mirror is best-effort */
1264
+ catch (err) {
1265
+ const msg = err instanceof Error ? err.message : String(err);
1266
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1267
+ res.end(JSON.stringify({ error: msg }));
1193
1268
  }
1194
- res.writeHead(200, { 'Content-Type': 'application/json' });
1195
- res.end(JSON.stringify({ sessionId, ...out }));
1196
1269
  }
1197
- catch (err) {
1198
- const msg = err instanceof Error ? err.message : String(err);
1199
- res.writeHead(500, { 'Content-Type': 'application/json' });
1200
- res.end(JSON.stringify({ error: msg }));
1270
+ catch {
1271
+ if (process.env.CODEHERE_DEBUG === '1')
1272
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1201
1273
  }
1202
- });
1274
+ })();
1203
1275
  return;
1204
1276
  }
1205
1277
  }
@@ -1207,6 +1279,67 @@ export function startMCPServer(options = {}) {
1207
1279
  res.end(JSON.stringify({ error: 'Method not allowed for this path' }));
1208
1280
  return;
1209
1281
  }
1282
+ // v0.6.x P0 — GET /api/failover-offers
1283
+ // Returns active failover offers so the dashboard can surface a
1284
+ // "Claude rate-limited — switch to Codex?" banner. Offers expire
1285
+ // after 60 s (handled by SessionManager).
1286
+ if (req.method === 'GET' && req.url === '/api/failover-offers') {
1287
+ const offers = SessionManager.getInstance().getFailoverOffers();
1288
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1289
+ res.end(JSON.stringify({ offers }));
1290
+ return;
1291
+ }
1292
+ // v0.6.x P0 — POST /api/failover-accept
1293
+ // User clicked "Switch" on the failoffer banner. Spawns a
1294
+ // replacement session on the target agent, preserving the
1295
+ // original prompt + cwd.
1296
+ if (req.method === 'POST' && req.url === '/api/failover-accept') {
1297
+ (async () => {
1298
+ try {
1299
+ const body = await readBody(req, res);
1300
+ let parsed;
1301
+ try {
1302
+ const raw = JSON.parse(body);
1303
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1304
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1305
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1306
+ return;
1307
+ }
1308
+ parsed = raw;
1309
+ }
1310
+ catch {
1311
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1312
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1313
+ return;
1314
+ }
1315
+ if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
1316
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1317
+ res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
1318
+ return;
1319
+ }
1320
+ try {
1321
+ const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
1322
+ if (result) {
1323
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1324
+ res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
1325
+ }
1326
+ else {
1327
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1328
+ res.end(JSON.stringify({ ok: false, error: 'target_unavailable', detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.` }));
1329
+ }
1330
+ }
1331
+ catch (e) {
1332
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1333
+ res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
1334
+ }
1335
+ }
1336
+ catch {
1337
+ if (process.env.CODEHERE_DEBUG === '1')
1338
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1339
+ }
1340
+ })();
1341
+ return;
1342
+ }
1210
1343
  // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
1211
1344
  // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
1212
1345
  if (req.url === '/mcp') {
@@ -1215,25 +1348,28 @@ export function startMCPServer(options = {}) {
1215
1348
  res.end(JSON.stringify({ error: 'MCP endpoint accepts POST only', method: req.method }));
1216
1349
  return;
1217
1350
  }
1218
- let body = '';
1219
- req.on('data', (chunk) => {
1220
- body += chunk;
1221
- });
1222
- req.on('end', () => {
1351
+ (async () => {
1223
1352
  try {
1224
- const mcpReq = JSON.parse(body);
1225
- const mcpRes = dispatch(mcpReq);
1226
- res.writeHead(200, {
1227
- 'Content-Type': 'application/json',
1228
- 'Access-Control-Allow-Origin': 'http://localhost',
1229
- });
1230
- res.end(JSON.stringify(mcpRes));
1353
+ const body = await readBody(req, res);
1354
+ try {
1355
+ const mcpReq = JSON.parse(body);
1356
+ const mcpRes = dispatch(mcpReq);
1357
+ res.writeHead(200, {
1358
+ 'Content-Type': 'application/json',
1359
+ 'Access-Control-Allow-Origin': 'http://localhost',
1360
+ });
1361
+ res.end(JSON.stringify(mcpRes));
1362
+ }
1363
+ catch {
1364
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1365
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
1366
+ }
1231
1367
  }
1232
1368
  catch {
1233
- res.writeHead(400, { 'Content-Type': 'application/json' });
1234
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1369
+ if (process.env.CODEHERE_DEBUG === '1')
1370
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1235
1371
  }
1236
- });
1372
+ })();
1237
1373
  return;
1238
1374
  }
1239
1375
  // /api/version — small completeness item for ops monitoring (rc.17).