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.
- package/README.md +8 -8
- package/dist/index.js +41 -1
- package/dist/infrastructure/hooks/claude-sdk-hook.js +44 -2
- package/dist/infrastructure/orchestration/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +41 -0
- package/dist/infrastructure/orchestration/session-manager.js +497 -6
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +447 -311
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +303 -37
- package/dist/infrastructure/storage/project-thread-mirror.js +25 -15
- package/dist/presentation/cli/repl.js +29 -0
- package/package.json +3 -1
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
752
|
-
req.on('data', (c) => {
|
|
753
|
-
body += c;
|
|
754
|
-
});
|
|
755
|
-
req.on('end', () => {
|
|
756
|
-
let parsed;
|
|
839
|
+
(async () => {
|
|
757
840
|
try {
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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: '
|
|
854
|
+
res.end(JSON.stringify({ error: 'invalid_json' }));
|
|
765
855
|
return;
|
|
766
856
|
}
|
|
767
|
-
parsed
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
return;
|
|
877
|
+
if (process.env.CODEHERE_DEBUG === '1')
|
|
878
|
+
console.error('[codehere] readBody rejected (oversized or aborted)');
|
|
773
879
|
}
|
|
774
|
-
|
|
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
|
-
|
|
812
|
-
req.on('data', (c) => { body += c; });
|
|
813
|
-
req.on('end', () => {
|
|
814
|
-
let parsed;
|
|
898
|
+
(async () => {
|
|
815
899
|
try {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
867
|
-
agent:
|
|
868
|
-
|
|
869
|
-
|
|
964
|
+
sessionId: session.sessionId,
|
|
965
|
+
agent: session.agent,
|
|
966
|
+
status: session.status,
|
|
967
|
+
startedAt: session.startedAt,
|
|
870
968
|
});
|
|
871
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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: '
|
|
1007
|
+
res.end(JSON.stringify({ error: 'prompt (string) required' }));
|
|
953
1008
|
return;
|
|
954
1009
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1156
|
-
req.on('data', (c) => {
|
|
1157
|
-
body += c.toString('utf-8');
|
|
1158
|
-
});
|
|
1159
|
-
req.on('end', async () => {
|
|
1230
|
+
(async () => {
|
|
1160
1231
|
try {
|
|
1161
|
-
const
|
|
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
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
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
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1219
|
-
req.on('data', (chunk) => {
|
|
1220
|
-
body += chunk;
|
|
1221
|
-
});
|
|
1222
|
-
req.on('end', () => {
|
|
1351
|
+
(async () => {
|
|
1223
1352
|
try {
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
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).
|