codehere 0.8.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +22 -13
  2. package/dist/application/services/verdict-service.js +154 -0
  3. package/dist/application/use-cases/assess-cross-vendor.js +126 -0
  4. package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
  5. package/dist/application/use-cases/export-aat.js +107 -0
  6. package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
  7. package/dist/application/use-cases/generate-final-report.js +140 -15
  8. package/dist/application/use-cases/generate-narration.js +5 -3
  9. package/dist/application/use-cases/generate-thread-title.js +5 -2
  10. package/dist/application/use-cases/plan-auto-review.js +57 -0
  11. package/dist/application/use-cases/reconcile-audit.js +100 -0
  12. package/dist/application/use-cases/rerun-tests.js +226 -0
  13. package/dist/application/use-cases/search-sessions.js +70 -0
  14. package/dist/application/use-cases/should-auto-review.js +71 -0
  15. package/dist/application/use-cases/thread-export.js +90 -0
  16. package/dist/application/use-cases/verify-claim.js +187 -5
  17. package/dist/domain/audit.js +11 -0
  18. package/dist/domain/services/codebase-detector.js +0 -6
  19. package/dist/domain/task.js +91 -0
  20. package/dist/error-handler.js +3 -115
  21. package/dist/index.js +269 -25
  22. package/dist/infrastructure/completion/bash-completion.js +7 -3
  23. package/dist/infrastructure/completion/fish-completion.js +7 -12
  24. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  25. package/dist/infrastructure/config/config-manager.js +4 -3
  26. package/dist/infrastructure/config/product-context.js +1 -19
  27. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  28. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  29. package/dist/infrastructure/context/github-api.js +110 -0
  30. package/dist/infrastructure/context/github-device-flow.js +65 -0
  31. package/dist/infrastructure/context/oauth-store.js +5 -2
  32. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  33. package/dist/infrastructure/cost/budget-config.js +3 -2
  34. package/dist/infrastructure/cost/cost-router.js +16 -23
  35. package/dist/infrastructure/cost/spend-aggregator.js +90 -15
  36. package/dist/infrastructure/cost/team-ledger.js +355 -0
  37. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  38. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  39. package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
  40. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  41. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  42. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  43. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  44. package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
  45. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  46. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  47. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  48. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  49. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  50. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  51. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  52. package/dist/infrastructure/orchestration/failover-config.js +0 -10
  53. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  54. package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
  55. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  56. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
  57. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  58. package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
  59. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  60. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  61. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  62. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  63. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  64. package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
  65. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  66. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  67. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  68. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  69. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  70. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  71. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  72. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  73. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  74. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  75. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  76. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  77. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  78. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  79. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  80. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  81. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  82. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  83. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  84. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  85. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  86. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  87. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  88. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
  89. package/dist/infrastructure/protocols/verify-page.js +849 -155
  90. package/dist/infrastructure/security/fix-code.js +144 -0
  91. package/dist/infrastructure/security/fix-secret.js +89 -0
  92. package/dist/infrastructure/security/permissions-config.js +3 -2
  93. package/dist/infrastructure/security/scan-types.js +10 -0
  94. package/dist/infrastructure/security/ship-scan.js +195 -0
  95. package/dist/infrastructure/security/trust-scorer.js +70 -3
  96. package/dist/infrastructure/security/vibe-checks.js +99 -0
  97. package/dist/infrastructure/storage/atomic-write.js +86 -0
  98. package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
  99. package/dist/infrastructure/storage/project-thread-reader.js +261 -24
  100. package/dist/monitoring.js +2 -2
  101. package/dist/presentation/cli/commands/audit-command.js +79 -4
  102. package/dist/presentation/cli/commands/budget-command.js +2 -2
  103. package/dist/presentation/cli/commands/check-command.js +96 -0
  104. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  105. package/dist/presentation/cli/commands/help-command.js +5 -5
  106. package/dist/presentation/cli/commands/hook-command.js +5 -5
  107. package/dist/presentation/cli/commands/init-command.js +26 -14
  108. package/dist/presentation/cli/commands/memory-command.js +28 -28
  109. package/dist/presentation/cli/commands/permission-command.js +1 -12
  110. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  111. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  112. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  113. package/dist/presentation/cli/commands/report-command.js +48 -32
  114. package/dist/presentation/cli/commands/scan-command.js +52 -0
  115. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  116. package/dist/presentation/cli/commands/search-command.js +51 -0
  117. package/dist/presentation/cli/commands/setup-command.js +31 -8
  118. package/dist/presentation/cli/commands/status-command.js +39 -86
  119. package/dist/presentation/cli/commands/team-command.js +256 -0
  120. package/dist/presentation/cli/commands/thread-command.js +87 -5
  121. package/dist/presentation/cli/commands/trust-command.js +53 -31
  122. package/dist/presentation/cli/commands/verify-command.js +74 -9
  123. package/dist/presentation/cli/error-display.js +0 -8
  124. package/dist/presentation/cli/repl.js +15 -7
  125. package/dist/presentation/cli/task-deliverable.js +36 -10
  126. package/dist/testing/replay-adapter.js +0 -19
  127. package/dist/ui.js +51 -239
  128. package/dist/utils/version.js +0 -6
  129. package/package.json +30 -10
  130. package/dist/domain/entities/task.js +0 -61
  131. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  132. package/dist/infrastructure/security/input-validator.js +0 -152
  133. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  134. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -19,103 +19,84 @@
19
19
  * codehere serve --port 8080 # custom port
20
20
  */
21
21
  import http from 'http';
22
- import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from 'fs';
23
- import { join, relative, dirname } from 'path';
22
+ import { readFileSync, existsSync, statSync, watchFile, unwatchFile, rmSync, mkdirSync } from 'fs';
23
+ import { execFile } from 'child_process';
24
+ import { promisify } from 'util';
25
+ import { join, dirname, resolve as resolvePath } from 'path';
26
+ import { shipScan } from '../security/ship-scan.js';
27
+ import { fixExposedSecret } from '../security/fix-secret.js';
28
+ import { fixCodeVuln } from '../security/fix-code.js';
24
29
  import { fileURLToPath } from 'url';
25
- import { collectFilesForMCP, scoreFileForMCP } from './mcp-helpers.js';
26
- import { scanPayload, calculateRiskScore, AUDIT_FILE, verifyAuditChain } from '../hooks/claude-sdk-hook.js';
30
+ import { AUDIT_FILE, MOCK_AUDIT_FILE, verifyAuditChain } from '../hooks/claude-sdk-hook.js';
31
+ import { signAuditTip, pruneSignState } from '../hooks/audit-tip-signature.js';
27
32
  import { analyzeSessions, isRealHostSession, previewFromToolInput } from '../hooks/session-analyzer.js';
28
33
  import { CodexControlSurface } from '../orchestration/control/codex-control-surface.js';
29
34
  import { getVersion } from '../../utils/version.js';
30
- import { HISTORY_HTML, THREAD_HTML } from './ui-page.js';
35
+ import { TOOLS, dispatch } from './mcp-tools.js';
31
36
  import { TASK_HTML_V2 } from './ui-page-v2.js';
32
37
  import { VERIFY_HTML } from './verify-page.js';
33
- import { verifyClaim } from '../../application/use-cases/verify-claim.js';
34
- import { signBundle, verifyBundle, shortFingerprint, } from '../../application/use-cases/thread-export.js';
38
+ import { composeVerdict } from '../../application/services/verdict-service.js';
39
+ import { signBundle, verifyBundle, shortFingerprint, collectAuditEntriesForSession, verifyEmbeddedAuditChain, } from '../../application/use-cases/thread-export.js';
35
40
  import { loadOrCreateKeypair, trustedKeySet, trustKey } from '../orchestration/thread-key-store.js';
36
- import { loadSessionById } from '../orchestration/session-persistence.js';
41
+ import { loadSessionById, appendResolved, appendCompleted } from '../orchestration/session-persistence.js';
42
+ import { buildCrossVendorVerdictById, buildCrossVendorVerdictForWriter, } from '../../application/use-cases/build-cross-vendor-verdict.js';
37
43
  import { validateIntent as validateWritePassIntent, validateDecision as validateWritePassDecision, applyDecision as applyWritePassDecision, applyResponse as applyWritePassResponse, statusToAuditLine as writePassStatusToAuditLine, newPassId, WritePassError, } from '../../application/use-cases/write-pass.js';
38
44
  import { putPending as wpPutPending, getById as wpGetById, setStatus as wpSetStatus, listForSession as wpListForSession, listPending as wpListPending, } from '../orchestration/write-pass-store.js';
39
45
  import { resolveWriteRequest, WritePassResolveError } from '../context/write-pass-resolver.js';
40
46
  import { trustDomain as trustDomainStore } from '../../application/use-cases/domain-trust.js';
41
- import { appendAuditLog, appendOverrideAudit } from '../hooks/claude-sdk-hook.js';
47
+ import { appendAuditLog, appendAuditLogDirect, appendOverrideAudit } from '../hooks/claude-sdk-hook.js';
42
48
  import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
43
49
  import { resolveContextSourceUrls } from '../context/context-resolver.js';
44
50
  import { getDefaultRegistry, createDefaultFetchContextWithPost } from '../context/registry-bootstrap.js';
45
51
  import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuthProvider, listProviders as listOAuthProviders, } from '../context/oauth-flow.js';
46
- import { saveToken, clearToken, connectionStatus } from '../context/oauth-store.js';
52
+ import { saveToken, clearToken, connectionStatus, loadToken } from '../context/oauth-store.js';
53
+ import { requestDeviceCode, pollForToken, githubClientId } from '../context/github-device-flow.js';
54
+ import { fetchIssue, listIssues, parseRepoRef, issueToTaskPrompt, commentOnIssue, issueRefFromText, } from '../context/github-api.js';
47
55
  import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
48
56
  import { isAbsolute } from 'path';
49
57
  import { findProjectRoot } from '../orchestration/memory-injector.js';
50
58
  import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
51
59
  import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
52
- import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
53
- import { mirrorReplyToThread, renameProject } from '../storage/project-thread-mirror.js';
60
+ import { listProjects, readMergedProjectThread, parseGitHubCloneTarget } from '../storage/project-thread-reader.js';
61
+ import { mirrorReplyToThread, renameProject, setTaskArchived } from '../storage/project-thread-mirror.js';
54
62
  import { randomUUID, timingSafeEqual } from 'crypto';
55
63
  import { installAgent } from '../orchestration/agent-installer.js';
56
64
  import { detectReadiness } from '../orchestration/readiness.js';
57
65
  import { getHookMode } from '../hooks/hook-mode.js';
58
66
  import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
59
67
  import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
60
- import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
61
- // ── Tool definitions ─────────────────────────────────────────────────────────
62
- const TOOLS = [
63
- {
64
- name: 'codehere_trust',
65
- description: 'Scan a file or code snippet for security vulnerabilities. Returns a trust score 0-100, grade, and plain-English findings. Zero API calls — runs entirely locally.',
66
- inputSchema: {
67
- type: 'object',
68
- properties: {
69
- filepath: { type: 'string', description: 'Path to file (relative to cwd)' },
70
- code: { type: 'string', description: 'Code content to scan (alternative to filepath)' },
71
- verbose: { type: 'boolean', description: 'Include all findings with line numbers' },
72
- },
73
- },
74
- },
75
- {
76
- name: 'codehere_scan',
77
- description: 'Scan an instruction, prompt, or command text for injection attacks, credential theft attempts, destructive operations, and other threats. Returns block/allow decision with findings.',
78
- inputSchema: {
79
- type: 'object',
80
- required: ['text'],
81
- properties: {
82
- text: { type: 'string', description: 'Text to scan (instruction, prompt, command)' },
83
- },
84
- },
85
- },
86
- {
87
- name: 'codehere_review',
88
- description: 'Review all files in a directory and return trust scores. Identifies the riskiest files to focus review effort.',
89
- inputSchema: {
90
- type: 'object',
91
- properties: {
92
- path: { type: 'string', description: 'Directory path (default: current directory)' },
93
- topN: { type: 'number', description: 'Return top N riskiest files (default: 10)' },
94
- },
95
- },
96
- },
97
- {
98
- name: 'codehere_audit',
99
- 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.',
100
- inputSchema: {
101
- type: 'object',
102
- properties: {
103
- limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
104
- blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
105
- overrideOnly: {
106
- type: 'boolean',
107
- 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.',
108
- },
109
- verify: {
110
- type: 'boolean',
111
- 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).',
112
- },
113
- },
114
- },
115
- },
116
- ];
68
+ import { loadBudget, saveBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
69
+ import { loadTeamConfig, saveTeamConfig, initTeamDir, codehereHome, writeMyShare, buildTeamView, } from '../cost/team-ledger.js';
70
+ import { recordOpenOncePerDay, discoverySummary, recruitUrl } from '../discovery/discovery-log.js';
71
+ // ── Brand favicon ────────────────────────────────────────────────────────────
72
+ // Byte-for-byte copy of the landing's app/icon.svg (codehere-landing repo —
73
+ // the brand source of truth, per Ega's standing rule). The gradient-C:
74
+ // orange #ff9a76 → blue #4a90e2, 135° (x1 0%/y1 0% → x2 100%/y2 100%).
75
+ // Embedded so the local-first daemon never hotlinks codehere.uk.
76
+ const BRAND_ICON_SVG = `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
77
+ <defs>
78
+ <linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
79
+ <stop offset="0%" style="stop-color:#ff9a76;stop-opacity:1" />
80
+ <stop offset="100%" style="stop-color:#4a90e2;stop-opacity:1" />
81
+ </linearGradient>
82
+ </defs>
83
+ <text
84
+ x="16"
85
+ y="24"
86
+ font-family="Inter, system-ui, sans-serif"
87
+ font-size="24"
88
+ font-weight="900"
89
+ text-anchor="middle"
90
+ fill="url(#gradient)"
91
+ >
92
+ C
93
+ </text>
94
+ </svg>
95
+
96
+ `;
97
+ // ── MCP message types ────────────────────────────────────────────────────────
117
98
  // ── Tool handlers ────────────────────────────────────────────────────────────
118
- import { homedir } from 'os';
99
+ import { homedir, tmpdir } from 'os';
119
100
  const controlSessions = new Map();
120
101
  let controlSeq = 0;
121
102
  /**
@@ -125,7 +106,7 @@ let controlSeq = 0;
125
106
  * public key is meant for client-side embedding (analytics tracking key, not
126
107
  * a secret) so committing the URL is safe.
127
108
  *
128
- * Why runtime injection instead of editing TASK_HTML/HISTORY_HTML directly:
109
+ * Why runtime injection instead of editing the page templates directly:
129
110
  * the templates stay identical between dev and release builds. The only
130
111
  * delta is the env var. This keeps the release surface clean (zero-telemetry
131
112
  * value per PRD §1.5 #2 stays intact) while letting elu observe founder
@@ -140,203 +121,6 @@ function withDevElu(html) {
140
121
  return html;
141
122
  return html.replace('</head>', `${ELU_DEV_SCRIPT}\n</head>`);
142
123
  }
143
- function handleTrust(params) {
144
- const { filepath, code, verbose } = params;
145
- let content;
146
- let label;
147
- if (code) {
148
- content = code;
149
- label = 'provided code';
150
- }
151
- else if (filepath) {
152
- const resolved = join(process.cwd(), filepath);
153
- if (!existsSync(resolved)) {
154
- return JSON.stringify({ error: `File not found: ${filepath}` });
155
- }
156
- content = readFileSync(resolved, 'utf-8');
157
- label = filepath;
158
- }
159
- else {
160
- return JSON.stringify({ error: 'Provide either filepath or code' });
161
- }
162
- const result = scoreFileForMCP(label, content);
163
- if (!verbose) {
164
- // Compact response
165
- return JSON.stringify({
166
- score: result.score,
167
- grade: result.grade,
168
- summary: result.summary,
169
- criticalCount: result.findings.filter((f) => f.severity === 'critical').length,
170
- highCount: result.findings.filter((f) => f.severity === 'high').length,
171
- recommendation: result.score >= 75
172
- ? 'Safe — minor issues can be addressed in follow-up'
173
- : result.score >= 60
174
- ? 'Review recommended before merge'
175
- : 'Block — resolve critical/high findings first',
176
- });
177
- }
178
- return JSON.stringify(result, null, 2);
179
- }
180
- function handleScan(params) {
181
- const text = String(params.text ?? '');
182
- const findings = scanPayload(text);
183
- const riskScore = calculateRiskScore(findings);
184
- const criticalFindings = findings.filter((f) => f.severity === 'critical');
185
- const blocked = criticalFindings.length > 0 || riskScore >= 60;
186
- return JSON.stringify({
187
- blocked,
188
- riskScore,
189
- decision: blocked ? 'block' : 'allow',
190
- reason: blocked
191
- ? criticalFindings.length > 0
192
- ? `${criticalFindings[0].category}: ${criticalFindings[0].description}`
193
- : `Risk score ${riskScore}/100 exceeds threshold`
194
- : 'No threats detected',
195
- findings: findings.slice(0, 10), // cap at 10 to keep response small
196
- });
197
- }
198
- /**
199
- * Coerce a client-supplied numeric param to a bounded integer. Daemon
200
- * endpoints take topN/limit straight from request bodies; a negative or NaN
201
- * value silently corrupts slice() math (limit=-1 → slice keeps all-but-last,
202
- * NaN → slice returns everything). Clamp to a sane range instead of trusting
203
- * the wire.
204
- */
205
- export function clampInt(v, def, min, max) {
206
- // null/undefined = absent → default. Number(null) is 0 (finite), so without
207
- // this guard a literal null would coerce to the min instead of the default.
208
- if (v === null || v === undefined)
209
- return def;
210
- const n = Math.floor(Number(v));
211
- if (!Number.isFinite(n))
212
- return def;
213
- return Math.max(min, Math.min(n, max));
214
- }
215
- function handleReview(params) {
216
- const targetPath = String(params.path ?? process.cwd());
217
- const topN = clampInt(params.topN, 10, 1, 100);
218
- try {
219
- const files = collectFilesForMCP(targetPath);
220
- if (files.length === 0) {
221
- return JSON.stringify({ message: 'No code files found', path: targetPath });
222
- }
223
- const results = files.map((f) => scoreFileForMCP(relative(targetPath, f.filepath), f.code));
224
- results.sort((a, b) => a.score - b.score); // worst first
225
- const top = results.slice(0, topN);
226
- const overallScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
227
- return JSON.stringify({
228
- overallScore,
229
- grade: overallScore >= 90 ? 'A' : overallScore >= 75 ? 'B' : overallScore >= 60 ? 'C' : overallScore >= 40 ? 'D' : 'F',
230
- totalFiles: results.length,
231
- riskiestFiles: top.map((r) => ({
232
- filepath: r.filepath,
233
- score: r.score,
234
- grade: r.grade,
235
- summary: r.summary,
236
- criticalCount: r.findings.filter((f) => f.severity === 'critical').length,
237
- highCount: r.findings.filter((f) => f.severity === 'high').length,
238
- })),
239
- });
240
- }
241
- catch (e) {
242
- return JSON.stringify({ error: String(e) });
243
- }
244
- }
245
- function handleAudit(params) {
246
- const limit = clampInt(params.limit, 20, 1, 500);
247
- const blockedOnly = Boolean(params.blockedOnly);
248
- const overrideOnly = Boolean(params.overrideOnly);
249
- const verify = Boolean(params.verify);
250
- if (verify) {
251
- // Tamper-evidence verification — walks the file, recomputes every hash,
252
- // checks the chain. Returns full report including ALL break points.
253
- const result = verifyAuditChain(AUDIT_FILE);
254
- return JSON.stringify(result, null, 2);
255
- }
256
- if (!existsSync(AUDIT_FILE)) {
257
- return JSON.stringify({
258
- message: 'No audit log yet — install hooks first with: codehere hook install',
259
- entries: [],
260
- });
261
- }
262
- const lines = readFileSync(AUDIT_FILE, 'utf-8')
263
- .split('\n')
264
- .filter(Boolean)
265
- .slice(-Math.min(limit * 3, 500)); // read more than needed to account for filter
266
- const entries = lines
267
- .map((l) => {
268
- try {
269
- return JSON.parse(l);
270
- }
271
- catch {
272
- return null;
273
- }
274
- })
275
- .filter(Boolean)
276
- .filter((e) => {
277
- if (overrideOnly)
278
- return Boolean(e.overrideReason);
279
- if (blockedOnly)
280
- return Boolean(e.blocked);
281
- return true;
282
- })
283
- .slice(-limit);
284
- return JSON.stringify({ entries, total: entries.length });
285
- }
286
- // ── JSON-RPC dispatcher ──────────────────────────────────────────────────────
287
- function dispatch(req) {
288
- const id = req.id;
289
- const params = (req.params ?? {});
290
- try {
291
- switch (req.method) {
292
- case 'initialize':
293
- return {
294
- jsonrpc: '2.0',
295
- id,
296
- result: {
297
- protocolVersion: '2024-11-05',
298
- capabilities: { tools: {} },
299
- serverInfo: { name: 'codehere', version: getVersion() },
300
- },
301
- };
302
- case 'tools/list':
303
- return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
304
- case 'tools/call': {
305
- const toolName = String(params.name ?? '');
306
- const toolParams = (params.arguments ?? {});
307
- let content;
308
- switch (toolName) {
309
- case 'codehere_trust':
310
- content = handleTrust(toolParams);
311
- break;
312
- case 'codehere_scan':
313
- content = handleScan(toolParams);
314
- break;
315
- case 'codehere_review':
316
- content = handleReview(toolParams);
317
- break;
318
- case 'codehere_audit':
319
- content = handleAudit(toolParams);
320
- break;
321
- default:
322
- return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${toolName}` } };
323
- }
324
- return {
325
- jsonrpc: '2.0',
326
- id,
327
- result: { content: [{ type: 'text', text: content }] },
328
- };
329
- }
330
- case 'ping':
331
- return { jsonrpc: '2.0', id, result: {} };
332
- default:
333
- return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${req.method}` } };
334
- }
335
- }
336
- catch (e) {
337
- return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
338
- }
339
- }
340
124
  const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
341
125
  // v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
342
126
  // Lifecycle: begin() inserts; callback resolves and removes; entries also
@@ -377,6 +161,20 @@ export function startMCPServer(options = {}) {
377
161
  const port = options.port ?? 3773;
378
162
  const host = options.host ?? '127.0.0.1'; // localhost only by default
379
163
  const token = options.token;
164
+ // S9 · practice mode starts clean and leaves no residue: when the mock
165
+ // chain (stable tmp path) is in use, wipe last session's practice chain
166
+ // and forget its sign-state key — otherwise the stale key reads the fresh
167
+ // chain as an rm-bypass (a false tamper alarm), and dead keys pile up in
168
+ // the real keystore. Explicit CODEHERE_AUDIT_FILE (tests) is untouched.
169
+ if (process.env.CODEHERE_MOCK === '1' && AUDIT_FILE === MOCK_AUDIT_FILE) {
170
+ try {
171
+ rmSync(dirname(MOCK_AUDIT_FILE), { recursive: true, force: true });
172
+ pruneSignState(MOCK_AUDIT_FILE);
173
+ }
174
+ catch {
175
+ /* best-effort — a leftover practice chain never blocks boot */
176
+ }
177
+ }
380
178
  const server = http.createServer((req, res) => {
381
179
  // v0.7 Week-2.2 · CORS for read-only public endpoints.
382
180
  // The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
@@ -388,13 +186,22 @@ export function startMCPServer(options = {}) {
388
186
  // /api/writepass/:id/:decision, etc.) are NOT covered by this · they
389
187
  // remain same-origin-only.
390
188
  const url = req.url ?? '';
189
+ // Query-stripped path for static brand assets — a cache-busted
190
+ // /icon.svg?v=N must still hit the icon route, not fall to 404.
191
+ const urlPath = url.split('?')[0];
391
192
  const isReadOnlyPublic = url === '/health' ||
392
193
  url === '/api/version' ||
393
194
  url === '/api/tasks' ||
394
195
  url === '/api/host-sessions' ||
395
196
  url.startsWith('/api/host-sessions/') ||
396
- url.startsWith('/api/audit/stats') ||
397
- url.startsWith('/api/readiness/agents');
197
+ // MED #32 (atomic audit) · this allowlisted '/api/audit/stats', a route
198
+ // that never existed (CORS headers on a 404). The real read-only route
199
+ // the footer polls is /api/audit/summary.
200
+ url.startsWith('/api/audit/summary') ||
201
+ url.startsWith('/api/readiness/agents') ||
202
+ // Brand favicon — static public bytes, no state.
203
+ urlPath === '/icon.svg' ||
204
+ urlPath === '/favicon.ico';
398
205
  if (isReadOnlyPublic) {
399
206
  res.setHeader('Access-Control-Allow-Origin', '*');
400
207
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
@@ -448,7 +255,24 @@ export function startMCPServer(options = {}) {
448
255
  res.writeHead(200, { 'Content-Type': 'application/json' });
449
256
  // `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
450
257
  // attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
451
- res.end(JSON.stringify({ status: 'ok', server: 'codehere-mcp', version: getVersion(), tools: TOOLS.length, mock: process.env.CODEHERE_MOCK === '1' }));
258
+ res.end(JSON.stringify({
259
+ status: 'ok',
260
+ server: 'codehere-mcp',
261
+ version: getVersion(),
262
+ tools: TOOLS.length,
263
+ mock: process.env.CODEHERE_MOCK === '1',
264
+ }));
265
+ return;
266
+ }
267
+ // Brand favicon · S16 one-brand-everywhere. Byte-for-byte copy of the
268
+ // landing's app/icon.svg (codehere.uk = brand source of truth) — embedded,
269
+ // never hotlinked, because the product is local-first/offline.
270
+ // /favicon.ico answers with the same SVG bytes: browsers request it by
271
+ // default on any tab whose HTML they haven't parsed yet, so the brand
272
+ // holds even on surfaces a <link rel=icon> hasn't reached.
273
+ if (req.method === 'GET' && (urlPath === '/icon.svg' || urlPath === '/favicon.ico')) {
274
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
275
+ res.end(BRAND_ICON_SVG);
452
276
  return;
453
277
  }
454
278
  // v2 Task-Centric PM UI (primary — served at /)
@@ -474,22 +298,24 @@ export function startMCPServer(options = {}) {
474
298
  res.end(VERIFY_HTML);
475
299
  return;
476
300
  }
477
- // History / audit feed (what we built Day 0; now secondary)
478
- if (req.method === 'GET' && (req.url === '/history' || req.url === '/history/')) {
479
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
480
- res.end(withDevElu(HISTORY_HTML));
481
- return;
482
- }
483
- // v0.6.0 M1 — project thread view; consumes /api/projects + /api/projects/:slug
301
+ // Legacy v1-era pages DELETED 2026-06-10 (Ega hit /history and called it
302
+ // what it was). The v1-MVP surface is exactly: / (the v2 dashboard),
303
+ // /verify, the JSON API, and /mcp. /history's audit feed lives on in the
304
+ // v2 AUDIT footer + `codehere audit`; /threads' project view IS the v2
305
+ // goal-first home. Old bookmarks land on the real product, never a relic.
484
306
  if (req.method === 'GET' &&
485
- (req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
486
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
487
- res.end(withDevElu(THREAD_HTML));
307
+ (req.url === '/history' ||
308
+ req.url === '/history/' ||
309
+ req.url === '/threads' ||
310
+ req.url === '/threads/' ||
311
+ req.url?.startsWith('/threads?'))) {
312
+ res.writeHead(301, { Location: '/' });
313
+ res.end();
488
314
  return;
489
315
  }
490
- // Backwards-compat: /ui used to be the Mission Control audit view — now at /history
316
+ // Backwards-compat: /ui used to be the Mission Control audit view
491
317
  if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/')) {
492
- res.writeHead(302, { Location: '/history' });
318
+ res.writeHead(302, { Location: '/' });
493
319
  res.end();
494
320
  return;
495
321
  }
@@ -592,9 +418,46 @@ export function startMCPServer(options = {}) {
592
418
  }
593
419
  };
594
420
  watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
421
+ // Live-progress push: sessions.jsonl is only written on spawn + completion,
422
+ // so disk-watching alone left the room at "0 steps" for the whole run and
423
+ // then jumped to the outcome (user-#1, 2026-06-07). Steps update in memory;
424
+ // SessionManager emits a tick when they do. Push it (throttled to ~400ms so
425
+ // a chatty agent can't flood the stream) so the room watches work live.
426
+ // Leading-edge throttle: push the FIRST step the instant it lands (no
427
+ // 400ms wait), then at most every 250ms with a trailing flush for any
428
+ // steps that arrived during the window. The old trailing-only throttle
429
+ // delayed the first step and collapsed bursts into one "jump" — the
430
+ // running view felt batched, not streamed. (Ega dogfood, watch-it-work)
431
+ let liveTimer = null;
432
+ let livePending = false;
433
+ const pushLive = () => {
434
+ try {
435
+ res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
436
+ }
437
+ catch {
438
+ /* transient */
439
+ }
440
+ };
441
+ const offLive = SessionManager.getInstance().onLiveChange(() => {
442
+ if (liveTimer) {
443
+ livePending = true;
444
+ return;
445
+ }
446
+ pushLive();
447
+ liveTimer = setTimeout(() => {
448
+ liveTimer = null;
449
+ if (livePending) {
450
+ livePending = false;
451
+ pushLive();
452
+ }
453
+ }, 250);
454
+ });
595
455
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);
596
456
  req.on('close', () => {
597
457
  clearInterval(ping);
458
+ if (liveTimer)
459
+ clearTimeout(liveTimer);
460
+ offLive();
598
461
  unwatchFile(SESSIONS_FILE, onChange);
599
462
  });
600
463
  return;
@@ -611,9 +474,14 @@ export function startMCPServer(options = {}) {
611
474
  if (req.method === 'GET' && req.url === '/api/audit/verify') {
612
475
  try {
613
476
  const r = verifyAuditChain();
477
+ // A walk over zero entries trivially succeeds, so valid:true here would tell the
478
+ // dashboard "hash chain verified" about a log that does not exist — the same claim
479
+ // the CLI's --json path was making. Nothing to verify is not the same as verified.
480
+ const verifiable = r.totalEntries > 0;
614
481
  res.writeHead(200, { 'Content-Type': 'application/json' });
615
482
  res.end(JSON.stringify({
616
- valid: r.valid,
483
+ valid: verifiable && r.valid,
484
+ verifiable,
617
485
  totalEntries: r.totalEntries,
618
486
  chainedEntries: r.chainedEntries,
619
487
  legacyEntries: r.legacyEntries,
@@ -622,7 +490,13 @@ export function startMCPServer(options = {}) {
622
490
  }
623
491
  catch {
624
492
  res.writeHead(200, { 'Content-Type': 'application/json' });
625
- res.end(JSON.stringify({ valid: true, totalEntries: 0, chainedEntries: 0, legacyEntries: 0, errors: [] }));
493
+ res.end(JSON.stringify({
494
+ valid: false,
495
+ totalEntries: 0,
496
+ chainedEntries: 0,
497
+ legacyEntries: 0,
498
+ errors: [{ lineNumber: 0, reason: 'unreadable', detail: 'audit verification failed to run' }],
499
+ }));
626
500
  }
627
501
  return;
628
502
  }
@@ -691,6 +565,118 @@ export function startMCPServer(options = {}) {
691
565
  })();
692
566
  return;
693
567
  }
568
+ // Ship-readiness scan — run gitleaks + semgrep on a folder, return the prioritized findings.
569
+ // Deterministic, no model tokens. Powers the home's "is your app safe to ship?" hook.
570
+ if (req.method === 'POST' && req.url === '/api/scan') {
571
+ (async () => {
572
+ try {
573
+ const body = await readBody(req, res);
574
+ let parsed;
575
+ try {
576
+ parsed = JSON.parse(body || '{}');
577
+ }
578
+ catch {
579
+ res.writeHead(400, { 'Content-Type': 'application/json' });
580
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
581
+ return;
582
+ }
583
+ const dir = resolvePath(String(parsed.dir || '.'));
584
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
585
+ res.writeHead(200, { 'Content-Type': 'application/json' });
586
+ res.end(JSON.stringify({ error: `Not a folder on this machine: ${dir}` }));
587
+ return;
588
+ }
589
+ const result = shipScan(dir);
590
+ res.writeHead(200, { 'Content-Type': 'application/json' });
591
+ res.end(JSON.stringify(result));
592
+ }
593
+ catch (e) {
594
+ res.writeHead(500, { 'Content-Type': 'application/json' });
595
+ res.end(JSON.stringify({ error: 'scan_failed', detail: String(e?.message || e) }));
596
+ }
597
+ })();
598
+ return;
599
+ }
600
+ // Fix + prove closed — for now the deterministic exposed-secret fix (move out of code, re-verify).
601
+ if (req.method === 'POST' && req.url === '/api/fix') {
602
+ (async () => {
603
+ try {
604
+ const body = await readBody(req, res);
605
+ let parsed;
606
+ try {
607
+ parsed = JSON.parse(body || '{}');
608
+ }
609
+ catch {
610
+ res.writeHead(400, { 'Content-Type': 'application/json' });
611
+ res.end(JSON.stringify({ ok: false, message: 'Invalid JSON body' }));
612
+ return;
613
+ }
614
+ const dir = resolvePath(String(parsed.dir || '.'));
615
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
616
+ res.writeHead(200, { 'Content-Type': 'application/json' });
617
+ res.end(JSON.stringify({ ok: false, message: `Not a folder on this machine: ${dir}` }));
618
+ return;
619
+ }
620
+ // Stream progress: the AI code-fix can take a few minutes, so we send live steps over SSE
621
+ // instead of leaving the browser frozen on "Fixing…". The final 'done' event carries the result.
622
+ res.writeHead(200, {
623
+ 'Content-Type': 'text/event-stream',
624
+ 'Cache-Control': 'no-cache',
625
+ Connection: 'keep-alive',
626
+ 'X-Accel-Buffering': 'no',
627
+ });
628
+ res.flushHeaders?.();
629
+ res.socket?.setNoDelay?.(true);
630
+ const send = (obj) => {
631
+ try {
632
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
633
+ }
634
+ catch {
635
+ /* client gone — the fix still runs to completion */
636
+ }
637
+ };
638
+ // Yield a tick after each send so the chunk actually leaves the socket before the next
639
+ // synchronous scanner (spawnSync) blocks the event loop — otherwise it all flushes at the end.
640
+ const flush = () => new Promise((r) => setImmediate(r));
641
+ send({ type: 'step', label: 'Looking at your code' });
642
+ await flush();
643
+ // Route by the top finding: a leaked secret → deterministic move-out (instant, no spend);
644
+ // a code danger → AI fix + re-scan proof (uses the Claude Code subscription).
645
+ const scan = shipScan(dir);
646
+ const top = scan.top;
647
+ let result;
648
+ if (!top) {
649
+ result = { ok: false, provenClosed: false, message: 'No ship-blockers found — nothing to fix.' };
650
+ }
651
+ else if (top.tool === 'gitleaks') {
652
+ send({ type: 'step', label: 'Moving the secret out of your code' });
653
+ result = fixExposedSecret(dir);
654
+ }
655
+ else {
656
+ result = await fixCodeVuln(dir, top, (label) => send({ type: 'step', label }));
657
+ }
658
+ send({ type: 'done', result });
659
+ res.end();
660
+ }
661
+ catch (e) {
662
+ // If the SSE stream already started, headers are sent — emit a done event instead of a new header.
663
+ if (res.headersSent) {
664
+ try {
665
+ res.write(`data: ${JSON.stringify({ type: 'done', result: { ok: false, provenClosed: false, message: 'fix_failed', detail: String(e?.message || e) } })}\n\n`);
666
+ }
667
+ catch {
668
+ /* best effort */
669
+ }
670
+ res.end();
671
+ }
672
+ else {
673
+ res.writeHead(500, { 'Content-Type': 'application/json' });
674
+ res.end(JSON.stringify({ ok: false, message: 'fix_failed', detail: String(e?.message || e) }));
675
+ }
676
+ }
677
+ })();
678
+ return;
679
+ }
694
680
  if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
695
681
  const params = new URLSearchParams(req.url.split('?')[1] || '');
696
682
  const cwd = params.get('cwd') || '';
@@ -920,7 +906,11 @@ export function startMCPServer(options = {}) {
920
906
  totalCalls: calls.length,
921
907
  blockedCalls: calls.filter((c) => c.blocked).length,
922
908
  criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
923
- chainValid: chain.valid,
909
+ // Same rule as /api/audit/verify: an empty chain walks clean, so validity is only
910
+ // meaningful once there is something to walk. The panel needs the third state to
911
+ // say "nothing recorded yet" instead of picking between verified and broken.
912
+ chainValid: chain.totalEntries > 0 && chain.valid,
913
+ chainVerifiable: chain.totalEntries > 0,
924
914
  chainEntries: chain.chainedEntries,
925
915
  }));
926
916
  return;
@@ -1012,6 +1002,156 @@ export function startMCPServer(options = {}) {
1012
1002
  // returns the new entry's seq + short hash, so the UI can show "logged ·
1013
1003
  // chain continues <hash>" with the REAL hash, not a mockup. Same-origin
1014
1004
  // only (mutating · not in the CORS read-only list).
1005
+ // (helper for the append route) last-10-lines tail scan by identity tuple.
1006
+ function findRecentAuditSeq(sessionId, toolName, timestamp) {
1007
+ try {
1008
+ if (!existsSync(AUDIT_FILE))
1009
+ return null;
1010
+ const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
1011
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
1012
+ try {
1013
+ const e = JSON.parse(lines[i]);
1014
+ if (e.sessionId === sessionId && e.toolName === toolName && e.timestamp === timestamp) {
1015
+ return typeof e.seq === 'number' ? e.seq : null;
1016
+ }
1017
+ }
1018
+ catch {
1019
+ /* skip malformed */
1020
+ }
1021
+ }
1022
+ }
1023
+ catch {
1024
+ /* unreadable → no match */
1025
+ }
1026
+ return null;
1027
+ }
1028
+ // R1.1 · the daemon as sole audit writer — append endpoint. Loopback-bound
1029
+ // (the server binds 127.0.0.1 by default). Accepts ONLY the pre-chain entry
1030
+ // shape; chain position (seq/prevHash/entryHash) is the DAEMON'S to compute —
1031
+ // a caller supplying any chain field is rejected outright, so an entry
1032
+ // cannot be forge-positioned VIA THIS ENDPOINT (direct file access by the
1033
+ // same uid is R1.2/R1.3's territory). Marks writer:'daemon'. Mirrors
1034
+ // the override route's no-screen-lies rule: success is claimed only after
1035
+ // the entry is CONFIRMED on disk (appendAuditLog is fail-open by design).
1036
+ if (req.method === 'POST' && req.url === '/api/audit/append') {
1037
+ (async () => {
1038
+ try {
1039
+ const text = await readBody(req, res);
1040
+ let raw;
1041
+ try {
1042
+ raw = JSON.parse(text || '{}');
1043
+ }
1044
+ catch {
1045
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1046
+ res.end(JSON.stringify({ error: 'invalid JSON' }));
1047
+ return;
1048
+ }
1049
+ if (typeof raw.auditFile === 'string' && raw.auditFile !== AUDIT_FILE) {
1050
+ // The caller records into a different audit file than this daemon —
1051
+ // we are not its recorder. 409 so the hook falls back to a direct
1052
+ // write into its own chain (R1.2 honest-routing invariant).
1053
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1054
+ res.end(JSON.stringify({ error: 'this daemon records a different audit file' }));
1055
+ return;
1056
+ }
1057
+ delete raw.auditFile; // accepted — not part of the entry shape
1058
+ if ('seq' in raw || 'prevHash' in raw || 'entryHash' in raw || 'writer' in raw) {
1059
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1060
+ res.end(JSON.stringify({
1061
+ error: 'chain position and writer provenance are computed by the daemon — do not supply seq/prevHash/entryHash/writer',
1062
+ }));
1063
+ return;
1064
+ }
1065
+ if (typeof raw.sessionId !== 'string' ||
1066
+ !raw.sessionId ||
1067
+ typeof raw.toolName !== 'string' ||
1068
+ !raw.toolName) {
1069
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1070
+ res.end(JSON.stringify({ error: 'sessionId and toolName are required strings' }));
1071
+ return;
1072
+ }
1073
+ const entry = {
1074
+ timestamp: typeof raw.timestamp === 'string' && raw.timestamp ? raw.timestamp : new Date().toISOString(),
1075
+ sessionId: raw.sessionId,
1076
+ toolName: raw.toolName,
1077
+ toolInput: typeof raw.toolInput === 'string'
1078
+ ? raw.toolInput.slice(0, 2000)
1079
+ : JSON.stringify(raw.toolInput ?? {}).slice(0, 2000),
1080
+ riskScore: typeof raw.riskScore === 'number' && Number.isFinite(raw.riskScore) ? raw.riskScore : 0,
1081
+ blocked: raw.blocked === true,
1082
+ findings: typeof raw.findings === 'number' && Number.isFinite(raw.findings) ? raw.findings : 0,
1083
+ criticalFindings: typeof raw.criticalFindings === 'number' && Number.isFinite(raw.criticalFindings)
1084
+ ? raw.criticalFindings
1085
+ : 0,
1086
+ // Override provenance is legitimate hook data (R1.2 routes the
1087
+ // hook's bypass-path entries here) — enum-validated, never free-form.
1088
+ ...(typeof raw.overrideReason === 'string' &&
1089
+ [
1090
+ 'stdin_read_failure',
1091
+ 'event_parse_failure',
1092
+ 'scan_exception',
1093
+ 'log_only_mode',
1094
+ 'user_override',
1095
+ 'audit_write_failure',
1096
+ ].includes(raw.overrideReason)
1097
+ ? { overrideReason: raw.overrideReason }
1098
+ : {}),
1099
+ ...(typeof raw.overrideNote === 'string' && raw.overrideNote.trim()
1100
+ ? { overrideNote: raw.overrideNote.trim().slice(0, 500) }
1101
+ : {}),
1102
+ writer: 'daemon',
1103
+ };
1104
+ // Idempotency: a hook may retry after a client-side abort even though
1105
+ // this daemon already wrote the entry — same (timestamp, sessionId,
1106
+ // toolName) in the tail means it's already recorded; return its seq
1107
+ // instead of minting a duplicate row (refuter-demonstrated risk).
1108
+ const dupSeq = findRecentAuditSeq(entry.sessionId, entry.toolName, entry.timestamp);
1109
+ if (dupSeq !== null) {
1110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1111
+ res.end(JSON.stringify({ ok: true, seq: dupSeq, idempotent: true }));
1112
+ return;
1113
+ }
1114
+ appendAuditLogDirect(entry);
1115
+ // Confirm on disk before claiming success (append is fail-open).
1116
+ let seq = null;
1117
+ try {
1118
+ const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
1119
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
1120
+ const e = JSON.parse(lines[i]);
1121
+ if (e.sessionId === entry.sessionId && e.toolName === entry.toolName && e.timestamp === entry.timestamp) {
1122
+ seq = typeof e.seq === 'number' ? e.seq : null;
1123
+ break;
1124
+ }
1125
+ }
1126
+ }
1127
+ catch {
1128
+ /* unreadable → unconfirmed */
1129
+ }
1130
+ if (seq === null) {
1131
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1132
+ res.end(JSON.stringify({ error: 'append could not be confirmed on disk' }));
1133
+ return;
1134
+ }
1135
+ // R1.3 · sign-at-write: every daemon append advances the signed tip,
1136
+ // so the protected prefix grows per WRITE, not per session-completion.
1137
+ // ed25519 sign is sub-ms; fail-open (signing never breaks the append).
1138
+ signAuditTip(AUDIT_FILE);
1139
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1140
+ res.end(JSON.stringify({ ok: true, seq }));
1141
+ }
1142
+ catch (err) {
1143
+ // readBody may have already 413'd the response before rejecting —
1144
+ // re-writing headers then would crash embedded callers with
1145
+ // ERR_HTTP_HEADERS_SENT (refuter caveat c; the /api/permissions
1146
+ // route's pattern).
1147
+ if (!res.headersSent) {
1148
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1149
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
1150
+ }
1151
+ }
1152
+ })();
1153
+ return;
1154
+ }
1015
1155
  if (req.method === 'POST' && req.url === '/api/audit/override') {
1016
1156
  (async () => {
1017
1157
  try {
@@ -1031,7 +1171,11 @@ export function startMCPServer(options = {}) {
1031
1171
  res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
1032
1172
  return;
1033
1173
  }
1034
- appendOverrideAudit('user_override', {
1174
+ // A failed-task recovery acknowledgement is NOT a security override —
1175
+ // record it as its own reason so an audit reader can't conflate the
1176
+ // two (refuter caveat). Default stays user_override.
1177
+ const reasonType = raw.reasonType === 'recovery_ack' ? 'recovery_ack' : 'user_override';
1178
+ appendOverrideAudit(reasonType, {
1035
1179
  sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
1036
1180
  toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
1037
1181
  note: reason,
@@ -1058,7 +1202,11 @@ export function startMCPServer(options = {}) {
1058
1202
  catch {
1059
1203
  continue;
1060
1204
  }
1061
- if (e.overrideReason === 'user_override' && e.sessionId === wantSid && e.overrideNote === wantNote) {
1205
+ // S6 · match the reasonType we just wrote — a recovery ack is
1206
+ // recorded as 'recovery_ack', and matching only 'user_override'
1207
+ // made the readback miss it: the entry landed but the user was
1208
+ // told "could not be confirmed".
1209
+ if (e.overrideReason === reasonType && e.sessionId === wantSid && e.overrideNote === wantNote) {
1062
1210
  seq = typeof e.seq === 'number' ? e.seq : null;
1063
1211
  entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
1064
1212
  confirmed = true;
@@ -1074,6 +1222,14 @@ export function startMCPServer(options = {}) {
1074
1222
  res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
1075
1223
  return;
1076
1224
  }
1225
+ // S3 · mirror a confirmed recovery ack into task state so the
1226
+ // resolved badge survives reload (the chain entry alone is not
1227
+ // what the recovery card re-renders from).
1228
+ if (reasonType === 'recovery_ack' && typeof raw.sessionId === 'string') {
1229
+ const resolvedAt = Date.now();
1230
+ appendResolved({ id: raw.sessionId, resolvedAt });
1231
+ SessionManager.getInstance().markPersistedResolved(raw.sessionId, resolvedAt);
1232
+ }
1077
1233
  res.writeHead(200, { 'Content-Type': 'application/json' });
1078
1234
  res.end(JSON.stringify({
1079
1235
  ok: true,
@@ -1172,32 +1328,381 @@ export function startMCPServer(options = {}) {
1172
1328
  }
1173
1329
  return;
1174
1330
  }
1175
- // ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
1176
- // GET /api/spend/today — read-only aggregate of today's estimated USD spend
1177
- // plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
1178
- // dashboard's "spent today" pill + budget warn/block colors. Per
1179
- // claim_market_position v3 capture-strategy edge "cost-bounded execution":
1180
- // this is the read surface; the actual block happens at /api/task spawn.
1181
- if (req.method === 'GET' && req.url === '/api/spend/today') {
1182
- try {
1183
- const spend = computeSpendToday();
1184
- const budget = loadBudget();
1185
- const pctUsed = budgetUsedPct(spend.totalUsd, budget);
1186
- res.writeHead(200, { 'Content-Type': 'application/json' });
1187
- res.end(JSON.stringify({
1188
- ...spend,
1189
- budget: {
1190
- dailyUsd: budget.dailyUsd,
1191
- warnAtPct: budget.warnAtPct,
1192
- pctUsed,
1193
- over: isOverBudget(spend.totalUsd, budget),
1194
- },
1195
- }));
1196
- }
1197
- catch (e) {
1198
- res.writeHead(500, { 'Content-Type': 'application/json' });
1199
- res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
1200
- }
1331
+ // ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
1332
+ // GET /api/spend/today — read-only aggregate of today's estimated USD spend
1333
+ // plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
1334
+ // dashboard's "spent today" pill + budget warn/block colors. Per
1335
+ // claim_market_position v3 capture-strategy edge "cost-bounded execution":
1336
+ // this is the read surface; the actual block happens at /api/task spawn.
1337
+ if (req.method === 'GET' && req.url === '/api/spend/today') {
1338
+ try {
1339
+ const spend = computeSpendToday();
1340
+ const budget = loadBudget();
1341
+ const pctUsed = budgetUsedPct(spend.totalUsd, budget);
1342
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1343
+ res.end(JSON.stringify({
1344
+ ...spend,
1345
+ budget: {
1346
+ dailyUsd: budget.dailyUsd,
1347
+ warnAtPct: budget.warnAtPct,
1348
+ pctUsed,
1349
+ over: isOverBudget(spend.totalUsd, budget),
1350
+ },
1351
+ }));
1352
+ }
1353
+ catch (e) {
1354
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1355
+ res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
1356
+ }
1357
+ return;
1358
+ }
1359
+ // GET /api/team — merged team cost+audit ledger for the dashboard hero.
1360
+ // Refreshes THIS machine's snapshot in the team dir (no git push; the CLI
1361
+ // `codehere team` is the active pull/push), then merges every machine's
1362
+ // snapshot. Read-only of the network: nothing leaves the box, the merge is
1363
+ // from the team's own dir. { configured:false } when no team dir is set.
1364
+ if (req.method === 'GET' && req.url === '/api/team') {
1365
+ try {
1366
+ const cfg = loadTeamConfig();
1367
+ if (!cfg.dir) {
1368
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1369
+ res.end(JSON.stringify({ configured: false }));
1370
+ return;
1371
+ }
1372
+ try {
1373
+ writeMyShare(cfg.dir); // refresh this machine's live numbers; best-effort
1374
+ }
1375
+ catch {
1376
+ /* dir not writable (e.g. missing) — still try to read what's there */
1377
+ }
1378
+ const view = buildTeamView(cfg.dir, cfg.dailyUsd);
1379
+ recordOpenOncePerDay(); // discovery outcome: the lead opened the shared record (deduped per day, best-effort)
1380
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1381
+ res.end(JSON.stringify({ configured: true, dir: cfg.dir, recruitUrl: recruitUrl(), ...view }));
1382
+ }
1383
+ catch (e) {
1384
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1385
+ res.end(JSON.stringify({ error: 'team_view_failed', detail: String(e) }));
1386
+ }
1387
+ return;
1388
+ }
1389
+ // GET /api/discovery/summary — the continuous-discovery outcome (record-open
1390
+ // behaviour) read back for the weekly cadence. Read-only, local.
1391
+ if (req.method === 'GET' && req.url === '/api/discovery/summary') {
1392
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1393
+ res.end(JSON.stringify(discoverySummary()));
1394
+ return;
1395
+ }
1396
+ // POST /api/team/init { dir? } — create a team from the dashboard: make +
1397
+ // git-init a shared dir, point at it, write the first snapshot. Same core as
1398
+ // `codehere team init`. Local-first: nothing leaves the box.
1399
+ if (req.method === 'POST' && req.url === '/api/team/init') {
1400
+ (async () => {
1401
+ try {
1402
+ const raw = await readBody(req, res);
1403
+ let parsed = {};
1404
+ try {
1405
+ parsed = JSON.parse(raw || '{}');
1406
+ }
1407
+ catch {
1408
+ /* empty/invalid body → default dir */
1409
+ }
1410
+ const rawDir = parsed.dir && String(parsed.dir).trim();
1411
+ // "undefined"/"null" are client-bug tripwires, not paths — a Jul-1
1412
+ // client sent one and six days of shares landed in a cwd-relative
1413
+ // agent/undefined/. Relative inputs resolve to absolute here.
1414
+ if (rawDir === 'undefined' || rawDir === 'null') {
1415
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1416
+ res.end(JSON.stringify({
1417
+ error: 'dir_invalid',
1418
+ dir: rawDir,
1419
+ message: 'That dir looks unset (the literal string "' +
1420
+ rawDir +
1421
+ '"). Send a real path or omit dir for the default.',
1422
+ }));
1423
+ return;
1424
+ }
1425
+ const dir = rawDir ? resolvePath(rawDir) : join(codehereHome(), 'team-shared');
1426
+ initTeamDir(dir);
1427
+ const cfg = loadTeamConfig();
1428
+ cfg.dir = dir;
1429
+ saveTeamConfig(cfg);
1430
+ try {
1431
+ writeMyShare(dir);
1432
+ }
1433
+ catch {
1434
+ /* best-effort first snapshot */
1435
+ }
1436
+ const view = buildTeamView(dir, cfg.dailyUsd);
1437
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1438
+ res.end(JSON.stringify({ configured: true, dir, ...view }));
1439
+ }
1440
+ catch (e) {
1441
+ // readBody may have already sent 413 + ended on an oversized body —
1442
+ // guard so we don't double-write headers (otherwise process-fatal).
1443
+ if (!res.headersSent) {
1444
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1445
+ res.end(JSON.stringify({ error: 'team_init_failed', detail: String(e?.message || e) }));
1446
+ }
1447
+ }
1448
+ })();
1449
+ return;
1450
+ }
1451
+ // POST /api/team/set-dir { dir } — point at an EXISTING shared team dir (a
1452
+ // member who cloned the team repo, or a lead pointing at a shared drive).
1453
+ if (req.method === 'POST' && req.url === '/api/team/set-dir') {
1454
+ (async () => {
1455
+ try {
1456
+ const raw = await readBody(req, res);
1457
+ let parsed = {};
1458
+ try {
1459
+ parsed = JSON.parse(raw || '{}');
1460
+ }
1461
+ catch {
1462
+ /* fall through to validation */
1463
+ }
1464
+ const rawDir = parsed.dir ? String(parsed.dir).trim() : '';
1465
+ if (!rawDir || rawDir === 'undefined' || rawDir === 'null') {
1466
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1467
+ res.end(JSON.stringify({
1468
+ error: 'dir_required',
1469
+ message: 'Provide { dir } — the shared team folder.' +
1470
+ (rawDir ? ' (got the literal string "' + rawDir + '" — a client-side unset variable)' : ''),
1471
+ }));
1472
+ return;
1473
+ }
1474
+ const dir = resolvePath(rawDir); // relative input would pin the record to a cwd
1475
+ if (!existsSync(dir)) {
1476
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1477
+ res.end(JSON.stringify({
1478
+ error: 'dir_not_found',
1479
+ dir,
1480
+ message: 'That folder does not exist. Clone the team repo first, or use /api/team/init.',
1481
+ }));
1482
+ return;
1483
+ }
1484
+ const cfg = loadTeamConfig();
1485
+ cfg.dir = dir;
1486
+ saveTeamConfig(cfg);
1487
+ try {
1488
+ writeMyShare(dir);
1489
+ }
1490
+ catch {
1491
+ /* best-effort */
1492
+ }
1493
+ const view = buildTeamView(dir, cfg.dailyUsd);
1494
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1495
+ res.end(JSON.stringify({ configured: true, dir, ...view }));
1496
+ }
1497
+ catch (e) {
1498
+ if (!res.headersSent) {
1499
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1500
+ res.end(JSON.stringify({ error: 'team_setdir_failed', detail: String(e?.message || e) }));
1501
+ }
1502
+ }
1503
+ })();
1504
+ return;
1505
+ }
1506
+ // POST /api/github/connect/start — begin the OAuth device flow. Returns the
1507
+ // user code + verification URL to show the user; the deviceCode round-trips
1508
+ // back on /poll (server stays stateless). Needs codehere's GitHub OAuth App
1509
+ // client_id (CODEHERE_GITHUB_CLIENT_ID); no secret, local-first.
1510
+ if (req.method === 'POST' && req.url === '/api/github/connect/start') {
1511
+ (async () => {
1512
+ const clientId = githubClientId();
1513
+ if (!clientId) {
1514
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1515
+ res.end(JSON.stringify({
1516
+ error: 'github_not_configured',
1517
+ message: "GitHub isn't set up yet: set CODEHERE_GITHUB_CLIENT_ID to codehere's OAuth App client id.",
1518
+ }));
1519
+ return;
1520
+ }
1521
+ try {
1522
+ const dc = await requestDeviceCode(clientId);
1523
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1524
+ res.end(JSON.stringify({
1525
+ userCode: dc.userCode,
1526
+ verificationUri: dc.verificationUri,
1527
+ deviceCode: dc.deviceCode,
1528
+ intervalSec: dc.intervalSec,
1529
+ expiresInSec: dc.expiresInSec,
1530
+ }));
1531
+ }
1532
+ catch (e) {
1533
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1534
+ res.end(JSON.stringify({ error: 'github_device_code_failed', detail: String(e?.message || e) }));
1535
+ }
1536
+ })();
1537
+ return;
1538
+ }
1539
+ // POST /api/github/connect/poll {deviceCode} — poll once for the token. On
1540
+ // success the token is persisted under 'github' via oauth-store. The client
1541
+ // repeats at intervalSec until status is connected or error.
1542
+ if (req.method === 'POST' && req.url === '/api/github/connect/poll') {
1543
+ (async () => {
1544
+ const clientId = githubClientId();
1545
+ if (!clientId) {
1546
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1547
+ res.end(JSON.stringify({ error: 'github_not_configured' }));
1548
+ return;
1549
+ }
1550
+ try {
1551
+ const body = await readBody(req, res);
1552
+ let parsed;
1553
+ try {
1554
+ parsed = JSON.parse(body || '{}');
1555
+ }
1556
+ catch (e) {
1557
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1558
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1559
+ return;
1560
+ }
1561
+ const deviceCode = parsed.deviceCode;
1562
+ if (!deviceCode || typeof deviceCode !== 'string') {
1563
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1564
+ res.end(JSON.stringify({ error: 'deviceCode (string) required' }));
1565
+ return;
1566
+ }
1567
+ const outcome = await pollForToken(clientId, deviceCode);
1568
+ if (outcome.status === 'connected') {
1569
+ saveToken(outcome.token);
1570
+ }
1571
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1572
+ res.end(JSON.stringify(outcome.status === 'connected' ? { status: 'connected', scope: outcome.token.scope } : outcome));
1573
+ }
1574
+ catch {
1575
+ if (process.env.CODEHERE_DEBUG === '1')
1576
+ console.error('[codehere] github poll readBody rejected');
1577
+ }
1578
+ })();
1579
+ return;
1580
+ }
1581
+ // POST /api/github/issue-to-task {ref} | {repo, issueNumber} — fetch the
1582
+ // issue via the stored token and return a ready task prompt + issue metadata.
1583
+ // The client spawns it through the existing POST /api/task (one proven path).
1584
+ if (req.method === 'POST' && req.url === '/api/github/issue-to-task') {
1585
+ (async () => {
1586
+ const tok = loadToken('github');
1587
+ if (!tok?.accessToken) {
1588
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1589
+ res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
1590
+ return;
1591
+ }
1592
+ try {
1593
+ const body = await readBody(req, res);
1594
+ let parsed;
1595
+ try {
1596
+ parsed = JSON.parse(body || '{}');
1597
+ }
1598
+ catch (e) {
1599
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1600
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1601
+ return;
1602
+ }
1603
+ const refStr = parsed.ref || (parsed.repo && parsed.issueNumber ? `${parsed.repo}#${parsed.issueNumber}` : '');
1604
+ const ref = parseRepoRef(refStr);
1605
+ if (!ref || ref.number === undefined) {
1606
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1607
+ res.end(JSON.stringify({
1608
+ error: 'bad_ref',
1609
+ message: 'Provide ref ("owner/repo#123" or an issue URL) or repo + issueNumber.',
1610
+ }));
1611
+ return;
1612
+ }
1613
+ const issue = await fetchIssue(tok.accessToken, ref.owner, ref.repo, ref.number);
1614
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1615
+ res.end(JSON.stringify({ prompt: issueToTaskPrompt(issue), issue }));
1616
+ }
1617
+ catch (e) {
1618
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1619
+ res.end(JSON.stringify({ error: 'github_issue_fetch_failed', detail: String(e?.message || e) }));
1620
+ }
1621
+ })();
1622
+ return;
1623
+ }
1624
+ // POST /api/github/post-result {text|ref, body} — comment the agent's result
1625
+ // back on the source GitHub issue. ref ("owner/repo#123") OR text (the task
1626
+ // prompt, from which the issue link is parsed). Non-destructive (no branches).
1627
+ if (req.method === 'POST' && req.url === '/api/github/post-result') {
1628
+ (async () => {
1629
+ const tok = loadToken('github');
1630
+ if (!tok?.accessToken) {
1631
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1632
+ res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
1633
+ return;
1634
+ }
1635
+ try {
1636
+ const raw = await readBody(req, res);
1637
+ let parsed;
1638
+ try {
1639
+ parsed = JSON.parse(raw || '{}');
1640
+ }
1641
+ catch (e) {
1642
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1643
+ res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1644
+ return;
1645
+ }
1646
+ const ref = parsed.ref ? parseRepoRef(parsed.ref) : issueRefFromText(parsed.text || '');
1647
+ if (!ref || ref.number === undefined) {
1648
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1649
+ res.end(JSON.stringify({ error: 'no_issue_ref', message: 'No GitHub issue found in ref/text.' }));
1650
+ return;
1651
+ }
1652
+ const comment = (parsed.body || '').trim();
1653
+ if (!comment) {
1654
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1655
+ res.end(JSON.stringify({ error: 'empty_body', message: 'body (the result comment) is required.' }));
1656
+ return;
1657
+ }
1658
+ const r = await commentOnIssue(tok.accessToken, ref.owner, ref.repo, ref.number, comment);
1659
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1660
+ res.end(JSON.stringify({ htmlUrl: r.htmlUrl, issue: `${ref.owner}/${ref.repo}#${ref.number}` }));
1661
+ }
1662
+ catch (e) {
1663
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1664
+ res.end(JSON.stringify({ error: 'github_comment_failed', detail: String(e?.message || e) }));
1665
+ }
1666
+ })();
1667
+ return;
1668
+ }
1669
+ // GET /api/github/status — does the composer/import panel have a connected
1670
+ // GitHub to work with? Cheap, no network: reads the stored token + whether a
1671
+ // client_id is even configured (so the UI can show "connect" vs "set it up").
1672
+ if (req.method === 'GET' && req.url === '/api/github/status') {
1673
+ const tok = loadToken('github');
1674
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1675
+ res.end(JSON.stringify({ connected: !!tok?.accessToken, clientIdConfigured: !!githubClientId() }));
1676
+ return;
1677
+ }
1678
+ // GET /api/github/issues?repo=owner/repo — list open issues so the user can
1679
+ // PICK one instead of typing "owner/repo#123" from memory. Uses the stored
1680
+ // device-flow token (loadToken). PRs are filtered out in listIssues.
1681
+ if (req.method === 'GET' && req.url?.startsWith('/api/github/issues')) {
1682
+ (async () => {
1683
+ const tok = loadToken('github');
1684
+ if (!tok?.accessToken) {
1685
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1686
+ res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
1687
+ return;
1688
+ }
1689
+ const repoParam = new URL(req.url || '', 'http://localhost').searchParams.get('repo') || '';
1690
+ const ref = parseRepoRef(repoParam);
1691
+ if (!ref) {
1692
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1693
+ res.end(JSON.stringify({ error: 'bad_repo', message: 'Provide repo as owner/repo.' }));
1694
+ return;
1695
+ }
1696
+ try {
1697
+ const issues = await listIssues(tok.accessToken, ref.owner, ref.repo);
1698
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1699
+ res.end(JSON.stringify({ repo: `${ref.owner}/${ref.repo}`, issues }));
1700
+ }
1701
+ catch (e) {
1702
+ res.writeHead(502, { 'Content-Type': 'application/json' });
1703
+ res.end(JSON.stringify({ error: 'github_issues_failed', detail: String(e?.message || e) }));
1704
+ }
1705
+ })();
1201
1706
  return;
1202
1707
  }
1203
1708
  // ── F1a readiness check ───────────────────────────────────────────────────
@@ -1262,7 +1767,12 @@ export function startMCPServer(options = {}) {
1262
1767
  return;
1263
1768
  }
1264
1769
  res.writeHead(200, { 'Content-Type': 'application/json' });
1265
- res.end(JSON.stringify({ ok: true, created: r.created.length, skipped: r.skipped.length, refused: r.refused.length + r.refusedDirs.length }));
1770
+ res.end(JSON.stringify({
1771
+ ok: true,
1772
+ created: r.created.length,
1773
+ skipped: r.skipped.length,
1774
+ refused: r.refused.length + r.refusedDirs.length,
1775
+ }));
1266
1776
  }
1267
1777
  catch (e) {
1268
1778
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1280,7 +1790,19 @@ export function startMCPServer(options = {}) {
1280
1790
  // populated by migration + forward-mirror). Drives the new thread-view UI.
1281
1791
  if (req.method === 'GET' && req.url === '/api/projects') {
1282
1792
  try {
1283
- const projects = listProjects();
1793
+ // Drop ephemeral/dead projects from the board: a "project" rooted in a throwaway temp dir
1794
+ // (eval/test harnesses), with no local path at all, or whose dirs are all gone, isn't real
1795
+ // work the user can open. Same temp-dir rule as the /api/tasks filter, plus an existence check.
1796
+ const tmpRoot = tmpdir().replace(/^\/private/, '');
1797
+ const isEphemeralPath = (cwd) => {
1798
+ if (!cwd)
1799
+ return true;
1800
+ const norm = cwd.replace(/^\/private/, '');
1801
+ if (norm === tmpRoot || norm.startsWith(tmpRoot + '/') || norm === '/tmp' || norm.startsWith('/tmp/'))
1802
+ return true;
1803
+ return !existsSync(cwd); // a project whose directory is gone isn't workable
1804
+ };
1805
+ const projects = listProjects().filter((p) => (p.localPaths || []).some((c) => !isEphemeralPath(c)));
1284
1806
  res.writeHead(200, { 'Content-Type': 'application/json' });
1285
1807
  res.end(JSON.stringify({ projects }));
1286
1808
  }
@@ -1301,14 +1823,33 @@ export function startMCPServer(options = {}) {
1301
1823
  res.end(JSON.stringify({ error: 'invalid_slug' }));
1302
1824
  return;
1303
1825
  }
1304
- const thread = readProjectThread(slug);
1826
+ // Merge clones so the room shows the repo's FULL history, and attach per-task
1827
+ // cost (same estimator as /api/tasks) so the conversation rows carry their bill.
1828
+ const thread = readMergedProjectThread(slug);
1305
1829
  if (!thread) {
1306
1830
  res.writeHead(404, { 'Content-Type': 'application/json' });
1307
1831
  res.end(JSON.stringify({ error: 'project_not_found', slug }));
1308
1832
  return;
1309
1833
  }
1834
+ const tasksWithCost = thread.tasks.map((t) => {
1835
+ // F-07 / R11 · the same single verdict object /api/tasks serves — the
1836
+ // room UI reads THIS route, and without it no persisted task could
1837
+ // ever show its trust verdict (found live: 465 tasks, 0 verdicts).
1838
+ const verdict = composeVerdict({ finalReport: t.finalReport ?? undefined, artifacts: t.artifacts, steps: t.steps }, t.auditRecon);
1839
+ const u = t.artifacts?.usage;
1840
+ if (!u || (!u.inputTokens && !u.outputTokens))
1841
+ return { ...t, verdict };
1842
+ const usd = estimateCostUsd(String(t.agent || ''), {
1843
+ inputTokens: u.inputTokens || 0,
1844
+ outputTokens: u.outputTokens || 0,
1845
+ cacheReadTokens: u.cacheReadTokens,
1846
+ cacheCreationTokens: u.cacheCreationTokens,
1847
+ model: u.model,
1848
+ });
1849
+ return { ...t, verdict, cost: { spent: usd * 100, paid: usd > 0 } };
1850
+ });
1310
1851
  res.writeHead(200, { 'Content-Type': 'application/json' });
1311
- res.end(JSON.stringify({ thread }));
1852
+ res.end(JSON.stringify({ thread: { ...thread, tasks: tasksWithCost } }));
1312
1853
  }
1313
1854
  catch (e) {
1314
1855
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1366,6 +1907,171 @@ export function startMCPServer(options = {}) {
1366
1907
  })();
1367
1908
  return;
1368
1909
  }
1910
+ // POST /api/projects/:slug/archive — body { sessionId, archived } — soft-delete /
1911
+ // restore a task. Append-only + audit-safe (the task record is never deleted).
1912
+ if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/archive$/)) {
1913
+ const slug = req.url.split('/')[3];
1914
+ (async () => {
1915
+ try {
1916
+ const body = await readBody(req, res);
1917
+ let parsed;
1918
+ try {
1919
+ const raw = JSON.parse(body);
1920
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1921
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1922
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1923
+ return;
1924
+ }
1925
+ parsed = raw;
1926
+ }
1927
+ catch {
1928
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1929
+ res.end(JSON.stringify({ error: 'invalid_json' }));
1930
+ return;
1931
+ }
1932
+ if (typeof parsed.sessionId !== 'string') {
1933
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1934
+ res.end(JSON.stringify({ error: 'sessionId_required' }));
1935
+ return;
1936
+ }
1937
+ const result = setTaskArchived(slug, parsed.sessionId, parsed.archived === true);
1938
+ if (!result.ok) {
1939
+ let status = 400;
1940
+ if (result.error === 'Project not found')
1941
+ status = 404;
1942
+ else if (result.error === 'Write failed')
1943
+ status = 500;
1944
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1945
+ res.end(JSON.stringify({ error: result.error }));
1946
+ return;
1947
+ }
1948
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1949
+ res.end(JSON.stringify({ ok: true, sessionId: parsed.sessionId, archived: parsed.archived === true }));
1950
+ }
1951
+ catch {
1952
+ if (process.env.CODEHERE_DEBUG === '1')
1953
+ console.error('[codehere] readBody rejected (oversized or aborted)');
1954
+ }
1955
+ })();
1956
+ return;
1957
+ }
1958
+ // POST /api/budget — body { dailyUsd: number | null } — set the daily spend cap
1959
+ // (null/0 disables). Used by first-time setup so the cost-bounded promise is real
1960
+ // from minute one; same store the `codehere budget set` CLI writes.
1961
+ if (req.method === 'POST' && req.url === '/api/budget') {
1962
+ (async () => {
1963
+ try {
1964
+ const body = await readBody(req, res);
1965
+ let parsed;
1966
+ try {
1967
+ const raw = JSON.parse(body);
1968
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1969
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1970
+ res.end(JSON.stringify({ error: 'invalid_body' }));
1971
+ return;
1972
+ }
1973
+ parsed = raw;
1974
+ }
1975
+ catch {
1976
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1977
+ res.end(JSON.stringify({ error: 'invalid_json' }));
1978
+ return;
1979
+ }
1980
+ let dailyUsd;
1981
+ if (parsed.dailyUsd === null || parsed.dailyUsd === 0) {
1982
+ dailyUsd = null; // disable enforcement
1983
+ }
1984
+ else if (typeof parsed.dailyUsd === 'number' && Number.isFinite(parsed.dailyUsd) && parsed.dailyUsd > 0) {
1985
+ dailyUsd = parsed.dailyUsd;
1986
+ }
1987
+ else {
1988
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1989
+ res.end(JSON.stringify({ error: 'dailyUsd must be a positive number, 0, or null' }));
1990
+ return;
1991
+ }
1992
+ const cfg = loadBudget();
1993
+ cfg.dailyUsd = dailyUsd;
1994
+ saveBudget(cfg);
1995
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1996
+ res.end(JSON.stringify({ ok: true, dailyUsd }));
1997
+ }
1998
+ catch {
1999
+ if (process.env.CODEHERE_DEBUG === '1')
2000
+ console.error('[codehere] readBody rejected (oversized or aborted)');
2001
+ }
2002
+ })();
2003
+ return;
2004
+ }
2005
+ // POST /api/projects/clone — { url } → clone a GitHub repo locally, return its cwd.
2006
+ // The room opens from a cwd; the project record auto-creates on the first task
2007
+ // spawned there. Clone target is ~/codehere-projects/<repo> (visible, predictable).
2008
+ // Idempotent: if that dir already holds the same repo, we open it instead of
2009
+ // re-cloning. Async (execFile, not execFileSync) so a slow clone never freezes
2010
+ // the daemon. Uses the user's existing git auth — fails open with a plain reason.
2011
+ if (req.method === 'POST' && req.url === '/api/projects/clone') {
2012
+ (async () => {
2013
+ try {
2014
+ const body = await readBody(req, res);
2015
+ let parsed;
2016
+ try {
2017
+ const raw = JSON.parse(body);
2018
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
2019
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2020
+ res.end(JSON.stringify({ error: 'invalid_body' }));
2021
+ return;
2022
+ }
2023
+ parsed = raw;
2024
+ }
2025
+ catch {
2026
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2027
+ res.end(JSON.stringify({ error: 'invalid_json' }));
2028
+ return;
2029
+ }
2030
+ const baseDir = join(process.env.CODEHERE_CLONE_DIR || join(homedir(), 'codehere-projects'));
2031
+ const target = parseGitHubCloneTarget(typeof parsed.url === 'string' ? parsed.url : '', baseDir);
2032
+ if (!target.ok) {
2033
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2034
+ res.end(JSON.stringify({ error: target.error }));
2035
+ return;
2036
+ }
2037
+ // Already cloned here? Open it (idempotent), don't re-clone.
2038
+ if (existsSync(join(target.dir, '.git'))) {
2039
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2040
+ res.end(JSON.stringify({ ok: true, cwd: target.dir, repo: target.repo, alreadyHere: true }));
2041
+ return;
2042
+ }
2043
+ if (existsSync(target.dir)) {
2044
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2045
+ res.end(JSON.stringify({ error: 'dir_exists', detail: target.dir }));
2046
+ return;
2047
+ }
2048
+ mkdirSync(baseDir, { recursive: true });
2049
+ try {
2050
+ // `--` ends option parsing: a cloneUrl beginning with '-' is then a
2051
+ // positional, never read by git as a flag (defense even though execFile
2052
+ // already avoids a shell). --config protocol.ext.allow=never blocks the
2053
+ // ext:: transport (git's RCE vector) belt-and-suspenders to the parser.
2054
+ await promisify(execFile)('git', ['-c', 'protocol.ext.allow=never', 'clone', '--depth', '1', '--', target.cloneUrl, target.dir], {
2055
+ timeout: 120000,
2056
+ maxBuffer: 8 * 1024 * 1024,
2057
+ });
2058
+ }
2059
+ catch (cloneErr) {
2060
+ const msg = String(cloneErr?.stderr || cloneErr?.message || cloneErr).slice(0, 400);
2061
+ res.writeHead(502, { 'Content-Type': 'application/json' });
2062
+ res.end(JSON.stringify({ error: 'clone_failed', detail: msg }));
2063
+ return;
2064
+ }
2065
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2066
+ res.end(JSON.stringify({ ok: true, cwd: target.dir, repo: target.repo }));
2067
+ }
2068
+ catch {
2069
+ if (process.env.CODEHERE_DEBUG === '1')
2070
+ console.error('[codehere] clone readBody rejected');
2071
+ }
2072
+ })();
2073
+ return;
2074
+ }
1369
2075
  // ── F1 task orchestration routes ─────────────────────────────────────────
1370
2076
  // POST /api/task — { prompt, cwd? } → { sessionId }
1371
2077
  // GET /api/tasks — list all sessions
@@ -1414,6 +2120,10 @@ export function startMCPServer(options = {}) {
1414
2120
  const batchBudget = loadBudget();
1415
2121
  const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
1416
2122
  const results = [];
2123
+ const seenCwds = new Set();
2124
+ // It-4 V1-MULTITASK — one batch call = one parent; children share
2125
+ // the batchId so the room can render a live-derived rollup row.
2126
+ const batchId = randomUUID();
1417
2127
  for (const t of tasks) {
1418
2128
  const prompt = t.prompt;
1419
2129
  if (!prompt || typeof prompt !== 'string') {
@@ -1434,6 +2144,42 @@ export function startMCPServer(options = {}) {
1434
2144
  results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
1435
2145
  continue;
1436
2146
  }
2147
+ let tCwdIsDir = false;
2148
+ try {
2149
+ tCwdIsDir = statSync(t.cwd).isDirectory();
2150
+ }
2151
+ catch {
2152
+ /* missing path */
2153
+ }
2154
+ if (!tCwdIsDir) {
2155
+ results.push({
2156
+ error: `That folder doesn't exist on this machine: ${t.cwd.slice(0, 200)}`,
2157
+ agent: agentId,
2158
+ });
2159
+ continue;
2160
+ }
2161
+ }
2162
+ // The batch contract is "independent files only — non-overlapping
2163
+ // cwds" (W2). It was a comment, not a guard (AUD audit 2026-06-07):
2164
+ // two tasks on the same cwd both spawned, racing writes on one
2165
+ // repo path. Enforce it per task; undefined cwds share one key.
2166
+ // Literal-string guard, not canonicalized — '/tmp' vs '/private/tmp'
2167
+ // aren't deduped; this catches the paste-same-path-twice footgun.
2168
+ // readOnly tasks (the @all fanout: same QUESTION to every ready
2169
+ // adapter) are exempt — read concurrency was never the race this
2170
+ // guard stops. Caller-asserted, like the rest of the batch
2171
+ // contract; write fanout stays blocked until worktree isolation.
2172
+ const readOnly = t.readOnly === true;
2173
+ const cwdKey = t.cwd ?? '<daemon-cwd>';
2174
+ if (!readOnly) {
2175
+ if (seenCwds.has(cwdKey)) {
2176
+ results.push({
2177
+ error: `cwd already used by another task in this batch (${cwdKey.slice(0, 200)}) — batch tasks must target independent folders`,
2178
+ agent: agentId,
2179
+ });
2180
+ continue;
2181
+ }
2182
+ seenCwds.add(cwdKey);
1437
2183
  }
1438
2184
  // Per-task budget check (paid agents only).
1439
2185
  if (batchBudget.dailyUsd !== null) {
@@ -1449,7 +2195,7 @@ export function startMCPServer(options = {}) {
1449
2195
  }
1450
2196
  }
1451
2197
  try {
1452
- const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
2198
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd, batchId });
1453
2199
  results.push({
1454
2200
  sessionId: session.sessionId,
1455
2201
  agent: session.agent,
@@ -1532,6 +2278,27 @@ export function startMCPServer(options = {}) {
1532
2278
  }));
1533
2279
  return;
1534
2280
  }
2281
+ // R7 onboarding fix (2026-06-07): an explicitly-picked vendor that
2282
+ // readiness already KNOWS isn't usable (aider without a billed key,
2283
+ // gemini without auth) used to accept the spawn and fail generically
2284
+ // afterwards. Refuse upfront with the same plain-English hint the
2285
+ // squad bar shows. Mock mode skips (mock spawns run regardless);
2286
+ // conductor routes around readiness itself; states readiness can't
2287
+ // detect (claude subscription auth) still rely on post-spawn
2288
+ // error fingerprints by design.
2289
+ if (process.env.CODEHERE_MOCK !== '1' && agentId !== 'conductor') {
2290
+ const ready = readinessForAllAgents().find((a) => a.id === agentId);
2291
+ if (ready && ready.state !== 'ready') {
2292
+ const hint = ready.authHint?.detail;
2293
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2294
+ res.end(JSON.stringify({
2295
+ error: 'agent_not_ready',
2296
+ state: ready.state,
2297
+ message: `${ready.label} isn't ready on this machine (${String(ready.state).replace(/_/g, ' ')}).${hint ? ' ' + hint : ''} Pick another agent, or fix the setup and retry.`,
2298
+ }));
2299
+ return;
2300
+ }
2301
+ }
1535
2302
  // Path-traversal + null-byte defense · cwd must be absolute and
1536
2303
  // free of NUL bytes (some filesystems treat \0 as terminator).
1537
2304
  if (cwd !== undefined) {
@@ -1540,11 +2307,33 @@ export function startMCPServer(options = {}) {
1540
2307
  res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1541
2308
  return;
1542
2309
  }
2310
+ // Lane J S3 (2026-06-07 audit): a nonexistent cwd returned 200
2311
+ // and the task hung "running" forever — empty steps, no error,
2312
+ // nothing on the stream. Fail at intake with a human answer.
2313
+ let cwdIsDir = false;
2314
+ try {
2315
+ cwdIsDir = statSync(cwd).isDirectory();
2316
+ }
2317
+ catch {
2318
+ /* missing path */
2319
+ }
2320
+ if (!cwdIsDir) {
2321
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2322
+ res.end(JSON.stringify({
2323
+ error: `That folder doesn't exist on this machine: ${cwd.slice(0, 200)} — check the path and try again.`,
2324
+ }));
2325
+ return;
2326
+ }
1543
2327
  }
1544
2328
  const budgetForSpawn = loadBudget();
1545
2329
  if (budgetForSpawn.dailyUsd !== null) {
1546
2330
  const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1547
- const isPaidSpawn = probe > 0;
2331
+ // Conductor (the auto router) is NOT refused here — it self-manages
2332
+ // by failing over to a free agent when over budget (Ega-approved
2333
+ // 2026-06-08), refusing only if no free agent is ready. Explicit
2334
+ // paid-agent picks ARE refused (respect the choice + never spend
2335
+ // over budget). See spawnConductor's budgetBounded routing.
2336
+ const isPaidSpawn = probe > 0 && agentId !== 'conductor';
1548
2337
  if (isPaidSpawn) {
1549
2338
  const todaySpend = computeSpendToday();
1550
2339
  if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
@@ -1588,7 +2377,13 @@ export function startMCPServer(options = {}) {
1588
2377
  // enrichment instead of failing outright.
1589
2378
  console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
1590
2379
  }
1591
- const session = manager.spawn(promptForSpawn, agentId, { cwd });
2380
+ const parentSessionId = typeof parsed.parentSessionId === 'string' && parsed.parentSessionId
2381
+ ? parsed.parentSessionId
2382
+ : undefined;
2383
+ const session = manager.spawn(promptForSpawn, agentId, {
2384
+ cwd,
2385
+ ...(parentSessionId ? { parentSessionId } : {}),
2386
+ });
1592
2387
  res.writeHead(200, { 'Content-Type': 'application/json' });
1593
2388
  res.end(JSON.stringify({
1594
2389
  sessionId: session.sessionId,
@@ -1617,52 +2412,144 @@ export function startMCPServer(options = {}) {
1617
2412
  return;
1618
2413
  }
1619
2414
  if (req.method === 'GET' && req.url === '/api/tasks') {
2415
+ // Tier 0.1 · a MOCK run fabricates its own artifacts, so verify-claim
2416
+ // compares the synthetic report to the synthetic artifacts and the green
2417
+ // "verified" is tautological — it proves wiring, not a real result. Flag
2418
+ // real mock RUNS so the surface says "wiring only". Seeded demo-* sessions
2419
+ // are deliberate showcases (and the /verify coverage fixture) — left green.
2420
+ // S18 · classification holds on the RECORD: appendSpawned stamps mockRun
2421
+ // at run time, so a daemon restart / mode flip never reclassifies
2422
+ // history. Legacy persisted records written before the stamp are
2423
+ // recovered on read from the mock adapter's own resume token
2424
+ // (recordedMockRun in session-persistence). The env stamp below applies
2425
+ // only to (a) in-memory sessions of THIS daemon (env == record by
2426
+ // definition) and (b) legacy records with neither stamp nor token —
2427
+ // genuinely no other signal exists for those.
2428
+ const mockDaemon = process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
2429
+ const isMockRun = (id) => mockDaemon && !id.startsWith('demo-');
2430
+ // Per-task bill · the card already shows steps/files/verdict but never the
2431
+ // cost (it read t.cost, which nothing populated). Derive it from the task's
2432
+ // own token usage so "what each agent did" includes "what it cost" inline.
2433
+ // cents (sub-cent precision kept; costPill formats). undefined when there's
2434
+ // no usage data — never fabricate a $0.
2435
+ const costFor = (agent, artifacts) => {
2436
+ const u = artifacts?.usage;
2437
+ if (!u || (!u.inputTokens && !u.outputTokens))
2438
+ return undefined;
2439
+ const usd = estimateCostUsd(String(agent || ''), {
2440
+ inputTokens: u.inputTokens || 0,
2441
+ outputTokens: u.outputTokens || 0,
2442
+ cacheReadTokens: u.cacheReadTokens,
2443
+ cacheCreationTokens: u.cacheCreationTokens,
2444
+ model: u.model,
2445
+ });
2446
+ return { spent: usd * 100, paid: usd > 0 };
2447
+ };
1620
2448
  const spawned = manager.list().map((s) => {
1621
2449
  const completedAt = manager.getCompletedAt(s.sessionId);
1622
2450
  const finalReport = manager.getFinalReport(s.sessionId);
2451
+ const acceptanceCriteria = manager.getAcceptanceCriteria(s.sessionId);
1623
2452
  const cwd = manager.getCwd(s.sessionId);
1624
2453
  const continuedFrom = manager.getContinuedFrom(s.sessionId);
1625
2454
  const continuedAs = manager.getContinuedAs(s.sessionId);
2455
+ const recoveredFrom = manager.getRecoveredFrom(s.sessionId);
2456
+ const auditRecon = manager.getAuditRecon(s.sessionId);
2457
+ const batchId = manager.getBatchId(s.sessionId);
2458
+ const delegatedTo = manager.getDelegatedTo(s.sessionId);
1626
2459
  const failedReport = manager.hasFailedReport(s.sessionId);
1627
2460
  const stalledReason = manager.getStallReason(s.sessionId);
1628
- // F-07 · verifier-agent block · run claim vs reality on every task.
1629
- // Pure function, no I/O, runs sync · cheap to do per request.
1630
- // The UI renders the verdict + findings inline below the Summary.
1631
- const verifyResult = verifyClaim({
1632
- summary: finalReport?.summary,
1633
- artifacts: s.artifacts,
1634
- steps: s.steps,
1635
- });
2461
+ // F-07 / R11 · the session's honesty verdict, ONE object owned by the
2462
+ // VerdictService: claim-vs-reality + audit-vs-disk reconciliation.
2463
+ // Pure, no I/O, runs sync · cheap to do per request. The UI renders
2464
+ // verdict.claim + verdict.audit without re-joining anything.
2465
+ const verdict = composeVerdict({ finalReport, artifacts: s.artifacts, steps: s.steps }, auditRecon);
2466
+ const cost = costFor(s.agent, s.artifacts);
1636
2467
  return {
1637
2468
  sessionId: s.sessionId,
1638
- prompt: s.prompt,
2469
+ prompt: manager.getDisplayPrompt(s.sessionId) ?? s.prompt,
1639
2470
  startedAt: s.startedAt,
1640
2471
  status: s.status,
1641
2472
  agent: s.agent,
1642
2473
  steps: s.steps ?? [],
1643
2474
  artifacts: s.artifacts,
2475
+ ...(cost ? { cost } : {}),
1644
2476
  ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
1645
2477
  ...(finalReport !== undefined ? { finalReport } : {}),
2478
+ ...(acceptanceCriteria && acceptanceCriteria.length ? { acceptanceCriteria } : {}),
1646
2479
  ...(failedReport ? { failedReport: true } : {}),
1647
2480
  ...(stalledReason ? { stalledReason } : {}),
1648
- verifyResult,
2481
+ verdict,
1649
2482
  canAppendMessage: s.canAppendMessage === true,
2483
+ // S1 · the server is the single source of truth on whether a
2484
+ // follow-up can actually be DELIVERED — the UI renders from this,
2485
+ // never guesses.
2486
+ canFollowUp: s.canAppendMessage === true,
1650
2487
  ...(cwd !== undefined ? { cwd } : {}),
1651
2488
  ...(continuedFrom !== undefined ? { continuedFrom } : {}),
1652
2489
  ...(continuedAs !== undefined ? { continuedAs } : {}),
2490
+ // Recovery lineage → activates the dashboard "recovered from <vendor>"
2491
+ // badge + the failover kicker (gemini → claude · QUOTA · COMPLETED).
2492
+ ...(recoveredFrom !== undefined
2493
+ ? { failoverFrom: recoveredFrom.agent, failoverTo: s.agent, failoverReason: recoveredFrom.reason }
2494
+ : {}),
2495
+ ...(batchId !== undefined ? { batchId } : {}),
2496
+ ...(delegatedTo !== undefined ? { delegatedTo } : {}),
2497
+ ...(isMockRun(s.sessionId) ? { mockRun: true } : {}),
1653
2498
  provenance: 'spawned',
1654
2499
  };
1655
2500
  });
1656
2501
  const activeAgents = manager.listActiveAgents();
1657
- // F-07 · attach verifyResult to persisted records too · same logic.
2502
+ // F-07 / R11 · persisted records get the same single verdict object.
2503
+ // 2.2 · the audit reconciliation now PERSISTS (appendAuditRecon), so a
2504
+ // reloaded session carries its OBSERVED signal and the composite grade
2505
+ // can still earn green after a restart — not just live in-memory tasks.
1658
2506
  const persisted = manager.listPersisted().map((p) => ({
1659
2507
  ...p,
1660
- verifyResult: verifyClaim({
1661
- summary: p.finalReport?.summary,
1662
- artifacts: p.artifacts,
1663
- steps: p.steps,
1664
- }),
2508
+ verdict: composeVerdict(p, p.auditRecon),
2509
+ ...(costFor(p.agent, p.artifacts) ? { cost: costFor(p.agent, p.artifacts) } : {}),
2510
+ // S18 · the durable record (carried by ...p) wins; the env stamp is
2511
+ // only the fallback for legacy records that predate the run-time stamp.
2512
+ ...(p.mockRun === undefined && isMockRun(p.sessionId) ? { mockRun: true } : {}),
2513
+ // S1 · a dropped-from-memory session can only continue when
2514
+ // reconstructAppendable can rebuild it: claude-code + its native
2515
+ // resume token + cwd. Every other vendor keeps no resume token
2516
+ // codehere can use once the session is gone — say so.
2517
+ canFollowUp: p.agent === 'claude-code' && !!p.nativeSessionId && !!p.cwd,
1665
2518
  }));
2519
+ // A REAL (non-mock) dashboard must never show mock/demo sessions. They
2520
+ // are dev fixtures — seeded `demo-*` showcases (the /verify coverage set)
2521
+ // and `mockRun` wiring tests — that can leak into a persisted store
2522
+ // (e.g. a mock run launched without an isolated CODEHERE_HOME). In mock
2523
+ // mode the board needs them, so they stay; in real mode they are dev
2524
+ // pollution and are filtered out so a genuine user only sees real work.
2525
+ // A task run inside a throwaway temp dir (eval/test harnesses use mkdtemp —
2526
+ // e.g. proven-fix's /…/T/pf-XXXX) is NOT the user's real work; it's harness
2527
+ // pollution leaking into ~/.codehere/sessions.jsonl. Strip it from a real
2528
+ // dashboard alongside mock/demo, so "your day so far" shows genuine work.
2529
+ const tmpRoot = tmpdir().replace(/^\/private/, '');
2530
+ const isEphemeralCwd = (cwd) => {
2531
+ if (!cwd)
2532
+ return false;
2533
+ const norm = cwd.replace(/^\/private/, '');
2534
+ return norm === tmpRoot || norm.startsWith(tmpRoot + '/') || norm.startsWith('/tmp/');
2535
+ };
2536
+ // Hide STALE throwaway-dir pollution only — NOT recent/live work. The old
2537
+ // filter dropped any /tmp-cwd task outright, which silently hid a task the
2538
+ // user just spawned AND broke verify-real-run (it sandboxes in mkdtemp under
2539
+ // /tmp). A real run there proved the loop works but was invisible. So: an
2540
+ // ephemeral-cwd task is filtered only once it's older than the window — a
2541
+ // running/just-finished one always shows; only yesterday's eval junk fades.
2542
+ const EPHEMERAL_STALE_MS = 2 * 60 * 60 * 1000; // 2h
2543
+ const nowMs = Date.now();
2544
+ const isStaleEphemeral = (cwd, ts) => isEphemeralCwd(cwd) && (!ts || nowMs - ts > EPHEMERAL_STALE_MS);
2545
+ const realSpawned = mockDaemon
2546
+ ? spawned
2547
+ : spawned.filter((s) => !s.mockRun && !String(s.sessionId || '').startsWith('demo-') && !isStaleEphemeral(s.cwd, s.startedAt));
2548
+ const realPersisted = mockDaemon
2549
+ ? persisted
2550
+ : persisted.filter((p) => p.mockRun !== true &&
2551
+ !String(p.sessionId || '').startsWith('demo-') &&
2552
+ !isStaleEphemeral(p.cwd, p.completedAt || p.startedAt));
1666
2553
  // Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
1667
2554
  let lastEditAt = null;
1668
2555
  try {
@@ -1674,11 +2561,11 @@ export function startMCPServer(options = {}) {
1674
2561
  }
1675
2562
  res.writeHead(200, { 'Content-Type': 'application/json' });
1676
2563
  res.end(JSON.stringify({
1677
- spawned,
1678
- persisted,
2564
+ spawned: realSpawned,
2565
+ persisted: realPersisted,
1679
2566
  activeAgents,
1680
2567
  lastEditAt,
1681
- sessions: spawned, // back-compat for earlier UI code
2568
+ sessions: realSpawned, // back-compat for earlier UI code
1682
2569
  observed: manager.listObserved(), // legacy, kept for /history or future use
1683
2570
  }));
1684
2571
  return;
@@ -1720,6 +2607,27 @@ export function startMCPServer(options = {}) {
1720
2607
  res.end(JSON.stringify({ blocks }));
1721
2608
  return;
1722
2609
  }
2610
+ // Cross-vendor verdict — GET /api/task/:id/cross-vendor → the receipt when
2611
+ // this session reviewed an earlier DIFFERENT-vendor writer in the same room
2612
+ // (who wrote, who reviewed, audit attribution, chain integrity, cost). null
2613
+ // when the session isn't a cross-vendor review (the common case). Read-only.
2614
+ const cvMatch = req.url.match(/^\/api\/task\/([^/]+)\/cross-vendor$/);
2615
+ if (cvMatch && req.method === 'GET') {
2616
+ const sessId = decodeURIComponent(cvMatch[1]);
2617
+ let verdict = null;
2618
+ try {
2619
+ // Reviewer-keyed first (the session IS a review); else writer-keyed
2620
+ // (the session was REVIEWED by a different vendor) so opening the
2621
+ // writer's run also shows "a different vendor reviewed this."
2622
+ verdict = buildCrossVendorVerdictById(sessId) ?? buildCrossVendorVerdictForWriter(sessId) ?? null;
2623
+ }
2624
+ catch {
2625
+ /* fail open — null (no verdict shown) */
2626
+ }
2627
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2628
+ res.end(JSON.stringify({ crossVendor: verdict }));
2629
+ return;
2630
+ }
1723
2631
  // v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
1724
2632
  // Read-only; SessionManager generates the narration in a background
1725
2633
  // timer and caches the latest. Frontend polls (~30s) while the
@@ -1736,8 +2644,65 @@ export function startMCPServer(options = {}) {
1736
2644
  const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
1737
2645
  if (m) {
1738
2646
  const [, sessionId, verb] = m;
1739
- const session = manager.get(sessionId);
2647
+ let session = manager.get(sessionId);
2648
+ // BUG-2 (E2E 2.5) · verify accepts git-style 7+-char short ids; kill/
2649
+ // events/message 404'd on the same prefix with a false "no running task
2650
+ // found" while the task WAS running. Resolve a UNIQUE prefix against
2651
+ // live sessions; ambiguous prefixes still fall through to the honest 404.
2652
+ if (!session && sessionId.length >= 7) {
2653
+ const matches = manager.list().filter((s) => s.sessionId.startsWith(sessionId));
2654
+ if (matches.length === 1)
2655
+ session = matches[0];
2656
+ }
2657
+ // resume-after-restart: a completed session dropped from memory (a daemon
2658
+ // restart, or eviction past MAX_COMPLETED) can still be CONTINUED — rebuild
2659
+ // an appendable handle from its persisted native resume token + cwd. Only
2660
+ // for the message verb (events/kill need a live process).
2661
+ if (!session && verb === 'message') {
2662
+ session = manager.reconstructAppendable(sessionId);
2663
+ }
1740
2664
  if (!session) {
2665
+ // S13 refuter · a ZOMBIE running record (daemon restarted, process
2666
+ // long gone, status still 'running' on disk) was unstoppable: kill
2667
+ // 404'd forever while the row inflated the RUNNING count. There is
2668
+ // no live process to signal — record the honest bookkeeping kill so
2669
+ // the record stops claiming a life it doesn't have.
2670
+ if (verb === 'kill') {
2671
+ const zrec = loadSessionById(sessionId);
2672
+ const zLive = zrec && ['running', 'queued', 'paused', 'blocked'].includes(zrec.status);
2673
+ if (zrec && zLive) {
2674
+ appendCompleted({
2675
+ id: zrec.sessionId,
2676
+ status: 'killed',
2677
+ completedAt: Date.now(),
2678
+ durationMs: zrec.durationMs || 0,
2679
+ steps: zrec.steps ?? [],
2680
+ artifacts: zrec.artifacts ?? {},
2681
+ });
2682
+ SessionManager.getInstance().markPersistedKilled(zrec.sessionId);
2683
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2684
+ res.end(JSON.stringify({
2685
+ sessionId,
2686
+ status: 'killed',
2687
+ note: 'no live process · the stale record was marked killed',
2688
+ }));
2689
+ return;
2690
+ }
2691
+ }
2692
+ // S1 · honest reason over a generic 404: if the task EXISTS but its
2693
+ // agent can't continue a finished session, say exactly that — the
2694
+ // composer should already be disabled, but the server is the gate.
2695
+ if (verb === 'message') {
2696
+ const rec = loadSessionById(sessionId);
2697
+ if (rec) {
2698
+ res.writeHead(409, { 'Content-Type': 'application/json' });
2699
+ res.end(JSON.stringify({
2700
+ error: `${rec.agent} can't continue a finished task — its session can't be resumed by codehere. Start a new task; the project thread carries the context.`,
2701
+ canFollowUp: false,
2702
+ }));
2703
+ return;
2704
+ }
2705
+ }
1741
2706
  res.writeHead(404, { 'Content-Type': 'application/json' });
1742
2707
  res.end(JSON.stringify({ error: `No session: ${sessionId}` }));
1743
2708
  return;
@@ -1788,7 +2753,12 @@ export function startMCPServer(options = {}) {
1788
2753
  const out = await session.appendMessage(text);
1789
2754
  const replyCompletedAt = Date.now();
1790
2755
  try {
1791
- const replyCwd = manager.getCwd(sessionId);
2756
+ // Live sessions: cwd from the in-memory map. Reconstructed
2757
+ // (resume-after-restart) sessions: the map is empty — fall
2758
+ // back to the cwd the handle carried from the persisted
2759
+ // record, so the resumed turn still lands in thread.jsonl
2760
+ // (no invisible agent activity).
2761
+ const replyCwd = manager.getCwd(sessionId) ?? session.cwd;
1792
2762
  if (replyCwd) {
1793
2763
  mirrorReplyToThread({
1794
2764
  parentSessionId: sessionId,
@@ -1895,26 +2865,71 @@ export function startMCPServer(options = {}) {
1895
2865
  // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
1896
2866
  // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
1897
2867
  if (req.url === '/mcp') {
2868
+ // MCP Streamable HTTP transport. Plain-JSON POST (Claude Desktop, Claude
2869
+ // Code) AND SSE-framed POST + a GET stream + Mcp-Session-Id, so strict
2870
+ // clients (Codex, Cursor) connect too. One server, every agent.
2871
+ const mcpSessionId = 'codehere-mcp-' + process.pid;
2872
+ // GET opens the server->client SSE channel some clients require.
2873
+ if (req.method === 'GET') {
2874
+ res.writeHead(200, {
2875
+ 'Content-Type': 'text/event-stream',
2876
+ 'Cache-Control': 'no-cache',
2877
+ Connection: 'keep-alive',
2878
+ 'Mcp-Session-Id': mcpSessionId,
2879
+ });
2880
+ res.write(': codehere mcp stream open\n\n');
2881
+ const ka = setInterval(() => {
2882
+ try {
2883
+ res.write(': keep-alive\n\n');
2884
+ }
2885
+ catch {
2886
+ /* socket closed */
2887
+ }
2888
+ }, 25000);
2889
+ req.on('close', () => clearInterval(ka));
2890
+ return;
2891
+ }
1898
2892
  if (req.method !== 'POST') {
1899
- res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
1900
- res.end(JSON.stringify({ error: 'MCP endpoint accepts POST only', method: req.method }));
2893
+ res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET, POST' });
2894
+ res.end(JSON.stringify({ error: 'MCP endpoint accepts GET (stream) or POST', method: req.method }));
1901
2895
  return;
1902
2896
  }
1903
2897
  (async () => {
1904
2898
  try {
1905
2899
  const body = await readBody(req, res);
2900
+ let mcpReq;
1906
2901
  try {
1907
- const mcpReq = JSON.parse(body);
1908
- const mcpRes = dispatch(mcpReq);
1909
- res.writeHead(200, {
1910
- 'Content-Type': 'application/json',
1911
- 'Access-Control-Allow-Origin': 'http://localhost',
1912
- });
1913
- res.end(JSON.stringify(mcpRes));
2902
+ mcpReq = JSON.parse(body);
1914
2903
  }
1915
2904
  catch {
1916
2905
  res.writeHead(400, { 'Content-Type': 'application/json' });
1917
2906
  res.end(JSON.stringify({ error: 'Invalid JSON' }));
2907
+ return;
2908
+ }
2909
+ // Notifications (no id, e.g. notifications/initialized) get a 202 with
2910
+ // no body, per the JSON-RPC / MCP Streamable HTTP spec.
2911
+ if (mcpReq.id === undefined || mcpReq.id === null) {
2912
+ res.writeHead(202, { 'Mcp-Session-Id': mcpSessionId });
2913
+ res.end();
2914
+ return;
2915
+ }
2916
+ const mcpRes = dispatch(mcpReq);
2917
+ // Strict Streamable-HTTP clients send Accept: text/event-stream and
2918
+ // want the response as a single SSE message; lenient ones take JSON.
2919
+ const accept = String(req.headers['accept'] || '');
2920
+ if (accept.includes('text/event-stream')) {
2921
+ res.writeHead(200, {
2922
+ 'Content-Type': 'text/event-stream',
2923
+ 'Cache-Control': 'no-cache',
2924
+ Connection: 'keep-alive',
2925
+ 'Mcp-Session-Id': mcpSessionId,
2926
+ });
2927
+ res.write('event: message\ndata: ' + JSON.stringify(mcpRes) + '\n\n');
2928
+ res.end();
2929
+ }
2930
+ else {
2931
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Mcp-Session-Id': mcpSessionId });
2932
+ res.end(JSON.stringify(mcpRes));
1918
2933
  }
1919
2934
  }
1920
2935
  catch {
@@ -1947,12 +2962,31 @@ export function startMCPServer(options = {}) {
1947
2962
  clientIdConfigured: !!process.env[p.clientIdEnv],
1948
2963
  };
1949
2964
  });
2965
+ // GitHub is special: it authenticates via the device flow (/api/github/connect/*),
2966
+ // not the generic auth-code OAuth above. Surface it as ONE connectable entry
2967
+ // with its real device-flow status, so the panel stops showing the
2968
+ // contradictory "public · no auth needed" chip alongside a Connect button
2969
+ // elsewhere. authType 'github-device' tells the UI to drive the device flow.
2970
+ const ghStatus = connectionStatus('github');
2971
+ const githubItem = {
2972
+ id: 'github',
2973
+ label: 'GitHub',
2974
+ scheme: 'github',
2975
+ authType: 'github-device',
2976
+ status: ghStatus.status,
2977
+ userLabel: ghStatus.userLabel ?? null,
2978
+ connectedAt: ghStatus.connectedAt ?? null,
2979
+ expiresAt: ghStatus.expiresAt ?? null,
2980
+ clientIdConfigured: !!githubClientId(),
2981
+ description: 'Import issues and post results back. Connect once.',
2982
+ };
1950
2983
  // v0.8 S5 · public-by-URL adapters appear with status='available'
1951
- // (no Connect button needed · they work out of the box).
2984
+ // (no Connect button needed · they work out of the box). GitHub is
2985
+ // excluded here — it's rendered as the connectable githubItem above.
1952
2986
  const oauthIds = new Set(oauthItems.map((i) => i.id));
1953
2987
  const reg = getDefaultRegistry();
1954
2988
  const noAuthItems = [...reg.bySchemeOnly.values()]
1955
- .filter((s) => !oauthIds.has(s.id))
2989
+ .filter((s) => !oauthIds.has(s.id) && s.id !== 'github')
1956
2990
  .map((s) => ({
1957
2991
  id: s.id,
1958
2992
  label: s.label,
@@ -1966,7 +3000,7 @@ export function startMCPServer(options = {}) {
1966
3000
  description: s.description,
1967
3001
  }));
1968
3002
  res.writeHead(200, { 'Content-Type': 'application/json' });
1969
- res.end(JSON.stringify({ providers: [...oauthItems, ...noAuthItems] }));
3003
+ res.end(JSON.stringify({ providers: [...oauthItems, githubItem, ...noAuthItems] }));
1970
3004
  }
1971
3005
  catch (e) {
1972
3006
  console.error('[/api/context-sources] failed:', e);
@@ -2155,26 +3189,9 @@ export function startMCPServer(options = {}) {
2155
3189
  })();
2156
3190
  return;
2157
3191
  }
2158
- if (req.url === '/api/audit/verify' && req.method === 'GET') {
2159
- try {
2160
- const r = verifyAuditChain(AUDIT_FILE);
2161
- res.writeHead(200, { 'Content-Type': 'application/json' });
2162
- res.end(JSON.stringify({
2163
- path: AUDIT_FILE,
2164
- valid: r.valid,
2165
- totalEntries: r.totalEntries,
2166
- chainedEntries: r.chainedEntries,
2167
- legacyEntries: r.legacyEntries,
2168
- errors: r.errors,
2169
- }));
2170
- }
2171
- catch (e) {
2172
- console.error('[/api/audit/verify] failed:', e);
2173
- res.writeHead(500, { 'Content-Type': 'application/json' });
2174
- res.end(JSON.stringify({ error: 'Failed to verify audit chain' }));
2175
- }
2176
- return;
2177
- }
3192
+ // (MED #34, atomic audit) · a second GET /api/audit/verify handler lived
3193
+ // here — unreachable dead code (the earlier handler wins the dispatch)
3194
+ // with a DIFFERENT response shape. Deleted; one route, one truth.
2178
3195
  // v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
2179
3196
  // bundle of one session that the recipient can replay locally. No relay
2180
3197
  // through codehere's infrastructure · the response stays on-machine.
@@ -2194,7 +3211,12 @@ export function startMCPServer(options = {}) {
2194
3211
  kind: 'thread',
2195
3212
  sessionId: session.sessionId,
2196
3213
  session,
2197
- exporter: { codehereVersion: '0.7.0' },
3214
+ // Embed the tool-call audit so a teammate can inspect what the agent
3215
+ // did (signer-attested; see ThreadBundleBody.auditChain trust model).
3216
+ auditChain: collectAuditEntriesForSession(session.sessionId),
3217
+ // LOW #36 (atomic audit) · was hardcoded '0.7.0' while the build is 0.9.x —
3218
+ // a stale version stamped into every exported bundle's metadata.
3219
+ exporter: { codehereVersion: getVersion() },
2198
3220
  exportedAt: new Date().toISOString(),
2199
3221
  };
2200
3222
  const bundle = signBundle(body, keys);
@@ -2247,10 +3269,33 @@ export function startMCPServer(options = {}) {
2247
3269
  }
2248
3270
  const finalBundle = verdict.bundle;
2249
3271
  const ses = finalBundle.body.session;
3272
+ const importFp = shortFingerprint(finalBundle.publicKey);
3273
+ // No-clobber / idempotent import (Phase 2.5 multi-human): a re-import
3274
+ // must not duplicate the session in a shared room.
3275
+ if (loadSessionById(ses.sessionId)) {
3276
+ res.writeHead(200, { 'Content-Type': 'application/json' });
3277
+ res.end(JSON.stringify({ status: 'already_imported', sessionId: ses.sessionId, fingerprint: importFp }));
3278
+ return;
3279
+ }
2250
3280
  const home = process.env.CODEHERE_HOME || homedir();
2251
3281
  const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
2252
3282
  const fs = await import('fs');
2253
3283
  fs.mkdirSync(dirname(sessionsPath), { recursive: true });
3284
+ // Re-verify the embedded tool-call audit so the importer SEES what the
3285
+ // agent did. attested gated on verified>0 + no tamper (never bare .valid,
3286
+ // true for an empty chain). Computed BEFORE persistence so it can be
3287
+ // stored on the session — the bundle isn't kept, so the room needs this.
3288
+ const auditCheck = finalBundle.body.auditChain
3289
+ ? verifyEmbeddedAuditChain(finalBundle.body.auditChain)
3290
+ : undefined;
3291
+ const importAudit = auditCheck
3292
+ ? {
3293
+ total: auditCheck.total,
3294
+ verified: auditCheck.verified,
3295
+ tampered: auditCheck.tampered.length,
3296
+ attested: auditCheck.verified > 0 && auditCheck.tampered.length === 0,
3297
+ }
3298
+ : undefined;
2254
3299
  const lines = [];
2255
3300
  lines.push(JSON.stringify({
2256
3301
  t: 'spawned',
@@ -2259,6 +3304,16 @@ export function startMCPServer(options = {}) {
2259
3304
  prompt: ses.prompt,
2260
3305
  cwd: ses.cwd,
2261
3306
  startedAt: ses.startedAt,
3307
+ // S18 · practice classification is a property of the RUN on the
3308
+ // exporter's machine, carried by the bundle — never the importing
3309
+ // daemon's env. Absent in a legacy bundle = the work was real and
3310
+ // audit-attested; an import performed on a mock daemon must never
3311
+ // demote a teammate's real work to practice. Stamped explicitly so
3312
+ // no spawned-record writer leaves the field env-classified.
3313
+ mockRun: ses.mockRun === true,
3314
+ // Provenance: attribute the imported session to its signer.
3315
+ importedFrom: importFp,
3316
+ ...(importAudit ? { importAudit } : {}),
2262
3317
  }));
2263
3318
  lines.push(JSON.stringify({
2264
3319
  t: 'completed',
@@ -2279,11 +3334,14 @@ export function startMCPServer(options = {}) {
2279
3334
  }));
2280
3335
  }
2281
3336
  fs.appendFileSync(sessionsPath, lines.join('\n') + '\n', 'utf-8');
3337
+ // auditCheck was computed above (before persistence); reuse it for the
3338
+ // response so the importer sees the same attestation that got stored.
2282
3339
  res.writeHead(200, { 'Content-Type': 'application/json' });
2283
3340
  res.end(JSON.stringify({
2284
3341
  status: 'imported',
2285
3342
  sessionId: ses.sessionId,
2286
3343
  fingerprint: shortFingerprint(finalBundle.publicKey),
3344
+ audit: auditCheck ? { ...importAudit, legacy: auditCheck.legacy } : null,
2287
3345
  }));
2288
3346
  }
2289
3347
  catch (e) {
@@ -2375,7 +3433,11 @@ export function startMCPServer(options = {}) {
2375
3433
  return;
2376
3434
  }
2377
3435
  // Server assigns the id · agents shouldn't pick collisions.
2378
- raw.id = typeof raw.id === 'string' && raw.id ? raw.id : newPassId();
3436
+ // S11 refuter caveat · the pass id is the DAEMON's to mint, always.
3437
+ // A caller-supplied id could collide, or match the demo-wp- prefix
3438
+ // the modal renders as not-sendable (a real pass made permanently
3439
+ // un-approvable by its own name).
3440
+ raw.id = newPassId();
2379
3441
  raw.emittedAt =
2380
3442
  typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
2381
3443
  let intent;
@@ -2712,12 +3774,20 @@ export function startMCPServer(options = {}) {
2712
3774
  // Per claim_user_persona HIGH non-technical-first: a non-technical user
2713
3775
  // who typos a URL gets a useful page back, not a void.
2714
3776
  const requestedPath = (req.url ?? '/').split('?')[0];
3777
+ // API clients are machines: an unknown /api/* path gets JSON, never an
3778
+ // HTML page a fetch().json() chokes on (2026-06-07 audit, Lane API).
3779
+ if (requestedPath.startsWith('/api/')) {
3780
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3781
+ res.end(JSON.stringify({ error: `Unknown API route: ${requestedPath.slice(0, 200)}` }));
3782
+ return;
3783
+ }
2715
3784
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
2716
3785
  res.end(`<!doctype html>
2717
3786
  <html lang="en">
2718
3787
  <head>
2719
3788
  <meta charset="utf-8"/>
2720
3789
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
3790
+ <link rel="icon" type="image/svg+xml" href="/icon.svg"/>
2721
3791
  <title>codehere · page not found</title>
2722
3792
  <style>
2723
3793
  :root {
@@ -2777,8 +3847,7 @@ export function startMCPServer(options = {}) {
2777
3847
  <div class="routes">
2778
3848
  <div class="routes-label">VALID ROUTES</div>
2779
3849
  <a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
2780
- <a class="route" href="/threads"><span class="route-name">Project threads</span><span class="route-path">/threads</span></a>
2781
- <a class="route" href="/history"><span class="route-name">Audit history</span><span class="route-path">/history</span></a>
3850
+ <a class="route" href="/verify"><span class="route-name">Verify at the surface</span><span class="route-path">/verify</span></a>
2782
3851
  </div>
2783
3852
  </main>
2784
3853
  </body>
@@ -2801,6 +3870,19 @@ export function startMCPServer(options = {}) {
2801
3870
  catch {
2802
3871
  /* best-effort */
2803
3872
  }
3873
+ // Project identity backfill — detect + cache each project's git remote in
3874
+ // _meta once, so the project list can collapse clones of one repo without
3875
+ // shelling git on every poll. Idempotent (skips already-checked projects).
3876
+ try {
3877
+ const { backfillProjectRemotes } = await import('../storage/project-thread-mirror.js');
3878
+ const r = await backfillProjectRemotes();
3879
+ if (process.env.CODEHERE_DEBUG === '1' && r.detected > 0) {
3880
+ console.error(`[daemon-start] detected ${r.detected}/${r.scanned} project remotes`);
3881
+ }
3882
+ }
3883
+ catch {
3884
+ /* best-effort */
3885
+ }
2804
3886
  // Stale-session reconciliation. Walks per-project thread.jsonl files
2805
3887
  // and marks any sessions stuck in 'running' for >60 min as crashed.
2806
3888
  // Closes the dogfood-found gap where 3-day-old tasks displayed as
@@ -2829,10 +3911,10 @@ export function startMCPServer(options = {}) {
2829
3911
  function callbackHtml(status, message, providerLabel) {
2830
3912
  const safe = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2831
3913
  const tone = status === 'ok' ? '#28c840' : '#ff5f57';
2832
- const icon = status === 'ok' ? '✓' : '✗';
3914
+ const icon = status === 'ok' ? '✓' : '✕';
2833
3915
  const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
2834
3916
  return `<!doctype html><html lang="en"><head>
2835
- <meta charset="utf-8"><title>${safe(title)}</title>
3917
+ <meta charset="utf-8"><link rel="icon" type="image/svg+xml" href="/icon.svg"><title>${safe(title)}</title>
2836
3918
  <style>
2837
3919
  body { margin:0; background:#0a0a0a; color:#fff; font-family:ui-sans-serif,system-ui,-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; min-height:100vh; }
2838
3920
  .card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }