codehere 1.0.1 → 1.0.3

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 (163) hide show
  1. package/README.md +35 -193
  2. package/dist/application/use-cases/check-claimed-tests.js +81 -0
  3. package/dist/application/use-cases/detect-test-weakening.js +237 -0
  4. package/dist/application/use-cases/rerun-tests.js +110 -7
  5. package/dist/index.js +38 -1212
  6. package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -323
  7. package/dist/presentation/cli/commands/check-command.js +178 -23
  8. package/dist/presentation/cli/commands/help-command.js +21 -285
  9. package/dist/ui.js +5 -236
  10. package/dist/utils/version.js +0 -6
  11. package/package.json +9 -38
  12. package/dist/application/services/verdict-service.js +0 -154
  13. package/dist/application/use-cases/assess-cross-vendor.js +0 -126
  14. package/dist/application/use-cases/build-cross-vendor-verdict.js +0 -118
  15. package/dist/application/use-cases/domain-trust.js +0 -110
  16. package/dist/application/use-cases/export-aat.js +0 -107
  17. package/dist/application/use-cases/generate-acceptance-criteria.js +0 -223
  18. package/dist/application/use-cases/generate-final-report.js +0 -259
  19. package/dist/application/use-cases/generate-narration.js +0 -130
  20. package/dist/application/use-cases/generate-thread-title.js +0 -181
  21. package/dist/application/use-cases/narrator-prompt-template.js +0 -158
  22. package/dist/application/use-cases/plan-auto-review.js +0 -57
  23. package/dist/application/use-cases/reconcile-audit.js +0 -100
  24. package/dist/application/use-cases/search-sessions.js +0 -70
  25. package/dist/application/use-cases/should-auto-review.js +0 -71
  26. package/dist/application/use-cases/thread-export.js +0 -245
  27. package/dist/application/use-cases/verify-claim.js +0 -456
  28. package/dist/application/use-cases/write-pass.js +0 -277
  29. package/dist/domain/audit.js +0 -11
  30. package/dist/domain/services/codebase-detector.js +0 -85
  31. package/dist/domain/task.js +0 -91
  32. package/dist/error-handler.js +0 -139
  33. package/dist/infrastructure/completion/bash-completion.js +0 -29
  34. package/dist/infrastructure/completion/fish-completion.js +0 -17
  35. package/dist/infrastructure/completion/zsh-completion.js +0 -25
  36. package/dist/infrastructure/config/config-manager.js +0 -174
  37. package/dist/infrastructure/config/product-context.js +0 -49
  38. package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
  39. package/dist/infrastructure/context/adapters/figma-source.js +0 -198
  40. package/dist/infrastructure/context/adapters/github-issue-source.js +0 -160
  41. package/dist/infrastructure/context/adapters/linear-source.js +0 -190
  42. package/dist/infrastructure/context/context-resolver.js +0 -144
  43. package/dist/infrastructure/context/context-source.js +0 -114
  44. package/dist/infrastructure/context/github-api.js +0 -110
  45. package/dist/infrastructure/context/github-device-flow.js +0 -65
  46. package/dist/infrastructure/context/oauth-flow.js +0 -207
  47. package/dist/infrastructure/context/oauth-store.js +0 -122
  48. package/dist/infrastructure/context/registry-bootstrap.js +0 -95
  49. package/dist/infrastructure/context/write-pass-resolver.js +0 -106
  50. package/dist/infrastructure/cost/budget-config.js +0 -114
  51. package/dist/infrastructure/cost/cost-router.js +0 -97
  52. package/dist/infrastructure/cost/spend-aggregator.js +0 -222
  53. package/dist/infrastructure/cost/team-ledger.js +0 -355
  54. package/dist/infrastructure/discovery/discovery-log.js +0 -85
  55. package/dist/infrastructure/hooks/audit-tip-signature.js +0 -336
  56. package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -1018
  57. package/dist/infrastructure/hooks/hook-mode.js +0 -87
  58. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -49
  59. package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
  60. package/dist/infrastructure/hooks/session-analyzer.js +0 -177
  61. package/dist/infrastructure/observability/execution-tracer.js +0 -227
  62. package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
  63. package/dist/infrastructure/orchestration/agent-installer.js +0 -101
  64. package/dist/infrastructure/orchestration/agents-config-seed.js +0 -68
  65. package/dist/infrastructure/orchestration/aider-adapter.js +0 -611
  66. package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
  67. package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -592
  68. package/dist/infrastructure/orchestration/codex-adapter.js +0 -574
  69. package/dist/infrastructure/orchestration/conductor-adapter.js +0 -416
  70. package/dist/infrastructure/orchestration/context-handoff.js +0 -162
  71. package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
  72. package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
  73. package/dist/infrastructure/orchestration/detect-ollama.js +0 -75
  74. package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
  75. package/dist/infrastructure/orchestration/failover-config.js +0 -67
  76. package/dist/infrastructure/orchestration/failover-policy.js +0 -106
  77. package/dist/infrastructure/orchestration/gemini-adapter.js +0 -663
  78. package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -689
  79. package/dist/infrastructure/orchestration/graphify-context.js +0 -209
  80. package/dist/infrastructure/orchestration/memory-injector.js +0 -380
  81. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -388
  82. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
  83. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
  84. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
  85. package/dist/infrastructure/orchestration/opencode-adapter.js +0 -580
  86. package/dist/infrastructure/orchestration/progress-aggregator.js +0 -906
  87. package/dist/infrastructure/orchestration/readiness.js +0 -103
  88. package/dist/infrastructure/orchestration/session-manager.js +0 -1590
  89. package/dist/infrastructure/orchestration/session-persistence.js +0 -394
  90. package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
  91. package/dist/infrastructure/orchestration/write-pass-store.js +0 -38
  92. package/dist/infrastructure/protocols/cloud-sync.js +0 -207
  93. package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
  94. package/dist/infrastructure/protocols/mcp-server-impl.js +0 -3935
  95. package/dist/infrastructure/protocols/mcp-tools.js +0 -401
  96. package/dist/infrastructure/protocols/probe-port.js +0 -36
  97. package/dist/infrastructure/protocols/thread-phase.js +0 -105
  98. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +0 -121
  99. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +0 -44
  100. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +0 -56
  101. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +0 -588
  102. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +0 -168
  103. package/dist/infrastructure/protocols/ui/app-script-conversation.js +0 -67
  104. package/dist/infrastructure/protocols/ui/app-script-drawer.js +0 -115
  105. package/dist/infrastructure/protocols/ui/app-script-failover.js +0 -133
  106. package/dist/infrastructure/protocols/ui/app-script-github.js +0 -113
  107. package/dist/infrastructure/protocols/ui/app-script-observer.js +0 -104
  108. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +0 -80
  109. package/dist/infrastructure/protocols/ui/app-script-overlay.js +0 -203
  110. package/dist/infrastructure/protocols/ui/app-script-recovery.js +0 -136
  111. package/dist/infrastructure/protocols/ui/app-script-render.js +0 -166
  112. package/dist/infrastructure/protocols/ui/app-script-share.js +0 -78
  113. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +0 -194
  114. package/dist/infrastructure/protocols/ui/app-script-tour.js +0 -78
  115. package/dist/infrastructure/protocols/ui/app-script-wpass.js +0 -312
  116. package/dist/infrastructure/protocols/ui/app-script.js +0 -2998
  117. package/dist/infrastructure/protocols/ui/font-faces.js +0 -7
  118. package/dist/infrastructure/protocols/ui/styles.js +0 -2147
  119. package/dist/infrastructure/protocols/ui/tokens.js +0 -96
  120. package/dist/infrastructure/protocols/ui-page-v2.js +0 -320
  121. package/dist/infrastructure/protocols/verify-page.js +0 -1677
  122. package/dist/infrastructure/security/file-collection.js +0 -118
  123. package/dist/infrastructure/security/fix-code.js +0 -144
  124. package/dist/infrastructure/security/fix-secret.js +0 -89
  125. package/dist/infrastructure/security/permissions-config.js +0 -235
  126. package/dist/infrastructure/security/scan-types.js +0 -10
  127. package/dist/infrastructure/security/ship-scan.js +0 -195
  128. package/dist/infrastructure/security/trust-scorer.js +0 -724
  129. package/dist/infrastructure/security/vibe-checks.js +0 -99
  130. package/dist/infrastructure/storage/atomic-write.js +0 -86
  131. package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
  132. package/dist/infrastructure/storage/project-thread-migration.js +0 -237
  133. package/dist/infrastructure/storage/project-thread-mirror.js +0 -671
  134. package/dist/infrastructure/storage/project-thread-reader.js +0 -409
  135. package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
  136. package/dist/monitoring.js +0 -212
  137. package/dist/presentation/cli/commands/audit-command.js +0 -201
  138. package/dist/presentation/cli/commands/budget-command.js +0 -79
  139. package/dist/presentation/cli/commands/config-command.js +0 -125
  140. package/dist/presentation/cli/commands/context-source-command.js +0 -217
  141. package/dist/presentation/cli/commands/hook-command.js +0 -265
  142. package/dist/presentation/cli/commands/init-command.js +0 -590
  143. package/dist/presentation/cli/commands/memory-command.js +0 -1031
  144. package/dist/presentation/cli/commands/narrator-command.js +0 -101
  145. package/dist/presentation/cli/commands/permission-command.js +0 -105
  146. package/dist/presentation/cli/commands/protocols-builder.js +0 -254
  147. package/dist/presentation/cli/commands/protocols-command.js +0 -84
  148. package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
  149. package/dist/presentation/cli/commands/report-command.js +0 -403
  150. package/dist/presentation/cli/commands/scan-command.js +0 -52
  151. package/dist/presentation/cli/commands/scan-tool-command.js +0 -110
  152. package/dist/presentation/cli/commands/search-command.js +0 -51
  153. package/dist/presentation/cli/commands/setup-command.js +0 -330
  154. package/dist/presentation/cli/commands/status-command.js +0 -614
  155. package/dist/presentation/cli/commands/team-command.js +0 -256
  156. package/dist/presentation/cli/commands/thread-command.js +0 -370
  157. package/dist/presentation/cli/commands/trust-command.js +0 -328
  158. package/dist/presentation/cli/commands/verify-command.js +0 -298
  159. package/dist/presentation/cli/error-display.js +0 -328
  160. package/dist/presentation/cli/repl.js +0 -805
  161. package/dist/presentation/cli/task-deliverable.js +0 -153
  162. package/dist/testing/cassette.js +0 -125
  163. package/dist/testing/replay-adapter.js +0 -147
@@ -1,3935 +0,0 @@
1
- /**
2
- * codehere MCP Server — Working Implementation
3
- *
4
- * Starts an HTTP server that speaks the Model Context Protocol.
5
- * Exposes codehere's trust scanning tools to any MCP-compatible client:
6
- * Claude Desktop, Cursor, Zed, Continue, Cline, and any agent that supports MCP.
7
- *
8
- * Tools exposed:
9
- * - codehere_trust — score a file or code snippet (Layer 0, no API)
10
- * - codehere_scan — scan an instruction/text for injection/threats
11
- * - codehere_review — list findings for a codebase path
12
- * - codehere_audit — read recent hook audit log entries
13
- *
14
- * Protocol: MCP over HTTP+SSE (Model Context Protocol Spec 2024-11)
15
- * Authentication: Bearer token (optional, configurable)
16
- *
17
- * Usage:
18
- * codehere serve # start on port 3773
19
- * codehere serve --port 8080 # custom port
20
- */
21
- import http from 'http';
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';
29
- import { fileURLToPath } from 'url';
30
- import { AUDIT_FILE, MOCK_AUDIT_FILE, verifyAuditChain } from '../hooks/claude-sdk-hook.js';
31
- import { signAuditTip, pruneSignState } from '../hooks/audit-tip-signature.js';
32
- import { analyzeSessions, isRealHostSession, previewFromToolInput } from '../hooks/session-analyzer.js';
33
- import { CodexControlSurface } from '../orchestration/control/codex-control-surface.js';
34
- import { getVersion } from '../../utils/version.js';
35
- import { TOOLS, dispatch } from './mcp-tools.js';
36
- import { TASK_HTML_V2 } from './ui-page-v2.js';
37
- import { VERIFY_HTML } from './verify-page.js';
38
- import { composeVerdict } from '../../application/services/verdict-service.js';
39
- import { signBundle, verifyBundle, shortFingerprint, collectAuditEntriesForSession, verifyEmbeddedAuditChain, } from '../../application/use-cases/thread-export.js';
40
- import { loadOrCreateKeypair, trustedKeySet, trustKey } from '../orchestration/thread-key-store.js';
41
- import { loadSessionById, appendResolved, appendCompleted } from '../orchestration/session-persistence.js';
42
- import { buildCrossVendorVerdictById, buildCrossVendorVerdictForWriter, } from '../../application/use-cases/build-cross-vendor-verdict.js';
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';
44
- import { putPending as wpPutPending, getById as wpGetById, setStatus as wpSetStatus, listForSession as wpListForSession, listPending as wpListPending, } from '../orchestration/write-pass-store.js';
45
- import { resolveWriteRequest, WritePassResolveError } from '../context/write-pass-resolver.js';
46
- import { trustDomain as trustDomainStore } from '../../application/use-cases/domain-trust.js';
47
- import { appendAuditLog, appendAuditLogDirect, appendOverrideAudit } from '../hooks/claude-sdk-hook.js';
48
- import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
49
- import { resolveContextSourceUrls } from '../context/context-resolver.js';
50
- import { getDefaultRegistry, createDefaultFetchContextWithPost } from '../context/registry-bootstrap.js';
51
- import { begin as oauthBegin, complete as oauthComplete, getProvider as getOAuthProvider, listProviders as listOAuthProviders, } from '../context/oauth-flow.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';
55
- import { SessionManager, availableAgents, readinessForAllAgents, getAdapter, allAdapters, } from '../orchestration/session-manager.js';
56
- import { isAbsolute } from 'path';
57
- import { findProjectRoot } from '../orchestration/memory-injector.js';
58
- import { scaffoldProjectAt } from '../../presentation/cli/commands/init-command.js';
59
- import { readCrossToolAgents } from '../storage/cross-tool-agents.js';
60
- import { listProjects, readMergedProjectThread, parseGitHubCloneTarget } from '../storage/project-thread-reader.js';
61
- import { mirrorReplyToThread, renameProject, setTaskArchived } from '../storage/project-thread-mirror.js';
62
- import { randomUUID, timingSafeEqual } from 'crypto';
63
- import { installAgent } from '../orchestration/agent-installer.js';
64
- import { detectReadiness } from '../orchestration/readiness.js';
65
- import { getHookMode } from '../hooks/hook-mode.js';
66
- import { addPermissionEntry, loadPermissions, removePermissionEntry } from '../security/permissions-config.js';
67
- import { computeSpendToday, estimateCostUsd } from '../cost/spend-aggregator.js';
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 ────────────────────────────────────────────────────────
98
- // ── Tool handlers ────────────────────────────────────────────────────────────
99
- import { homedir, tmpdir } from 'os';
100
- const controlSessions = new Map();
101
- let controlSeq = 0;
102
- /**
103
- * Dev-only behavior-observer injection (v0.6.0 — elu joined the team roster
104
- * 2026-05-03 as behavior reviewer). Activated only when CODEHERE_DEV_ELU=1
105
- * is set in the founder's local env; never in shipped releases. The script's
106
- * public key is meant for client-side embedding (analytics tracking key, not
107
- * a secret) so committing the URL is safe.
108
- *
109
- * Why runtime injection instead of editing the page templates directly:
110
- * the templates stay identical between dev and release builds. The only
111
- * delta is the env var. This keeps the release surface clean (zero-telemetry
112
- * value per PRD §1.5 #2 stays intact) while letting elu observe founder
113
- * dogfood sessions.
114
- *
115
- * Removal trigger lives in team_roster.md memory: if elu produces zero
116
- * unique findings within one month, remove this helper + roster entry.
117
- */
118
- const ELU_DEV_SCRIPT = '<script async src="https://elu.dev/v1/elu_pk_live_HrKo9yFFLx03tz9xuSKFcE2Jpv.js"></script>';
119
- function withDevElu(html) {
120
- if (process.env.CODEHERE_DEV_ELU !== '1')
121
- return html;
122
- return html.replace('</head>', `${ELU_DEV_SCRIPT}\n</head>`);
123
- }
124
- const MAX_BODY_SIZE = 2_097_152; // 2 MiB — covers 1MB code-scan payloads + framing
125
- // v0.8 S2.5 · in-memory map of pending OAuth flows keyed by `state` value.
126
- // Lifecycle: begin() inserts; callback resolves and removes; entries also
127
- // expire on a 10-min wall clock (the oauth-flow.ts complete() does the
128
- // check). Daemon restart wipes this · users just click Connect again.
129
- const PENDING_OAUTH_FLOWS = new Map();
130
- function readBody(req, res) {
131
- return new Promise((resolve, reject) => {
132
- let body = '';
133
- req.on('data', (c) => {
134
- body += c;
135
- if (body.length > MAX_BODY_SIZE) {
136
- req.pause();
137
- res.writeHead(413, { 'Content-Type': 'application/json' });
138
- res.end(JSON.stringify({ error: 'Request body too large' }));
139
- reject(new Error('oversized'));
140
- }
141
- });
142
- req.on('end', () => resolve(body));
143
- req.on('error', () => reject(new Error('request aborted')));
144
- });
145
- }
146
- /** v0.9 S6 · Load context-sources.json into the shape resolveWriteRequest
147
- * expects. Same on-disk file as the READ adapters · symmetric storage. */
148
- function loadContextSourcesConfigForWritePass() {
149
- const path = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'context-sources.json');
150
- if (!fsExistsSync(path))
151
- return {};
152
- try {
153
- const raw = JSON.parse(fsReadFileSync(path, 'utf-8'));
154
- return raw;
155
- }
156
- catch {
157
- return {};
158
- }
159
- }
160
- export function startMCPServer(options = {}) {
161
- const port = options.port ?? 3773;
162
- const host = options.host ?? '127.0.0.1'; // localhost only by default
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
- }
178
- const server = http.createServer((req, res) => {
179
- // v0.7 Week-2.2 · CORS for read-only public endpoints.
180
- // The landing-page daemon-status pill (codehere.uk → 127.0.0.1:3773)
181
- // fetches /health · /api/version · /api/tasks across origins to render
182
- // "running locally · vX.Y.Z · N tasks." Without these headers the
183
- // browser blocks the cross-origin fetch. Safe to allow * on these
184
- // because the endpoints expose only version + task COUNTS (no PII ·
185
- // no auth-state · no payload). Mutation endpoints (POST /api/task,
186
- // /api/writepass/:id/:decision, etc.) are NOT covered by this · they
187
- // remain same-origin-only.
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];
192
- const isReadOnlyPublic = url === '/health' ||
193
- url === '/api/version' ||
194
- url === '/api/tasks' ||
195
- url === '/api/host-sessions' ||
196
- url.startsWith('/api/host-sessions/') ||
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';
205
- if (isReadOnlyPublic) {
206
- res.setHeader('Access-Control-Allow-Origin', '*');
207
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
208
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
209
- if (req.method === 'OPTIONS') {
210
- res.writeHead(204);
211
- res.end();
212
- return;
213
- }
214
- }
215
- // Auth check — timing-safe comparison
216
- if (token) {
217
- const authHeader = req.headers.authorization ?? '';
218
- const expected = `Bearer ${token}`;
219
- const cmpLen = Math.max(expected.length, authHeader.length);
220
- const bufExpected = Buffer.alloc(cmpLen);
221
- const bufActual = Buffer.alloc(cmpLen);
222
- bufExpected.write(expected);
223
- bufActual.write(authHeader);
224
- const ok = timingSafeEqual(bufExpected, bufActual);
225
- if (!ok) {
226
- res.writeHead(401, { 'Content-Type': 'application/json' });
227
- res.end(JSON.stringify({ error: 'Unauthorized' }));
228
- return;
229
- }
230
- }
231
- // CSRF / DNS-rebind defense for state-changing requests. The daemon binds
232
- // localhost, but the user's own browser can reach it — so a page on a
233
- // malicious site could fire a cross-origin POST (a text/plain "simple
234
- // request" sidesteps the CORS preflight) to spawn an agent or mutate state.
235
- // `codehere start` runs without a bearer token, so that gate is off by
236
- // default; this enforces the same-origin-only intent noted above. Browsers
237
- // attach Origin / Sec-Fetch-Site on these requests; native clients (the
238
- // CLI, curl, MCP stdio) do not — so we reject only requests a browser
239
- // marks as cross-origin, and leave header-less non-browser clients alone.
240
- const method = req.method ?? 'GET';
241
- if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
242
- const sfs = req.headers['sec-fetch-site'];
243
- const origin = req.headers.origin;
244
- const selfOrigins = new Set([`http://${host}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`]);
245
- const browserCrossOrigin = (typeof sfs === 'string' && sfs !== 'same-origin' && sfs !== 'none') ||
246
- (typeof origin === 'string' && origin !== 'null' && !selfOrigins.has(origin));
247
- if (browserCrossOrigin) {
248
- res.writeHead(403, { 'Content-Type': 'application/json' });
249
- res.end(JSON.stringify({ error: 'cross-origin request rejected' }));
250
- return;
251
- }
252
- }
253
- // Health check
254
- if (req.method === 'GET' && req.url === '/health') {
255
- res.writeHead(200, { 'Content-Type': 'application/json' });
256
- // `mock` lets a no-spend tool (verify-all) confirm a daemon is mock BEFORE
257
- // attaching — so it can refuse to drive a LIVE daemon while claiming "no spend".
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);
276
- return;
277
- }
278
- // v2 Task-Centric PM UI (primary — served at /)
279
- if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
280
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
281
- res.end(withDevElu(TASK_HTML_V2));
282
- return;
283
- }
284
- // /v1 deleted 2026-05-23 · failover UI ported to v2 (see ui-page-v2.ts
285
- // .failover-banner). The route now redirects to / so old bookmarks land
286
- // on the real product instead of 404.
287
- if (req.method === 'GET' && (req.url === '/v1' || req.url === '/v1/')) {
288
- res.writeHead(301, { Location: '/' });
289
- res.end();
290
- return;
291
- }
292
- // verify-at-the-surface dashboard · /verify (+ /verify/replay, query params).
293
- // Serves the SAME HTML for both — the page reads ?mode=replay itself. It
294
- // embeds `/` in an iframe as the real observation surface. Not dev-elu
295
- // wrapped: it IS the verification view, the elu overlay would be noise.
296
- if (req.method === 'GET' && req.url && /^\/verify(\/replay)?(\?.*)?$/.test(req.url)) {
297
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
298
- res.end(VERIFY_HTML);
299
- return;
300
- }
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.
306
- if (req.method === 'GET' &&
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();
314
- return;
315
- }
316
- // Backwards-compat: /ui used to be the Mission Control audit view
317
- if (req.method === 'GET' && (req.url === '/ui' || req.url === '/ui/')) {
318
- res.writeHead(302, { Location: '/' });
319
- res.end();
320
- return;
321
- }
322
- // Live audit event stream (SSE)
323
- if (req.method === 'GET' && req.url === '/events') {
324
- res.writeHead(200, {
325
- 'Content-Type': 'text/event-stream',
326
- 'Cache-Control': 'no-cache',
327
- Connection: 'keep-alive',
328
- });
329
- // Replay last 50 events on connect so the UI isn't empty
330
- let lastSize = 0;
331
- if (existsSync(AUDIT_FILE)) {
332
- try {
333
- const content = readFileSync(AUDIT_FILE, 'utf-8');
334
- lastSize = Buffer.byteLength(content, 'utf-8');
335
- const lines = content.split('\n').filter((l) => l.trim().length > 0);
336
- for (const line of lines.slice(-50)) {
337
- res.write(`data: ${line}\n\n`);
338
- }
339
- }
340
- catch {
341
- /* no audit yet */
342
- }
343
- }
344
- // Tail the audit file for new events
345
- const onChange = () => {
346
- try {
347
- if (!existsSync(AUDIT_FILE))
348
- return;
349
- const currentSize = statSync(AUDIT_FILE).size;
350
- if (currentSize <= lastSize)
351
- return;
352
- const fd = readFileSync(AUDIT_FILE, 'utf-8');
353
- const fresh = fd.slice(lastSize);
354
- lastSize = Buffer.byteLength(fd, 'utf-8');
355
- for (const line of fresh.split('\n').filter((l) => l.trim().length > 0)) {
356
- res.write(`data: ${line}\n\n`);
357
- }
358
- }
359
- catch {
360
- /* transient read errors are fine */
361
- }
362
- };
363
- watchFile(AUDIT_FILE, { interval: 500 }, onChange);
364
- // Keep-alive ping every 15s so proxies don't kill the stream
365
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
366
- req.on('close', () => {
367
- clearInterval(ping);
368
- unwatchFile(AUDIT_FILE, onChange);
369
- });
370
- return;
371
- }
372
- // ── P4 SSE — session lifecycle events (realtime /v2 updates) ────────────
373
- // Tails sessions.jsonl and pushes "change" events so the /v2 UI can
374
- // re-fetch task state instantly instead of waiting 8s for the next poll.
375
- // Same pattern as /events (audit log tail) but for sessions.
376
- const SESSIONS_FILE = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'sessions.jsonl');
377
- if (req.method === 'GET' && req.url === '/api/events/sessions') {
378
- res.writeHead(200, {
379
- 'Content-Type': 'text/event-stream',
380
- 'Cache-Control': 'no-cache',
381
- Connection: 'keep-alive',
382
- });
383
- // Send current session state on connect
384
- try {
385
- const manager = SessionManager.getInstance();
386
- const sessions = manager.list().map((s) => ({
387
- sessionId: s.sessionId,
388
- status: s.status,
389
- agent: s.agent,
390
- }));
391
- res.write(`data: ${JSON.stringify({ type: 'init', sessions })}\n\n`);
392
- }
393
- catch {
394
- res.write(`data: ${JSON.stringify({ type: 'init', sessions: [] })}\n\n`);
395
- }
396
- // Tail sessions.jsonl for changes
397
- let lastSize = 0;
398
- if (existsSync(SESSIONS_FILE)) {
399
- try {
400
- lastSize = statSync(SESSIONS_FILE).size;
401
- }
402
- catch {
403
- /* first time */
404
- }
405
- }
406
- const onChange = () => {
407
- try {
408
- if (!existsSync(SESSIONS_FILE))
409
- return;
410
- const currentSize = statSync(SESSIONS_FILE).size;
411
- if (currentSize <= lastSize)
412
- return;
413
- lastSize = currentSize;
414
- res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
415
- }
416
- catch {
417
- /* transient */
418
- }
419
- };
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
- });
455
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
456
- req.on('close', () => {
457
- clearInterval(ping);
458
- if (liveTimer)
459
- clearTimeout(liveTimer);
460
- offLive();
461
- unwatchFile(SESSIONS_FILE, onChange);
462
- });
463
- return;
464
- }
465
- // ── F2 agent registry ─────────────────────────────────────────────────────
466
- if (req.method === 'GET' && req.url === '/api/agents') {
467
- res.writeHead(200, { 'Content-Type': 'application/json' });
468
- res.end(JSON.stringify({ agents: availableAgents() }));
469
- return;
470
- }
471
- // P12 slice — tamper-evidence verification of the audit log.
472
- // Calls verifyAuditChain() and returns a compact summary. /history
473
- // polls this every minute and shows a red banner if valid=false.
474
- if (req.method === 'GET' && req.url === '/api/audit/verify') {
475
- try {
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;
481
- res.writeHead(200, { 'Content-Type': 'application/json' });
482
- res.end(JSON.stringify({
483
- valid: verifiable && r.valid,
484
- verifiable,
485
- totalEntries: r.totalEntries,
486
- chainedEntries: r.chainedEntries,
487
- legacyEntries: r.legacyEntries,
488
- errors: r.errors.slice(0, 10),
489
- }));
490
- }
491
- catch {
492
- res.writeHead(200, { 'Content-Type': 'application/json' });
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
- }));
500
- }
501
- return;
502
- }
503
- // P18 slice 3 — permissions read/write surface. Lets the future
504
- // dashboard panel (when a hook block fires) call "Approve in this
505
- // folder" without shelling out. The CLI command and this endpoint
506
- // both call addPermissionEntry → single source of truth.
507
- if (req.method === 'GET' && req.url?.startsWith('/api/permissions?')) {
508
- const cwd = decodeURIComponent(req.url.split('cwd=')[1] || '');
509
- if (!cwd || !isAbsolute(cwd)) {
510
- res.writeHead(400, { 'Content-Type': 'application/json' });
511
- res.end(JSON.stringify({ error: 'cwd query param required (absolute path)' }));
512
- return;
513
- }
514
- try {
515
- const cfg = loadPermissions(cwd);
516
- res.writeHead(200, { 'Content-Type': 'application/json' });
517
- res.end(JSON.stringify({ cwd, permissions: cfg }));
518
- }
519
- catch {
520
- res.writeHead(200, { 'Content-Type': 'application/json' });
521
- res.end(JSON.stringify({ cwd, permissions: { allow: [], deny: [] } }));
522
- }
523
- return;
524
- }
525
- if (req.method === 'POST' && req.url === '/api/permissions') {
526
- (async () => {
527
- try {
528
- const body = await readBody(req, res);
529
- let parsed;
530
- try {
531
- parsed = JSON.parse(body || '{}');
532
- }
533
- catch (e) {
534
- res.writeHead(400, { 'Content-Type': 'application/json' });
535
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
536
- return;
537
- }
538
- const cwd = parsed.cwd;
539
- const pattern = parsed.pattern;
540
- const bucket = parsed.bucket === 'deny' ? 'deny' : 'allow';
541
- if (!cwd || typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.includes('\0')) {
542
- res.writeHead(400, { 'Content-Type': 'application/json' });
543
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
544
- return;
545
- }
546
- if (!pattern || typeof pattern !== 'string') {
547
- res.writeHead(400, { 'Content-Type': 'application/json' });
548
- res.end(JSON.stringify({ error: 'pattern (non-empty string) required' }));
549
- return;
550
- }
551
- try {
552
- const result = addPermissionEntry({ cwd, pattern, bucket, reason: parsed.reason });
553
- res.writeHead(200, { 'Content-Type': 'application/json' });
554
- res.end(JSON.stringify(result));
555
- }
556
- catch (e) {
557
- res.writeHead(500, { 'Content-Type': 'application/json' });
558
- res.end(JSON.stringify({ error: 'write_failed', detail: String(e?.message || e) }));
559
- }
560
- }
561
- catch {
562
- if (process.env.CODEHERE_DEBUG === '1')
563
- console.error('[codehere] readBody rejected (oversized or aborted)');
564
- }
565
- })();
566
- return;
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
- }
680
- if (req.method === 'DELETE' && req.url?.startsWith('/api/permissions?')) {
681
- const params = new URLSearchParams(req.url.split('?')[1] || '');
682
- const cwd = params.get('cwd') || '';
683
- const pattern = params.get('pattern') || '';
684
- if (!cwd || !isAbsolute(cwd) || !pattern) {
685
- res.writeHead(400, { 'Content-Type': 'application/json' });
686
- res.end(JSON.stringify({ error: 'cwd (absolute) + pattern query params required' }));
687
- return;
688
- }
689
- try {
690
- const result = removePermissionEntry(cwd, pattern);
691
- res.writeHead(200, { 'Content-Type': 'application/json' });
692
- res.end(JSON.stringify(result));
693
- }
694
- catch (e) {
695
- res.writeHead(500, { 'Content-Type': 'application/json' });
696
- res.end(JSON.stringify({ error: 'remove_failed', detail: String(e?.message || e) }));
697
- }
698
- return;
699
- }
700
- // Pattern 20 — hook-mode indicator. Read-only by design; mode
701
- // switching goes through `codehere hook install/uninstall` so the
702
- // safety surface re-validates. Cheap (one settings.json parse);
703
- // safe to poll from the dashboard header at 30s cadence.
704
- if (req.method === 'GET' && req.url === '/api/hook/mode') {
705
- let mode;
706
- try {
707
- mode = getHookMode();
708
- }
709
- catch {
710
- mode = 'off';
711
- }
712
- res.writeHead(200, { 'Content-Type': 'application/json' });
713
- res.end(JSON.stringify({ mode }));
714
- return;
715
- }
716
- // ── Gate B3 · Audit summary (moat visible on main page) ───────────────────
717
- // Principle #3 status-at-a-glance + #7 agent-provenance. Reads the hook audit
718
- // log's last 24h, aggregates: how many actions fired across all agents, how
719
- // many codehere blocked, mean trust (= 100 - mean riskScore).
720
- if (req.method === 'GET' && req.url === '/api/audit/summary') {
721
- const windowMs = 24 * 60 * 60 * 1000;
722
- const cutoff = Date.now() - windowMs;
723
- let actions = 0, blocked = 0, riskSum = 0, riskN = 0;
724
- const sessionIds = new Set();
725
- try {
726
- if (existsSync(AUDIT_FILE)) {
727
- const content = readFileSync(AUDIT_FILE, 'utf-8');
728
- const lines = content.split('\n');
729
- // Walk backwards; stop at first line older than cutoff (log is append-chronological)
730
- for (let i = lines.length - 1; i >= 0; i--) {
731
- const line = lines[i].trim();
732
- if (!line)
733
- continue;
734
- let entry;
735
- try {
736
- entry = JSON.parse(line);
737
- }
738
- catch {
739
- continue;
740
- }
741
- const ts = typeof entry.timestamp === 'number'
742
- ? entry.timestamp
743
- : entry.timestamp
744
- ? Date.parse(String(entry.timestamp))
745
- : 0;
746
- if (!ts || ts < cutoff)
747
- break;
748
- actions += 1;
749
- if (entry.blocked)
750
- blocked += 1;
751
- if (typeof entry.riskScore === 'number') {
752
- riskSum += entry.riskScore;
753
- riskN += 1;
754
- }
755
- if (entry.sessionId)
756
- sessionIds.add(entry.sessionId);
757
- }
758
- }
759
- }
760
- catch {
761
- /* empty audit — zeros are fine */
762
- }
763
- const avgTrust = riskN > 0 ? Math.max(0, Math.round(100 - riskSum / riskN)) : null;
764
- res.writeHead(200, { 'Content-Type': 'application/json' });
765
- res.end(JSON.stringify({
766
- windowHours: 24,
767
- actions,
768
- blocked,
769
- agents: sessionIds.size,
770
- avgTrust,
771
- }));
772
- return;
773
- }
774
- // ── Host sessions — the coding-agent CLIs codehere observes via the hook ──
775
- // These are NOT spawned-by-codehere agents (those live in /api/tasks). These
776
- // are the Claude Code / Codex / etc. sessions running in the user's own
777
- // terminal, captured by the PreToolUse hook. This is the surface that proves
778
- // the INWARD axis: every coding agent — including the one the user is talking
779
- // to right now — is a first-class workspace member, not a black box.
780
- if (req.method === 'GET' && req.url === '/api/host-sessions') {
781
- const entries = [];
782
- try {
783
- if (existsSync(AUDIT_FILE)) {
784
- const lines = readFileSync(AUDIT_FILE, 'utf-8').split('\n');
785
- for (const line of lines) {
786
- const t = line.trim();
787
- if (!t)
788
- continue;
789
- try {
790
- const e = JSON.parse(t);
791
- if (e && typeof e.sessionId === 'string')
792
- entries.push(e);
793
- }
794
- catch {
795
- /* skip malformed line */
796
- }
797
- }
798
- }
799
- }
800
- catch {
801
- /* empty audit — empty list is fine */
802
- }
803
- // Only surface genuine host coding sessions — not seed fixtures or the
804
- // synthetic WritePass/pre_prompt audit rows. isRealHostSession() requires
805
- // a real coding tool + 48h recency. Without it the strip would claim
806
- // "10 sessions running" when 9 are stale demo artifacts — the screen-lie
807
- // the no-screen-lies rule bans.
808
- const now = Date.now();
809
- const sessions = analyzeSessions(entries)
810
- .filter((s) => s.sessionId !== 'unknown')
811
- .filter((s) => isRealHostSession(s, now))
812
- .sort((a, b) => new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime())
813
- .slice(0, 10)
814
- .map((s) => ({
815
- sessionId: s.sessionId,
816
- shortId: s.sessionId.substring(0, 8),
817
- totalCalls: s.totalCalls,
818
- blockedCalls: s.blockedCalls,
819
- criticalCalls: s.criticalCalls,
820
- durationMs: s.durationMs,
821
- lastSeen: s.lastSeen,
822
- risk: s.amplificationRisk,
823
- toolMix: s.toolMix,
824
- }));
825
- res.writeHead(200, { 'Content-Type': 'application/json' });
826
- res.end(JSON.stringify({ sessions, total: sessions.length }));
827
- return;
828
- }
829
- // ── Host session as a THREAD — full tool-call timeline for one observed ──
830
- // session. This is the meta-dogfood payoff: open the coding-agent session
831
- // running in your terminal and see every call codehere scanned, in order,
832
- // hash-chained. GET /api/host-sessions/:id
833
- const hostDetailMatch = req.url?.match(/^\/api\/host-sessions\/([^/?]+)$/);
834
- if (req.method === 'GET' && hostDetailMatch) {
835
- const wantId = decodeURIComponent(hostDetailMatch[1]);
836
- // Group matching entries by their FULL session id, then resolve to ONE
837
- // session. The strip links with an 8-char short id; if two sessions share
838
- // that prefix, a naive startsWith() would merge their timelines into a
839
- // single bogus session. Grouping + resolving keeps each session distinct.
840
- const bySession = new Map();
841
- const lastTs = new Map();
842
- try {
843
- if (existsSync(AUDIT_FILE)) {
844
- const lines = readFileSync(AUDIT_FILE, 'utf-8').split('\n');
845
- for (const line of lines) {
846
- const t = line.trim();
847
- if (!t)
848
- continue;
849
- let e;
850
- try {
851
- e = JSON.parse(t);
852
- }
853
- catch {
854
- continue;
855
- }
856
- const sid = e.sessionId;
857
- if (!sid || (sid !== wantId && !sid.startsWith(wantId)))
858
- continue;
859
- // Plain-English preview: the bash command or the file path, never
860
- // the full JSON blob. Persona-facing surface, not a log dump.
861
- const preview = previewFromToolInput(e.toolInput);
862
- const call = {
863
- timestamp: e.timestamp,
864
- toolName: e.toolName,
865
- riskScore: e.riskScore ?? 0,
866
- blocked: !!e.blocked,
867
- findings: e.findings ?? 0,
868
- criticalFindings: e.criticalFindings ?? 0,
869
- seq: e.seq,
870
- chained: typeof e.entryHash === 'string' && e.entryHash.length > 0,
871
- preview,
872
- };
873
- if (!bySession.has(sid))
874
- bySession.set(sid, []);
875
- bySession.get(sid).push(call);
876
- const ts = e.timestamp ? Date.parse(e.timestamp) : 0;
877
- if (Number.isFinite(ts) && ts > (lastTs.get(sid) ?? 0))
878
- lastTs.set(sid, ts);
879
- }
880
- }
881
- }
882
- catch {
883
- /* empty / unreadable audit — empty timeline is honest */
884
- }
885
- // Resolve to one session: an exact full-id match wins; otherwise the most
886
- // recently active session carrying the prefix. We never merge two.
887
- let resolvedId;
888
- if (bySession.has(wantId)) {
889
- resolvedId = wantId;
890
- }
891
- else if (bySession.size >= 1) {
892
- resolvedId = [...bySession.keys()].sort((a, b) => (lastTs.get(b) ?? 0) - (lastTs.get(a) ?? 0))[0];
893
- }
894
- const calls = resolvedId ? bySession.get(resolvedId) : [];
895
- if (calls.length === 0) {
896
- res.writeHead(404, { 'Content-Type': 'application/json' });
897
- res.end(JSON.stringify({ error: 'No observed session matching ' + wantId }));
898
- return;
899
- }
900
- calls.sort((a, b) => new Date(String(a.timestamp)).getTime() - new Date(String(b.timestamp)).getTime());
901
- const chain = verifyAuditChain(AUDIT_FILE);
902
- res.writeHead(200, { 'Content-Type': 'application/json' });
903
- res.end(JSON.stringify({
904
- sessionId: resolvedId ?? wantId,
905
- calls,
906
- totalCalls: calls.length,
907
- blockedCalls: calls.filter((c) => c.blocked).length,
908
- criticalCalls: calls.filter((c) => c.criticalFindings > 0).length,
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,
914
- chainEntries: chain.chainedEntries,
915
- }));
916
- return;
917
- }
918
- // ── Remote control · drive a running agent from the web ───────────────
919
- // POST /api/control/open { agent, prompt } → opens a ControlSurface, starts
920
- // a thread + initial turn, returns { controlId, threadId }.
921
- // POST /api/control/:id/inject { message } → turn/steer into the running
922
- // turn (the remote-control primitive).
923
- // GET /api/control/:id/events → what the driven agent emitted.
924
- // Same-origin only (mutating). Under CODEHERE_MOCK=1 the surface drives the
925
- // mock app-server — zero API, no rate-limit.
926
- if (req.method === 'POST' && req.url === '/api/control/open') {
927
- (async () => {
928
- try {
929
- const raw = JSON.parse((await readBody(req, res)) || '{}');
930
- const agent = String(raw.agent ?? 'codex');
931
- const prompt = String(raw.prompt ?? '').trim();
932
- if (!prompt) {
933
- res.writeHead(400, { 'Content-Type': 'application/json' });
934
- res.end(JSON.stringify({ error: 'prompt required' }));
935
- return;
936
- }
937
- if (agent !== 'codex') {
938
- res.writeHead(400, { 'Content-Type': 'application/json' });
939
- res.end(JSON.stringify({ error: 'only codex control surface is wired today' }));
940
- return;
941
- }
942
- const surface = new CodexControlSurface();
943
- const controlId = 'ctrl-' + ++controlSeq;
944
- const entry = { surface, agent, threadId: '', events: [], openedAt: Date.now() };
945
- surface.onEvent((e) => entry.events.push(e));
946
- controlSessions.set(controlId, entry);
947
- const { threadId } = await surface.open(prompt);
948
- entry.threadId = threadId;
949
- res.writeHead(200, { 'Content-Type': 'application/json' });
950
- res.end(JSON.stringify({ controlId, threadId, agent }));
951
- }
952
- catch (e) {
953
- res.writeHead(500, { 'Content-Type': 'application/json' });
954
- res.end(JSON.stringify({ error: 'control open failed: ' + e.message }));
955
- }
956
- })();
957
- return;
958
- }
959
- const ctlInject = req.url?.match(/^\/api\/control\/([^/]+)\/inject$/);
960
- if (req.method === 'POST' && ctlInject) {
961
- (async () => {
962
- const entry = controlSessions.get(decodeURIComponent(ctlInject[1]));
963
- if (!entry) {
964
- res.writeHead(404, { 'Content-Type': 'application/json' });
965
- res.end(JSON.stringify({ error: 'no such control session' }));
966
- return;
967
- }
968
- try {
969
- const raw = JSON.parse((await readBody(req, res)) || '{}');
970
- const message = String(raw.message ?? '').trim();
971
- if (!message) {
972
- res.writeHead(400, { 'Content-Type': 'application/json' });
973
- res.end(JSON.stringify({ error: 'message required' }));
974
- return;
975
- }
976
- const r = await entry.surface.inject(message);
977
- res.writeHead(200, { 'Content-Type': 'application/json' });
978
- res.end(JSON.stringify({ accepted: r.accepted }));
979
- }
980
- catch (e) {
981
- res.writeHead(500, { 'Content-Type': 'application/json' });
982
- res.end(JSON.stringify({ error: 'inject failed: ' + e.message }));
983
- }
984
- })();
985
- return;
986
- }
987
- const ctlEvents = req.url?.match(/^\/api\/control\/([^/]+)\/events$/);
988
- if (req.method === 'GET' && ctlEvents) {
989
- const entry = controlSessions.get(decodeURIComponent(ctlEvents[1]));
990
- if (!entry) {
991
- res.writeHead(404, { 'Content-Type': 'application/json' });
992
- res.end(JSON.stringify({ error: 'no such control session' }));
993
- return;
994
- }
995
- res.writeHead(200, { 'Content-Type': 'application/json' });
996
- res.end(JSON.stringify({ threadId: entry.threadId, agent: entry.agent, events: entry.events }));
997
- return;
998
- }
999
- // ── User override · a human reviewed a block and chose to proceed ──────
1000
- // POST /api/audit/override · body { sessionId, toolName, reason }. Appends
1001
- // a 'user_override' entry with the free-text reason to the hash chain and
1002
- // returns the new entry's seq + short hash, so the UI can show "logged ·
1003
- // chain continues <hash>" with the REAL hash, not a mockup. Same-origin
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
- }
1155
- if (req.method === 'POST' && req.url === '/api/audit/override') {
1156
- (async () => {
1157
- try {
1158
- const text = await readBody(req, res);
1159
- let raw;
1160
- try {
1161
- raw = JSON.parse(text || '{}');
1162
- }
1163
- catch {
1164
- res.writeHead(400, { 'Content-Type': 'application/json' });
1165
- res.end(JSON.stringify({ error: 'invalid JSON' }));
1166
- return;
1167
- }
1168
- const reason = String(raw.reason ?? '').trim();
1169
- if (!reason) {
1170
- res.writeHead(400, { 'Content-Type': 'application/json' });
1171
- res.end(JSON.stringify({ error: 'a reason is required to log an override' }));
1172
- return;
1173
- }
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, {
1179
- sessionId: typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown',
1180
- toolName: typeof raw.toolName === 'string' ? raw.toolName : 'unknown',
1181
- note: reason,
1182
- });
1183
- // appendOverrideAudit → appendAuditLog is fail-open: it swallows
1184
- // write errors so a logging failure never blocks a tool call. That's
1185
- // right for the hook, but here the UI shows "✓ override logged", so
1186
- // we must CONFIRM our entry actually landed before claiming success
1187
- // (no screen-lies). Scan the tail backward for our exact entry — a
1188
- // concurrent hook process may have appended after ours, so it isn't
1189
- // guaranteed to be the very last line.
1190
- const wantSid = typeof raw.sessionId === 'string' ? raw.sessionId : 'unknown';
1191
- const wantNote = reason.slice(0, 500);
1192
- let seq = null;
1193
- let entryHash = '';
1194
- let confirmed = false;
1195
- try {
1196
- const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
1197
- for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
1198
- let e;
1199
- try {
1200
- e = JSON.parse(lines[i]);
1201
- }
1202
- catch {
1203
- continue;
1204
- }
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) {
1210
- seq = typeof e.seq === 'number' ? e.seq : null;
1211
- entryHash = typeof e.entryHash === 'string' ? e.entryHash : '';
1212
- confirmed = true;
1213
- break;
1214
- }
1215
- }
1216
- }
1217
- catch {
1218
- /* readback failed — cannot confirm the write landed */
1219
- }
1220
- if (!confirmed) {
1221
- res.writeHead(500, { 'Content-Type': 'application/json' });
1222
- res.end(JSON.stringify({ ok: false, error: 'override could not be confirmed in the audit log' }));
1223
- return;
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
- }
1233
- res.writeHead(200, { 'Content-Type': 'application/json' });
1234
- res.end(JSON.stringify({
1235
- ok: true,
1236
- seq,
1237
- entryHash,
1238
- shortHash: entryHash ? entryHash.slice(0, 4) + '...' + entryHash.slice(-4) : '',
1239
- }));
1240
- }
1241
- catch {
1242
- res.writeHead(500, { 'Content-Type': 'application/json' });
1243
- res.end(JSON.stringify({ error: 'override failed' }));
1244
- }
1245
- })();
1246
- return;
1247
- }
1248
- // ── F1 · Agent install (zero-terminal setup — principle #10) ──────────────
1249
- // POST /api/agents/:id/install → SSE stream of install output.
1250
- // The command comes from the adapter's checkReadiness().installHint — never
1251
- // from the user over the wire. codehere is the thing that runs the install,
1252
- // not a page that tells the user how to run it.
1253
- const installMatch = req.url?.match(/^\/api\/agents\/([a-z0-9-]+)\/install$/i);
1254
- if (req.method === 'POST' && installMatch) {
1255
- const adapterId = installMatch[1];
1256
- const adapter = getAdapter(adapterId);
1257
- if (!adapter) {
1258
- res.writeHead(404, { 'Content-Type': 'application/json' });
1259
- res.end(JSON.stringify({ error: `No such agent: ${adapterId}` }));
1260
- return;
1261
- }
1262
- const session = installAgent(adapter);
1263
- if (!session) {
1264
- res.writeHead(400, { 'Content-Type': 'application/json' });
1265
- res.end(JSON.stringify({ error: 'Agent already installed or has no install hint' }));
1266
- return;
1267
- }
1268
- res.writeHead(200, {
1269
- 'Content-Type': 'text/event-stream',
1270
- 'Cache-Control': 'no-cache',
1271
- Connection: 'keep-alive',
1272
- });
1273
- const unsubscribe = session.subscribe((evt) => {
1274
- res.write(`data: ${JSON.stringify(evt)}\n\n`);
1275
- });
1276
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
1277
- req.on('close', () => {
1278
- clearInterval(ping);
1279
- unsubscribe();
1280
- });
1281
- return;
1282
- }
1283
- // ── E2 · Per-agent readiness (onboarding surface) ─────────────────────────
1284
- // Full registry snapshot: install state + auth state + hints. Powers the
1285
- // setup-aware agent dropdown and the one-click swap flow on auth errors.
1286
- //
1287
- // Apr 29: Conductor moved to a separate `auto` field. The web UI IS the
1288
- // Conductor surface — exposing "conductor" as a peer in the agents array
1289
- // is the "Gmail asks you to pick 'email' before composing" anti-pattern.
1290
- // But Conductor's readiness state IS the Auto option's readiness, so the
1291
- // UI needs it preserved (codex Round 1 caught: fresh install with no
1292
- // specialists installed would otherwise lose the setup prompt — Auto
1293
- // gets selected by default, submission fails at /api/task because
1294
- // conductor.isInstalled() returns false). The separate `auto` field
1295
- // keeps the setup hint plumbing intact while preventing the dropdown
1296
- // from showing Conductor as a peer choice.
1297
- if (req.method === 'GET' && req.url === '/api/readiness/agents') {
1298
- try {
1299
- const all = readinessForAllAgents();
1300
- const auto = all.find((a) => a.id === 'conductor');
1301
- const agents = all.filter((a) => a.id !== 'conductor');
1302
- // v0.6.0 M8 — surface cross-tool agent definitions (.opencode/agents/,
1303
- // .claude/agents/, .codehere/agents/) alongside the built-in adapters.
1304
- // These don't have a `state` (ready/needs_install) — they're definitions
1305
- // not runtimes. UI can render them as "available specialists" tagged
1306
- // with their source format. Best-effort: failures fall back to no
1307
- // cross-tool agents listed.
1308
- let crossTool = [];
1309
- try {
1310
- const result = readCrossToolAgents(process.cwd());
1311
- crossTool = result.agents.map((a) => ({
1312
- name: a.name,
1313
- description: a.description.length > 240 ? a.description.slice(0, 237) + '…' : a.description,
1314
- sourceFormat: a.sourceFormat,
1315
- mode: a.mode,
1316
- sourcePath: a.sourcePath,
1317
- }));
1318
- }
1319
- catch {
1320
- // best-effort; ignore parse failures
1321
- }
1322
- res.writeHead(200, { 'Content-Type': 'application/json' });
1323
- res.end(JSON.stringify({ agents, auto, crossTool }));
1324
- }
1325
- catch (e) {
1326
- res.writeHead(500, { 'Content-Type': 'application/json' });
1327
- res.end(JSON.stringify({ error: 'readiness_check_failed', detail: String(e) }));
1328
- }
1329
- return;
1330
- }
1331
- // ── v0.6 S3 — spend-today aggregator + budget state ──────────────────────
1332
- // GET /api/spend/today — read-only aggregate of today's estimated USD spend
1333
- // plus budget config (slice 2: dailyUsd, pctUsed, over). Powers the
1334
- // dashboard's "spent today" pill + budget warn/block colors. Per
1335
- // claim_market_position v3 capture-strategy edge "cost-bounded execution":
1336
- // this is the read surface; the actual block happens at /api/task spawn.
1337
- if (req.method === 'GET' && req.url === '/api/spend/today') {
1338
- try {
1339
- const spend = computeSpendToday();
1340
- const budget = loadBudget();
1341
- const pctUsed = budgetUsedPct(spend.totalUsd, budget);
1342
- res.writeHead(200, { 'Content-Type': 'application/json' });
1343
- res.end(JSON.stringify({
1344
- ...spend,
1345
- budget: {
1346
- dailyUsd: budget.dailyUsd,
1347
- warnAtPct: budget.warnAtPct,
1348
- pctUsed,
1349
- over: isOverBudget(spend.totalUsd, budget),
1350
- },
1351
- }));
1352
- }
1353
- catch (e) {
1354
- res.writeHead(500, { 'Content-Type': 'application/json' });
1355
- res.end(JSON.stringify({ error: 'spend_today_failed', detail: String(e) }));
1356
- }
1357
- return;
1358
- }
1359
- // GET /api/team — merged team cost+audit ledger for the dashboard hero.
1360
- // Refreshes THIS machine's snapshot in the team dir (no git push; the CLI
1361
- // `codehere team` is the active pull/push), then merges every machine's
1362
- // snapshot. Read-only of the network: nothing leaves the box, the merge is
1363
- // from the team's own dir. { configured:false } when no team dir is set.
1364
- if (req.method === 'GET' && req.url === '/api/team') {
1365
- try {
1366
- const cfg = loadTeamConfig();
1367
- if (!cfg.dir) {
1368
- res.writeHead(200, { 'Content-Type': 'application/json' });
1369
- res.end(JSON.stringify({ configured: false }));
1370
- return;
1371
- }
1372
- try {
1373
- writeMyShare(cfg.dir); // refresh this machine's live numbers; best-effort
1374
- }
1375
- catch {
1376
- /* dir not writable (e.g. missing) — still try to read what's there */
1377
- }
1378
- const view = buildTeamView(cfg.dir, cfg.dailyUsd);
1379
- recordOpenOncePerDay(); // discovery outcome: the lead opened the shared record (deduped per day, best-effort)
1380
- res.writeHead(200, { 'Content-Type': 'application/json' });
1381
- res.end(JSON.stringify({ configured: true, dir: cfg.dir, recruitUrl: recruitUrl(), ...view }));
1382
- }
1383
- catch (e) {
1384
- res.writeHead(500, { 'Content-Type': 'application/json' });
1385
- res.end(JSON.stringify({ error: 'team_view_failed', detail: String(e) }));
1386
- }
1387
- return;
1388
- }
1389
- // GET /api/discovery/summary — the continuous-discovery outcome (record-open
1390
- // behaviour) read back for the weekly cadence. Read-only, local.
1391
- if (req.method === 'GET' && req.url === '/api/discovery/summary') {
1392
- res.writeHead(200, { 'Content-Type': 'application/json' });
1393
- res.end(JSON.stringify(discoverySummary()));
1394
- return;
1395
- }
1396
- // POST /api/team/init { dir? } — create a team from the dashboard: make +
1397
- // git-init a shared dir, point at it, write the first snapshot. Same core as
1398
- // `codehere team init`. Local-first: nothing leaves the box.
1399
- if (req.method === 'POST' && req.url === '/api/team/init') {
1400
- (async () => {
1401
- try {
1402
- const raw = await readBody(req, res);
1403
- let parsed = {};
1404
- try {
1405
- parsed = JSON.parse(raw || '{}');
1406
- }
1407
- catch {
1408
- /* empty/invalid body → default dir */
1409
- }
1410
- const rawDir = parsed.dir && String(parsed.dir).trim();
1411
- // "undefined"/"null" are client-bug tripwires, not paths — a Jul-1
1412
- // client sent one and six days of shares landed in a cwd-relative
1413
- // agent/undefined/. Relative inputs resolve to absolute here.
1414
- if (rawDir === 'undefined' || rawDir === 'null') {
1415
- res.writeHead(400, { 'Content-Type': 'application/json' });
1416
- res.end(JSON.stringify({
1417
- error: 'dir_invalid',
1418
- dir: rawDir,
1419
- message: 'That dir looks unset (the literal string "' +
1420
- rawDir +
1421
- '"). Send a real path or omit dir for the default.',
1422
- }));
1423
- return;
1424
- }
1425
- const dir = rawDir ? resolvePath(rawDir) : join(codehereHome(), 'team-shared');
1426
- initTeamDir(dir);
1427
- const cfg = loadTeamConfig();
1428
- cfg.dir = dir;
1429
- saveTeamConfig(cfg);
1430
- try {
1431
- writeMyShare(dir);
1432
- }
1433
- catch {
1434
- /* best-effort first snapshot */
1435
- }
1436
- const view = buildTeamView(dir, cfg.dailyUsd);
1437
- res.writeHead(200, { 'Content-Type': 'application/json' });
1438
- res.end(JSON.stringify({ configured: true, dir, ...view }));
1439
- }
1440
- catch (e) {
1441
- // readBody may have already sent 413 + ended on an oversized body —
1442
- // guard so we don't double-write headers (otherwise process-fatal).
1443
- if (!res.headersSent) {
1444
- res.writeHead(500, { 'Content-Type': 'application/json' });
1445
- res.end(JSON.stringify({ error: 'team_init_failed', detail: String(e?.message || e) }));
1446
- }
1447
- }
1448
- })();
1449
- return;
1450
- }
1451
- // POST /api/team/set-dir { dir } — point at an EXISTING shared team dir (a
1452
- // member who cloned the team repo, or a lead pointing at a shared drive).
1453
- if (req.method === 'POST' && req.url === '/api/team/set-dir') {
1454
- (async () => {
1455
- try {
1456
- const raw = await readBody(req, res);
1457
- let parsed = {};
1458
- try {
1459
- parsed = JSON.parse(raw || '{}');
1460
- }
1461
- catch {
1462
- /* fall through to validation */
1463
- }
1464
- const rawDir = parsed.dir ? String(parsed.dir).trim() : '';
1465
- if (!rawDir || rawDir === 'undefined' || rawDir === 'null') {
1466
- res.writeHead(400, { 'Content-Type': 'application/json' });
1467
- res.end(JSON.stringify({
1468
- error: 'dir_required',
1469
- message: 'Provide { dir } — the shared team folder.' +
1470
- (rawDir ? ' (got the literal string "' + rawDir + '" — a client-side unset variable)' : ''),
1471
- }));
1472
- return;
1473
- }
1474
- const dir = resolvePath(rawDir); // relative input would pin the record to a cwd
1475
- if (!existsSync(dir)) {
1476
- res.writeHead(400, { 'Content-Type': 'application/json' });
1477
- res.end(JSON.stringify({
1478
- error: 'dir_not_found',
1479
- dir,
1480
- message: 'That folder does not exist. Clone the team repo first, or use /api/team/init.',
1481
- }));
1482
- return;
1483
- }
1484
- const cfg = loadTeamConfig();
1485
- cfg.dir = dir;
1486
- saveTeamConfig(cfg);
1487
- try {
1488
- writeMyShare(dir);
1489
- }
1490
- catch {
1491
- /* best-effort */
1492
- }
1493
- const view = buildTeamView(dir, cfg.dailyUsd);
1494
- res.writeHead(200, { 'Content-Type': 'application/json' });
1495
- res.end(JSON.stringify({ configured: true, dir, ...view }));
1496
- }
1497
- catch (e) {
1498
- if (!res.headersSent) {
1499
- res.writeHead(500, { 'Content-Type': 'application/json' });
1500
- res.end(JSON.stringify({ error: 'team_setdir_failed', detail: String(e?.message || e) }));
1501
- }
1502
- }
1503
- })();
1504
- return;
1505
- }
1506
- // POST /api/github/connect/start — begin the OAuth device flow. Returns the
1507
- // user code + verification URL to show the user; the deviceCode round-trips
1508
- // back on /poll (server stays stateless). Needs codehere's GitHub OAuth App
1509
- // client_id (CODEHERE_GITHUB_CLIENT_ID); no secret, local-first.
1510
- if (req.method === 'POST' && req.url === '/api/github/connect/start') {
1511
- (async () => {
1512
- const clientId = githubClientId();
1513
- if (!clientId) {
1514
- res.writeHead(400, { 'Content-Type': 'application/json' });
1515
- res.end(JSON.stringify({
1516
- error: 'github_not_configured',
1517
- message: "GitHub isn't set up yet: set CODEHERE_GITHUB_CLIENT_ID to codehere's OAuth App client id.",
1518
- }));
1519
- return;
1520
- }
1521
- try {
1522
- const dc = await requestDeviceCode(clientId);
1523
- res.writeHead(200, { 'Content-Type': 'application/json' });
1524
- res.end(JSON.stringify({
1525
- userCode: dc.userCode,
1526
- verificationUri: dc.verificationUri,
1527
- deviceCode: dc.deviceCode,
1528
- intervalSec: dc.intervalSec,
1529
- expiresInSec: dc.expiresInSec,
1530
- }));
1531
- }
1532
- catch (e) {
1533
- res.writeHead(502, { 'Content-Type': 'application/json' });
1534
- res.end(JSON.stringify({ error: 'github_device_code_failed', detail: String(e?.message || e) }));
1535
- }
1536
- })();
1537
- return;
1538
- }
1539
- // POST /api/github/connect/poll {deviceCode} — poll once for the token. On
1540
- // success the token is persisted under 'github' via oauth-store. The client
1541
- // repeats at intervalSec until status is connected or error.
1542
- if (req.method === 'POST' && req.url === '/api/github/connect/poll') {
1543
- (async () => {
1544
- const clientId = githubClientId();
1545
- if (!clientId) {
1546
- res.writeHead(400, { 'Content-Type': 'application/json' });
1547
- res.end(JSON.stringify({ error: 'github_not_configured' }));
1548
- return;
1549
- }
1550
- try {
1551
- const body = await readBody(req, res);
1552
- let parsed;
1553
- try {
1554
- parsed = JSON.parse(body || '{}');
1555
- }
1556
- catch (e) {
1557
- res.writeHead(400, { 'Content-Type': 'application/json' });
1558
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1559
- return;
1560
- }
1561
- const deviceCode = parsed.deviceCode;
1562
- if (!deviceCode || typeof deviceCode !== 'string') {
1563
- res.writeHead(400, { 'Content-Type': 'application/json' });
1564
- res.end(JSON.stringify({ error: 'deviceCode (string) required' }));
1565
- return;
1566
- }
1567
- const outcome = await pollForToken(clientId, deviceCode);
1568
- if (outcome.status === 'connected') {
1569
- saveToken(outcome.token);
1570
- }
1571
- res.writeHead(200, { 'Content-Type': 'application/json' });
1572
- res.end(JSON.stringify(outcome.status === 'connected' ? { status: 'connected', scope: outcome.token.scope } : outcome));
1573
- }
1574
- catch {
1575
- if (process.env.CODEHERE_DEBUG === '1')
1576
- console.error('[codehere] github poll readBody rejected');
1577
- }
1578
- })();
1579
- return;
1580
- }
1581
- // POST /api/github/issue-to-task {ref} | {repo, issueNumber} — fetch the
1582
- // issue via the stored token and return a ready task prompt + issue metadata.
1583
- // The client spawns it through the existing POST /api/task (one proven path).
1584
- if (req.method === 'POST' && req.url === '/api/github/issue-to-task') {
1585
- (async () => {
1586
- const tok = loadToken('github');
1587
- if (!tok?.accessToken) {
1588
- res.writeHead(400, { 'Content-Type': 'application/json' });
1589
- res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
1590
- return;
1591
- }
1592
- try {
1593
- const body = await readBody(req, res);
1594
- let parsed;
1595
- try {
1596
- parsed = JSON.parse(body || '{}');
1597
- }
1598
- catch (e) {
1599
- res.writeHead(400, { 'Content-Type': 'application/json' });
1600
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1601
- return;
1602
- }
1603
- const refStr = parsed.ref || (parsed.repo && parsed.issueNumber ? `${parsed.repo}#${parsed.issueNumber}` : '');
1604
- const ref = parseRepoRef(refStr);
1605
- if (!ref || ref.number === undefined) {
1606
- res.writeHead(400, { 'Content-Type': 'application/json' });
1607
- res.end(JSON.stringify({
1608
- error: 'bad_ref',
1609
- message: 'Provide ref ("owner/repo#123" or an issue URL) or repo + issueNumber.',
1610
- }));
1611
- return;
1612
- }
1613
- const issue = await fetchIssue(tok.accessToken, ref.owner, ref.repo, ref.number);
1614
- res.writeHead(200, { 'Content-Type': 'application/json' });
1615
- res.end(JSON.stringify({ prompt: issueToTaskPrompt(issue), issue }));
1616
- }
1617
- catch (e) {
1618
- res.writeHead(502, { 'Content-Type': 'application/json' });
1619
- res.end(JSON.stringify({ error: 'github_issue_fetch_failed', detail: String(e?.message || e) }));
1620
- }
1621
- })();
1622
- return;
1623
- }
1624
- // POST /api/github/post-result {text|ref, body} — comment the agent's result
1625
- // back on the source GitHub issue. ref ("owner/repo#123") OR text (the task
1626
- // prompt, from which the issue link is parsed). Non-destructive (no branches).
1627
- if (req.method === 'POST' && req.url === '/api/github/post-result') {
1628
- (async () => {
1629
- const tok = loadToken('github');
1630
- if (!tok?.accessToken) {
1631
- res.writeHead(400, { 'Content-Type': 'application/json' });
1632
- res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
1633
- return;
1634
- }
1635
- try {
1636
- const raw = await readBody(req, res);
1637
- let parsed;
1638
- try {
1639
- parsed = JSON.parse(raw || '{}');
1640
- }
1641
- catch (e) {
1642
- res.writeHead(400, { 'Content-Type': 'application/json' });
1643
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
1644
- return;
1645
- }
1646
- const ref = parsed.ref ? parseRepoRef(parsed.ref) : issueRefFromText(parsed.text || '');
1647
- if (!ref || ref.number === undefined) {
1648
- res.writeHead(400, { 'Content-Type': 'application/json' });
1649
- res.end(JSON.stringify({ error: 'no_issue_ref', message: 'No GitHub issue found in ref/text.' }));
1650
- return;
1651
- }
1652
- const comment = (parsed.body || '').trim();
1653
- if (!comment) {
1654
- res.writeHead(400, { 'Content-Type': 'application/json' });
1655
- res.end(JSON.stringify({ error: 'empty_body', message: 'body (the result comment) is required.' }));
1656
- return;
1657
- }
1658
- const r = await commentOnIssue(tok.accessToken, ref.owner, ref.repo, ref.number, comment);
1659
- res.writeHead(200, { 'Content-Type': 'application/json' });
1660
- res.end(JSON.stringify({ htmlUrl: r.htmlUrl, issue: `${ref.owner}/${ref.repo}#${ref.number}` }));
1661
- }
1662
- catch (e) {
1663
- res.writeHead(502, { 'Content-Type': 'application/json' });
1664
- res.end(JSON.stringify({ error: 'github_comment_failed', detail: String(e?.message || e) }));
1665
- }
1666
- })();
1667
- return;
1668
- }
1669
- // GET /api/github/status — does the composer/import panel have a connected
1670
- // GitHub to work with? Cheap, no network: reads the stored token + whether a
1671
- // client_id is even configured (so the UI can show "connect" vs "set it up").
1672
- if (req.method === 'GET' && req.url === '/api/github/status') {
1673
- const tok = loadToken('github');
1674
- res.writeHead(200, { 'Content-Type': 'application/json' });
1675
- res.end(JSON.stringify({ connected: !!tok?.accessToken, clientIdConfigured: !!githubClientId() }));
1676
- return;
1677
- }
1678
- // GET /api/github/issues?repo=owner/repo — list open issues so the user can
1679
- // PICK one instead of typing "owner/repo#123" from memory. Uses the stored
1680
- // device-flow token (loadToken). PRs are filtered out in listIssues.
1681
- if (req.method === 'GET' && req.url?.startsWith('/api/github/issues')) {
1682
- (async () => {
1683
- const tok = loadToken('github');
1684
- if (!tok?.accessToken) {
1685
- res.writeHead(400, { 'Content-Type': 'application/json' });
1686
- res.end(JSON.stringify({ error: 'github_not_connected', message: 'Connect GitHub first.' }));
1687
- return;
1688
- }
1689
- const repoParam = new URL(req.url || '', 'http://localhost').searchParams.get('repo') || '';
1690
- const ref = parseRepoRef(repoParam);
1691
- if (!ref) {
1692
- res.writeHead(400, { 'Content-Type': 'application/json' });
1693
- res.end(JSON.stringify({ error: 'bad_repo', message: 'Provide repo as owner/repo.' }));
1694
- return;
1695
- }
1696
- try {
1697
- const issues = await listIssues(tok.accessToken, ref.owner, ref.repo);
1698
- res.writeHead(200, { 'Content-Type': 'application/json' });
1699
- res.end(JSON.stringify({ repo: `${ref.owner}/${ref.repo}`, issues }));
1700
- }
1701
- catch (e) {
1702
- res.writeHead(502, { 'Content-Type': 'application/json' });
1703
- res.end(JSON.stringify({ error: 'github_issues_failed', detail: String(e?.message || e) }));
1704
- }
1705
- })();
1706
- return;
1707
- }
1708
- // ── F1a readiness check ───────────────────────────────────────────────────
1709
- if (req.method === 'GET' && req.url === '/api/readiness') {
1710
- try {
1711
- const report = detectReadiness();
1712
- res.writeHead(200, { 'Content-Type': 'application/json' });
1713
- res.end(JSON.stringify(report));
1714
- }
1715
- catch (e) {
1716
- res.writeHead(500, { 'Content-Type': 'application/json' });
1717
- res.end(JSON.stringify({ error: 'readiness_check_failed', detail: String(e) }));
1718
- }
1719
- return;
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
- }
1786
- // ── v0.6.0 M1 project thread routes ──────────────────────────────────────
1787
- // GET /api/projects — list all known projects (slug, cwd, title, taskCount, lastActivityAt)
1788
- // GET /api/projects/:slug — single project thread (meta + tasks merged by sessionId)
1789
- // Reader source of truth: ~/.codehere/projects/<slug>/thread.jsonl (per-project append-only log
1790
- // populated by migration + forward-mirror). Drives the new thread-view UI.
1791
- if (req.method === 'GET' && req.url === '/api/projects') {
1792
- try {
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)));
1806
- res.writeHead(200, { 'Content-Type': 'application/json' });
1807
- res.end(JSON.stringify({ projects }));
1808
- }
1809
- catch (e) {
1810
- res.writeHead(500, { 'Content-Type': 'application/json' });
1811
- res.end(JSON.stringify({ error: 'list_projects_failed', detail: String(e) }));
1812
- }
1813
- return;
1814
- }
1815
- if (req.method === 'GET' && req.url?.startsWith('/api/projects/')) {
1816
- try {
1817
- // Slug is the segment after /api/projects/. Strip optional query string.
1818
- const slug = req.url.replace('/api/projects/', '').split('?')[0];
1819
- // Slug shape is sha1[:12] (12 hex chars) OR the literal 'unknown' bucket.
1820
- // Reject anything else to keep the surface tight (and stop ../ traversal).
1821
- if (!/^([a-f0-9]{12}|unknown)$/.test(slug)) {
1822
- res.writeHead(400, { 'Content-Type': 'application/json' });
1823
- res.end(JSON.stringify({ error: 'invalid_slug' }));
1824
- return;
1825
- }
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);
1829
- if (!thread) {
1830
- res.writeHead(404, { 'Content-Type': 'application/json' });
1831
- res.end(JSON.stringify({ error: 'project_not_found', slug }));
1832
- return;
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
- });
1851
- res.writeHead(200, { 'Content-Type': 'application/json' });
1852
- res.end(JSON.stringify({ thread: { ...thread, tasks: tasksWithCost } }));
1853
- }
1854
- catch (e) {
1855
- res.writeHead(500, { 'Content-Type': 'application/json' });
1856
- res.end(JSON.stringify({ error: 'read_project_failed', detail: String(e) }));
1857
- }
1858
- return;
1859
- }
1860
- // POST /api/projects/:slug/rename — body { title }
1861
- // User-initiated rename. Title agent generates a title from the first
1862
- // prompt; project intent drifts. Without rename, /agent dir gets stuck
1863
- // titled "Scan Auth File" forever even after 100+ unrelated tasks.
1864
- if (req.method === 'POST' && req.url?.match(/^\/api\/projects\/[^/]+\/rename$/)) {
1865
- const slug = req.url.split('/')[3];
1866
- (async () => {
1867
- try {
1868
- const body = await readBody(req, res);
1869
- let parsed;
1870
- try {
1871
- const raw = JSON.parse(body);
1872
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
1873
- res.writeHead(400, { 'Content-Type': 'application/json' });
1874
- res.end(JSON.stringify({ error: 'invalid_body' }));
1875
- return;
1876
- }
1877
- parsed = raw;
1878
- }
1879
- catch {
1880
- res.writeHead(400, { 'Content-Type': 'application/json' });
1881
- res.end(JSON.stringify({ error: 'invalid_json' }));
1882
- return;
1883
- }
1884
- if (typeof parsed.title !== 'string') {
1885
- res.writeHead(400, { 'Content-Type': 'application/json' });
1886
- res.end(JSON.stringify({ error: 'title_required' }));
1887
- return;
1888
- }
1889
- const result = renameProject(slug, parsed.title);
1890
- if (!result.ok) {
1891
- let status = 400;
1892
- if (result.error === 'Project not found')
1893
- status = 404;
1894
- else if (result.error === 'Write failed')
1895
- status = 500;
1896
- res.writeHead(status, { 'Content-Type': 'application/json' });
1897
- res.end(JSON.stringify({ error: result.error }));
1898
- return;
1899
- }
1900
- res.writeHead(200, { 'Content-Type': 'application/json' });
1901
- res.end(JSON.stringify({ ok: true, slug, title: parsed.title.trim() }));
1902
- }
1903
- catch {
1904
- if (process.env.CODEHERE_DEBUG === '1')
1905
- console.error('[codehere] readBody rejected (oversized or aborted)');
1906
- }
1907
- })();
1908
- return;
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
- }
2075
- // ── F1 task orchestration routes ─────────────────────────────────────────
2076
- // POST /api/task — { prompt, cwd? } → { sessionId }
2077
- // GET /api/tasks — list all sessions
2078
- // GET /api/task/:id/events — SSE stream
2079
- // POST /api/task/:id/kill — terminate
2080
- if (req.url && req.url.startsWith('/api/task')) {
2081
- const manager = SessionManager.getInstance();
2082
- // v0.6 M5 slice 1 — POST /api/task/batch.
2083
- // Spawns N tasks at once (independent files only, per W2 — same-file
2084
- // conflicts ship in v0.7). Each spawn flows through SessionManager
2085
- // so pre-prompt scan + audit still run per task. Cap of 5 concurrent
2086
- // to prevent runaway. Returns array of { sessionId, agent } in input
2087
- // order; per-task failures are surfaced as { error, agent } entries
2088
- // so a partial-failure batch still returns useful info.
2089
- if (req.method === 'POST' && req.url === '/api/task/batch') {
2090
- (async () => {
2091
- try {
2092
- const body = await readBody(req, res);
2093
- let parsed;
2094
- try {
2095
- parsed = JSON.parse(body || '{}');
2096
- }
2097
- catch (e) {
2098
- res.writeHead(400, { 'Content-Type': 'application/json' });
2099
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
2100
- return;
2101
- }
2102
- const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
2103
- if (tasks.length === 0) {
2104
- res.writeHead(400, { 'Content-Type': 'application/json' });
2105
- res.end(JSON.stringify({ error: 'tasks (non-empty array) required' }));
2106
- return;
2107
- }
2108
- if (tasks.length > 5) {
2109
- res.writeHead(400, { 'Content-Type': 'application/json' });
2110
- res.end(JSON.stringify({ error: 'batch size capped at 5', requested: tasks.length }));
2111
- return;
2112
- }
2113
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
2114
- // v0.6 S3 slice 2 — pre-spawn budget gate, applied per-task. Fan-out
2115
- // is the most likely way to overshoot a daily budget (5 paid spawns
2116
- // at once); checking once at batch entry then re-checking per task
2117
- // is overkill, so we snapshot the budget + spend ONCE and let each
2118
- // paid spawn that would push us over fail with the same error shape
2119
- // /api/task uses. Free-tier spawns always pass.
2120
- const batchBudget = loadBudget();
2121
- const batchSpendNow = batchBudget.dailyUsd !== null ? computeSpendToday().totalUsd : 0;
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();
2127
- for (const t of tasks) {
2128
- const prompt = t.prompt;
2129
- if (!prompt || typeof prompt !== 'string') {
2130
- results.push({ error: 'prompt (string) required', agent: t.agent ?? null });
2131
- continue;
2132
- }
2133
- if (prompt.trim().length === 0) {
2134
- results.push({ error: 'prompt is empty — describe what you want done', agent: t.agent ?? null });
2135
- continue;
2136
- }
2137
- const agentId = typeof t.agent === 'string' && t.agent.length > 0 ? t.agent : 'conductor';
2138
- if (!validAgentIds.has(agentId)) {
2139
- results.push({ error: `Unknown agent: ${agentId}`, agent: agentId });
2140
- continue;
2141
- }
2142
- if (t.cwd !== undefined) {
2143
- if (typeof t.cwd !== 'string' || t.cwd.includes('\0') || !isAbsolute(t.cwd)) {
2144
- results.push({ error: 'cwd must be absolute, no NUL bytes', agent: agentId });
2145
- continue;
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);
2183
- }
2184
- // Per-task budget check (paid agents only).
2185
- if (batchBudget.dailyUsd !== null) {
2186
- const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
2187
- if (probe > 0 && isOverBudget(batchSpendNow, batchBudget)) {
2188
- results.push({
2189
- error: 'daily_budget_exhausted',
2190
- agent: agentId,
2191
- spentTodayUsd: batchSpendNow,
2192
- dailyBudgetUsd: batchBudget.dailyUsd,
2193
- });
2194
- continue;
2195
- }
2196
- }
2197
- try {
2198
- const session = manager.spawn(prompt, agentId, { cwd: t.cwd, batchId });
2199
- results.push({
2200
- sessionId: session.sessionId,
2201
- agent: session.agent,
2202
- status: session.status,
2203
- startedAt: session.startedAt,
2204
- });
2205
- }
2206
- catch (e) {
2207
- const msg = String(e?.message || e);
2208
- const code = msg.includes('pre-prompt scan blocked') ? 'pre_prompt_blocked' : 'spawn_failed';
2209
- results.push({ error: code, reason: msg, agent: agentId });
2210
- }
2211
- }
2212
- res.writeHead(200, { 'Content-Type': 'application/json' });
2213
- res.end(JSON.stringify({ tasks: results, count: results.length }));
2214
- }
2215
- catch {
2216
- if (process.env.CODEHERE_DEBUG === '1')
2217
- console.error('[codehere] readBody rejected (oversized or aborted)');
2218
- }
2219
- })();
2220
- return;
2221
- }
2222
- if (req.method === 'POST' && req.url === '/api/task') {
2223
- (async () => {
2224
- try {
2225
- const body = await readBody(req, res);
2226
- // Two-phase try: JSON parse failures get one error shape, spawn
2227
- // failures get a different one. Pre-fix the catch wrapped every
2228
- // failure as "Invalid JSON body" — including the pre-prompt scan
2229
- // throw — which read as a parse bug to the client.
2230
- let parsed;
2231
- try {
2232
- parsed = JSON.parse(body || '{}');
2233
- }
2234
- catch (e) {
2235
- res.writeHead(400, { 'Content-Type': 'application/json' });
2236
- res.end(JSON.stringify({ error: 'Invalid JSON body', detail: String(e) }));
2237
- return;
2238
- }
2239
- try {
2240
- const { prompt, cwd, agent } = parsed;
2241
- if (!prompt || typeof prompt !== 'string') {
2242
- res.writeHead(400, { 'Content-Type': 'application/json' });
2243
- res.end(JSON.stringify({ error: 'prompt (string) required' }));
2244
- return;
2245
- }
2246
- // Whitespace-only prompts are a real spawn with nothing to do —
2247
- // they burn an agent run for no work (dogfood 2026-05-27 Phase D).
2248
- // Reject before spawn; require something to act on.
2249
- if (prompt.trim().length === 0) {
2250
- res.writeHead(400, { 'Content-Type': 'application/json' });
2251
- res.end(JSON.stringify({ error: 'prompt is empty — describe what you want done' }));
2252
- return;
2253
- }
2254
- // CRIT-5 fix · whitelist agent ID against the registry BEFORE any
2255
- // log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
2256
- // future-proofs against partial-match exploits, and fails fast
2257
- // with a clear 400 instead of letting SessionManager.spawn throw.
2258
- //
2259
- // Apr 29: default flipped from 'claude-code' to 'conductor'. The web
2260
- // UI is the Conductor surface; tasks submitted without an explicit
2261
- // agent route through the supervisor (rule-based routing today,
2262
- // LLM classifier in v0.6). Empty string is treated like undefined
2263
- // so the form submitting `agent: ""` (Auto option) hits the same
2264
- // path. Specialists remain explicit overrides via the dropdown.
2265
- // Type-check is preserved: non-string non-undefined values still 400.
2266
- if (agent !== undefined && agent !== null && typeof agent !== 'string') {
2267
- res.writeHead(400, { 'Content-Type': 'application/json' });
2268
- res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
2269
- return;
2270
- }
2271
- const validAgentIds = new Set(allAdapters().map((a) => a.id));
2272
- const agentId = typeof agent === 'string' && agent.length > 0 ? agent : 'conductor';
2273
- if (!validAgentIds.has(agentId)) {
2274
- res.writeHead(400, { 'Content-Type': 'application/json' });
2275
- res.end(JSON.stringify({
2276
- error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
2277
- valid: [...validAgentIds],
2278
- }));
2279
- return;
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
- }
2302
- // Path-traversal + null-byte defense · cwd must be absolute and
2303
- // free of NUL bytes (some filesystems treat \0 as terminator).
2304
- if (cwd !== undefined) {
2305
- if (typeof cwd !== 'string' || cwd.includes('\0') || !isAbsolute(cwd)) {
2306
- res.writeHead(400, { 'Content-Type': 'application/json' });
2307
- res.end(JSON.stringify({ error: 'cwd must be an absolute path with no NUL bytes' }));
2308
- return;
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
- }
2327
- }
2328
- const budgetForSpawn = loadBudget();
2329
- if (budgetForSpawn.dailyUsd !== null) {
2330
- const probe = estimateCostUsd(agentId, { inputTokens: 1, outputTokens: 1 });
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';
2337
- if (isPaidSpawn) {
2338
- const todaySpend = computeSpendToday();
2339
- if (isOverBudget(todaySpend.totalUsd, budgetForSpawn)) {
2340
- res.writeHead(429, { 'Content-Type': 'application/json' });
2341
- res.end(JSON.stringify({
2342
- error: 'daily_budget_exhausted',
2343
- message: `Today's estimated spend ($${todaySpend.totalUsd.toFixed(2)}) has reached the daily budget ($${budgetForSpawn.dailyUsd.toFixed(2)}). Switch to a free agent (gemini, opencode) or raise the budget with \`codehere budget set <amount>\`.`,
2344
- spentTodayUsd: todaySpend.totalUsd,
2345
- dailyBudgetUsd: budgetForSpawn.dailyUsd,
2346
- }));
2347
- return;
2348
- }
2349
- }
2350
- }
2351
- // v0.8 S2 · resolve any context-source URLs in the prompt
2352
- // (linear://INFRA-247, figma://file/<key>/...) before spawn.
2353
- // Best-effort · resolver failures get logged inline in the
2354
- // prompt + emit audit lines, never block the spawn.
2355
- const reg = getDefaultRegistry();
2356
- const fetchCtx = createDefaultFetchContextWithPost({
2357
- emitAuditLine: (line) => {
2358
- // Future · feed into the hook audit chain (F-05/F-06).
2359
- // For now console-debug only · the spawn audit line below
2360
- // captures the user-facing outcome.
2361
- if (process.env.CODEHERE_DEBUG === '1') {
2362
- console.error('[context-source]', line.status, line.source, line.url, line.detail ?? '');
2363
- }
2364
- },
2365
- });
2366
- let promptForSpawn = prompt;
2367
- try {
2368
- const r = await resolveContextSourceUrls(prompt, reg, fetchCtx);
2369
- promptForSpawn = r.enrichedPrompt;
2370
- if (r.outcomes.length && process.env.CODEHERE_DEBUG === '1') {
2371
- console.error('[context-source] resolved', r.outcomes.length, 'URL(s) ·', r.totalEstimatedTokens, 'tokens');
2372
- }
2373
- }
2374
- catch (e) {
2375
- // Resolver-level failure (registry corrupt, etc) · log and
2376
- // fall back to the raw prompt. The agent runs without
2377
- // enrichment instead of failing outright.
2378
- console.error('[context-source] resolver failed:', e instanceof Error ? e.message : String(e));
2379
- }
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
- });
2387
- res.writeHead(200, { 'Content-Type': 'application/json' });
2388
- res.end(JSON.stringify({
2389
- sessionId: session.sessionId,
2390
- startedAt: session.startedAt,
2391
- status: session.status,
2392
- agent: session.agent,
2393
- canAppendMessage: session.canAppendMessage === true,
2394
- }));
2395
- }
2396
- catch (e) {
2397
- const msg = String(e?.message || e);
2398
- if (msg.includes('pre-prompt scan blocked')) {
2399
- res.writeHead(422, { 'Content-Type': 'application/json' });
2400
- res.end(JSON.stringify({ error: 'pre_prompt_blocked', reason: msg }));
2401
- return;
2402
- }
2403
- res.writeHead(500, { 'Content-Type': 'application/json' });
2404
- res.end(JSON.stringify({ error: 'spawn_failed', detail: msg }));
2405
- }
2406
- }
2407
- catch {
2408
- if (process.env.CODEHERE_DEBUG === '1')
2409
- console.error('[codehere] readBody rejected (oversized or aborted)');
2410
- }
2411
- })();
2412
- return;
2413
- }
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
- };
2448
- const spawned = manager.list().map((s) => {
2449
- const completedAt = manager.getCompletedAt(s.sessionId);
2450
- const finalReport = manager.getFinalReport(s.sessionId);
2451
- const acceptanceCriteria = manager.getAcceptanceCriteria(s.sessionId);
2452
- const cwd = manager.getCwd(s.sessionId);
2453
- const continuedFrom = manager.getContinuedFrom(s.sessionId);
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);
2459
- const failedReport = manager.hasFailedReport(s.sessionId);
2460
- const stalledReason = manager.getStallReason(s.sessionId);
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);
2467
- return {
2468
- sessionId: s.sessionId,
2469
- prompt: manager.getDisplayPrompt(s.sessionId) ?? s.prompt,
2470
- startedAt: s.startedAt,
2471
- status: s.status,
2472
- agent: s.agent,
2473
- steps: s.steps ?? [],
2474
- artifacts: s.artifacts,
2475
- ...(cost ? { cost } : {}),
2476
- ...(completedAt !== undefined ? { completedAt, durationMs: completedAt - s.startedAt } : {}),
2477
- ...(finalReport !== undefined ? { finalReport } : {}),
2478
- ...(acceptanceCriteria && acceptanceCriteria.length ? { acceptanceCriteria } : {}),
2479
- ...(failedReport ? { failedReport: true } : {}),
2480
- ...(stalledReason ? { stalledReason } : {}),
2481
- verdict,
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,
2487
- ...(cwd !== undefined ? { cwd } : {}),
2488
- ...(continuedFrom !== undefined ? { continuedFrom } : {}),
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 } : {}),
2498
- provenance: 'spawned',
2499
- };
2500
- });
2501
- const activeAgents = manager.listActiveAgents();
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.
2506
- const persisted = manager.listPersisted().map((p) => ({
2507
- ...p,
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,
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));
2553
- // Layer 7: timestamp of most recent hook-audit line (= "last edit on this machine")
2554
- let lastEditAt = null;
2555
- try {
2556
- if (existsSync(AUDIT_FILE))
2557
- lastEditAt = statSync(AUDIT_FILE).mtimeMs;
2558
- }
2559
- catch {
2560
- /* no audit yet — null */
2561
- }
2562
- res.writeHead(200, { 'Content-Type': 'application/json' });
2563
- res.end(JSON.stringify({
2564
- spawned: realSpawned,
2565
- persisted: realPersisted,
2566
- activeAgents,
2567
- lastEditAt,
2568
- sessions: realSpawned, // back-compat for earlier UI code
2569
- observed: manager.listObserved(), // legacy, kept for /history or future use
2570
- }));
2571
- return;
2572
- }
2573
- // P18 slice 4 — GET /api/task/:id/blocks → audit entries with
2574
- // blocked:true for this session. Lets the session-card UI panel
2575
- // surface "codehere noticed this pattern" prompts when the per-
2576
- // tool hook fires mid-session. Reads from the tail of the audit
2577
- // log; cheap because hook entries are short.
2578
- const blocksMatch = req.url.match(/^\/api\/task\/([^/]+)\/blocks$/);
2579
- if (blocksMatch && req.method === 'GET') {
2580
- const sessId = blocksMatch[1];
2581
- const blocks = [];
2582
- try {
2583
- if (existsSync(AUDIT_FILE)) {
2584
- const content = readFileSync(AUDIT_FILE, 'utf-8');
2585
- const lines = content.split('\n');
2586
- // Walk backward — most recent first — collect up to 20.
2587
- for (let i = lines.length - 1; i >= 0 && blocks.length < 20; i--) {
2588
- const ln = lines[i].trim();
2589
- if (!ln)
2590
- continue;
2591
- try {
2592
- const e = JSON.parse(ln);
2593
- if (e.sessionId === sessId && e.blocked === true) {
2594
- blocks.unshift(e);
2595
- }
2596
- }
2597
- catch {
2598
- /* skip malformed */
2599
- }
2600
- }
2601
- }
2602
- }
2603
- catch {
2604
- /* fail open — empty list */
2605
- }
2606
- res.writeHead(200, { 'Content-Type': 'application/json' });
2607
- res.end(JSON.stringify({ blocks }));
2608
- return;
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
- }
2631
- // v0.6 M3 — GET /api/task/:id/narration → latest mid-task summary.
2632
- // Read-only; SessionManager generates the narration in a background
2633
- // timer and caches the latest. Frontend polls (~30s) while the
2634
- // session is running.
2635
- const narrMatch = req.url.match(/^\/api\/task\/([^/]+)\/narration$/);
2636
- if (narrMatch && req.method === 'GET') {
2637
- const sessId = narrMatch[1];
2638
- const narration = manager.getNarration(sessId);
2639
- res.writeHead(200, { 'Content-Type': 'application/json' });
2640
- res.end(JSON.stringify({ narration: narration ?? null }));
2641
- return;
2642
- }
2643
- // /api/task/:id/events or /api/task/:id/kill
2644
- const m = req.url.match(/^\/api\/task\/([^/]+)\/(events|kill|message)$/);
2645
- if (m) {
2646
- const [, sessionId, verb] = m;
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
- }
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
- }
2706
- res.writeHead(404, { 'Content-Type': 'application/json' });
2707
- res.end(JSON.stringify({ error: `No session: ${sessionId}` }));
2708
- return;
2709
- }
2710
- if (verb === 'events' && req.method === 'GET') {
2711
- res.writeHead(200, {
2712
- 'Content-Type': 'text/event-stream',
2713
- 'Cache-Control': 'no-cache',
2714
- Connection: 'keep-alive',
2715
- });
2716
- const unsubscribe = session.subscribe((event) => {
2717
- res.write(`data: ${JSON.stringify(event)}\n\n`);
2718
- });
2719
- const ping = setInterval(() => res.write(': ping\n\n'), 15000);
2720
- req.on('close', () => {
2721
- clearInterval(ping);
2722
- unsubscribe();
2723
- });
2724
- return;
2725
- }
2726
- if (verb === 'kill' && req.method === 'POST') {
2727
- session.kill();
2728
- res.writeHead(200, { 'Content-Type': 'application/json' });
2729
- res.end(JSON.stringify({ sessionId, status: session.status }));
2730
- return;
2731
- }
2732
- // Iter 12 — POST /api/task/:id/message — follow-up message to a
2733
- // completed session. The reply-to-thread surface in the web UI calls
2734
- // this. Body: { text }. Response: { result, durationMs }.
2735
- if (verb === 'message' && req.method === 'POST') {
2736
- if (!session.canAppendMessage || !session.appendMessage) {
2737
- res.writeHead(400, { 'Content-Type': 'application/json' });
2738
- res.end(JSON.stringify({ error: `Agent ${session.agent} does not support follow-up messages yet.` }));
2739
- return;
2740
- }
2741
- (async () => {
2742
- try {
2743
- const body = await readBody(req, res);
2744
- try {
2745
- const parsed = JSON.parse(body || '{}');
2746
- const text = (parsed.text ?? '').trim();
2747
- if (!text) {
2748
- res.writeHead(400, { 'Content-Type': 'application/json' });
2749
- res.end(JSON.stringify({ error: 'text is required' }));
2750
- return;
2751
- }
2752
- const replyStartedAt = Date.now();
2753
- const out = await session.appendMessage(text);
2754
- const replyCompletedAt = Date.now();
2755
- try {
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;
2762
- if (replyCwd) {
2763
- mirrorReplyToThread({
2764
- parentSessionId: sessionId,
2765
- replySessionId: randomUUID(),
2766
- agent: session.agent,
2767
- prompt: text,
2768
- cwd: replyCwd,
2769
- startedAt: replyStartedAt,
2770
- completedAt: replyCompletedAt,
2771
- durationMs: out.durationMs,
2772
- result: out.result,
2773
- });
2774
- }
2775
- }
2776
- catch {
2777
- /* mirror is best-effort */
2778
- }
2779
- res.writeHead(200, { 'Content-Type': 'application/json' });
2780
- res.end(JSON.stringify({ sessionId, ...out }));
2781
- }
2782
- catch (err) {
2783
- const msg = err instanceof Error ? err.message : String(err);
2784
- res.writeHead(500, { 'Content-Type': 'application/json' });
2785
- res.end(JSON.stringify({ error: msg }));
2786
- }
2787
- }
2788
- catch {
2789
- if (process.env.CODEHERE_DEBUG === '1')
2790
- console.error('[codehere] readBody rejected (oversized or aborted)');
2791
- }
2792
- })();
2793
- return;
2794
- }
2795
- }
2796
- res.writeHead(405, { 'Content-Type': 'application/json' });
2797
- res.end(JSON.stringify({ error: 'Method not allowed for this path' }));
2798
- return;
2799
- }
2800
- // v0.6.x P0 — GET /api/failover-offers
2801
- // Returns active failover offers so the dashboard can surface a
2802
- // "Claude rate-limited — switch to Codex?" banner. Offers expire
2803
- // after 60 s (handled by SessionManager).
2804
- if (req.method === 'GET' && req.url === '/api/failover-offers') {
2805
- const offers = SessionManager.getInstance().getFailoverOffers();
2806
- res.writeHead(200, { 'Content-Type': 'application/json' });
2807
- res.end(JSON.stringify({ offers }));
2808
- return;
2809
- }
2810
- // v0.6.x P0 — POST /api/failover-accept
2811
- // User clicked "Switch" on the failoffer banner. Spawns a
2812
- // replacement session on the target agent, preserving the
2813
- // original prompt + cwd.
2814
- if (req.method === 'POST' && req.url === '/api/failover-accept') {
2815
- (async () => {
2816
- try {
2817
- const body = await readBody(req, res);
2818
- let parsed;
2819
- try {
2820
- const raw = JSON.parse(body);
2821
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
2822
- res.writeHead(400, { 'Content-Type': 'application/json' });
2823
- res.end(JSON.stringify({ error: 'invalid_body' }));
2824
- return;
2825
- }
2826
- parsed = raw;
2827
- }
2828
- catch {
2829
- res.writeHead(400, { 'Content-Type': 'application/json' });
2830
- res.end(JSON.stringify({ error: 'invalid_body' }));
2831
- return;
2832
- }
2833
- if (typeof parsed.sessionId !== 'string' || typeof parsed.targetAgent !== 'string') {
2834
- res.writeHead(400, { 'Content-Type': 'application/json' });
2835
- res.end(JSON.stringify({ error: 'missing_sessionId_or_targetAgent' }));
2836
- return;
2837
- }
2838
- try {
2839
- const result = SessionManager.getInstance().acceptFailover(parsed.sessionId, parsed.targetAgent);
2840
- if (result) {
2841
- res.writeHead(200, { 'Content-Type': 'application/json' });
2842
- res.end(JSON.stringify({ ok: true, newSessionId: result.sessionId }));
2843
- }
2844
- else {
2845
- res.writeHead(200, { 'Content-Type': 'application/json' });
2846
- res.end(JSON.stringify({
2847
- ok: false,
2848
- error: 'target_unavailable',
2849
- detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.`,
2850
- }));
2851
- }
2852
- }
2853
- catch (e) {
2854
- res.writeHead(500, { 'Content-Type': 'application/json' });
2855
- res.end(JSON.stringify({ error: 'failover_failed', detail: String(e) }));
2856
- }
2857
- }
2858
- catch {
2859
- if (process.env.CODEHERE_DEBUG === '1')
2860
- console.error('[codehere] readBody rejected (oversized or aborted)');
2861
- }
2862
- })();
2863
- return;
2864
- }
2865
- // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
2866
- // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
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
- }
2892
- if (req.method !== 'POST') {
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 }));
2895
- return;
2896
- }
2897
- (async () => {
2898
- try {
2899
- const body = await readBody(req, res);
2900
- let mcpReq;
2901
- try {
2902
- mcpReq = JSON.parse(body);
2903
- }
2904
- catch {
2905
- res.writeHead(400, { 'Content-Type': 'application/json' });
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));
2933
- }
2934
- }
2935
- catch {
2936
- if (process.env.CODEHERE_DEBUG === '1')
2937
- console.error('[codehere] readBody rejected (oversized or aborted)');
2938
- }
2939
- })();
2940
- return;
2941
- }
2942
- // F-05 / F-06 · public endpoint for the audit chain verifier · powers
2943
- // the web UI's "Verify chain" button + can be hit directly by CI.
2944
- // v0.8 S2.5 · context-source OAuth routes
2945
- // GET /api/context-sources · list all providers + status
2946
- // POST /api/context-sources/:id/connect · returns authorize URL + opens browser
2947
- // POST /api/context-sources/:id/disconnect · clears stored token
2948
- // GET /oauth/callback?code=...&state=... · provider redirect target
2949
- if (req.url === '/api/context-sources' && req.method === 'GET') {
2950
- try {
2951
- const oauthItems = listOAuthProviders().map((p) => {
2952
- const status = connectionStatus(p.id);
2953
- return {
2954
- id: p.id,
2955
- label: p.label,
2956
- scheme: p.id,
2957
- authType: 'oauth',
2958
- status: status.status,
2959
- userLabel: status.userLabel ?? null,
2960
- connectedAt: status.connectedAt ?? null,
2961
- expiresAt: status.expiresAt ?? null,
2962
- clientIdConfigured: !!process.env[p.clientIdEnv],
2963
- };
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
- };
2983
- // v0.8 S5 · public-by-URL adapters appear with status='available'
2984
- // (no Connect button needed · they work out of the box). GitHub is
2985
- // excluded here — it's rendered as the connectable githubItem above.
2986
- const oauthIds = new Set(oauthItems.map((i) => i.id));
2987
- const reg = getDefaultRegistry();
2988
- const noAuthItems = [...reg.bySchemeOnly.values()]
2989
- .filter((s) => !oauthIds.has(s.id) && s.id !== 'github')
2990
- .map((s) => ({
2991
- id: s.id,
2992
- label: s.label,
2993
- scheme: s.scheme,
2994
- authType: 'public',
2995
- status: 'available',
2996
- userLabel: null,
2997
- connectedAt: null,
2998
- expiresAt: null,
2999
- clientIdConfigured: true,
3000
- description: s.description,
3001
- }));
3002
- res.writeHead(200, { 'Content-Type': 'application/json' });
3003
- res.end(JSON.stringify({ providers: [...oauthItems, githubItem, ...noAuthItems] }));
3004
- }
3005
- catch (e) {
3006
- console.error('[/api/context-sources] failed:', e);
3007
- res.writeHead(500, { 'Content-Type': 'application/json' });
3008
- res.end(JSON.stringify({ error: 'Failed to list context sources' }));
3009
- }
3010
- return;
3011
- }
3012
- const connectMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/connect$/);
3013
- if (connectMatch && req.method === 'POST') {
3014
- const providerId = connectMatch[1];
3015
- const provider = getOAuthProvider(providerId);
3016
- if (!provider) {
3017
- res.writeHead(404, { 'Content-Type': 'application/json' });
3018
- res.end(JSON.stringify({ error: `unknown provider: ${providerId}` }));
3019
- return;
3020
- }
3021
- const clientId = process.env[provider.clientIdEnv] || null;
3022
- const r = oauthBegin(provider, { clientId });
3023
- if ('kind' in r && r.kind === 'not_registered') {
3024
- res.writeHead(409, { 'Content-Type': 'application/json' });
3025
- res.end(JSON.stringify({
3026
- error: 'provider_not_registered',
3027
- provider: providerId,
3028
- envVar: provider.clientIdEnv,
3029
- message: `Set ${provider.clientIdEnv} to the OAuth client id from ${provider.label}. Until then, paste a manual key into ~/.codehere/context-sources.json under '${providerId}.apiKey'.`,
3030
- }));
3031
- return;
3032
- }
3033
- const result = r;
3034
- PENDING_OAUTH_FLOWS.set(result.pending.state, result.pending);
3035
- res.writeHead(200, { 'Content-Type': 'application/json' });
3036
- res.end(JSON.stringify({ authorizeUrl: result.authorizeUrl, state: result.pending.state }));
3037
- return;
3038
- }
3039
- // S2.6 · /test endpoint · proves the stored token actually works against
3040
- // the provider with a minimal read. Returns ok or detail.
3041
- const testMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/test$/);
3042
- if (testMatch && req.method === 'POST') {
3043
- const providerId = testMatch[1];
3044
- const provider = getOAuthProvider(providerId);
3045
- if (!provider) {
3046
- res.writeHead(404, { 'Content-Type': 'application/json' });
3047
- res.end(JSON.stringify({ ok: false, error: 'unknown_provider' }));
3048
- return;
3049
- }
3050
- (async () => {
3051
- try {
3052
- const status = connectionStatus(providerId);
3053
- if (status.status === 'disconnected') {
3054
- res.writeHead(400, { 'Content-Type': 'application/json' });
3055
- res.end(JSON.stringify({ ok: false, error: 'not_connected' }));
3056
- return;
3057
- }
3058
- // Minimal probe per provider · the user-info endpoint is the
3059
- // cheapest "does this token read at all" call.
3060
- const userInfoUrl = provider.userInfoUrl;
3061
- if (!userInfoUrl) {
3062
- res.writeHead(501, { 'Content-Type': 'application/json' });
3063
- res.end(JSON.stringify({ ok: false, error: 'no_test_endpoint' }));
3064
- return;
3065
- }
3066
- const cfg = JSON.parse(readFileSync(join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'context-sources.json'), 'utf-8'));
3067
- const entry = cfg[providerId] ?? {};
3068
- let probeRes;
3069
- if (providerId === 'linear') {
3070
- const auth = entry.oauth?.accessToken ? `Bearer ${entry.oauth.accessToken}` : (entry.apiKey ?? '');
3071
- probeRes = await fetch(userInfoUrl, {
3072
- method: 'POST',
3073
- headers: { Authorization: auth, 'Content-Type': 'application/json' },
3074
- body: JSON.stringify({ query: '{ viewer { name email } }' }),
3075
- });
3076
- }
3077
- else {
3078
- // figma · GET with X-Figma-Token OR Bearer
3079
- const headers = entry.oauth?.accessToken
3080
- ? { Authorization: `Bearer ${entry.oauth.accessToken}` }
3081
- : { 'X-Figma-Token': entry.token ?? '' };
3082
- probeRes = await fetch(userInfoUrl, { headers });
3083
- }
3084
- if (!probeRes.ok) {
3085
- res.writeHead(200, { 'Content-Type': 'application/json' });
3086
- res.end(JSON.stringify({ ok: false, error: 'provider_returned_' + probeRes.status }));
3087
- return;
3088
- }
3089
- const body = (await probeRes.json());
3090
- const who = body.data?.viewer?.email || body.data?.viewer?.name || body.email || body.handle || null;
3091
- res.writeHead(200, { 'Content-Type': 'application/json' });
3092
- res.end(JSON.stringify({ ok: true, who }));
3093
- }
3094
- catch (e) {
3095
- res.writeHead(500, { 'Content-Type': 'application/json' });
3096
- res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
3097
- }
3098
- })();
3099
- return;
3100
- }
3101
- const disconnectMatch = req.url?.match(/^\/api\/context-sources\/([^/]+)\/disconnect$/);
3102
- if (disconnectMatch && req.method === 'POST') {
3103
- const providerId = disconnectMatch[1];
3104
- const ok = clearToken(providerId);
3105
- res.writeHead(200, { 'Content-Type': 'application/json' });
3106
- res.end(JSON.stringify({ ok, provider: providerId }));
3107
- return;
3108
- }
3109
- if (req.url?.startsWith('/oauth/callback') && req.method === 'GET') {
3110
- (async () => {
3111
- try {
3112
- const u = new URL(req.url, 'http://127.0.0.1');
3113
- const code = u.searchParams.get('code');
3114
- const state = u.searchParams.get('state');
3115
- const error = u.searchParams.get('error');
3116
- if (error || !code || !state) {
3117
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
3118
- res.end(callbackHtml('error', error || 'missing code/state', null));
3119
- return;
3120
- }
3121
- const pending = PENDING_OAUTH_FLOWS.get(state);
3122
- if (!pending) {
3123
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
3124
- res.end(callbackHtml('error', 'unknown state · this flow was not started by this daemon or has expired', null));
3125
- return;
3126
- }
3127
- const provider = getOAuthProvider(pending.provider);
3128
- if (!provider) {
3129
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
3130
- res.end(callbackHtml('error', `unknown provider: ${pending.provider}`, null));
3131
- return;
3132
- }
3133
- const clientId = process.env[provider.clientIdEnv] || null;
3134
- const out = await oauthComplete(code, state, {
3135
- clientId,
3136
- pending: PENDING_OAUTH_FLOWS,
3137
- async httpPost(target, body, headers) {
3138
- const r = await fetch(target, { method: 'POST', headers: headers ?? {}, body });
3139
- const text = await r.text();
3140
- const h = {};
3141
- r.headers.forEach((v, k) => {
3142
- h[k.toLowerCase()] = v;
3143
- });
3144
- return { status: r.status, body: text, headers: h };
3145
- },
3146
- async fetchUserInfo(token, userInfoUrl) {
3147
- try {
3148
- if (provider.id === 'linear') {
3149
- const r = await fetch(userInfoUrl, {
3150
- method: 'POST',
3151
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
3152
- body: JSON.stringify({ query: '{ viewer { name email } }' }),
3153
- });
3154
- if (!r.ok)
3155
- return null;
3156
- const j = (await r.json());
3157
- return j.data?.viewer ?? null;
3158
- }
3159
- if (provider.id === 'figma') {
3160
- const r = await fetch(userInfoUrl, {
3161
- headers: { Authorization: `Bearer ${token}` },
3162
- });
3163
- if (!r.ok)
3164
- return null;
3165
- const j = (await r.json());
3166
- return { name: j.handle, email: j.email };
3167
- }
3168
- return null;
3169
- }
3170
- catch {
3171
- return null;
3172
- }
3173
- },
3174
- });
3175
- if ('kind' in out) {
3176
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
3177
- res.end(callbackHtml('error', `${out.kind}: ${out.detail}`, provider.label));
3178
- return;
3179
- }
3180
- saveToken(out);
3181
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3182
- res.end(callbackHtml('ok', `Connected as ${out.userLabel ?? '(unknown user)'}`, provider.label));
3183
- }
3184
- catch (e) {
3185
- console.error('[/oauth/callback] failed:', e);
3186
- res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
3187
- res.end(callbackHtml('error', e instanceof Error ? e.message : String(e), null));
3188
- }
3189
- })();
3190
- return;
3191
- }
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.
3195
- // v0.8 S4 · share-thread · POST /api/thread/:id/export returns a signed
3196
- // bundle of one session that the recipient can replay locally. No relay
3197
- // through codehere's infrastructure · the response stays on-machine.
3198
- if (req.method === 'POST' && req.url && /^\/api\/thread\/[^/]+\/export$/.test(req.url)) {
3199
- try {
3200
- const m = req.url.match(/^\/api\/thread\/([^/]+)\/export$/);
3201
- const sessionId = m ? decodeURIComponent(m[1]) : '';
3202
- const session = sessionId ? loadSessionById(sessionId) : undefined;
3203
- if (!session) {
3204
- res.writeHead(404, { 'Content-Type': 'application/json' });
3205
- res.end(JSON.stringify({ error: 'session not found', sessionId }));
3206
- return;
3207
- }
3208
- const keys = loadOrCreateKeypair();
3209
- const body = {
3210
- schema: 1,
3211
- kind: 'thread',
3212
- sessionId: session.sessionId,
3213
- session,
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() },
3220
- exportedAt: new Date().toISOString(),
3221
- };
3222
- const bundle = signBundle(body, keys);
3223
- const fp = shortFingerprint(bundle.publicKey);
3224
- const link = `codehere://thread/${session.sessionId}?signed=ed25519:${bundle.signature.slice(0, 24)}`;
3225
- res.writeHead(200, { 'Content-Type': 'application/json' });
3226
- res.end(JSON.stringify({ bundle, link, fingerprint: fp }));
3227
- }
3228
- catch (e) {
3229
- console.error('[/api/thread/.../export] failed:', e);
3230
- res.writeHead(500, { 'Content-Type': 'application/json' });
3231
- res.end(JSON.stringify({ error: 'export failed' }));
3232
- }
3233
- return;
3234
- }
3235
- // v0.8 S4 · share-thread · POST /api/thread/import · accepts a bundle in
3236
- // the body, verifies it, optionally trusts the signer if the caller sets
3237
- // { trust: true }, persists the session record on success.
3238
- if (req.method === 'POST' && req.url === '/api/thread/import') {
3239
- (async () => {
3240
- try {
3241
- const text = await readBody(req, res);
3242
- let payload = {};
3243
- try {
3244
- payload = JSON.parse(text || '{}');
3245
- }
3246
- catch {
3247
- res.writeHead(400, { 'Content-Type': 'application/json' });
3248
- res.end(JSON.stringify({ error: 'invalid JSON' }));
3249
- return;
3250
- }
3251
- const verdict = verifyBundle(payload.bundle, trustedKeySet());
3252
- if (verdict.status === 'malformed' || verdict.status === 'tampered') {
3253
- res.writeHead(verdict.status === 'malformed' ? 400 : 409, { 'Content-Type': 'application/json' });
3254
- res.end(JSON.stringify({ status: verdict.status, reason: verdict.reason }));
3255
- return;
3256
- }
3257
- if (verdict.status === 'unknown_key') {
3258
- if (!payload.trust) {
3259
- res.writeHead(202, { 'Content-Type': 'application/json' });
3260
- res.end(JSON.stringify({
3261
- status: 'unknown_key',
3262
- publicKey: verdict.publicKey,
3263
- fingerprint: shortFingerprint(verdict.publicKey),
3264
- hint: 'Re-POST with { "trust": true } to trust this key and import.',
3265
- }));
3266
- return;
3267
- }
3268
- trustKey(verdict.publicKey);
3269
- }
3270
- const finalBundle = verdict.bundle;
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
- }
3280
- const home = process.env.CODEHERE_HOME || homedir();
3281
- const sessionsPath = join(home, '.codehere', 'sessions.jsonl');
3282
- const fs = await import('fs');
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;
3299
- const lines = [];
3300
- lines.push(JSON.stringify({
3301
- t: 'spawned',
3302
- id: ses.sessionId,
3303
- agent: ses.agent,
3304
- prompt: ses.prompt,
3305
- cwd: ses.cwd,
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 } : {}),
3317
- }));
3318
- lines.push(JSON.stringify({
3319
- t: 'completed',
3320
- id: ses.sessionId,
3321
- status: ses.status,
3322
- durationMs: ses.durationMs,
3323
- completedAt: ses.completedAt,
3324
- steps: ses.steps,
3325
- artifacts: ses.artifacts,
3326
- }));
3327
- if (ses.finalReport) {
3328
- lines.push(JSON.stringify({
3329
- t: 'finalReport',
3330
- id: ses.sessionId,
3331
- summary: ses.finalReport.summary,
3332
- generatedAt: ses.finalReport.generatedAt,
3333
- durationMs: ses.finalReport.durationMs,
3334
- }));
3335
- }
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.
3339
- res.writeHead(200, { 'Content-Type': 'application/json' });
3340
- res.end(JSON.stringify({
3341
- status: 'imported',
3342
- sessionId: ses.sessionId,
3343
- fingerprint: shortFingerprint(finalBundle.publicKey),
3344
- audit: auditCheck ? { ...importAudit, legacy: auditCheck.legacy } : null,
3345
- }));
3346
- }
3347
- catch (e) {
3348
- console.error('[/api/thread/import] failed:', e);
3349
- res.writeHead(500, { 'Content-Type': 'application/json' });
3350
- res.end(JSON.stringify({ error: 'import failed' }));
3351
- }
3352
- })();
3353
- return;
3354
- }
3355
- /* v0.9 S7 · overlay-renderer · preview a 3rd-party URL via context-source */
3356
- // GET /api/context-source/preview?url=<urlencoded scheme://...>
3357
- if (req.method === 'GET' && req.url && req.url.startsWith('/api/context-source/preview')) {
3358
- (async () => {
3359
- try {
3360
- const u = new URL(req.url, 'http://localhost');
3361
- const target = u.searchParams.get('url');
3362
- if (!target) {
3363
- res.writeHead(400, { 'Content-Type': 'application/json' });
3364
- res.end(JSON.stringify({ error: 'url required' }));
3365
- return;
3366
- }
3367
- const reg = getDefaultRegistry();
3368
- const fetchCtx = createDefaultFetchContextWithPost({
3369
- emitAuditLine: (line) => {
3370
- if (process.env.CODEHERE_DEBUG === '1') {
3371
- console.error('[overlay-renderer]', line.status, line.source, line.url, line.detail ?? '');
3372
- }
3373
- },
3374
- });
3375
- const { fetchViaRegistry } = await import('../context/context-source.js');
3376
- try {
3377
- const payload = await fetchViaRegistry(reg, target, fetchCtx);
3378
- if (!payload) {
3379
- // No adapter registered for that scheme · honest 400 instead of
3380
- // silent ok:true with payload:null.
3381
- res.writeHead(400, { 'Content-Type': 'application/json' });
3382
- res.end(JSON.stringify({
3383
- ok: false,
3384
- kind: 'invalid_url',
3385
- error: `No adapter registered for "${target}" · supported: linear://, github://, figma://, drawio://`,
3386
- }));
3387
- return;
3388
- }
3389
- res.writeHead(200, { 'Content-Type': 'application/json' });
3390
- res.end(JSON.stringify({ ok: true, payload }));
3391
- }
3392
- catch (err) {
3393
- const code = err.kind ?? 'unknown';
3394
- const message = err instanceof Error ? err.message : String(err);
3395
- // Map adapter errors to honest HTTP codes so the UI can render
3396
- // a useful empty/error state instead of swallowing everything.
3397
- const httpStatus = code === 'not_found'
3398
- ? 404
3399
- : code === 'auth'
3400
- ? 401
3401
- : code === 'rate_limit'
3402
- ? 429
3403
- : code === 'invalid_url'
3404
- ? 400
3405
- : 502;
3406
- res.writeHead(httpStatus, { 'Content-Type': 'application/json' });
3407
- res.end(JSON.stringify({ ok: false, kind: code, error: message }));
3408
- }
3409
- }
3410
- catch (e) {
3411
- console.error('[/api/context-source/preview] failed:', e);
3412
- res.writeHead(500, { 'Content-Type': 'application/json' });
3413
- res.end(JSON.stringify({ ok: false, error: 'preview failed' }));
3414
- }
3415
- })();
3416
- return;
3417
- }
3418
- /* v0.9 S6 part B · W-PASS write-pass routes ────────────────────────── */
3419
- // POST /api/writepass/intent · agent emits an intent.
3420
- // Body: { sessionId, target, method, payload, reason, emittedBy? }
3421
- // Response: 201 { status: 'pending', intent: WritePassIntent }
3422
- if (req.method === 'POST' && req.url === '/api/writepass/intent') {
3423
- (async () => {
3424
- try {
3425
- const text = await readBody(req, res);
3426
- let raw;
3427
- try {
3428
- raw = JSON.parse(text || '{}');
3429
- }
3430
- catch {
3431
- res.writeHead(400, { 'Content-Type': 'application/json' });
3432
- res.end(JSON.stringify({ error: 'invalid JSON' }));
3433
- return;
3434
- }
3435
- // Server assigns the id · agents shouldn't pick collisions.
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();
3441
- raw.emittedAt =
3442
- typeof raw.emittedAt === 'number' && Number.isFinite(raw.emittedAt) ? raw.emittedAt : Date.now();
3443
- let intent;
3444
- try {
3445
- intent = validateWritePassIntent(raw);
3446
- }
3447
- catch (err) {
3448
- if (err instanceof WritePassError) {
3449
- res.writeHead(400, { 'Content-Type': 'application/json' });
3450
- res.end(JSON.stringify({ error: err.code, detail: err.message }));
3451
- return;
3452
- }
3453
- throw err;
3454
- }
3455
- const status = wpPutPending(intent);
3456
- // Audit the intent immediately · the chain captures the agent's
3457
- // request even if the user never decides (i.e. session ends).
3458
- try {
3459
- const line = writePassStatusToAuditLine(status);
3460
- appendAuditLog({
3461
- timestamp: line.timestamp,
3462
- sessionId: line.sessionId,
3463
- toolName: line.toolName,
3464
- toolInput: line.toolInput,
3465
- riskScore: line.riskScore,
3466
- blocked: line.blocked,
3467
- findings: line.findings,
3468
- criticalFindings: line.criticalFindings,
3469
- });
3470
- }
3471
- catch (e) {
3472
- // Audit failure must not block the API (matches claude-sdk-hook discipline).
3473
- console.error('[w-pass] audit append failed for intent:', e);
3474
- }
3475
- res.writeHead(201, { 'Content-Type': 'application/json' });
3476
- res.end(JSON.stringify(status));
3477
- }
3478
- catch (e) {
3479
- console.error('[/api/writepass/intent] failed:', e);
3480
- res.writeHead(500, { 'Content-Type': 'application/json' });
3481
- res.end(JSON.stringify({ error: 'intent failed' }));
3482
- }
3483
- })();
3484
- return;
3485
- }
3486
- // GET /api/writepass/pending · list all pending intents (UI poll target).
3487
- if (req.method === 'GET' && req.url === '/api/writepass/pending') {
3488
- try {
3489
- const pending = wpListPending();
3490
- res.writeHead(200, { 'Content-Type': 'application/json' });
3491
- res.end(JSON.stringify({ passes: pending }));
3492
- }
3493
- catch (e) {
3494
- console.error('[/api/writepass/pending] failed:', e);
3495
- res.writeHead(500, { 'Content-Type': 'application/json' });
3496
- res.end(JSON.stringify({ error: 'list failed' }));
3497
- }
3498
- return;
3499
- }
3500
- // GET /api/writepass/session/:id · list all passes for a session.
3501
- if (req.method === 'GET' && req.url && /^\/api\/writepass\/session\/[^/]+$/.test(req.url)) {
3502
- try {
3503
- const m = req.url.match(/^\/api\/writepass\/session\/([^/]+)$/);
3504
- const sessionId = m ? decodeURIComponent(m[1]) : '';
3505
- const passes = wpListForSession(sessionId);
3506
- res.writeHead(200, { 'Content-Type': 'application/json' });
3507
- res.end(JSON.stringify({ sessionId, passes }));
3508
- }
3509
- catch (e) {
3510
- console.error('[/api/writepass/session] failed:', e);
3511
- res.writeHead(500, { 'Content-Type': 'application/json' });
3512
- res.end(JSON.stringify({ error: 'list failed' }));
3513
- }
3514
- return;
3515
- }
3516
- // GET /api/writepass/:id/trust · returns the domain trust grade the
3517
- // pending pass would resolve to. UI uses this to render the trust chip
3518
- // BEFORE the user clicks approve.
3519
- if (req.method === 'GET' && req.url && /^\/api\/writepass\/[^/]+\/trust$/.test(req.url)) {
3520
- try {
3521
- const m = req.url.match(/^\/api\/writepass\/([^/]+)\/trust$/);
3522
- const passId = m ? decodeURIComponent(m[1]) : '';
3523
- const current = wpGetById(passId);
3524
- if (!current) {
3525
- res.writeHead(404, { 'Content-Type': 'application/json' });
3526
- res.end(JSON.stringify({ error: 'pass not found' }));
3527
- return;
3528
- }
3529
- const payload = current.kind === 'edited' ? current.effectivePayload : current.intent.payload;
3530
- try {
3531
- const resolved = resolveWriteRequest(current.intent, payload, loadContextSourcesConfigForWritePass());
3532
- res.writeHead(200, { 'Content-Type': 'application/json' });
3533
- res.end(JSON.stringify(resolved.trust));
3534
- }
3535
- catch (err) {
3536
- // Resolver failed (auth missing, unsupported scheme) · return null
3537
- // trust so the UI knows there's no domain to grade yet.
3538
- res.writeHead(200, { 'Content-Type': 'application/json' });
3539
- res.end(JSON.stringify({
3540
- host: '',
3541
- grade: 'F',
3542
- reason: err instanceof Error ? err.message : 'resolve failed',
3543
- userTrusted: false,
3544
- requiresFirstWriteConfirmation: false,
3545
- }));
3546
- }
3547
- }
3548
- catch (e) {
3549
- console.error('[/api/writepass/.../trust] failed:', e);
3550
- res.writeHead(500, { 'Content-Type': 'application/json' });
3551
- res.end(JSON.stringify({ error: 'trust lookup failed' }));
3552
- }
3553
- return;
3554
- }
3555
- // POST /api/writepass/:id/decide · user approves / edits / cancels.
3556
- // Body: { kind: 'approve'|'edit'|'cancel', editedPayload?, note? }
3557
- // On approve/edit · the daemon resolves + sends, audits the outcome.
3558
- if (req.method === 'POST' && req.url && /^\/api\/writepass\/[^/]+\/decide$/.test(req.url)) {
3559
- (async () => {
3560
- try {
3561
- const m = req.url.match(/^\/api\/writepass\/([^/]+)\/decide$/);
3562
- const passId = m ? decodeURIComponent(m[1]) : '';
3563
- const current = wpGetById(passId);
3564
- if (!current) {
3565
- res.writeHead(404, { 'Content-Type': 'application/json' });
3566
- res.end(JSON.stringify({ error: 'pass not found', passId }));
3567
- return;
3568
- }
3569
- if (current.kind !== 'pending') {
3570
- res.writeHead(409, { 'Content-Type': 'application/json' });
3571
- res.end(JSON.stringify({ error: 'already decided', current }));
3572
- return;
3573
- }
3574
- const bodyText = await readBody(req, res);
3575
- let raw;
3576
- try {
3577
- raw = JSON.parse(bodyText || '{}');
3578
- }
3579
- catch {
3580
- res.writeHead(400, { 'Content-Type': 'application/json' });
3581
- res.end(JSON.stringify({ error: 'invalid JSON' }));
3582
- return;
3583
- }
3584
- // Default decidedAt to now if the client didn't set one.
3585
- raw.passId = passId;
3586
- raw.decidedAt =
3587
- typeof raw.decidedAt === 'number' && Number.isFinite(raw.decidedAt) ? raw.decidedAt : Date.now();
3588
- let decision;
3589
- try {
3590
- decision = validateWritePassDecision(raw, current.intent);
3591
- }
3592
- catch (err) {
3593
- if (err instanceof WritePassError) {
3594
- res.writeHead(400, { 'Content-Type': 'application/json' });
3595
- res.end(JSON.stringify({ error: err.code, detail: err.message }));
3596
- return;
3597
- }
3598
- throw err;
3599
- }
3600
- const next = applyWritePassDecision(current.intent, decision);
3601
- wpSetStatus(next);
3602
- // Audit the decision.
3603
- try {
3604
- const line = writePassStatusToAuditLine(next);
3605
- appendAuditLog({
3606
- timestamp: line.timestamp,
3607
- sessionId: line.sessionId,
3608
- toolName: line.toolName,
3609
- toolInput: line.toolInput,
3610
- riskScore: line.riskScore,
3611
- blocked: line.blocked,
3612
- findings: line.findings,
3613
- criticalFindings: line.criticalFindings,
3614
- });
3615
- }
3616
- catch (e) {
3617
- console.error('[w-pass] audit append failed for decision:', e);
3618
- }
3619
- // Cancelled · we stop here.
3620
- if (next.kind === 'cancelled') {
3621
- res.writeHead(200, { 'Content-Type': 'application/json' });
3622
- res.end(JSON.stringify(next));
3623
- return;
3624
- }
3625
- // Approved or edited · do the mediated send.
3626
- if (next.kind !== 'approved' && next.kind !== 'edited') {
3627
- res.writeHead(500, { 'Content-Type': 'application/json' });
3628
- res.end(JSON.stringify({ error: 'unexpected status after decision', next }));
3629
- return;
3630
- }
3631
- const effectivePayload = next.kind === 'edited' ? next.effectivePayload : current.intent.payload;
3632
- // Load auth config from the same context-sources.json the read
3633
- // adapters use · symmetric storage.
3634
- const writePassConfig = loadContextSourcesConfigForWritePass();
3635
- let resolved;
3636
- try {
3637
- resolved = resolveWriteRequest(current.intent, effectivePayload, writePassConfig);
3638
- }
3639
- catch (err) {
3640
- if (err instanceof WritePassResolveError) {
3641
- const failResponse = {
3642
- passId,
3643
- status: 0,
3644
- bodySnippet: '',
3645
- receivedAt: Date.now(),
3646
- error: err.message,
3647
- };
3648
- const failed = applyWritePassResponse(next, failResponse);
3649
- wpSetStatus(failed);
3650
- try {
3651
- const line = writePassStatusToAuditLine(failed);
3652
- appendAuditLog({
3653
- timestamp: line.timestamp,
3654
- sessionId: line.sessionId,
3655
- toolName: line.toolName,
3656
- toolInput: line.toolInput,
3657
- riskScore: line.riskScore,
3658
- blocked: line.blocked,
3659
- findings: line.findings,
3660
- criticalFindings: line.criticalFindings,
3661
- });
3662
- }
3663
- catch { }
3664
- res.writeHead(424, { 'Content-Type': 'application/json' });
3665
- res.end(JSON.stringify({ error: err.code, detail: err.message, status: failed }));
3666
- return;
3667
- }
3668
- throw err;
3669
- }
3670
- // v0.9 · per-domain mediated-write trust gate. If the resolved
3671
- // target's host is graded F (unknown), require the decision to
3672
- // explicitly include trustThisDomain:true before sending. Once
3673
- // set, persist the host to trusted-domains.json so subsequent
3674
- // writes auto-allow at grade B.
3675
- if (resolved.trust.requiresFirstWriteConfirmation) {
3676
- const decisionRaw = raw;
3677
- if (!decisionRaw.trustThisDomain) {
3678
- res.writeHead(202, { 'Content-Type': 'application/json' });
3679
- res.end(JSON.stringify({
3680
- status: 'requires_domain_trust',
3681
- host: resolved.trust.host,
3682
- grade: resolved.trust.grade,
3683
- reason: resolved.trust.reason,
3684
- hint: 'Re-POST with { "kind": "approve", "trustThisDomain": true } to trust this host once + send.',
3685
- }));
3686
- return;
3687
- }
3688
- try {
3689
- trustDomainStore(resolved.trust.host, 'trusted during first W-PASS approval');
3690
- }
3691
- catch (e) {
3692
- console.error('[w-pass] trustDomain store failed:', e);
3693
- }
3694
- }
3695
- // Mediated send.
3696
- let response;
3697
- try {
3698
- const f = await fetch(resolved.url, {
3699
- method: resolved.method,
3700
- headers: resolved.headers,
3701
- body: resolved.body,
3702
- });
3703
- const text = await f.text();
3704
- response = {
3705
- passId,
3706
- status: f.status,
3707
- bodySnippet: text.slice(0, 2048),
3708
- receivedAt: Date.now(),
3709
- };
3710
- }
3711
- catch (err) {
3712
- response = {
3713
- passId,
3714
- status: 0,
3715
- bodySnippet: '',
3716
- receivedAt: Date.now(),
3717
- error: err instanceof Error ? err.message : String(err),
3718
- };
3719
- }
3720
- const final = applyWritePassResponse(next, response);
3721
- wpSetStatus(final);
3722
- try {
3723
- const line = writePassStatusToAuditLine(final);
3724
- appendAuditLog({
3725
- timestamp: line.timestamp,
3726
- sessionId: line.sessionId,
3727
- toolName: line.toolName,
3728
- toolInput: line.toolInput,
3729
- riskScore: line.riskScore,
3730
- blocked: line.blocked,
3731
- findings: line.findings,
3732
- criticalFindings: line.criticalFindings,
3733
- });
3734
- }
3735
- catch (e) {
3736
- console.error('[w-pass] audit append failed for response:', e);
3737
- }
3738
- res.writeHead(final.kind === 'sent' ? 200 : 502, { 'Content-Type': 'application/json' });
3739
- res.end(JSON.stringify(final));
3740
- }
3741
- catch (e) {
3742
- console.error('[/api/writepass/decide] failed:', e);
3743
- res.writeHead(500, { 'Content-Type': 'application/json' });
3744
- res.end(JSON.stringify({ error: 'decide failed' }));
3745
- }
3746
- })();
3747
- return;
3748
- }
3749
- // /api/version — small completeness item for ops monitoring (rc.17).
3750
- // ESM-friendly: resolve package.json relative to the compiled module's
3751
- // own URL via fileURLToPath. Works in both src/ (during dev) and dist/
3752
- // because the relative path up to package.json is the same in both.
3753
- if (req.url === '/api/version' && req.method === 'GET') {
3754
- try {
3755
- const moduleDir = dirname(fileURLToPath(import.meta.url));
3756
- const pkgPath = join(moduleDir, '..', '..', '..', 'package.json');
3757
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
3758
- res.writeHead(200, { 'Content-Type': 'application/json' });
3759
- res.end(JSON.stringify({ version: pkg.version, name: pkg.name }));
3760
- }
3761
- catch (e) {
3762
- // rc.19: don't leak fs paths to the public HTTP surface.
3763
- // rc.20: but DO log the actual error context for the operator (Codex
3764
- // rc.19 review caught: previous version dropped `e` and logged only a
3765
- // static string, costing diagnosability).
3766
- console.error('[/api/version] failed to read package metadata:', e);
3767
- res.writeHead(500, { 'Content-Type': 'application/json' });
3768
- res.end(JSON.stringify({ error: 'Failed to read package metadata' }));
3769
- }
3770
- return;
3771
- }
3772
- // 404 — friendly HTML page with route list (Bug A6 fix May 6 2026).
3773
- // Pre-fix: empty body, blank-page failure mode the dogfood walk caught.
3774
- // Per claim_user_persona HIGH non-technical-first: a non-technical user
3775
- // who typos a URL gets a useful page back, not a void.
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
- }
3784
- res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
3785
- res.end(`<!doctype html>
3786
- <html lang="en">
3787
- <head>
3788
- <meta charset="utf-8"/>
3789
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
3790
- <link rel="icon" type="image/svg+xml" href="/icon.svg"/>
3791
- <title>codehere · page not found</title>
3792
- <style>
3793
- :root {
3794
- --bg: #0f0f12;
3795
- --fg: #f5f5f7;
3796
- --fg-50: #8b8b94;
3797
- --fg-30: #5a5a62;
3798
- --border: #2a2a32;
3799
- --accent: #ff9a76;
3800
- --mono: ui-monospace, 'SF Mono', Menlo, monospace;
3801
- }
3802
- * { box-sizing: border-box; }
3803
- body {
3804
- margin: 0; min-height: 100vh;
3805
- background: var(--bg); color: var(--fg);
3806
- font-family: Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
3807
- display: flex; flex-direction: column; align-items: center; justify-content: center;
3808
- padding: 48px 24px;
3809
- }
3810
- main { max-width: 480px; width: 100%; }
3811
- .label {
3812
- font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
3813
- color: var(--fg-30); margin-bottom: 12px;
3814
- }
3815
- h1 { font-size: 22px; font-weight: 600; margin: 0 0 12px 0; }
3816
- .path {
3817
- font-family: var(--mono); font-size: 13px;
3818
- color: var(--fg-50); background: rgba(255,255,255,0.04);
3819
- border: 1px solid var(--border); border-radius: 4px;
3820
- padding: 6px 10px; display: inline-block; margin-bottom: 28px;
3821
- word-break: break-all;
3822
- }
3823
- .routes {
3824
- border-top: 1px solid var(--border);
3825
- padding-top: 24px; margin-top: 8px;
3826
- }
3827
- .routes-label {
3828
- font-family: var(--mono); font-size: 11px; letter-spacing: 1.5px;
3829
- color: var(--fg-30); margin-bottom: 12px;
3830
- }
3831
- .route {
3832
- display: flex; align-items: baseline; gap: 14px;
3833
- padding: 8px 0; border-bottom: 1px solid var(--border);
3834
- text-decoration: none; color: var(--fg);
3835
- }
3836
- .route:last-child { border-bottom: none; }
3837
- .route:hover { background: rgba(255,255,255,0.02); }
3838
- .route-name { font-weight: 500; }
3839
- .route-path { font-family: var(--mono); font-size: 12px; color: var(--fg-50); margin-left: auto; }
3840
- </style>
3841
- </head>
3842
- <body>
3843
- <main>
3844
- <div class="label">404 · NOT FOUND</div>
3845
- <h1>That page doesn't exist</h1>
3846
- <div class="path">${escapeHtmlText(requestedPath)}</div>
3847
- <div class="routes">
3848
- <div class="routes-label">VALID ROUTES</div>
3849
- <a class="route" href="/"><span class="route-name">Dashboard</span><span class="route-path">/</span></a>
3850
- <a class="route" href="/verify"><span class="route-name">Verify at the surface</span><span class="route-path">/verify</span></a>
3851
- </div>
3852
- </main>
3853
- </body>
3854
- </html>`);
3855
- });
3856
- function escapeHtmlText(s) {
3857
- return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
3858
- }
3859
- server.listen(port, host, () => {
3860
- // Daemon-start data hygiene — runs once after listen so any failure
3861
- // doesn't block the server coming up. Both are best-effort.
3862
- void (async () => {
3863
- try {
3864
- const { backfillProjectTitles } = await import('../storage/project-thread-mirror.js');
3865
- const r = await backfillProjectTitles();
3866
- if (process.env.CODEHERE_DEBUG === '1' && r.rewritten > 0) {
3867
- console.error(`[daemon-start] backfilled ${r.rewritten}/${r.scanned} project titles`);
3868
- }
3869
- }
3870
- catch {
3871
- /* best-effort */
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
- }
3886
- // Stale-session reconciliation. Walks per-project thread.jsonl files
3887
- // and marks any sessions stuck in 'running' for >60 min as crashed.
3888
- // Closes the dogfood-found gap where 3-day-old tasks displayed as
3889
- // RUNNING in the threads view because a daemon crash never wrote
3890
- // their completion event.
3891
- try {
3892
- const { reconcileStaleProjectTasks } = await import('../storage/project-thread-mirror.js');
3893
- const r = await reconcileStaleProjectTasks();
3894
- if (process.env.CODEHERE_DEBUG === '1' && r.reconciled > 0) {
3895
- console.error(`[daemon-start] reconciled ${r.reconciled} stale running tasks`);
3896
- }
3897
- }
3898
- catch {
3899
- /* best-effort */
3900
- }
3901
- })();
3902
- });
3903
- return server;
3904
- }
3905
- /**
3906
- * v0.8 S2.5 · response page rendered to the user's browser after the OAuth
3907
- * provider redirects to /oauth/callback. Tiny styled card with status +
3908
- * "you can close this tab now" affordance. Auto-closes after 3s if window
3909
- * was opened via window.open() from our UI.
3910
- */
3911
- function callbackHtml(status, message, providerLabel) {
3912
- const safe = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3913
- const tone = status === 'ok' ? '#28c840' : '#ff5f57';
3914
- const icon = status === 'ok' ? '✓' : '✕';
3915
- const title = status === 'ok' ? `${providerLabel ?? 'Provider'} connected` : 'Connection failed';
3916
- return `<!doctype html><html lang="en"><head>
3917
- <meta charset="utf-8"><link rel="icon" type="image/svg+xml" href="/icon.svg"><title>${safe(title)}</title>
3918
- <style>
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; }
3920
- .card { max-width:480px; padding:32px 36px; border:1px solid rgba(255,255,255,0.08); border-radius:12px; background:#111; }
3921
- .icon { width:48px; height:48px; border-radius:50%; background:${tone}; display:flex; align-items:center; justify-content:center; font-size:28px; font-weight:600; color:#000; margin-bottom:16px; }
3922
- h1 { font-size:18px; font-weight:600; margin:0 0 8px; }
3923
- p { color:rgba(255,255,255,0.65); font-size:14px; line-height:1.5; margin:0 0 16px; }
3924
- .hint { color:rgba(255,255,255,0.4); font-size:12px; font-family:ui-monospace,Menlo,monospace; }
3925
- </style></head><body>
3926
- <div class="card">
3927
- <div class="icon">${icon}</div>
3928
- <h1>${safe(title)}</h1>
3929
- <p>${safe(message)}</p>
3930
- <p class="hint">You can close this tab. Codehere has stored the connection locally.</p>
3931
- </div>
3932
- <script>setTimeout(function(){ try{ window.close(); }catch(_){} }, 3000);</script>
3933
- </body></html>`;
3934
- }
3935
- //# sourceMappingURL=mcp-server-impl.js.map