codehere 0.7.1 → 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 (139) 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/narrator-prompt-template.js +1 -1
  11. package/dist/application/use-cases/plan-auto-review.js +57 -0
  12. package/dist/application/use-cases/reconcile-audit.js +100 -0
  13. package/dist/application/use-cases/rerun-tests.js +226 -0
  14. package/dist/application/use-cases/search-sessions.js +70 -0
  15. package/dist/application/use-cases/should-auto-review.js +71 -0
  16. package/dist/application/use-cases/thread-export.js +90 -0
  17. package/dist/application/use-cases/verify-claim.js +187 -5
  18. package/dist/domain/audit.js +11 -0
  19. package/dist/domain/services/codebase-detector.js +0 -6
  20. package/dist/domain/task.js +91 -0
  21. package/dist/error-handler.js +3 -115
  22. package/dist/index.js +279 -31
  23. package/dist/infrastructure/completion/bash-completion.js +7 -3
  24. package/dist/infrastructure/completion/fish-completion.js +7 -12
  25. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  26. package/dist/infrastructure/config/config-manager.js +4 -3
  27. package/dist/infrastructure/config/product-context.js +1 -19
  28. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  29. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  30. package/dist/infrastructure/context/github-api.js +110 -0
  31. package/dist/infrastructure/context/github-device-flow.js +65 -0
  32. package/dist/infrastructure/context/oauth-store.js +5 -2
  33. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  34. package/dist/infrastructure/cost/budget-config.js +4 -3
  35. package/dist/infrastructure/cost/cost-router.js +16 -23
  36. package/dist/infrastructure/cost/spend-aggregator.js +91 -16
  37. package/dist/infrastructure/cost/team-ledger.js +355 -0
  38. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  39. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  40. package/dist/infrastructure/hooks/claude-sdk-hook.js +139 -3
  41. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  42. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  43. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  44. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  45. package/dist/infrastructure/orchestration/agents-config-seed.js +23 -11
  46. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  47. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  48. package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
  49. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  50. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  51. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  52. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  53. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  54. package/dist/infrastructure/orchestration/failover-config.js +1 -11
  55. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  56. package/dist/infrastructure/orchestration/generic-cli-adapter.js +134 -3
  57. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  58. package/dist/infrastructure/orchestration/memory-injector.js +4 -1
  59. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +167 -9
  60. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  61. package/dist/infrastructure/orchestration/progress-aggregator.js +275 -23
  62. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  63. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  64. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  65. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  66. package/dist/infrastructure/protocols/cloud-sync.js +2 -2
  67. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  68. package/dist/infrastructure/protocols/mcp-server-impl.js +1498 -354
  69. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  70. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  71. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  72. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  73. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  74. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  75. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  76. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  77. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  78. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  79. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  80. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  81. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  82. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  83. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  84. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  85. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  86. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  87. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  88. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  89. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  90. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  91. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  92. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4126
  93. package/dist/infrastructure/protocols/verify-page.js +884 -148
  94. package/dist/infrastructure/security/fix-code.js +144 -0
  95. package/dist/infrastructure/security/fix-secret.js +89 -0
  96. package/dist/infrastructure/security/permissions-config.js +3 -2
  97. package/dist/infrastructure/security/scan-types.js +10 -0
  98. package/dist/infrastructure/security/ship-scan.js +195 -0
  99. package/dist/infrastructure/security/trust-scorer.js +70 -3
  100. package/dist/infrastructure/security/vibe-checks.js +99 -0
  101. package/dist/infrastructure/storage/atomic-write.js +86 -0
  102. package/dist/infrastructure/storage/project-thread-mirror.js +151 -4
  103. package/dist/infrastructure/storage/project-thread-reader.js +262 -25
  104. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  105. package/dist/monitoring.js +2 -2
  106. package/dist/presentation/cli/commands/audit-command.js +79 -4
  107. package/dist/presentation/cli/commands/budget-command.js +2 -2
  108. package/dist/presentation/cli/commands/check-command.js +96 -0
  109. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  110. package/dist/presentation/cli/commands/help-command.js +5 -5
  111. package/dist/presentation/cli/commands/hook-command.js +5 -5
  112. package/dist/presentation/cli/commands/init-command.js +89 -66
  113. package/dist/presentation/cli/commands/memory-command.js +28 -28
  114. package/dist/presentation/cli/commands/permission-command.js +1 -12
  115. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  116. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  117. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  118. package/dist/presentation/cli/commands/report-command.js +48 -32
  119. package/dist/presentation/cli/commands/scan-command.js +52 -0
  120. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  121. package/dist/presentation/cli/commands/search-command.js +51 -0
  122. package/dist/presentation/cli/commands/setup-command.js +31 -8
  123. package/dist/presentation/cli/commands/status-command.js +39 -86
  124. package/dist/presentation/cli/commands/team-command.js +256 -0
  125. package/dist/presentation/cli/commands/thread-command.js +87 -5
  126. package/dist/presentation/cli/commands/trust-command.js +53 -31
  127. package/dist/presentation/cli/commands/verify-command.js +74 -9
  128. package/dist/presentation/cli/error-display.js +0 -8
  129. package/dist/presentation/cli/repl.js +15 -7
  130. package/dist/presentation/cli/task-deliverable.js +36 -10
  131. package/dist/testing/replay-adapter.js +0 -19
  132. package/dist/ui.js +51 -239
  133. package/dist/utils/version.js +0 -6
  134. package/package.json +30 -10
  135. package/dist/domain/entities/task.js +0 -61
  136. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  137. package/dist/infrastructure/security/input-validator.js +0 -152
  138. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  139. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -19,101 +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';
57
+ import { findProjectRoot } from '../orchestration/memory-injector.js';
58
+ import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
49
59
  import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
50
- import { listProjects, readProjectThread } from '../storage/project-thread-reader.js';
51
- 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';
52
62
  import { randomUUID, timingSafeEqual } from 'crypto';
53
63
  import { installAgent } from '../orchestration/agent-installer.js';
54
64
  import { detectReadiness } from '../orchestration/readiness.js';
55
65
  import { getHookMode } from '../hooks/hook-mode.js';
56
66
  import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
57
67
  import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
58
- import { loadBudget, isOverBudget, budgetUsedPct } from '../cost/budget-config.js';
59
- // ── Tool definitions ─────────────────────────────────────────────────────────
60
- const TOOLS = [
61
- {
62
- name: 'codehere_trust',
63
- 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.',
64
- inputSchema: {
65
- type: 'object',
66
- properties: {
67
- filepath: { type: 'string', description: 'Path to file (relative to cwd)' },
68
- code: { type: 'string', description: 'Code content to scan (alternative to filepath)' },
69
- verbose: { type: 'boolean', description: 'Include all findings with line numbers' },
70
- },
71
- },
72
- },
73
- {
74
- name: 'codehere_scan',
75
- 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.',
76
- inputSchema: {
77
- type: 'object',
78
- required: ['text'],
79
- properties: {
80
- text: { type: 'string', description: 'Text to scan (instruction, prompt, command)' },
81
- },
82
- },
83
- },
84
- {
85
- name: 'codehere_review',
86
- description: 'Review all files in a directory and return trust scores. Identifies the riskiest files to focus review effort.',
87
- inputSchema: {
88
- type: 'object',
89
- properties: {
90
- path: { type: 'string', description: 'Directory path (default: current directory)' },
91
- topN: { type: 'number', description: 'Return top N riskiest files (default: 10)' },
92
- },
93
- },
94
- },
95
- {
96
- name: 'codehere_audit',
97
- 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.',
98
- inputSchema: {
99
- type: 'object',
100
- properties: {
101
- limit: { type: 'number', description: 'Number of recent entries to return (default: 20)' },
102
- blockedOnly: { type: 'boolean', description: 'Only return blocked events' },
103
- overrideOnly: {
104
- type: 'boolean',
105
- 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.',
106
- },
107
- verify: {
108
- type: 'boolean',
109
- 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).',
110
- },
111
- },
112
- },
113
- },
114
- ];
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 ────────────────────────────────────────────────────────
115
98
  // ── Tool handlers ────────────────────────────────────────────────────────────
116
- import { homedir } from 'os';
99
+ import { homedir, tmpdir } from 'os';
117
100
  const controlSessions = new Map();
118
101
  let controlSeq = 0;
119
102
  /**
@@ -123,7 +106,7 @@ let controlSeq = 0;
123
106
  * public key is meant for client-side embedding (analytics tracking key, not
124
107
  * a secret) so committing the URL is safe.
125
108
  *
126
- * Why runtime injection instead of editing TASK_HTML/HISTORY_HTML directly:
109
+ * Why runtime injection instead of editing the page templates directly:
127
110
  * the templates stay identical between dev and release builds. The only
128
111
  * delta is the env var. This keeps the release surface clean (zero-telemetry
129
112
  * value per PRD §1.5 #2 stays intact) while letting elu observe founder
@@ -138,203 +121,6 @@ function withDevElu(html) {
138
121
  return html;
139
122
  return html.replace('</head>', `${ELU_DEV_SCRIPT}\n</head>`);
140
123
  }
141
- function handleTrust(params) {
142
- const { filepath, code, verbose } = params;
143
- let content;
144
- let label;
145
- if (code) {
146
- content = code;
147
- label = 'provided code';
148
- }
149
- else if (filepath) {
150
- const resolved = join(process.cwd(), filepath);
151
- if (!existsSync(resolved)) {
152
- return JSON.stringify({ error: `File not found: ${filepath}` });
153
- }
154
- content = readFileSync(resolved, 'utf-8');
155
- label = filepath;
156
- }
157
- else {
158
- return JSON.stringify({ error: 'Provide either filepath or code' });
159
- }
160
- const result = scoreFileForMCP(label, content);
161
- if (!verbose) {
162
- // Compact response
163
- return JSON.stringify({
164
- score: result.score,
165
- grade: result.grade,
166
- summary: result.summary,
167
- criticalCount: result.findings.filter((f) => f.severity === 'critical').length,
168
- highCount: result.findings.filter((f) => f.severity === 'high').length,
169
- recommendation: result.score >= 75
170
- ? 'Safe — minor issues can be addressed in follow-up'
171
- : result.score >= 60
172
- ? 'Review recommended before merge'
173
- : 'Block — resolve critical/high findings first',
174
- });
175
- }
176
- return JSON.stringify(result, null, 2);
177
- }
178
- function handleScan(params) {
179
- const text = String(params.text ?? '');
180
- const findings = scanPayload(text);
181
- const riskScore = calculateRiskScore(findings);
182
- const criticalFindings = findings.filter((f) => f.severity === 'critical');
183
- const blocked = criticalFindings.length > 0 || riskScore >= 60;
184
- return JSON.stringify({
185
- blocked,
186
- riskScore,
187
- decision: blocked ? 'block' : 'allow',
188
- reason: blocked
189
- ? criticalFindings.length > 0
190
- ? `${criticalFindings[0].category}: ${criticalFindings[0].description}`
191
- : `Risk score ${riskScore}/100 exceeds threshold`
192
- : 'No threats detected',
193
- findings: findings.slice(0, 10), // cap at 10 to keep response small
194
- });
195
- }
196
- /**
197
- * Coerce a client-supplied numeric param to a bounded integer. Daemon
198
- * endpoints take topN/limit straight from request bodies; a negative or NaN
199
- * value silently corrupts slice() math (limit=-1 → slice keeps all-but-last,
200
- * NaN → slice returns everything). Clamp to a sane range instead of trusting
201
- * the wire.
202
- */
203
- export function clampInt(v, def, min, max) {
204
- // null/undefined = absent → default. Number(null) is 0 (finite), so without
205
- // this guard a literal null would coerce to the min instead of the default.
206
- if (v === null || v === undefined)
207
- return def;
208
- const n = Math.floor(Number(v));
209
- if (!Number.isFinite(n))
210
- return def;
211
- return Math.max(min, Math.min(n, max));
212
- }
213
- function handleReview(params) {
214
- const targetPath = String(params.path ?? process.cwd());
215
- const topN = clampInt(params.topN, 10, 1, 100);
216
- try {
217
- const files = collectFilesForMCP(targetPath);
218
- if (files.length === 0) {
219
- return JSON.stringify({ message: 'No code files found', path: targetPath });
220
- }
221
- const results = files.map((f) => scoreFileForMCP(relative(targetPath, f.filepath), f.code));
222
- results.sort((a, b) => a.score - b.score); // worst first
223
- const top = results.slice(0, topN);
224
- const overallScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
225
- return JSON.stringify({
226
- overallScore,
227
- grade: overallScore >= 90 ? 'A' : overallScore >= 75 ? 'B' : overallScore >= 60 ? 'C' : overallScore >= 40 ? 'D' : 'F',
228
- totalFiles: results.length,
229
- riskiestFiles: top.map((r) => ({
230
- filepath: r.filepath,
231
- score: r.score,
232
- grade: r.grade,
233
- summary: r.summary,
234
- criticalCount: r.findings.filter((f) => f.severity === 'critical').length,
235
- highCount: r.findings.filter((f) => f.severity === 'high').length,
236
- })),
237
- });
238
- }
239
- catch (e) {
240
- return JSON.stringify({ error: String(e) });
241
- }
242
- }
243
- function handleAudit(params) {
244
- const limit = clampInt(params.limit, 20, 1, 500);
245
- const blockedOnly = Boolean(params.blockedOnly);
246
- const overrideOnly = Boolean(params.overrideOnly);
247
- const verify = Boolean(params.verify);
248
- if (verify) {
249
- // Tamper-evidence verification — walks the file, recomputes every hash,
250
- // checks the chain. Returns full report including ALL break points.
251
- const result = verifyAuditChain(AUDIT_FILE);
252
- return JSON.stringify(result, null, 2);
253
- }
254
- if (!existsSync(AUDIT_FILE)) {
255
- return JSON.stringify({
256
- message: 'No audit log yet — install hooks first with: codehere hook install',
257
- entries: [],
258
- });
259
- }
260
- const lines = readFileSync(AUDIT_FILE, 'utf-8')
261
- .split('\n')
262
- .filter(Boolean)
263
- .slice(-Math.min(limit * 3, 500)); // read more than needed to account for filter
264
- const entries = lines
265
- .map((l) => {
266
- try {
267
- return JSON.parse(l);
268
- }
269
- catch {
270
- return null;
271
- }
272
- })
273
- .filter(Boolean)
274
- .filter((e) => {
275
- if (overrideOnly)
276
- return Boolean(e.overrideReason);
277
- if (blockedOnly)
278
- return Boolean(e.blocked);
279
- return true;
280
- })
281
- .slice(-limit);
282
- return JSON.stringify({ entries, total: entries.length });
283
- }
284
- // ── JSON-RPC dispatcher ──────────────────────────────────────────────────────
285
- function dispatch(req) {
286
- const id = req.id;
287
- const params = (req.params ?? {});
288
- try {
289
- switch (req.method) {
290
- case 'initialize':
291
- return {
292
- jsonrpc: '2.0',
293
- id,
294
- result: {
295
- protocolVersion: '2024-11-05',
296
- capabilities: { tools: {} },
297
- serverInfo: { name: 'codehere', version: getVersion() },
298
- },
299
- };
300
- case 'tools/list':
301
- return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
302
- case 'tools/call': {
303
- const toolName = String(params.name ?? '');
304
- const toolParams = (params.arguments ?? {});
305
- let content;
306
- switch (toolName) {
307
- case 'codehere_trust':
308
- content = handleTrust(toolParams);
309
- break;
310
- case 'codehere_scan':
311
- content = handleScan(toolParams);
312
- break;
313
- case 'codehere_review':
314
- content = handleReview(toolParams);
315
- break;
316
- case 'codehere_audit':
317
- content = handleAudit(toolParams);
318
- break;
319
- default:
320
- return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${toolName}` } };
321
- }
322
- return {
323
- jsonrpc: '2.0',
324
- id,
325
- result: { content: [{ type: 'text', text: content }] },
326
- };
327
- }
328
- case 'ping':
329
- return { jsonrpc: '2.0', id, result: {} };
330
- default:
331
- return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${req.method}` } };
332
- }
333
- }
334
- catch (e) {
335
- return { jsonrpc: '2.0', id, error: { code: -32603, message: String(e) } };
336
- }
337
- }
338
124
  const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
339
125
  // v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
340
126
  // Lifecycle: begin() inserts; callback resolves and removes; entries also
@@ -375,6 +161,20 @@ export function startMCPServer(options = {}) {
375
161
  const port = options.port ?? 3773;
376
162
  const host = options.host ?? '127.0.0.1'; // localhost only by default
377
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
+ }
378
178
  const server = http.createServer((req, res) => {
379
179
  // v0.7 Week-2.2 · CORS for read-only public endpoints.
380
180
  // The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
@@ -386,13 +186,22 @@ export function startMCPServer(options = {}) {
386
186
  // /api/writepass/:id/:decision, etc.) are NOT covered by this · they
387
187
  // remain same-origin-only.
388
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];
389
192
  const isReadOnlyPublic = url === '/health' ||
390
193
  url === '/api/version' ||
391
194
  url === '/api/tasks' ||
392
195
  url === '/api/host-sessions' ||
393
196
  url.startsWith('/api/host-sessions/') ||
394
- url.startsWith('/api/audit/stats') ||
395
- 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';
396
205
  if (isReadOnlyPublic) {
397
206
  res.setHeader('Access-Control-Allow-Origin', '*');
398
207
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
@@ -446,7 +255,24 @@ export function startMCPServer(options = {}) {
446
255
  res.writeHead(200, { 'Content-Type': 'application/json' });
447
256
  // `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
448
257
  // attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
449
- 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);
450
276
  return;
451
277
  }
452
278
  // v2 Task-Centric PM UI (primary — served at /)
@@ -472,22 +298,24 @@ export function startMCPServer(options = {}) {
472
298
  res.end(VERIFY_HTML);
473
299
  return;
474
300
  }
475
- // History / audit feed (what we built Day 0; now secondary)
476
- if (req.method === 'GET' && (req.url === '/history' || req.url === '/history/')) {
477
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
478
- res.end(withDevElu(HISTORY_HTML));
479
- return;
480
- }
481
- // 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.
482
306
  if (req.method === 'GET' &&
483
- (req.url === '/threads' || req.url === '/threads/' || req.url?.startsWith('/threads?'))) {
484
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
485
- 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();
486
314
  return;
487
315
  }
488
- // 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
489
317
  if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/')) {
490
- res.writeHead(302, { Location: '/history' });
318
+ res.writeHead(302, { Location: '/' });
491
319
  res.end();
492
320
  return;
493
321
  }
@@ -545,7 +373,7 @@ export function startMCPServer(options = {}) {
545
373
  // Tails sessions.jsonl and pushes "change" events so the /v2 UI can
546
374
  // re-fetch task state instantly instead of waiting 8s for the next poll.
547
375
  // Same pattern as /events (audit log tail) but for sessions.
548
- const SESSIONS_FILE = join(homedir(), '.codehere', 'sessions.jsonl');
376
+ const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
549
377
  if (req.method === 'GET' && req.url === '/api/events/sessions') {
550
378
  res.writeHead(200, {
551
379
  'Content-Type': 'text/event-stream',
@@ -590,9 +418,46 @@ export function startMCPServer(options = {}) {
590
418
  }
591
419
  };
592
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
+ });
593
455
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);
594
456
  req.on('close', () => {
595
457
  clearInterval(ping);
458
+ if (liveTimer)
459
+ clearTimeout(liveTimer);
460
+ offLive();
596
461
  unwatchFile(SESSIONS_FILE, onChange);
597
462
  });
598
463
  return;
@@ -609,9 +474,14 @@ export function startMCPServer(options = {}) {
609
474
  if (req.method === 'GET' && req.url === '/api/audit/verify') {
610
475
  try {
611
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;
612
481
  res.writeHead(200, { 'Content-Type': 'application/json' });
613
482
  res.end(JSON.stringify({
614
- valid: r.valid,
483
+ valid: verifiable && r.valid,
484
+ verifiable,
615
485
  totalEntries: r.totalEntries,
616
486
  chainedEntries: r.chainedEntries,
617
487
  legacyEntries: r.legacyEntries,
@@ -620,7 +490,13 @@ export function startMCPServer(options = {}) {
620
490
  }
621
491
  catch {
622
492
  res.writeHead(200, { 'Content-Type': 'application/json' });
623
- 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
+ }));
624
500
  }
625
501
  return;
626
502
  }
@@ -689,6 +565,118 @@ export function startMCPServer(options = {}) {
689
565
  })();
690
566
  return;
691
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
+ }
692
680
  if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
693
681
  const params = new URLSearchParams(req.url.split('?')[1] || '');
694
682
  const cwd = params.get('cwd') || '';
@@ -918,7 +906,11 @@ export function startMCPServer(options = {}) {
918
906
  totalCalls: calls.length,
919
907
  blockedCalls: calls.filter((c) => c.blocked).length,
920
908
  criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
921
- 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,
922
914
  chainEntries: chain.chainedEntries,
923
915
  }));
924
916
  return;
@@ -1010,6 +1002,156 @@ export function startMCPServer(options = {}) {
1010
1002
  // returns the new entry's seq + short hash, so the UI can show "logged ·
1011
1003
  // chain continues <hash>" with the REAL hash, not a mockup. Same-origin
1012
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
+ }
1013
1155
  if (req.method === 'POST' && req.url === '/api/audit/override') {
1014
1156
  (async () => {
1015
1157
  try {
@@ -1029,7 +1171,11 @@ export function startMCPServer(options = {}) {
1029
1171
  res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
1030
1172
  return;
1031
1173
  }
1032
- 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, {
1033
1179
  sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
1034
1180
  toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
1035
1181
  note: reason,
@@ -1056,7 +1202,11 @@ export function startMCPServer(options = {}) {
1056
1202
  catch {
1057
1203
  continue;
1058
1204
  }
1059
- 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) {
1060
1210
  seq = typeof e.seq === 'number' ? e.seq : null;
1061
1211
  entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
1062
1212
  confirmed = true;
@@ -1072,6 +1222,14 @@ export function startMCPServer(options = {}) {
1072
1222
  res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
1073
1223
  return;
1074
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
+ }
1075
1233
  res.writeHead(200, { 'Content-Type': 'application/json' });
1076
1234
  res.end(JSON.stringify({
1077
1235
  ok: true,
@@ -1198,6 +1356,355 @@ export function startMCPServer(options = {}) {
1198
1356
  }
1199
1357
  return;
1200
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
+ })();
1706
+ return;
1707
+ }
1201
1708
  // ── F1a readiness check ───────────────────────────────────────────────────
1202
1709
  if (req.method === 'GET' && req.url === '/api/readiness') {
1203
1710
  try {
@@ -1211,6 +1718,71 @@ export function startMCPServer(options = {}) {
1211
1718
  }
1212
1719
  return;
1213
1720
  }
1721
+ // ── v1 project-memory routes (the room's init nudge) ─────────────────────
1722
+ // Found by the stranger walk: web-only users never run `codehere init`, so
1723
+ // the spec's "agent remembers the project" half of nothing-lost silently
1724
+ // doesn't apply. Status reports the SAME readiness the injector acts on
1725
+ // (shared findProjectRoot); init runs the SAME hardened scaffold the CLI
1726
+ // does (shared scaffoldProjectAt — symlink defenses included).
1727
+ if (req.method === 'GET' && req.url?.startsWith('/api/memory/status')) {
1728
+ try {
1729
+ const u = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
1730
+ const cwd = u.searchParams.get('cwd') || '';
1731
+ if (!cwd || !isAbsolute(cwd) || cwd.includes('\0')) {
1732
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1733
+ res.end(JSON.stringify({ error: 'cwd (absolute path) required' }));
1734
+ return;
1735
+ }
1736
+ const root = findProjectRoot(cwd);
1737
+ const ready = !!root && existsSync(join(root, '.codehere', 'memory', 'working'));
1738
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1739
+ res.end(JSON.stringify({ ready, projectRoot: root }));
1740
+ }
1741
+ catch (e) {
1742
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1743
+ res.end(JSON.stringify({ error: 'memory_status_failed', detail: String(e) }));
1744
+ }
1745
+ return;
1746
+ }
1747
+ if (req.method === 'POST' && req.url === '/api/memory/init') {
1748
+ (async () => {
1749
+ try {
1750
+ const body = await readBody(req, res);
1751
+ let cwd = '';
1752
+ try {
1753
+ cwd = String(JSON.parse(body || '{}').cwd || '');
1754
+ }
1755
+ catch {
1756
+ /* falls through to the validation below */
1757
+ }
1758
+ if (!cwd || !isAbsolute(cwd) || cwd.includes('\0') || !existsSync(cwd) || !statSync(cwd).isDirectory()) {
1759
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1760
+ res.end(JSON.stringify({ error: 'cwd (absolute path to an existing directory) required' }));
1761
+ return;
1762
+ }
1763
+ const r = scaffoldProjectAt(cwd);
1764
+ if (!r.ok) {
1765
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1766
+ res.end(JSON.stringify({ ok: false, reason: r.reason }));
1767
+ return;
1768
+ }
1769
+ res.writeHead(200, { 'Content-Type': 'application/json' });
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
+ }));
1776
+ }
1777
+ catch (e) {
1778
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1779
+ res.end(JSON.stringify({ error: 'memory_init_failed', detail: String(e) }));
1780
+ }
1781
+ })().catch(() => {
1782
+ /* handled above — keep the route from rejecting unhandled */
1783
+ });
1784
+ return;
1785
+ }
1214
1786
  // ── v0.6.0 M1 project thread routes ──────────────────────────────────────
1215
1787
  // GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
1216
1788
  // GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
@@ -1218,7 +1790,19 @@ export function startMCPServer(options = {}) {
1218
1790
  // populated by migration + forward-mirror). Drives the new thread-view UI.
1219
1791
  if (req.method === 'GET' && req.url === '/api/projects') {
1220
1792
  try {
1221
- 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)));
1222
1806
  res.writeHead(200, { 'Content-Type': 'application/json' });
1223
1807
  res.end(JSON.stringify({ projects }));
1224
1808
  }
@@ -1239,14 +1823,33 @@ export function startMCPServer(options = {}) {
1239
1823
  res.end(JSON.stringify({ error: 'invalid_slug' }));
1240
1824
  return;
1241
1825
  }
1242
- 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);
1243
1829
  if (!thread) {
1244
1830
  res.writeHead(404, { 'Content-Type': 'application/json' });
1245
1831
  res.end(JSON.stringify({ error: 'project_not_found', slug }));
1246
1832
  return;
1247
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
+ });
1248
1851
  res.writeHead(200, { 'Content-Type': 'application/json' });
1249
- res.end(JSON.stringify({ thread }));
1852
+ res.end(JSON.stringify({ thread: { ...thread, tasks: tasksWithCost } }));
1250
1853
  }
1251
1854
  catch (e) {
1252
1855
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1304,6 +1907,171 @@ export function startMCPServer(options = {}) {
1304
1907
  })();
1305
1908
  return;
1306
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
+ }
1307
2075
  // ── F1 task orchestration routes ─────────────────────────────────────────
1308
2076
  // POST /api/task — { prompt, cwd? } → { sessionId }
1309
2077
  // GET /api/tasks — list all sessions
@@ -1352,6 +2120,10 @@ export function startMCPServer(options = {}) {
1352
2120
  const batchBudget = loadBudget();
1353
2121
  const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
1354
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();
1355
2127
  for (const t of tasks) {
1356
2128
  const prompt = t.prompt;
1357
2129
  if (!prompt || typeof prompt !== 'string') {
@@ -1372,6 +2144,42 @@ export function startMCPServer(options = {}) {
1372
2144
  results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
1373
2145
  continue;
1374
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);
1375
2183
  }
1376
2184
  // Per-task budget check (paid agents only).
1377
2185
  if (batchBudget.dailyUsd !== null) {
@@ -1387,7 +2195,7 @@ export function startMCPServer(options = {}) {
1387
2195
  }
1388
2196
  }
1389
2197
  try {
1390
- const session = manager.spawn(prompt, agentId, { cwd: t.cwd });
2198
+ const session = manager.spawn(prompt, agentId, { cwd: t.cwd, batchId });
1391
2199
  results.push({
1392
2200
  sessionId: session.sessionId,
1393
2201
  agent: session.agent,
@@ -1470,6 +2278,27 @@ export function startMCPServer(options = {}) {
1470
2278
  }));
1471
2279
  return;
1472
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
+ }
1473
2302
  // Path-traversal + null-byte defense · cwd must be absolute and
1474
2303
  // free of NUL bytes (some filesystems treat \0 as terminator).
1475
2304
  if (cwd !== undefined) {
@@ -1478,11 +2307,33 @@ export function startMCPServer(options = {}) {
1478
2307
  res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
1479
2308
  return;
1480
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
+ }
1481
2327
  }
1482
2328
  const budgetForSpawn = loadBudget();
1483
2329
  if (budgetForSpawn.dailyUsd !== null) {
1484
2330
  const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
1485
- 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';
1486
2337
  if (isPaidSpawn) {
1487
2338
  const todaySpend = computeSpendToday();
1488
2339
  if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
@@ -1526,7 +2377,13 @@ export function startMCPServer(options = {}) {
1526
2377
  // enrichment instead of failing outright.
1527
2378
  console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
1528
2379
  }
1529
- 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
+ });
1530
2387
  res.writeHead(200, { 'Content-Type': 'application/json' });
1531
2388
  res.end(JSON.stringify({
1532
2389
  sessionId: session.sessionId,
@@ -1555,52 +2412,144 @@ export function startMCPServer(options = {}) {
1555
2412
  return;
1556
2413
  }
1557
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
+ };
1558
2448
  const spawned = manager.list().map((s) => {
1559
2449
  const completedAt = manager.getCompletedAt(s.sessionId);
1560
2450
  const finalReport = manager.getFinalReport(s.sessionId);
2451
+ const acceptanceCriteria = manager.getAcceptanceCriteria(s.sessionId);
1561
2452
  const cwd = manager.getCwd(s.sessionId);
1562
2453
  const continuedFrom = manager.getContinuedFrom(s.sessionId);
1563
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);
1564
2459
  const failedReport = manager.hasFailedReport(s.sessionId);
1565
2460
  const stalledReason = manager.getStallReason(s.sessionId);
1566
- // F-07 · verifier-agent block · run claim vs reality on every task.
1567
- // Pure function, no I/O, runs sync · cheap to do per request.
1568
- // The UI renders the verdict + findings inline below the Summary.
1569
- const verifyResult = verifyClaim({
1570
- summary: finalReport?.summary,
1571
- artifacts: s.artifacts,
1572
- steps: s.steps,
1573
- });
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);
1574
2467
  return {
1575
2468
  sessionId: s.sessionId,
1576
- prompt: s.prompt,
2469
+ prompt: manager.getDisplayPrompt(s.sessionId) ?? s.prompt,
1577
2470
  startedAt: s.startedAt,
1578
2471
  status: s.status,
1579
2472
  agent: s.agent,
1580
2473
  steps: s.steps ?? [],
1581
2474
  artifacts: s.artifacts,
2475
+ ...(cost ? { cost } : {}),
1582
2476
  ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
1583
2477
  ...(finalReport !== undefined ? { finalReport } : {}),
2478
+ ...(acceptanceCriteria && acceptanceCriteria.length ? { acceptanceCriteria } : {}),
1584
2479
  ...(failedReport ? { failedReport: true } : {}),
1585
2480
  ...(stalledReason ? { stalledReason } : {}),
1586
- verifyResult,
2481
+ verdict,
1587
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,
1588
2487
  ...(cwd !== undefined ? { cwd } : {}),
1589
2488
  ...(continuedFrom !== undefined ? { continuedFrom } : {}),
1590
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 } : {}),
1591
2498
  provenance: 'spawned',
1592
2499
  };
1593
2500
  });
1594
2501
  const activeAgents = manager.listActiveAgents();
1595
- // 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.
1596
2506
  const persisted = manager.listPersisted().map((p) => ({
1597
2507
  ...p,
1598
- verifyResult: verifyClaim({
1599
- summary: p.finalReport?.summary,
1600
- artifacts: p.artifacts,
1601
- steps: p.steps,
1602
- }),
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,
1603
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));
1604
2553
  // Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
1605
2554
  let lastEditAt = null;
1606
2555
  try {
@@ -1612,11 +2561,11 @@ export function startMCPServer(options = {}) {
1612
2561
  }
1613
2562
  res.writeHead(200, { 'Content-Type': 'application/json' });
1614
2563
  res.end(JSON.stringify({
1615
- spawned,
1616
- persisted,
2564
+ spawned: realSpawned,
2565
+ persisted: realPersisted,
1617
2566
  activeAgents,
1618
2567
  lastEditAt,
1619
- sessions: spawned, // back-compat for earlier UI code
2568
+ sessions: realSpawned, // back-compat for earlier UI code
1620
2569
  observed: manager.listObserved(), // legacy, kept for /history or future use
1621
2570
  }));
1622
2571
  return;
@@ -1658,6 +2607,27 @@ export function startMCPServer(options = {}) {
1658
2607
  res.end(JSON.stringify({ blocks }));
1659
2608
  return;
1660
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
+ }
1661
2631
  // v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
1662
2632
  // Read-only; SessionManager generates the narration in a background
1663
2633
  // timer and caches the latest. Frontend polls (~30s) while the
@@ -1674,8 +2644,65 @@ export function startMCPServer(options = {}) {
1674
2644
  const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
1675
2645
  if (m) {
1676
2646
  const [, sessionId, verb] = m;
1677
- 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
+ }
1678
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
+ }
1679
2706
  res.writeHead(404, { 'Content-Type': 'application/json' });
1680
2707
  res.end(JSON.stringify({ error: `No session: ${sessionId}` }));
1681
2708
  return;
@@ -1726,7 +2753,12 @@ export function startMCPServer(options = {}) {
1726
2753
  const out = await session.appendMessage(text);
1727
2754
  const replyCompletedAt = Date.now();
1728
2755
  try {
1729
- 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;
1730
2762
  if (replyCwd) {
1731
2763
  mirrorReplyToThread({
1732
2764
  parentSessionId: sessionId,
@@ -1833,26 +2865,71 @@ export function startMCPServer(options = {}) {
1833
2865
  // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
1834
2866
  // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
1835
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
+ }
1836
2892
  if (req.method !== 'POST') {
1837
- res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
1838
- 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 }));
1839
2895
  return;
1840
2896
  }
1841
2897
  (async () => {
1842
2898
  try {
1843
2899
  const body = await readBody(req, res);
2900
+ let mcpReq;
1844
2901
  try {
1845
- const mcpReq = JSON.parse(body);
1846
- const mcpRes = dispatch(mcpReq);
1847
- res.writeHead(200, {
1848
- 'Content-Type': 'application/json',
1849
- 'Access-Control-Allow-Origin': 'http://localhost',
1850
- });
1851
- res.end(JSON.stringify(mcpRes));
2902
+ mcpReq = JSON.parse(body);
1852
2903
  }
1853
2904
  catch {
1854
2905
  res.writeHead(400, { 'Content-Type': 'application/json' });
1855
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));
1856
2933
  }
1857
2934
  }
1858
2935
  catch {
@@ -1885,12 +2962,31 @@ export function startMCPServer(options = {}) {
1885
2962
  clientIdConfigured: !!process.env[p.clientIdEnv],
1886
2963
  };
1887
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
+ };
1888
2983
  // v0.8 S5 · public-by-URL adapters appear with status='available'
1889
- // (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.
1890
2986
  const oauthIds = new Set(oauthItems.map((i) => i.id));
1891
2987
  const reg = getDefaultRegistry();
1892
2988
  const noAuthItems = [...reg.bySchemeOnly.values()]
1893
- .filter((s) => !oauthIds.has(s.id))
2989
+ .filter((s) => !oauthIds.has(s.id) && s.id !== 'github')
1894
2990
  .map((s) => ({
1895
2991
  id: s.id,
1896
2992
  label: s.label,
@@ -1904,7 +3000,7 @@ export function startMCPServer(options = {}) {
1904
3000
  description: s.description,
1905
3001
  }));
1906
3002
  res.writeHead(200, { 'Content-Type': 'application/json' });
1907
- res.end(JSON.stringify({ providers: [...oauthItems, ...noAuthItems] }));
3003
+ res.end(JSON.stringify({ providers: [...oauthItems, githubItem, ...noAuthItems] }));
1908
3004
  }
1909
3005
  catch (e) {
1910
3006
  console.error('[/api/context-sources] failed:', e);
@@ -2093,26 +3189,9 @@ export function startMCPServer(options = {}) {
2093
3189
  })();
2094
3190
  return;
2095
3191
  }
2096
- if (req.url === '/api/audit/verify' && req.method === 'GET') {
2097
- try {
2098
- const r = verifyAuditChain(AUDIT_FILE);
2099
- res.writeHead(200, { 'Content-Type': 'application/json' });
2100
- res.end(JSON.stringify({
2101
- path: AUDIT_FILE,
2102
- valid: r.valid,
2103
- totalEntries: r.totalEntries,
2104
- chainedEntries: r.chainedEntries,
2105
- legacyEntries: r.legacyEntries,
2106
- errors: r.errors,
2107
- }));
2108
- }
2109
- catch (e) {
2110
- console.error('[/api/audit/verify] failed:', e);
2111
- res.writeHead(500, { 'Content-Type': 'application/json' });
2112
- res.end(JSON.stringify({ error: 'Failed to verify audit chain' }));
2113
- }
2114
- return;
2115
- }
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.
2116
3195
  // v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
2117
3196
  // bundle of one session that the recipient can replay locally. No relay
2118
3197
  // through codehere's infrastructure · the response stays on-machine.
@@ -2132,7 +3211,12 @@ export function startMCPServer(options = {}) {
2132
3211
  kind: 'thread',
2133
3212
  sessionId: session.sessionId,
2134
3213
  session,
2135
- 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() },
2136
3220
  exportedAt: new Date().toISOString(),
2137
3221
  };
2138
3222
  const bundle = signBundle(body, keys);
@@ -2185,10 +3269,33 @@ export function startMCPServer(options = {}) {
2185
3269
  }
2186
3270
  const finalBundle = verdict.bundle;
2187
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
+ }
2188
3280
  const home = process.env.CODEHERE_HOME || homedir();
2189
3281
  const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
2190
3282
  const fs = await import('fs');
2191
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;
2192
3299
  const lines = [];
2193
3300
  lines.push(JSON.stringify({
2194
3301
  t: 'spawned',
@@ -2197,6 +3304,16 @@ export function startMCPServer(options = {}) {
2197
3304
  prompt: ses.prompt,
2198
3305
  cwd: ses.cwd,
2199
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 } : {}),
2200
3317
  }));
2201
3318
  lines.push(JSON.stringify({
2202
3319
  t: 'completed',
@@ -2217,11 +3334,14 @@ export function startMCPServer(options = {}) {
2217
3334
  }));
2218
3335
  }
2219
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.
2220
3339
  res.writeHead(200, { 'Content-Type': 'application/json' });
2221
3340
  res.end(JSON.stringify({
2222
3341
  status: 'imported',
2223
3342
  sessionId: ses.sessionId,
2224
3343
  fingerprint: shortFingerprint(finalBundle.publicKey),
3344
+ audit: auditCheck ? { ...importAudit, legacy: auditCheck.legacy } : null,
2225
3345
  }));
2226
3346
  }
2227
3347
  catch (e) {
@@ -2313,7 +3433,11 @@ export function startMCPServer(options = {}) {
2313
3433
  return;
2314
3434
  }
2315
3435
  // Server assigns the id · agents shouldn't pick collisions.
2316
- 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();
2317
3441
  raw.emittedAt =
2318
3442
  typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
2319
3443
  let intent;
@@ -2650,12 +3774,20 @@ export function startMCPServer(options = {}) {
2650
3774
  // Per claim_user_persona HIGH non-technical-first: a non-technical user
2651
3775
  // who typos a URL gets a useful page back, not a void.
2652
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
+ }
2653
3784
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
2654
3785
  res.end(`<!doctype html>
2655
3786
  <html lang="en">
2656
3787
  <head>
2657
3788
  <meta charset="utf-8"/>
2658
3789
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
3790
+ <link rel="icon" type="image/svg+xml" href="/icon.svg"/>
2659
3791
  <title>codehere · page not found</title>
2660
3792
  <style>
2661
3793
  :root {
@@ -2715,8 +3847,7 @@ export function startMCPServer(options = {}) {
2715
3847
  <div class="routes">
2716
3848
  <div class="routes-label">VALID ROUTES</div>
2717
3849
  <a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
2718
- <a class="route" href="/threads"><span class="route-name">Project threads</span><span class="route-path">/threads</span></a>
2719
- <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>
2720
3851
  </div>
2721
3852
  </main>
2722
3853
  </body>
@@ -2739,6 +3870,19 @@ export function startMCPServer(options = {}) {
2739
3870
  catch {
2740
3871
  /* best-effort */
2741
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
+ }
2742
3886
  // Stale-session reconciliation. Walks per-project thread.jsonl files
2743
3887
  // and marks any sessions stuck in 'running' for >60 min as crashed.
2744
3888
  // Closes the dogfood-found gap where 3-day-old tasks displayed as
@@ -2767,10 +3911,10 @@ export function startMCPServer(options = {}) {
2767
3911
  function callbackHtml(status, message, providerLabel) {
2768
3912
  const safe = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2769
3913
  const tone = status === 'ok' ? '#28c840' : '#ff5f57';
2770
- const icon = status === 'ok' ? '✓' : '✗';
3914
+ const icon = status === 'ok' ? '✓' : '✕';
2771
3915
  const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
2772
3916
  return `<!doctype html><html lang="en"><head>
2773
- <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>
2774
3918
  <style>
2775
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; }
2776
3920
  .card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }