codehere 0.5.0-alpha.5 → 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.
- package/README.md +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +75 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +117 -10
- package/dist/infrastructure/orchestration/session-persistence.js +28 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
- package/dist/infrastructure/protocols/ui-page.js +2728 -245
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +1 -1
|
@@ -23,15 +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, renameProject } from '../storage/project-thread-mirror.js';
|
|
34
|
+
import { randomUUID } from 'crypto';
|
|
33
35
|
import { installAgent } from '../orchestration/agent-installer.js';
|
|
34
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';
|
|
35
41
|
// ── Tool definitions ─────────────────────────────────────────────────────────
|
|
36
42
|
const TOOLS = [
|
|
37
43
|
{
|
|
@@ -70,12 +76,20 @@ const TOOLS = [
|
|
|
70
76
|
},
|
|
71
77
|
{
|
|
72
78
|
name: 'codehere_audit',
|
|
73
|
-
description: 'Read recent hook audit log entries — shows which tool calls codehere blocked or
|
|
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.',
|
|
74
80
|
inputSchema: {
|
|
75
81
|
type: 'object',
|
|
76
82
|
properties: {
|
|
77
83
|
limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
|
|
78
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
|
+
},
|
|
79
93
|
},
|
|
80
94
|
},
|
|
81
95
|
},
|
|
@@ -193,6 +207,14 @@ function handleReview(params) {
|
|
|
193
207
|
function handleAudit(params) {
|
|
194
208
|
const limit = Number(params.limit ?? 20);
|
|
195
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
|
+
}
|
|
196
218
|
if (!existsSync(AUDIT_FILE_PATH)) {
|
|
197
219
|
return JSON.stringify({
|
|
198
220
|
message: 'No audit log yet — install hooks first with: codehere hook install',
|
|
@@ -213,7 +235,13 @@ function handleAudit(params) {
|
|
|
213
235
|
}
|
|
214
236
|
})
|
|
215
237
|
.filter(Boolean)
|
|
216
|
-
.filter((e) =>
|
|
238
|
+
.filter((e) => {
|
|
239
|
+
if (overrideOnly)
|
|
240
|
+
return Boolean(e.overrideReason);
|
|
241
|
+
if (blockedOnly)
|
|
242
|
+
return Boolean(e.blocked);
|
|
243
|
+
return true;
|
|
244
|
+
})
|
|
217
245
|
.slice(-limit);
|
|
218
246
|
return JSON.stringify({ entries, total: entries.length });
|
|
219
247
|
}
|
|
@@ -304,7 +332,8 @@ export function startMCPServer(options = {}) {
|
|
|
304
332
|
return;
|
|
305
333
|
}
|
|
306
334
|
// v0.6.0 M1 — project thread view; consumes /api/projects + /api/projects/:slug
|
|
307
|
-
if (req.method === 'GET' &&
|
|
335
|
+
if (req.method === 'GET' &&
|
|
336
|
+
(req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
|
|
308
337
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
309
338
|
res.end(withDevElu(THREAD_HTML));
|
|
310
339
|
return;
|
|
@@ -371,6 +400,123 @@ export function startMCPServer(options = {}) {
|
|
|
371
400
|
res.end(JSON.stringify({ agents: availableAgents() }));
|
|
372
401
|
return;
|
|
373
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
|
+
}
|
|
374
520
|
// ── Gate B3 · Audit summary (moat visible on main page) ───────────────────
|
|
375
521
|
// Principle #3 status-at-a-glance + #7 agent-provenance. Reads the hook audit
|
|
376
522
|
// log's last 24h, aggregates: how many actions fired across all agents, how
|
|
@@ -512,6 +658,34 @@ export function startMCPServer(options = {}) {
|
|
|
512
658
|
}
|
|
513
659
|
return;
|
|
514
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
|
+
}
|
|
515
689
|
// ── F1a readiness check ───────────────────────────────────────────────────
|
|
516
690
|
if (req.method === 'GET' && req.url === '/api/readiness') {
|
|
517
691
|
try {
|
|
@@ -568,6 +742,57 @@ export function startMCPServer(options = {}) {
|
|
|
568
742
|
}
|
|
569
743
|
return;
|
|
570
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
|
+
}
|
|
571
796
|
// ── F1 task orchestration routes ─────────────────────────────────────────
|
|
572
797
|
// POST /api/task — { prompt, cwd? } → { sessionId }
|
|
573
798
|
// GET /api/tasks — list all sessions
|
|
@@ -575,14 +800,118 @@ export function startMCPServer(options = {}) {
|
|
|
575
800
|
// POST /api/task/:id/kill — terminate
|
|
576
801
|
if (req.url && req.url.startsWith('/api/task')) {
|
|
577
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
|
+
}
|
|
578
894
|
if (req.method === 'POST' && req.url === '/api/task') {
|
|
579
895
|
let body = '';
|
|
580
896
|
req.on('data', (c) => {
|
|
581
897
|
body += c;
|
|
582
898
|
});
|
|
583
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;
|
|
584
905
|
try {
|
|
585
|
-
|
|
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
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const { prompt, cwd, agent } = parsed;
|
|
586
915
|
if (!prompt || typeof prompt !== 'string') {
|
|
587
916
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
588
917
|
res.end(JSON.stringify({ error: 'prompt (string) required' }));
|
|
@@ -606,7 +935,7 @@ export function startMCPServer(options = {}) {
|
|
|
606
935
|
return;
|
|
607
936
|
}
|
|
608
937
|
const validAgentIds = new Set(allAdapters().map((a) => a.id));
|
|
609
|
-
const agentId =
|
|
938
|
+
const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
|
|
610
939
|
if (!validAgentIds.has(agentId)) {
|
|
611
940
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
612
941
|
res.end(JSON.stringify({
|
|
@@ -624,6 +953,31 @@ export function startMCPServer(options = {}) {
|
|
|
624
953
|
return;
|
|
625
954
|
}
|
|
626
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
|
+
}
|
|
627
981
|
const session = manager.spawn(prompt, agentId, { cwd });
|
|
628
982
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
629
983
|
res.end(JSON.stringify({
|
|
@@ -639,8 +993,20 @@ export function startMCPServer(options = {}) {
|
|
|
639
993
|
}));
|
|
640
994
|
}
|
|
641
995
|
catch (e) {
|
|
642
|
-
|
|
643
|
-
|
|
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 }));
|
|
644
1010
|
}
|
|
645
1011
|
});
|
|
646
1012
|
return;
|
|
@@ -653,6 +1019,7 @@ export function startMCPServer(options = {}) {
|
|
|
653
1019
|
// in completedAtBySession map (populated when exit event fires).
|
|
654
1020
|
const completedAt = manager.getCompletedAt(s.sessionId);
|
|
655
1021
|
const finalReport = manager.getFinalReport(s.sessionId);
|
|
1022
|
+
const cwd = manager.getCwd(s.sessionId);
|
|
656
1023
|
return {
|
|
657
1024
|
sessionId: s.sessionId,
|
|
658
1025
|
prompt: s.prompt,
|
|
@@ -666,6 +1033,10 @@ export function startMCPServer(options = {}) {
|
|
|
666
1033
|
// Iter 12 — surface canAppendMessage so the UI knows whether to
|
|
667
1034
|
// render the reply input or a disabled "follow-up unsupported" state.
|
|
668
1035
|
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
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
669
1040
|
provenance: 'spawned',
|
|
670
1041
|
};
|
|
671
1042
|
});
|
|
@@ -691,6 +1062,55 @@ export function startMCPServer(options = {}) {
|
|
|
691
1062
|
}));
|
|
692
1063
|
return;
|
|
693
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
|
+
}
|
|
694
1114
|
// /api/task/:id/events or /api/task/:id/kill
|
|
695
1115
|
const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
|
|
696
1116
|
if (m) {
|
|
@@ -733,7 +1153,9 @@ export function startMCPServer(options = {}) {
|
|
|
733
1153
|
return;
|
|
734
1154
|
}
|
|
735
1155
|
let body = '';
|
|
736
|
-
req.on('data', (c) => {
|
|
1156
|
+
req.on('data', (c) => {
|
|
1157
|
+
body += c.toString('utf-8');
|
|
1158
|
+
});
|
|
737
1159
|
req.on('end', async () => {
|
|
738
1160
|
try {
|
|
739
1161
|
const parsed = JSON.parse(body || '{}');
|
|
@@ -743,7 +1165,32 @@ export function startMCPServer(options = {}) {
|
|
|
743
1165
|
res.end(JSON.stringify({ error: 'text is required' }));
|
|
744
1166
|
return;
|
|
745
1167
|
}
|
|
1168
|
+
const replyStartedAt = Date.now();
|
|
746
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.
|
|
1175
|
+
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
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
/* mirror is best-effort */
|
|
1193
|
+
}
|
|
747
1194
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
748
1195
|
res.end(JSON.stringify({ sessionId, ...out }));
|
|
749
1196
|
}
|
|
@@ -812,12 +1259,116 @@ export function startMCPServer(options = {}) {
|
|
|
812
1259
|
}
|
|
813
1260
|
return;
|
|
814
1261
|
}
|
|
815
|
-
// 404
|
|
816
|
-
|
|
817
|
-
|
|
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>`);
|
|
818
1338
|
});
|
|
1339
|
+
function escapeHtmlText(s) {
|
|
1340
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
1341
|
+
}
|
|
819
1342
|
server.listen(port, host, () => {
|
|
820
|
-
//
|
|
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
|
+
})();
|
|
821
1372
|
});
|
|
822
1373
|
return server;
|
|
823
1374
|
}
|