@logicseek/hello-cc 0.1.8 → 1.0.0

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 (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +15 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +139 -15
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +61 -8
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +232 -29
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +143 -7
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5646 -262
@@ -0,0 +1,542 @@
1
+ // HTTP request routing for the web runtime, extracted from lib/web/runtime-main.mjs.
2
+ // The single handleWebRequest dispatcher: auth/cookie exchange, static UI,
3
+ // projects, sessions, peers, tmux/buffer-GC endpoints, and the status APIs.
4
+
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { URL } from 'node:url';
8
+ import { publicCliFailure, CliError } from '../shared/errors.mjs';
9
+ import { required } from '../cli-args.mjs';
10
+ import { packageRoot } from '../cli-runtime.mjs';
11
+ import { projectRecord } from '../runtime/projects.mjs';
12
+ import { writeRuntime } from '../runtime/state.mjs';
13
+ import { normalizeStateResources } from '../ui/state-render.mjs';
14
+ import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
15
+ import { authOk, readJsonRequest, requestMatchesProxyOrigin, requestOriginMatches, sendFile, sendJson, tokenMatches } from '../web/http.mjs';
16
+ import { API_VERSION, apiVersionUnsupportedBody, readHttpApiVersion } from '../web/api-version.mjs';
17
+
18
+ export function createHttpRoutes(deps) {
19
+ const {
20
+ ctx, sessions,
21
+ token, host, port, useTls, trustProxy, proxyOrigin,
22
+ broadcast, serializeSession, sessionsForProject, resolveSessionPeerId,
23
+ parseCookieSid, closeWebSession, issueSession, sessionCookieHeader,
24
+ expiredSessionCookieHeader, cookieSessionOk, webAuthMode,
25
+ rememberProject, knownProjects, resolveWebProjectContext,
26
+ connectWebProject, projectFromRequest, getSession, resolveWebActionSession,
27
+ prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
28
+ detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
29
+ killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
30
+ webStatusSnapshot, webPeerActionForProject,
31
+ now, addEvent, auditPayload, sendMessage,
32
+ getProcessIdentity, getActualPort,
33
+ shutdown, tlsCredentials,
34
+ renderWebIndex, renderWebLogin, sendWebHtml, webErrorStatus,
35
+ ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
36
+ } = deps;
37
+
38
+ const handleWebRequest = async (req, res) => {
39
+ const url = requestUrl(req);
40
+ try {
41
+ if (req.method === 'GET' && url.pathname === '/') {
42
+ const accept = req.headers.accept || '';
43
+ const isBrowserNav = req.headers['sec-fetch-mode'] === 'navigate' || accept.includes('text/html');
44
+ const queryToken = url.searchParams.get('token') || '';
45
+ const hasCookie = cookieSessionOk(req);
46
+ // Browser navigation with a valid ?token → exchange it for a session
47
+ // cookie and redirect to the bare URL (strips the token from the
48
+ // address bar). API-style fetches (Accept: */*) still get the HTML
49
+ // directly so existing CLI/test callers are unaffected.
50
+ if (isBrowserNav && queryToken && token && tokenMatches(queryToken, token)) {
51
+ if (trustProxy && !requestMatchesProxyOrigin(req, { trustProxy, proxyOrigin })) {
52
+ sendJson(res, 403, { ok: false, error: { code: 'PROXY_ORIGIN_MISMATCH', message: 'Trusted proxy headers do not match --proxy-origin' } });
53
+ return;
54
+ }
55
+ const sid = issueSession();
56
+ const params = new URLSearchParams();
57
+ for (const key of ['project', 'root']) {
58
+ const value = url.searchParams.get(key);
59
+ if (value) params.set(key, value);
60
+ }
61
+ const location = '/' + (params.toString() ? `?${params}` : '');
62
+ res.writeHead(302, { Location: location, 'Set-Cookie': sessionCookieHeader(sid, req) });
63
+ res.end();
64
+ return;
65
+ }
66
+ // Browser navigation with no credential at all → login page (bare-URL
67
+ // fallback, e.g. a bookmarked URL after the runtime restarted).
68
+ if (isBrowserNav && !hasCookie && !queryToken && token) {
69
+ sendWebHtml(res, renderWebLogin);
70
+ return;
71
+ }
72
+ sendWebHtml(res, renderWebIndex);
73
+ return;
74
+ }
75
+ if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
76
+ sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js'), 'application/javascript; charset=utf-8');
77
+ return;
78
+ }
79
+ if (req.method === 'GET' && url.pathname === '/assets/xterm.css') {
80
+ sendFile(res, path.join(packageRoot(), 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css'), 'text/css; charset=utf-8');
81
+ return;
82
+ }
83
+ if (req.method === 'POST' && url.pathname === '/login') {
84
+ const input = await readJsonRequest(req);
85
+ const loginToken = String(input.token || '');
86
+ if (!token || !tokenMatches(loginToken, token)) {
87
+ sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } });
88
+ return;
89
+ }
90
+ if (trustProxy && !requestMatchesProxyOrigin(req, { trustProxy, proxyOrigin })) {
91
+ sendJson(res, 403, { ok: false, error: { code: 'PROXY_ORIGIN_MISMATCH', message: 'Trusted proxy headers do not match --proxy-origin' } });
92
+ return;
93
+ }
94
+ const sid = issueSession();
95
+ res.writeHead(302, { Location: '/', 'Set-Cookie': sessionCookieHeader(sid, req) });
96
+ res.end();
97
+ return;
98
+ }
99
+ if (url.pathname.startsWith('/api/') && !readHttpApiVersion(req).ok) {
100
+ sendJson(res, 426, apiVersionUnsupportedBody());
101
+ return;
102
+ }
103
+ const authMode = webAuthMode(url, req);
104
+ if (!authMode) {
105
+ sendJson(res, 401, { ok: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
106
+ return;
107
+ }
108
+ const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method || '');
109
+ // Cookie-authenticated writes require affirmative same-origin evidence;
110
+ // a missing Origin is not sufficient. Token-authenticated CLI requests
111
+ // without cookies remain origin-free, as do tokenless loopback CLI
112
+ // requests; a supplied Origin on the tokenless runtime must still match.
113
+ const cookieAuthenticated = authMode === 'cookie' || cookieSessionOk(req);
114
+ const originRequired = cookieAuthenticated || (!token && Boolean(req.headers.origin));
115
+ if (!safeMethod && originRequired && !requestOriginMatches(req, { trustProxy, proxyOrigin })) {
116
+ sendJson(res, 403, { ok: false, error: { code: 'CSRF_ORIGIN', message: 'Cookie-authenticated writes require a same-origin request' } });
117
+ return;
118
+ }
119
+ if (req.method === 'POST' && url.pathname === '/logout') {
120
+ const sid = parseCookieSid(req);
121
+ if (sid) closeWebSession(sid, 'logged out');
122
+ res.writeHead(204, { 'Set-Cookie': expiredSessionCookieHeader(req) });
123
+ res.end();
124
+ return;
125
+ }
126
+ const reqCtx = projectFromRequest(req, url);
127
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
128
+ sendJson(res, 200, { projects: knownProjects(), current: projectRecord(reqCtx) });
129
+ return;
130
+ }
131
+ if (req.method === 'POST' && url.pathname === '/api/projects') {
132
+ const input = await readJsonRequest(req);
133
+ const requestedRoot = input.root || reqCtx.root;
134
+ const projectCtx = rememberProject(resolveWebProjectContext(
135
+ requestedRoot,
136
+ input.db || path.join(requestedRoot, '.hello-cc', 'mesh.db')
137
+ ), { register: true, nonblocking: true });
138
+ const db = connectWebProject(projectCtx);
139
+ db.close();
140
+ writeRuntime(projectCtx, {
141
+ product: PRODUCT_NAME,
142
+ version: VERSION,
143
+ api_version: API_VERSION,
144
+ pid: process.pid,
145
+ ...(getProcessIdentity() ? { process_identity: getProcessIdentity() } : {}),
146
+ root: projectCtx.root,
147
+ db: projectCtx.dbPath,
148
+ host,
149
+ port: getActualPort(),
150
+ base_url: runtimeBaseUrl(host, getActualPort(), useTls),
151
+ token,
152
+ tls: useTls,
153
+ trust_proxy: trustProxy,
154
+ proxy_origin: proxyOrigin,
155
+ tls_cert: useTls ? tlsCredentials.cert : undefined,
156
+ global_runtime: true,
157
+ started_at: now()
158
+ });
159
+ sendJson(res, 200, { project: projectRecord(projectCtx), projects: knownProjects() });
160
+ return;
161
+ }
162
+ if (req.method === 'GET' && url.pathname === '/api/state') {
163
+ const resources = normalizeStateResources([
164
+ ...url.searchParams.getAll('resource'),
165
+ url.searchParams.get('resources') || ''
166
+ ]);
167
+ sendJson(res, 200, webStatusSnapshot(reqCtx, url.searchParams.get('peer'), {
168
+ resources,
169
+ intent: url.searchParams.get('intent') || null,
170
+ scope: url.searchParams.get('scope') || null
171
+ }));
172
+ return;
173
+ }
174
+ const peerActionMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/actions\/([^/]+)$/);
175
+ if (peerActionMatch) {
176
+ const peer = decodeURIComponent(peerActionMatch[1]);
177
+ const action = decodeURIComponent(peerActionMatch[2]);
178
+ const readOnly = ['status', 'state', 'inbox'].includes(action);
179
+ if (readOnly && req.method !== 'GET') {
180
+ sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use GET for read-only peer actions' } });
181
+ return;
182
+ }
183
+ if (!readOnly && req.method !== 'POST') {
184
+ sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Use POST for mutating peer actions' } });
185
+ return;
186
+ }
187
+ const input = readOnly
188
+ ? {
189
+ ...Object.fromEntries(url.searchParams.entries()),
190
+ resource: url.searchParams.getAll('resource')
191
+ }
192
+ : await readJsonRequest(req);
193
+ const actionInput = readOnly
194
+ ? input
195
+ : { ...input, actorPeer: resolveWebActionSession(reqCtx, peer, input, req) };
196
+ sendJson(res, 200, webPeerActionForProject(reqCtx, peer, action, actionInput));
197
+ return;
198
+ }
199
+ // Detected sessions: peers registered via hooks/watcher but without PTY
200
+ if (req.method === 'GET' && url.pathname === '/api/detected') {
201
+ const db = connectWebProject(reqCtx);
202
+ let detected = [];
203
+ const managedIds = new Set();
204
+ const t = now();
205
+ try {
206
+ detected = db.prepare(`
207
+ SELECT id, kind, role, status, worktree, branch, pid, capabilities,
208
+ created_at, last_seen_at, (? - last_seen_at) AS age_sec
209
+ FROM peers
210
+ WHERE status != 'exited' AND last_seen_at >= ?
211
+ ORDER BY last_seen_at DESC, id ASC
212
+ LIMIT 100
213
+ `).all(t, t - DETECTED_PEER_MAX_AGE);
214
+ for (const session of sessionsForProject(reqCtx)) {
215
+ managedIds.add(session.id);
216
+ const peerId = resolveSessionPeerId(db, session);
217
+ if (peerId) managedIds.add(peerId);
218
+ }
219
+ } finally {
220
+ db.close();
221
+ }
222
+ // Exclude peers that are already in the managed sessions Map
223
+ sendJson(res, 200, {
224
+ now: t,
225
+ active_peer_ttl: ACTIVE_PEER_TTL,
226
+ detected: detected.filter(p => !managedIds.has(p.id))
227
+ });
228
+ return;
229
+ }
230
+ if (req.method === 'GET' && url.pathname === '/api/resumable') {
231
+ // Provider sessions hcc has seen (via hooks/detection) that carry a real
232
+ // provider session id or resumable provider session name.
233
+ const db = connectWebProject(reqCtx);
234
+ let rows = [];
235
+ try {
236
+ rows = db.prepare(`
237
+ SELECT b.provider, b.provider_session_id, b.provider_session_name, b.peer,
238
+ p.last_seen_at
239
+ FROM peer_bindings b
240
+ LEFT JOIN peers p ON p.id = b.peer
241
+ WHERE (b.provider_session_id IS NOT NULL AND b.provider_session_id != '')
242
+ OR (b.provider_session_name IS NOT NULL AND b.provider_session_name != '')
243
+ ORDER BY p.last_seen_at DESC, b.updated_at DESC
244
+ `).all();
245
+ } finally {
246
+ db.close();
247
+ }
248
+ const seen = new Set();
249
+ const resumable = [];
250
+ for (const r of rows) {
251
+ const resumeValue = r.provider_session_id || r.provider_session_name || '';
252
+ if (!resumeValue) continue;
253
+ const key = `${r.provider}:${resumeValue}`;
254
+ if (seen.has(key)) continue;
255
+ seen.add(key);
256
+ resumable.push({
257
+ provider: r.provider,
258
+ session_id: r.provider_session_id,
259
+ session_name: r.provider_session_name || null,
260
+ resume: resumeValue,
261
+ name: r.provider_session_name || null,
262
+ peer: r.peer
263
+ });
264
+ }
265
+ sendJson(res, 200, { resumable });
266
+ return;
267
+ }
268
+ if (req.method === 'GET' && url.pathname === '/api/runtime') {
269
+ sendJson(res, 200, {
270
+ product: PRODUCT_NAME,
271
+ version: VERSION,
272
+ api_version: API_VERSION,
273
+ pid: process.pid,
274
+ ...(getProcessIdentity() ? { process_identity: getProcessIdentity() } : {}),
275
+ root: reqCtx.root,
276
+ db: reqCtx.dbPath,
277
+ projects: knownProjects(),
278
+ sessions: sessionsForProject(reqCtx).length
279
+ });
280
+ return;
281
+ }
282
+ if (req.method === 'POST' && url.pathname === '/api/runtime/gc-buffers') {
283
+ const input = await readJsonRequest(req);
284
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
285
+ throw new CliError('BAD_REQUEST', 'buffer GC request must be an object');
286
+ }
287
+ if (input.phase === 'prepare') {
288
+ const allowed = new Set(['phase', 'retentionSec', 'dryRun']);
289
+ if (Object.keys(input).some((key) => !allowed.has(key))) {
290
+ throw new CliError('BAD_REQUEST', 'buffer GC prepare contains unsupported fields');
291
+ }
292
+ if (typeof input.dryRun !== 'boolean') {
293
+ throw new CliError('BAD_REQUEST', 'dryRun must be a boolean');
294
+ }
295
+ if (!Number.isSafeInteger(input.retentionSec) || input.retentionSec < 0 ||
296
+ Object.is(input.retentionSec, -0) ||
297
+ !Number.isSafeInteger(input.retentionSec * 1000)) {
298
+ throw new CliError('BAD_REQUEST', 'retentionSec must be a canonical non-negative safe integer');
299
+ }
300
+ sendJson(res, 200, prepareRuntimeBufferGc(reqCtx, input));
301
+ return;
302
+ }
303
+ if (input.phase === 'apply') {
304
+ const allowed = new Set(['phase', 'token']);
305
+ if (Object.keys(input).some((key) => !allowed.has(key)) ||
306
+ typeof input.token !== 'string' || input.token.length === 0) {
307
+ throw new CliError('BAD_REQUEST', 'buffer GC apply requires only its token');
308
+ }
309
+ sendJson(res, 200, applyPreparedRuntimeBufferGc(reqCtx, input.token));
310
+ return;
311
+ }
312
+ throw new CliError('BAD_REQUEST', 'buffer GC phase must be prepare or apply');
313
+ }
314
+ if (req.method === 'POST' && url.pathname === '/api/runtime/stop') {
315
+ sendJson(res, 200, { ok: true, pid: process.pid });
316
+ setTimeout(shutdown, 10);
317
+ return;
318
+ }
319
+ if (req.method === 'GET' && url.pathname === '/api/sessions') {
320
+ const db = connectWebProject(reqCtx);
321
+ try {
322
+ sendJson(res, 200, {
323
+ sessions: sessionsForProject(reqCtx).map((session) => serializeSession(session, db))
324
+ });
325
+ } finally {
326
+ db.close();
327
+ }
328
+ return;
329
+ }
330
+ if (req.method === 'POST' && url.pathname === '/api/sessions') {
331
+ const input = await readJsonRequest(req);
332
+ const session = startSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
333
+ sendJson(res, 200, { session: serializeSession(session) });
334
+ return;
335
+ }
336
+ if (req.method === 'POST' && url.pathname === '/api/sessions/attach') {
337
+ const input = await readJsonRequest(req);
338
+ const session = attachTmuxSession({ ...input, projectCtx: reqCtx, auditActorPeer: 'web', auditSource: 'web' });
339
+ sendJson(res, 200, { session: serializeSession(session) });
340
+ return;
341
+ }
342
+ const inputMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/input$/);
343
+ if (req.method === 'POST' && inputMatch) {
344
+ // This HTTP input route is the admin injection path used by the CLI
345
+ // (hcc inject / ask / broadcast). It is gated by the runtime token
346
+ // (authOk above) and intentionally does NOT require the per-session
347
+ // action token — the browser types via the WebSocket input frame
348
+ // (which DOES require it), and the CLI cannot obtain the action token.
349
+ const id = decodeURIComponent(inputMatch[1]);
350
+ const lookupDb = connectWebProject(reqCtx);
351
+ let session;
352
+ try {
353
+ session = getSession(reqCtx, id, lookupDb);
354
+ } finally {
355
+ lookupDb.close();
356
+ }
357
+ if (!session) {
358
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
359
+ return;
360
+ }
361
+ if (session.status !== 'running') {
362
+ sendJson(res, 409, { ok: false, error: { code: 'SESSION_NOT_RUNNING', message: 'Session is not running' } });
363
+ return;
364
+ }
365
+ const input = await readJsonRequest(req);
366
+ const text = String(input.text ?? input.data ?? '');
367
+ const data = input.data !== undefined ? String(input.data) : `${text}${input.enter === false ? '' : '\r'}`;
368
+ writeSessionInput(session, data);
369
+ const db = connectWebProject(session.ctx || reqCtx);
370
+ try {
371
+ addEvent(db, 'web.session.input', 'web', null, auditPayload({
372
+ actor: 'web',
373
+ target: session.peerId || id,
374
+ source: 'web',
375
+ admin: true,
376
+ peer: session.peerId || id,
377
+ runtime_session_id: session.id,
378
+ bytes: data.length,
379
+ enter: input.enter !== false
380
+ }));
381
+ } finally {
382
+ db.close();
383
+ }
384
+ sendJson(res, 200, { session: serializeSession(session), bytes: data.length });
385
+ return;
386
+ }
387
+ const stopMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
388
+ if (req.method === 'POST' && stopMatch) {
389
+ const id = decodeURIComponent(stopMatch[1]);
390
+ let stopInput = {};
391
+ try { stopInput = await readJsonRequest(req); } catch {}
392
+ const lookupDb = connectWebProject(reqCtx);
393
+ let session;
394
+ try {
395
+ session = getSession(reqCtx, id, lookupDb);
396
+ } finally {
397
+ lookupDb.close();
398
+ }
399
+ if (!session) {
400
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Session not found' } });
401
+ return;
402
+ }
403
+ if (session.status === 'running') {
404
+ if (session.type === 'external') {
405
+ // Stop the hcc run wrapper first so it can kill the PTY and clean
406
+ // buffer files; fall back to the child pid for older metadata.
407
+ if (session.wrapperPid) { try { process.kill(session.wrapperPid, 'SIGTERM'); } catch {} }
408
+ if (session.pid && session.pid !== session.wrapperPid) { try { process.kill(session.pid, 'SIGTERM'); } catch {} }
409
+ } else if (session.type === 'tmux') {
410
+ let killPlan = null;
411
+ const stopDb = connectWebProject(reqCtx);
412
+ try {
413
+ const peerId = resolveSessionPeerId(stopDb, session) || session.peerId || session.id;
414
+ if (stopInput.kill_tmux) {
415
+ killPlan = safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null);
416
+ }
417
+ } finally {
418
+ stopDb.close();
419
+ }
420
+ if (killPlan) executeTmuxKillPlan(reqCtx, killPlan);
421
+ detachTmuxSession(session, 'detached');
422
+ } else {
423
+ session.pty.kill();
424
+ }
425
+ }
426
+ const eventDb = connectWebProject(reqCtx);
427
+ try {
428
+ const peerId = resolveSessionPeerId(eventDb, session) || session.peerId || id;
429
+ addEvent(eventDb, 'web.session.stop_requested', 'web', null, auditPayload({
430
+ actor: 'web',
431
+ target: peerId,
432
+ source: 'web',
433
+ admin: true,
434
+ peer: peerId,
435
+ runtime_session_id: session.id,
436
+ kill_tmux: Boolean(stopInput.kill_tmux)
437
+ }));
438
+ } finally {
439
+ eventDb.close();
440
+ }
441
+ sendJson(res, 200, { session: serializeSession(session) });
442
+ return;
443
+ }
444
+ // Send a message to a detected (non-managed) peer's inbox
445
+ const detectedMsgMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/msg$/);
446
+ if (req.method === 'POST' && detectedMsgMatch) {
447
+ const peerId = decodeURIComponent(detectedMsgMatch[1]);
448
+ const input = await readJsonRequest(req);
449
+ const body = String(input.body || '');
450
+ const sender = 'web';
451
+ const taskId = input.task ? Number(input.task) : null;
452
+ if (!body) { sendJson(res, 400, { ok: false, error: { code: 'BAD_REQUEST', message: 'body required' } }); return; }
453
+ const db = connectWebProject(reqCtx);
454
+ let msgId;
455
+ try {
456
+ msgId = sendMessage(db, sender, peerId, taskId, 'note', body);
457
+ } finally {
458
+ db.close();
459
+ }
460
+ sendJson(res, 200, { ok: true, id: msgId });
461
+ return;
462
+ }
463
+ const detectedStopMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/stop$/);
464
+ if (req.method === 'POST' && detectedStopMatch) {
465
+ const peerId = decodeURIComponent(detectedStopMatch[1]);
466
+ let input = {};
467
+ try { input = await readJsonRequest(req); } catch {}
468
+ const db = connectWebProject(reqCtx);
469
+ try {
470
+ const now_ = now();
471
+ let killPlan = null;
472
+ if (input.kill_tmux) {
473
+ killPlan = killDbProvenTmuxSession(reqCtx, db, peerId);
474
+ }
475
+ // Preserve last_seen_at on death so the just-stopped owner is not
476
+ // misread as freshly active (hb-01); status='exited' drives liveness.
477
+ db.prepare('UPDATE peers SET status = ? WHERE id = ?').run('exited', peerId);
478
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?').run(now_, peerId);
479
+ addEvent(db, 'peer.stopped', 'web', null, auditPayload({
480
+ actor: 'web',
481
+ target: peerId,
482
+ source: 'web',
483
+ admin: true,
484
+ peer: peerId,
485
+ kill_tmux: Boolean(killPlan),
486
+ tmux_session: killPlan?.session || null,
487
+ runtime_target: killPlan?.runtime_target || null
488
+ }));
489
+ } finally {
490
+ db.close();
491
+ }
492
+ sendJson(res, 200, { ok: true, peer: peerId, status: 'exited' });
493
+ return;
494
+ }
495
+ const detectedRestartMatch = url.pathname.match(/^\/api\/detected\/([^/]+)\/restart$/);
496
+ if (req.method === 'POST' && detectedRestartMatch) {
497
+ const peerId = decodeURIComponent(detectedRestartMatch[1]);
498
+ const db = connectWebProject(reqCtx);
499
+ try {
500
+ // v1-detected-restart: only flip a real detected peer that has a live
501
+ // process or is not explicitly exited. Do not bump last_seen_at
502
+ // (the peer did not actually heartbeat) so the reaper's age filter
503
+ // still applies and a phantom cannot persist indefinitely.
504
+ const peer = db.prepare('SELECT id, pid, status FROM peers WHERE id = ?').get(peerId);
505
+ if (!peer || peer.status === 'exited') {
506
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No live detected peer for ${peerId}` } });
507
+ return;
508
+ }
509
+ db.prepare('UPDATE peers SET status = ? WHERE id = ?').run('running', peerId);
510
+ addEvent(db, 'peer.restarted', 'web', null, auditPayload({
511
+ actor: 'web',
512
+ target: peerId,
513
+ source: 'web',
514
+ admin: true,
515
+ peer: peerId
516
+ }));
517
+ } finally {
518
+ db.close();
519
+ }
520
+ sendJson(res, 200, { ok: true, peer: peerId, status: 'running' });
521
+ return;
522
+ }
523
+ sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
524
+ } catch (err) {
525
+ const publicFailure = publicCliFailure(err);
526
+ const publicError = publicFailure?.error || err;
527
+ const detail = publicFailure || process.env.HCC_DEBUG
528
+ ? publicError.message
529
+ : 'internal server error';
530
+ sendJson(res, webErrorStatus(publicError), {
531
+ ok: false,
532
+ error: {
533
+ code: publicError.code || 'SERVER_ERROR',
534
+ message: detail,
535
+ ...(publicFailure?.cleanupFailed ? { cleanup_failed: true } : {})
536
+ }
537
+ });
538
+ }
539
+ };
540
+
541
+ return { handleWebRequest };
542
+ }