@hncode/hncode 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/package.json +5 -3
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. package/src/web.js +380 -0
  147. /package/bin/{install.js → install.cjs} +0 -0
  148. /package/bin/{preinstall.js → preinstall.cjs} +0 -0
@@ -0,0 +1,871 @@
1
+ // The global hncode web UI.
2
+ //
3
+ // WHY A DAEMON: the per-session server in web.js can only ever serve the one TUI
4
+ // process that started it — its state lives in that process's closures. The user
5
+ // wants ONE address that lists every workspace and every session on the machine,
6
+ // with a fixed port and a fixed token, so a bookmark and a saved login keep
7
+ // working. That requires something that outlives an individual session.
8
+ //
9
+ // WHAT IT DOES
10
+ // 1. Owns the fixed port + token from config.toml.
11
+ // 2. Accepts registrations from running TUI sessions over a named pipe. Each
12
+ // registration names the loopback port that session's own web.js listens on.
13
+ // 3. Proxies /s/<id>/* to that session's port (live, two-way, SSE included).
14
+ // 4. Serves a session whose TUI has EXITED straight from its file in
15
+ // ~/.hncode/sessions (read-only), so nothing is lost when a terminal closes.
16
+ // 5. Exits when the last TUI session deregisters.
17
+ //
18
+ // The daemon deliberately does NOT re-implement SessionHub. A live session's
19
+ // transcript, actions and SSE stream stay in web.js, where they already work;
20
+ // this process only routes to them. A second copy of the incremental row state
21
+ // would be two sources of truth, which is exactly the class of bug this project
22
+ // keeps having to fix.
23
+
24
+ import http from 'node:http';
25
+ import net from 'node:net';
26
+ import path from 'node:path';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import { fileURLToPath } from 'node:url';
30
+ import * as sess from './session.js';
31
+ import { ensureWebToken } from './config.js';
32
+ import {
33
+ safeEqual, isLoopback, sameOriginOk, requestSecret, isAuthed,
34
+ } from './web-auth.js';
35
+
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+ const PUBLIC_DIR = path.join(HERE, 'web-public');
38
+ const COOKIE = 'hncode_web';
39
+ const LOGIN_DELAY_MS = 400;
40
+
41
+ /** How long a session may go without a heartbeat before it is presumed dead. */
42
+ const STALE_MS = 45_000;
43
+
44
+ /** Heartbeat cadence the client is told to use (it may send more often). */
45
+ export const HEARTBEAT_MS = 15_000;
46
+ /**
47
+ * How often the registry checks whether a session's process is still there.
48
+ *
49
+ * Short, because the check is `process.kill(pid, 0)` — a syscall, not I/O — and
50
+ * it is what makes quitting hncode clear the daemon promptly. Waiting for the
51
+ * heartbeat instead means up to STALE_MS of a stale entry, which is what left a
52
+ * daemon running after the last TUI had gone.
53
+ */
54
+ const SWEEP_MS = 5_000;
55
+
56
+ /** The pipe the sessions register on. */
57
+ export function defaultDaemonPipe() {
58
+ if (process.env.HNCODE_WEB_PIPE) return process.env.HNCODE_WEB_PIPE;
59
+ if (process.platform === 'win32') return '\\\\.\\pipe\\hncode-web-daemon';
60
+ return path.join(os.homedir(), '.hncode', 'web-daemon.sock');
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Registry
65
+
66
+ export class Registry {
67
+ constructor() {
68
+ /** sessionId -> { id, port, token, title, workspace, pid, at } */
69
+ this.live = new Map();
70
+ }
71
+
72
+ /** Add or refresh a session. Returns the stored record. */
73
+ register(info) {
74
+ const id = String((info && info.id) || '').trim();
75
+ if (!id) throw new Error('id is required');
76
+ const port = Number(info.port);
77
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('invalid port');
78
+ const rec = {
79
+ id,
80
+ port,
81
+ // The session's OWN access token. The daemon presents it upstream so
82
+ // web.js's checks pass without the browser logging in twice.
83
+ token: String(info.token || ''),
84
+ title: String(info.title || ''),
85
+ workspace: String(info.workspace || ''),
86
+ pid: Number(info.pid) || 0,
87
+ at: Date.now(),
88
+ };
89
+ this.live.set(id, rec);
90
+ return rec;
91
+ }
92
+
93
+ touch(id) {
94
+ const rec = this.live.get(String(id || ''));
95
+ if (rec) { rec.at = Date.now(); return true; }
96
+ return false;
97
+ }
98
+
99
+ unregister(id) {
100
+ return this.live.delete(String(id || ''));
101
+ }
102
+
103
+ get(id) {
104
+ const rec = this.live.get(String(id || ''));
105
+ if (!rec) return null;
106
+ // Same liveness rules as sweep, so a browser refresh reflects a session that
107
+ // has exited instead of waiting for the next sweep tick.
108
+ if (this.isProcessAlive(rec.pid) === false || Date.now() - rec.at > STALE_MS) {
109
+ this.live.delete(rec.id);
110
+ return null;
111
+ }
112
+ return rec;
113
+ }
114
+
115
+ /**
116
+ * Is the process that registered this session still alive?
117
+ *
118
+ * `process.kill(pid, 0)` sends no signal; it only asks the OS whether the pid
119
+ * exists, and it answers IMMEDIATELY. That matters because the alternative —
120
+ * waiting for the heartbeat to go stale — takes up to STALE_MS, and a TUI that
121
+ * exits through its own quit path cannot deregister reliably: `detach()` sends
122
+ * an async pipe request and `process.exit()` follows in the same tick, so the
123
+ * request is usually never written. The pid check turns "left a daemon running
124
+ * for a minute after I quit" into "gone on the next sweep".
125
+ *
126
+ * EPERM means the pid exists but belongs to another user — still alive. Only
127
+ * ESRCH means genuinely gone. An unknown pid (0) falls back to the heartbeat.
128
+ */
129
+ isProcessAlive(pid) {
130
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return null;
131
+ try {
132
+ process.kill(pid, 0);
133
+ return true;
134
+ } catch (e) {
135
+ if (e && e.code === 'ESRCH') return false;
136
+ return true; // EPERM and anything else: assume alive
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Drop sessions whose process is gone, or whose heartbeat stopped (the latter
142
+ * covers a same-machine pid we cannot see, and a session that lost its event
143
+ * loop while the pid stayed alive).
144
+ */
145
+ sweep(now = Date.now()) {
146
+ const gone = [];
147
+ for (const [id, rec] of this.live) {
148
+ const dead = this.isProcessAlive(rec.pid) === false;
149
+ const stale = now - rec.at > STALE_MS;
150
+ if (dead || stale) { this.live.delete(id); gone.push(id); }
151
+ }
152
+ return gone;
153
+ }
154
+
155
+ list() {
156
+ this.sweep();
157
+ // The token is stripped from anything sent to the browser.
158
+ return [...this.live.values()]
159
+ .sort((a, b) => b.at - a.at)
160
+ .map((r) => ({
161
+ id: r.id, title: r.title, workspace: r.workspace,
162
+ pid: r.pid, at: r.at, live: true,
163
+ }));
164
+ }
165
+
166
+ get size() { return this.live.size; }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Reading finished sessions off disk
171
+
172
+ /**
173
+ * Every saved session, newest first.
174
+ *
175
+ * `readSessionMeta` stops scanning at `"messages"`, so this is cheap even with
176
+ * hundreds of long transcripts. Sessions with no messages are skipped: an empty
177
+ * session is one the user never really started, and listing it is just noise
178
+ * beside the real ones.
179
+ */
180
+ export function listSavedSessions(storeDir) {
181
+ const dir = storeDir || sess.sessionsDir();
182
+ let files = [];
183
+ try { files = fs.readdirSync(dir); } catch { return []; }
184
+ const out = [];
185
+ for (const f of files) {
186
+ if (!f.endsWith('.json')) continue;
187
+ const full = path.join(dir, f);
188
+ let meta = null;
189
+ try { meta = sess.readSessionMeta(full); } catch { continue; }
190
+ if (!meta || !meta.id) continue;
191
+ out.push({
192
+ id: String(meta.id),
193
+ title: String(meta.title || ''),
194
+ workspace: String(meta.workspace || ''),
195
+ updatedAt: Number(meta.updatedAt || meta.createdAt || 0),
196
+ rounds: Number(meta.rounds || 0),
197
+ steps: Number(meta.steps || 0),
198
+ });
199
+ }
200
+ out.sort((a, b) => b.updatedAt - a.updatedAt);
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * A saved session rendered in the SAME payload shape web.js serves for a live
206
+ * one — `{ rows, status, events }` — so the browser needs no second renderer.
207
+ *
208
+ * Read-only by construction: no process is running, so there is nothing that
209
+ * could action a request. `status.readOnly` is what the UI keys its banner off,
210
+ * and it is also what makes the actions refuse rather than silently do nothing.
211
+ */
212
+ export function savedSessionState(sessionId, storeDir, opts = {}) {
213
+ const s = sess.loadSession(String(sessionId), storeDir);
214
+ if (!s) return null;
215
+ const msgs = s.messages || [];
216
+ // The WHOLE transcript by default. Serving only a tail made the page start
217
+ // mid-conversation and put a "load older" button in front of the user's own
218
+ // history — the wrong shape, and unnecessary now that the browser keeps only
219
+ // the visible rows mounted (web-public/vlist.js). `limit` is still honoured for
220
+ // callers that explicitly want a bounded tail.
221
+ const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 0;
222
+ const start = limit > 0 ? Math.max(0, msgs.length - limit) : 0;
223
+ const slice = start > 0 ? msgs.slice(start) : msgs;
224
+ const rows = [];
225
+ let seq = 0;
226
+ for (const m of slice) {
227
+ // convRowFor returns an ARRAY (an assistant turn expands into its own row
228
+ // plus one row per tool call) — flatten, never spread into an object.
229
+ for (const r of sess.convRowFor(m)) rows.push({ ...r, id: ++seq });
230
+ }
231
+ return {
232
+ rows,
233
+ // How much was left out, so the page can say so instead of silently looking
234
+ // like it starts mid-conversation.
235
+ hidden: start,
236
+ totalMessages: msgs.length,
237
+ status: {
238
+ session: s.id,
239
+ title: s.title || '',
240
+ model: s.model || '',
241
+ provider: '',
242
+ mode: s.mode || 'ask',
243
+ cwd: s.workspace || '',
244
+ plan: !!s.plan,
245
+ busy: false,
246
+ readOnly: true,
247
+ updatedAt: s.updatedAt || s.createdAt || 0,
248
+ rounds: s.rounds || 0,
249
+ steps: s.steps || 0,
250
+ ctxTokens: 0,
251
+ ctxMax: 0,
252
+ ctxPercent: 0,
253
+ },
254
+ events: [],
255
+ readOnly: true,
256
+ };
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Static assets
261
+
262
+ const MIME = {
263
+ '.html': 'text/html; charset=utf-8',
264
+ '.js': 'text/javascript; charset=utf-8',
265
+ '.css': 'text/css; charset=utf-8',
266
+ '.json': 'application/json; charset=utf-8',
267
+ '.svg': 'image/svg+xml',
268
+ '.ico': 'image/x-icon',
269
+ // The self-hosted faces under web-public/fonts. Without these the browser gets
270
+ // `application/octet-stream` and refuses to apply the font.
271
+ '.woff2': 'font/woff2',
272
+ '.woff': 'font/woff',
273
+ '.ttf': 'font/ttf',
274
+ };
275
+
276
+ export function readAsset(urlPath) {
277
+ const rel = String(urlPath || '/').split('?')[0];
278
+ // NOTE: '/' is NOT mapped to index.html here. The daemon serves TWO shells —
279
+ // the directory at '/', a session page under '/s/<id>/' — and picking one for
280
+ // the caller is what silently made the root serve the single-session UI. Every
281
+ // caller names the file it wants.
282
+ if (rel === '/' || rel === '') return null;
283
+ const safeRel = path.normalize(rel).replace(/^([/\\])+/, '');
284
+ const target = path.join(PUBLIC_DIR, safeRel);
285
+ // Containment check on the RESOLVED path: `..%2f..%2fetc/passwd` and friends
286
+ // must not escape the asset directory.
287
+ if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + path.sep)) return null;
288
+ try {
289
+ if (!fs.statSync(target).isFile()) return null;
290
+ return {
291
+ body: fs.readFileSync(target),
292
+ type: MIME[path.extname(target).toLowerCase()] || 'application/octet-stream',
293
+ };
294
+ } catch { return null; }
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Proxying one request to a session's own server
299
+
300
+ /**
301
+ * Forward `req` to `http://<host>:<port><targetPath>` and pipe the reply back.
302
+ *
303
+ * SSE needs no special handling beyond NOT buffering: `pipe` forwards each chunk
304
+ * as it arrives, which is exactly what an event stream needs. The session's own
305
+ * server does its own token check, so the daemon presents that token upstream —
306
+ * the browser authenticated once, against the daemon.
307
+ */
308
+ export function proxyRequest(req, res, targetPath, port, opts = {}) {
309
+ const upstreamHost = opts.host || '127.0.0.1';
310
+ const token = String(opts.token || '');
311
+ return new Promise((resolve) => {
312
+ const headers = { ...req.headers };
313
+ delete headers.host;
314
+ // The body length the browser declared is for the body we are about to pipe
315
+ // through unchanged, so it stays valid — but Node rejects a mismatched one,
316
+ // and the daemon reads some bodies itself. Let the pipe length be recomputed.
317
+ delete headers['content-length'];
318
+ if (token) headers.authorization = `Bearer ${token}`;
319
+ // Drop the browser's Origin/Referer. The session's own server runs the same
320
+ // CSRF check the daemon just ran, but it compares against ITS host and port —
321
+ // which the browser never used, because it talked to the daemon. Forwarding
322
+ // the header therefore made every state-changing request come back 403.
323
+ // Removing it leaves the upstream with no Origin, which web-auth treats as a
324
+ // token-bearing script (already authenticated here) and allows.
325
+ delete headers.origin;
326
+ delete headers.referer;
327
+
328
+ const proxied = http.request({
329
+ host: upstreamHost,
330
+ port,
331
+ method: req.method,
332
+ path: targetPath,
333
+ headers,
334
+ }, (upstream) => {
335
+ try { res.writeHead(upstream.statusCode || 502, upstream.headers); }
336
+ catch { resolve(false); return; }
337
+ upstream.pipe(res);
338
+ upstream.on('end', () => resolve(true));
339
+ upstream.on('error', () => { try { res.end(); } catch { /* gone */ } resolve(false); });
340
+ });
341
+ proxied.on('error', (e) => {
342
+ if (!res.headersSent) {
343
+ try {
344
+ res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
345
+ res.end(JSON.stringify({ ok: false, error: 'session unreachable: ' + e.message }));
346
+ } catch { /* gone */ }
347
+ }
348
+ resolve(false);
349
+ });
350
+ req.pipe(proxied);
351
+ });
352
+ }
353
+
354
+ /**
355
+ * Forward a WebSocket upgrade to an upstream session server and bridge the two
356
+ * raw sockets. The upstream answers its own 101 handshake; when it does, we pipe
357
+ * the client's socket to the upstream's upgraded socket (and the `head` bytes
358
+ * that arrived with the upgrade request).
359
+ */
360
+ export function proxyUpgrade(req, socket, head, targetPath, port, opts = {}) {
361
+ const upstreamHost = opts.host || '127.0.0.1';
362
+ const token = String(opts.token || '');
363
+ const headers = { ...req.headers };
364
+ delete headers.host;
365
+ if (token) headers.authorization = `Bearer ${token}`;
366
+ const proxied = http.request({
367
+ host: upstreamHost,
368
+ port,
369
+ method: 'GET',
370
+ path: targetPath,
371
+ headers,
372
+ });
373
+ proxied.on('upgrade', (upstreamRes, upstreamSocket, head2) => {
374
+ // Whatever the upstream decided (101 or a rejection) is replayed to the
375
+ // client, then the sockets are bridged so frames flow both ways.
376
+ const statusLine = 'HTTP/1.1 ' + (upstreamRes.statusCode || 502) + ' ' + (upstreamRes.statusMessage || '') + '\r\n';
377
+ const headerBlock = Object.entries(upstreamRes.headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
378
+ socket.write(statusLine + headerBlock + '\r\n');
379
+ if (head2 && head2.length) socket.write(head2);
380
+ if (head && head.length) upstreamSocket.write(head);
381
+
382
+ // A bridged socket must NEVER surface an unhandled 'error' — a client that
383
+ // closes mid-stream makes the upstream write fail with ECONNABORTED, and an
384
+ // uncaught error kills the whole daemon (which is what the log showed). Both
385
+ // directions are made safe, and one side closing shuts the other.
386
+ const shut = () => { try { upstreamSocket.destroy(); } catch { /* gone */ } try { socket.destroy(); } catch { /* gone */ } };
387
+ socket.on('error', shut);
388
+ upstreamSocket.on('error', shut);
389
+ socket.on('close', shut);
390
+ upstreamSocket.on('close', shut);
391
+
392
+ upstreamSocket.pipe(socket);
393
+ socket.pipe(upstreamSocket);
394
+ });
395
+ proxied.on('error', () => { try { socket.destroy(); } catch { /* gone */ } });
396
+ proxied.end();
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // The registration pipe
401
+ //
402
+ // One line of JSON per request, one line of JSON per reply — the same shape
403
+ // ci.js uses for its control socket, so there is one protocol idiom in the
404
+ // project rather than two.
405
+
406
+ export function attachRegistryPipe(registry, opts = {}) {
407
+ const pipePath = opts.path || defaultDaemonPipe();
408
+ const onEmpty = typeof opts.onEmpty === 'function' ? opts.onEmpty : () => {};
409
+ const address = typeof opts.address === 'function' ? opts.address : null;
410
+ const onShutdown = typeof opts.onShutdown === 'function' ? opts.onShutdown : null;
411
+ const isPipe = process.platform === 'win32' && pipePath.startsWith('\\\\');
412
+ if (!isPipe) {
413
+ try { fs.mkdirSync(path.dirname(pipePath), { recursive: true }); } catch { /* exists */ }
414
+ try {
415
+ const st = fs.statSync(pipePath);
416
+ if (st.isSocket() || st.isFIFO()) fs.unlinkSync(pipePath);
417
+ } catch { /* nothing to clean */ }
418
+ }
419
+
420
+ const server = net.createServer((conn) => {
421
+ let buf = '';
422
+ conn.on('data', (d) => {
423
+ buf += d.toString('utf8');
424
+ let i;
425
+ while ((i = buf.indexOf('\n')) >= 0) {
426
+ const line = buf.slice(0, i).trim();
427
+ buf = buf.slice(i + 1);
428
+ if (!line) continue;
429
+ handleLine(registry, line, address).then((res) => {
430
+ try { conn.write(JSON.stringify(res) + '\n'); } catch { /* peer gone */ }
431
+ // The last session leaving means nothing is left to serve. Reported
432
+ // AFTER the reply is written so the client is not left hanging.
433
+ if (res.ok && res.op === 'unregister' && registry.size === 0) onEmpty();
434
+ // Same ordering for shutdown: answer first, then go.
435
+ if (res.ok && res.op === 'shutdown' && onShutdown) onShutdown();
436
+ });
437
+ }
438
+ });
439
+ conn.on('error', () => { /* client hung up */ });
440
+ });
441
+
442
+ return new Promise((resolve) => {
443
+ server.on('error', (e) => resolve({ server: null, path: pipePath, error: e.message, close() {} }));
444
+ server.listen(pipePath, () => {
445
+ resolve({
446
+ server,
447
+ path: pipePath,
448
+ error: null,
449
+ close() {
450
+ try { server.close(); } catch { /* already closed */ }
451
+ if (!isPipe) { try { fs.unlinkSync(pipePath); } catch { /* already gone */ } }
452
+ },
453
+ });
454
+ });
455
+ });
456
+ }
457
+
458
+ async function handleLine(registry, line, address) {
459
+ let req;
460
+ try { req = JSON.parse(line); } catch { return { ok: false, error: 'invalid JSON' }; }
461
+ try {
462
+ switch (req.op) {
463
+ case 'register': return { ok: true, op: 'register', session: registry.register(req) };
464
+ case 'touch': return { ok: true, op: 'touch', found: registry.touch(req.id) };
465
+ case 'unregister': registry.unregister(req.id); return { ok: true, op: 'unregister' };
466
+ // The listen address travels with `ping` so a client never has to assume
467
+ // it. With a fixed port that is a convenience; with port 0 it is the only
468
+ // way to learn where the daemon actually landed.
469
+ case 'ping': {
470
+ const addr = address ? address() : {};
471
+ return { ok: true, op: 'ping', sessions: registry.size, host: addr.host, port: addr.port };
472
+ }
473
+ // Ask the daemon to stand down. Used by tests and by an operator who wants
474
+ // the port back; the same trust model as the rest of this pipe, which is a
475
+ // loopback/named-pipe channel only this user can open.
476
+ case 'shutdown': return { ok: true, op: 'shutdown' };
477
+ default: return { ok: false, error: `unknown op: ${req.op}` };
478
+ }
479
+ } catch (e) {
480
+ return { ok: false, error: e.message };
481
+ }
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // The daemon
486
+
487
+ /**
488
+ * @param {object} opts
489
+ * port fixed port (0 = OS-assigned, for tests)
490
+ * host bind address (default 127.0.0.1)
491
+ * token access token (default: the persisted one)
492
+ * storeDir session store directory
493
+ * registry injectable Registry (tests)
494
+ * idleExit exit once the last session deregisters (default: true)
495
+ * onEmpty called instead of exiting when idleExit is false
496
+ */
497
+ export async function startWebDaemon(opts = {}) {
498
+ const host = opts.host || '127.0.0.1';
499
+ const port = opts.port == null ? 0 : Number(opts.port);
500
+ const token = opts.token || ensureWebToken();
501
+ const storeDir = opts.storeDir || sess.sessionsDir();
502
+ const registry = opts.registry || new Registry();
503
+ const idleExit = opts.idleExit !== false;
504
+ const loopback = isLoopback(host);
505
+ const expectedHosts = [];
506
+ // The bind address decides whether a token is required, matching the HTTP rule:
507
+ // on loopback the daemon is reachable only from this machine (reading config.toml
508
+ // grants the same access), but bound beyond loopback it is on the network and the
509
+ // token is the only thing between it and a shell. The browser carries it as the
510
+ // `hncode_web` cookie; a script passes it as a Bearer token.
511
+ const wsAuthed = (req) => loopback || isAuthed(req, token, COOKIE);
512
+
513
+ let pipe = null;
514
+ let idleTimer = null;
515
+
516
+ const server = http.createServer((req, res) => {
517
+ handle(req, res).catch((e) => {
518
+ try {
519
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
520
+ res.end(JSON.stringify({ ok: false, error: (e && e.message) || String(e) }));
521
+ } catch { /* headers already sent */ }
522
+ });
523
+ });
524
+
525
+ // Forward WebSocket upgrades for a live session's /api/ws to that session's own
526
+ // server. Without this the daemon (which does auth + path routing) would drop
527
+ // the WS handshake — a plain http server destroys any upgrade it has no
528
+ // listener for. Route /s/<id>/api/ws like handle() does, then bridge the two
529
+ // sockets.
530
+ server.on('upgrade', (req, socket, head) => {
531
+ try {
532
+ // A WebSocket upgrade bypasses the normal request handler (that is what an
533
+ // upgrade IS), so auth cannot ride on handle(). Mirror the HTTP rule here,
534
+ // or a daemon bound beyond loopback would hand any session's full transcript
535
+ // to anyone who can reach the port.
536
+ if (!wsAuthed(req)) { socket.destroy(); return; }
537
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
538
+ const p = url.pathname;
539
+ const m = /^\/s\/([^/]+)(\/.*)?$/.exec(p);
540
+ if (!m || !m[2].startsWith('/api/ws')) { socket.destroy(); return; }
541
+ const id = decodeURIComponent(m[1]);
542
+ const live = registry.get(id);
543
+ if (!live) { socket.destroy(); return; }
544
+ proxyUpgrade(req, socket, head, m[2], live.port, { token: live.token });
545
+ } catch { socket.destroy(); }
546
+ });
547
+
548
+ function send(res, code, body, headers = {}) {
549
+ res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', ...headers });
550
+ res.end(body);
551
+ }
552
+ function json(res, code, obj) {
553
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
554
+ res.end(JSON.stringify(obj));
555
+ }
556
+
557
+ async function readBody(req, limit = 1 << 20) {
558
+ return new Promise((resolve, reject) => {
559
+ let size = 0; const chunks = [];
560
+ req.on('data', (c) => {
561
+ size += c.length;
562
+ if (size > limit) { reject(new Error('body too large')); req.destroy(); return; }
563
+ chunks.push(c);
564
+ });
565
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
566
+ req.on('error', reject);
567
+ });
568
+ }
569
+
570
+ // The Origin check accepts the host the browser actually used, plus localhost
571
+ // on the same port (a user may reach the daemon either way).
572
+ function noteHost(req) {
573
+ const h = String(req.headers.host || '');
574
+ if (h && !expectedHosts.includes(h)) expectedHosts.push(h);
575
+ const bare = h.replace(/:\d+$/, '');
576
+ for (const alt of [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]) {
577
+ if (!expectedHosts.includes(alt) && (bare === 'localhost' || bare === '127.0.0.1' || bare === '::1')) {
578
+ expectedHosts.push(alt);
579
+ }
580
+ }
581
+ }
582
+
583
+ async function handle(req, res) {
584
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
585
+ const p = url.pathname;
586
+ const method = (req.method || 'GET').toUpperCase();
587
+ noteHost(req);
588
+
589
+ // --- unauthenticated -----------------------------------------------------
590
+ // Reveals only that a daemon exists and how many sessions it holds.
591
+ if (p === '/healthz') {
592
+ return json(res, 200, { ok: true, service: 'hncode-web-daemon', sessions: registry.size });
593
+ }
594
+ // The login page and its assets have to load before a token exists, and the
595
+ // login script imports the message table. NONE carry session data.
596
+ if (p === '/login' || p === '/login.html' || p === '/login.css'
597
+ || p === '/login.js' || p === '/i18n.js'
598
+ || p === '/fonts.css' || p.startsWith('/fonts/')) {
599
+ const asset = readAsset(p === '/login' ? '/login.html' : p);
600
+ if (!asset) return send(res, 404, 'not found');
601
+ return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
602
+ }
603
+ // POST /api/login { token } -> HttpOnly cookie. The token travels in the
604
+ // BODY, never the query string, so it stays out of logs and history.
605
+ if (p === '/api/login' && method === 'POST') {
606
+ let body = {};
607
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch { /* handled below */ }
608
+ if (!safeEqual(String(body.token || ''), token)) {
609
+ await new Promise((r) => setTimeout(r, LOGIN_DELAY_MS));
610
+ return json(res, 401, { ok: false, error: 'invalid token' });
611
+ }
612
+ const secure = !loopback && String(req.headers['x-forwarded-proto'] || '') === 'https';
613
+ // A 30-day cookie: the point of a stable token is that the browser does not
614
+ // ask again every day.
615
+ const cookie = [
616
+ `${COOKIE}=${encodeURIComponent(token)}`,
617
+ 'HttpOnly', 'SameSite=Strict', 'Path=/',
618
+ secure ? 'Secure' : '',
619
+ `Max-Age=${60 * 60 * 24 * 30}`,
620
+ ].filter(Boolean).join('; ');
621
+ return send(res, 200, JSON.stringify({ ok: true }), {
622
+ 'content-type': 'application/json; charset=utf-8',
623
+ 'set-cookie': cookie,
624
+ });
625
+ }
626
+
627
+ // --- authentication ------------------------------------------------------
628
+ //
629
+ // A request from THIS machine, addressed to a loopback name, is let straight
630
+ // in without a token. That is the common case — the user starts the UI and
631
+ // opens it in their own browser — and asking for a token there protects
632
+ // nothing: anything that can open this socket already runs as this user and
633
+ // can read the token out of config.toml.
634
+ //
635
+ // Everything else still needs the token, which is the case that matters: a
636
+ // daemon bound beyond loopback, reached from another machine on the network.
637
+ //
638
+ // The rule keys off the BIND ADDRESS, not the client's: bound to a loopback
639
+ // address the daemon is reachable only from this machine, so a token would
640
+ // gate nothing the user does not already have access to. Bound anywhere else
641
+ // it is on the network, and the token is what stands between that network and
642
+ // a shell.
643
+ if (!loopback && !isAuthed(req, token, COOKIE)) {
644
+ if (p.startsWith('/api/')) return json(res, 401, { ok: false, error: 'unauthorized' });
645
+ if (method === 'GET') { res.writeHead(302, { location: '/login' }); return res.end(); }
646
+ return send(res, 401, 'unauthorized');
647
+ }
648
+
649
+ // CSRF: a state-changing request must come from a page we served.
650
+ if (method !== 'GET' && method !== 'HEAD') {
651
+ const ok = sameOriginOk(req, expectedHosts);
652
+ if (ok === false) return json(res, 403, { ok: false, error: 'cross-origin request refused' });
653
+ }
654
+
655
+ // --- directory -----------------------------------------------------------
656
+ // One request gives the landing page everything: live sessions from the
657
+ // registry and finished ones read off disk.
658
+ if (p === '/api/sessions') {
659
+ return json(res, 200, {
660
+ ok: true,
661
+ live: registry.list(),
662
+ saved: listSavedSessions(storeDir),
663
+ });
664
+ }
665
+
666
+ if (p === '/api/daemon') {
667
+ // `actualPort`, not the requested `port`: when the caller asks for 0 the OS
668
+ // picks one, and reporting 0 back would tell a client to connect nowhere.
669
+ return json(res, 200, {
670
+ ok: true,
671
+ host,
672
+ port: actualPort,
673
+ token,
674
+ heartbeatMs: HEARTBEAT_MS,
675
+ pid: process.pid,
676
+ });
677
+ }
678
+
679
+ // --- session-scoped ------------------------------------------------------
680
+ // /s/<id>/... — proxied to the live session, or served read-only from disk.
681
+ // The prefix is STRIPPED before forwarding, so the session's own server sees
682
+ // exactly the paths it always has and needs no changes.
683
+ const m = /^\/s\/([^/]+)(\/.*)?$/.exec(p);
684
+ if (m) {
685
+ const id = decodeURIComponent(m[1]);
686
+ // `/s/<id>` must become `/s/<id>/` before anything is served. The session
687
+ // page loads its assets and calls its API through RELATIVE paths (so that
688
+ // the same files work both standalone and behind this prefix); without the
689
+ // trailing slash the browser resolves `api/state` against `/s/` and every
690
+ // request misses. Redirecting once is cheaper and more reliable than
691
+ // rewriting every path in the HTML and JS.
692
+ if (m[2] === undefined) {
693
+ res.writeHead(302, { location: `/s/${encodeURIComponent(id)}/${url.search || ''}` });
694
+ return res.end();
695
+ }
696
+ const rest = m[2] + (url.search || '');
697
+ const live = registry.get(id);
698
+ if (live) {
699
+ // A plain GET of the session root is the page itself: mark it live so the
700
+ // UI enables the composer.
701
+ return proxyRequest(req, res, rest, live.port, { host: '127.0.0.1', token: live.token });
702
+ }
703
+ return serveSaved(res, id, rest, method, storeDir);
704
+ }
705
+
706
+ // --- the landing page ----------------------------------------------------
707
+ // Serve the SPA shell for any other GET so the client-side router works on a
708
+ // deep link (`/d/<ws>` etc.). Assets are matched by extension so a missing
709
+ // file 404s instead of silently returning HTML.
710
+ if (method === 'GET' || method === 'HEAD') {
711
+ const asset = readAsset(p);
712
+ if (asset) return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
713
+ if (p !== '/' && path.extname(p)) return send(res, 404, 'not found');
714
+ // The directory page is the daemon's root. A session page is only ever
715
+ // served under /s/<id>/ (see above), so this shell must NOT be index.html —
716
+ // that is the single-session UI and it would call a relative `api/state`
717
+ // against the daemon's root, which has no such endpoint.
718
+ const shell = readAsset('/home.html');
719
+ if (!shell) return send(res, 500, 'web assets missing');
720
+ return send(res, 200, shell.body, { 'content-type': shell.type, 'cache-control': 'no-store' });
721
+ }
722
+
723
+ return json(res, 404, { ok: false, error: 'not found' });
724
+ }
725
+
726
+
727
+ /**
728
+ * Serve a session whose TUI has exited, from its file on disk.
729
+ *
730
+ * Only the paths a live session's own server answers are answered (`/api/state`,
731
+ * `/api/events`, `/api/sessions` and the page + its assets); everything that
732
+ * would CHANGE something is refused with a clear reason. A read-only viewer that
733
+ * silently accepted a prompt would be worse than one that says it cannot.
734
+ */
735
+ function serveSaved(res, id, rest, method, storeDir2) {
736
+ const clean = rest.split('?')[0];
737
+ // `?limit=N` bounds how much of a long transcript is sent. It defaults to
738
+ // savedSessionState's own default; a caller can ask for more, but not for
739
+ // "everything" — the whole point is that the payload stays bounded.
740
+ const qs = rest.includes('?') ? new URLSearchParams(rest.slice(rest.indexOf('?') + 1)) : null;
741
+ const limitArg = qs && qs.get('limit');
742
+ const limit = limitArg && /^\d+$/.test(limitArg) ? Number(limitArg) : undefined;
743
+ if (clean === '/api/state') {
744
+ const state = savedSessionState(id, storeDir2, { limit });
745
+ if (!state) return json(res, 404, { ok: false, error: 'no such session' });
746
+ return json(res, 200, { ok: true, ...state });
747
+ }
748
+ // The session page's left rail asks for the directory. A LIVE session answers
749
+ // this from its own web.js; a finished one has no process to ask, so the
750
+ // daemon answers here. Without it the rail came up empty on exactly the pages
751
+ // that are served from disk — the header said "no other sessions" and the
752
+ // list the user just clicked in was gone.
753
+ if (clean === '/api/sessions') {
754
+ const live = registry.list().map((s) => ({
755
+ id: s.id, title: s.title, workspace: s.workspace, updatedAt: s.at,
756
+ }));
757
+ const liveIds = new Set(live.map((s) => s.id));
758
+ const saved = listSavedSessions(storeDir2).filter((s) => !liveIds.has(s.id));
759
+ return json(res, 200, { ok: true, current: id, sessions: [...live, ...saved] });
760
+ }
761
+ if (clean === '/api/events') {
762
+ // A finished session has no events to stream. Answer with a well-formed
763
+ // stream that immediately reports the (empty) snapshot and stays open, so
764
+ // the client's EventSource does not spin on reconnects.
765
+ res.writeHead(200, {
766
+ 'content-type': 'text/event-stream; charset=utf-8',
767
+ 'cache-control': 'no-cache',
768
+ connection: 'keep-alive',
769
+ });
770
+ const state = savedSessionState(id, storeDir2) || { rows: [], status: {}, events: [] };
771
+ res.write(`data: ${JSON.stringify({ type: 'snapshot', ...state, readOnly: true })}\n\n`);
772
+ return undefined;
773
+ }
774
+ if (clean === '/api/action') {
775
+ return json(res, 409, {
776
+ ok: false,
777
+ error: 'this session has exited — its transcript is read-only',
778
+ });
779
+ }
780
+ // Assets come from the same web-public directory as everything else.
781
+ const asset = readAsset(clean === '/' ? '/index.html' : clean);
782
+ if (asset) return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
783
+ if (method === 'GET' && !path.extname(clean)) {
784
+ const shell = readAsset('/index.html');
785
+ if (shell) return send(res, 200, shell.body, { 'content-type': shell.type });
786
+ }
787
+ return send(res, 404, 'not found');
788
+ }
789
+
790
+ // ---- bind ---------------------------------------------------------------
791
+ await new Promise((resolve, reject) => {
792
+ server.once('error', reject);
793
+ server.listen(port, host, resolve);
794
+ });
795
+ const actualPort = server.address().port;
796
+
797
+ /**
798
+ * Stand down once nothing is left to serve.
799
+ *
800
+ * Called from two places, and they are NOT the same event:
801
+ * * a session deregisters cleanly (its /web off, or the TUI exiting through
802
+ * its normal shutdown path);
803
+ * * the sweep drops a session that never deregistered because its process
804
+ * was killed outright — a closed terminal window, a crash, Task Manager.
805
+ * The second case is why this cannot live only in the deregister handler: a
806
+ * daemon started on this machine outlived the TUI that spawned it exactly
807
+ * because nothing re-checked emptiness after a sweep.
808
+ *
809
+ * The short delay lets the triggering reply flush before the listener goes.
810
+ */
811
+ function goIdleIfEmpty() {
812
+ if (registry.size > 0) return;
813
+ if (!idleExit) {
814
+ if (typeof opts.onEmpty === 'function') opts.onEmpty();
815
+ return;
816
+ }
817
+ clearTimeout(idleTimer);
818
+ idleTimer = setTimeout(() => {
819
+ if (registry.size === 0) close();
820
+ }, 1500);
821
+ if (idleTimer.unref) idleTimer.unref();
822
+ }
823
+
824
+ // ---- registration pipe --------------------------------------------------
825
+ pipe = await attachRegistryPipe(registry, {
826
+ path: opts.pipePath,
827
+ // The address is reported on `ping` so a client never has to guess it. When
828
+ // the caller asked for port 0 the OS picked one, and a client that assumed
829
+ // its configured port would connect to nothing.
830
+ address: () => ({ host, port: actualPort }),
831
+ onShutdown: () => {
832
+ // Let the reply reach the caller before the listeners go away.
833
+ setTimeout(() => close(), 60);
834
+ },
835
+ onEmpty: goIdleIfEmpty,
836
+ });
837
+
838
+ // A process killed outright never deregisters (no terminal to run the exit
839
+ // handler), so the sweep is what notices it. Dropping the entry is not enough
840
+ // on its own — the daemon also has to re-check whether anything is left.
841
+ const sweepTimer = setInterval(() => {
842
+ const gone = registry.sweep();
843
+ if (!gone.length) return;
844
+ if (typeof opts.onSweep === 'function') opts.onSweep(gone);
845
+ goIdleIfEmpty();
846
+ }, SWEEP_MS);
847
+ if (sweepTimer.unref) sweepTimer.unref();
848
+
849
+
850
+ let closed = false;
851
+ function close() {
852
+ if (closed) return;
853
+ closed = true;
854
+ clearInterval(sweepTimer);
855
+ clearTimeout(idleTimer);
856
+ try { server.close(); } catch { /* already closed */ }
857
+ if (pipe) pipe.close();
858
+ }
859
+
860
+ return {
861
+ server,
862
+ registry,
863
+ port: actualPort,
864
+ host,
865
+ token,
866
+ url: `http://${host}:${actualPort}/`,
867
+ pipePath: pipe && pipe.path,
868
+ pipeError: pipe && pipe.error,
869
+ close,
870
+ };
871
+ }