@hncode/hncode 0.3.6 → 0.4.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 (146) hide show
  1. package/package.json +4 -2
  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
package/src/web-ws.js ADDED
@@ -0,0 +1,226 @@
1
+ // Zero-dependency WebSocket server for the local web UI.
2
+ //
3
+ // The project ships with NO runtime dependencies (see package.json), so a
4
+ // WebSocket upgrade cannot use the `ws` package. RFC 6455 is implemented here by
5
+ // hand: the SHA-1 handshake uses node:crypto, and the data framing (text frames,
6
+ // ~7-byte masks, 16/64-bit lengths) is a small loop.
7
+ //
8
+ // This is deliberately the smallest subset the web UI needs: a server that
9
+ // accepts one text-message stream per connection, echoes nothing, and lets the
10
+ // caller push string messages. No ping/pong, no fragmentation (the browser never
11
+ // fragments), no binary. Compression is NOT negotiated, so the browser sends
12
+ // plain frames — enough for our JSON events.
13
+
14
+ import http from 'node:http';
15
+ import crypto from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ const KEEPALIVE_MS = 25000;
20
+
21
+ const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
22
+ // Hard cap on a single frame's payload. A malicious/formed peer can declare a
23
+ // 64-bit length; allocating that much would throw RangeError and crash the
24
+ // process, and buffering a slow trickle of an oversized frame would OOM.
25
+ const MAX_FRAME = 64 * 1024 * 1024;
26
+
27
+ /** A single accepted, open WebSocket connection. */
28
+ export class WsConn {
29
+ constructor(socket, onClose) {
30
+ this.socket = socket;
31
+ this._done = onClose; // internal close sink, fired once when the socket dies
32
+ this.open = true;
33
+ // The caller registers a message handler.
34
+ this.onMessage = null;
35
+ socket.on('data', (d) => this._onData(d));
36
+ socket.on('close', () => this._close());
37
+ socket.on('error', () => this._close());
38
+ // Server->client keepalive: idle proxies (EasyTier, the daemon, nginx)
39
+ // drop a silent WebSocket, the front-end reconnects and re-reads the whole
40
+ // snapshot (page appears to reload itself). A repeated ping keeps the path
41
+ // alive; the timer dies with the socket in _close().
42
+ this._ka = setInterval(function () { try { this.ping(); } catch { /* closing */ } }.bind(this), KEEPALIVE_MS);
43
+ if (this._ka.unref) this._ka.unref();
44
+ }
45
+
46
+ _buffer = Buffer.alloc(0);
47
+
48
+ _onData(chunk) {
49
+ this._buffer = Buffer.concat([this._buffer, chunk]);
50
+ // Guard against a peer trickling an oversized frame: the incomplete-frame
51
+ // path would otherwise keep appending to the buffer without bound.
52
+ if (this._buffer.length > MAX_FRAME) { try { this._close(); } catch {} return; }
53
+ // Try to parse as many complete frames as the buffer holds.
54
+ for (;;) {
55
+ const parsed = this._parseFrame(this._buffer);
56
+ if (!parsed) return;
57
+ const { consumed, data } = parsed;
58
+ this._buffer = this._buffer.subarray(consumed);
59
+ if (parsed.fatal) { try { this._close(); } catch {} return; }
60
+ if (this.onMessage) {
61
+ try { this.onMessage(data); } catch { /* a handler must not kill the conn */ }
62
+ }
63
+ }
64
+ }
65
+
66
+ /** Parse one WS frame from the front of `buf`. Returns null when the frame is
67
+ incomplete; otherwise `{ consumed, data }` with `data` the UTF-8 string. */
68
+ _parseFrame(buf) {
69
+ if (buf.length < 2) return null;
70
+ const fin = (buf[0] & 0x80) !== 0;
71
+ const opcode = buf[0] & 0x0f;
72
+ const masked = (buf[1] & 0x80) !== 0;
73
+ let len = buf[1] & 0x7f;
74
+ let off = 2;
75
+ if (len === 126) {
76
+ if (buf.length < off + 2) return null;
77
+ len = buf.readUInt16BE(off);
78
+ off += 2;
79
+ } else if (len === 127) {
80
+ if (buf.length < off + 8) return null;
81
+ const hi = buf.readUInt32BE(off);
82
+ const lo = buf.readUInt32BE(off + 4);
83
+ len = hi * 4294967296 + lo;
84
+ off += 8;
85
+ }
86
+ if (len > MAX_FRAME) return { consumed: 1, data: '', fatal: true };
87
+ // Only masked text frames from a browser; ignore anything else gracefully.
88
+ if (!masked) {
89
+ // Not masked is a protocol error. A browser always masks, so this means
90
+ // the stream is corrupted — flag it fatal and let _onData drop the
91
+ // connection instead of guessing a frame length (which could swallow
92
+ // following frames) or spinning forever (consumed:0 froze the server).
93
+ return { consumed: 1, data: '', fatal: true };
94
+ }
95
+ const maskLen = 4;
96
+ if (buf.length < off + maskLen) return null;
97
+ const mask = buf.subarray(off, off + maskLen);
98
+ off += maskLen;
99
+ if (buf.length < off + len) return null;
100
+ const payload = Buffer.alloc(len);
101
+ for (let i = 0; i < len; i++) payload[i] = buf[off + i] ^ mask[i & 3];
102
+ // opcode 0x9 = ping, 0xA = pong, 0x8 = close. We answer pings; skip the rest.
103
+ if (!fin) return { consumed: off + len, data: '' }; // no fragmentation support
104
+ let text = '';
105
+ if (opcode === 0x1) { // text frame
106
+ text = payload.toString('utf8');
107
+ } else if (opcode === 0x9) { // ping -> pong
108
+ this._writeFrame(0xA, payload);
109
+ } else if (opcode === 0x8) { // close -> close back
110
+ this._writeFrame(0x8, Buffer.alloc(0));
111
+ this._close();
112
+ }
113
+ return { consumed: off + len, data: text };
114
+ }
115
+
116
+ /** Send a text message as an unmasked frame, per RFC 6455 (server->client
117
+ frames are never masked). */
118
+ send(text) {
119
+ if (!this.open) return;
120
+ this._writeFrame(0x1, Buffer.from(String(text), 'utf8'));
121
+ }
122
+
123
+ /** Send a ping control frame (opcode 0x9). The peer answers with a pong,
124
+ keeping the socket and any proxy between us and the browser alive. */
125
+ ping() {
126
+ if (!this.open) return;
127
+ this._writeFrame(0x9, Buffer.alloc(0));
128
+ }
129
+
130
+ _writeFrame(opcode, payload) {
131
+ const len = payload.length;
132
+ const header = [0x80 | opcode];
133
+ if (len <= 125) {
134
+ header.push(len);
135
+ } else if (len <= 0xffff) {
136
+ // RFC 6455: opcode+mask bit, then 126, then a 16-bit big-endian length.
137
+ header.push(126);
138
+ const b = Buffer.alloc(2);
139
+ b.writeUInt16BE(len, 0);
140
+ header.push(b[0], b[1]);
141
+ } else {
142
+ // 127, then a 64-bit big-endian length.
143
+ header.push(127);
144
+ const b = Buffer.alloc(8);
145
+ b.writeUInt32BE(Math.floor(len / 4294967296), 0);
146
+ b.writeUInt32BE(len >>> 0, 4);
147
+ header.push(...b);
148
+ }
149
+ const buf = Buffer.concat([Buffer.from(header), payload]);
150
+ try { this.socket.write(buf); } catch { /* closing */ }
151
+ }
152
+
153
+ close() {
154
+ if (!this.open) return;
155
+ try { this._writeFrame(0x8, Buffer.alloc(0)); } catch { /* closing */ }
156
+ this._close();
157
+ }
158
+
159
+ _close() {
160
+ if (this._ka) { clearInterval(this._ka); this._ka = null; }
161
+ if (!this.open) return;
162
+ this.open = false;
163
+ try { this.socket.destroy(); } catch { /* already gone */ }
164
+ if (this._done) { try { this._done(); } catch { /* best-effort */ } }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Attach a WebSocket upgrade handler to an http.Server.
170
+ *
171
+ * onConnection(conn) — called once a connection is accepted and open. `conn` is
172
+ * a WsConn with `.send(text)` and `.close()`; set `conn.onMessage` to receive
173
+ * client frames.
174
+ * onUpgrade(req) — optional; return a string to reject (sent as the HTTP status
175
+ * for the upgrade), or null to accept.
176
+ */
177
+ export function attachWebSocket(server, opts = {}) {
178
+ const { path = '/api/ws', onConnection, onUpgrade } = opts;
179
+ server.on('upgrade', (req, socket, head) => {
180
+ const url = req.url || '/';
181
+ const p = url.split('?')[0];
182
+ if (p !== path) { socket.destroy(); return; }
183
+ if (onUpgrade) {
184
+ const reject = onUpgrade(req);
185
+ if (reject) {
186
+ socket.write(
187
+ 'HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n' + reject,
188
+ );
189
+ socket.destroy();
190
+ return;
191
+ }
192
+ }
193
+ const key = req.headers['sec-websocket-key'];
194
+ if (!key) { socket.destroy(); return; }
195
+ const accept = crypto
196
+ .createHash('sha1')
197
+ .update(key + WS_MAGIC)
198
+ .digest('base64');
199
+ socket.write(
200
+ 'HTTP/1.1 101 Switching Protocols\r\n'
201
+ + 'Upgrade: websocket\r\n'
202
+ + 'Connection: Upgrade\r\n'
203
+ + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
204
+ );
205
+ try {
206
+ const conn = new WsConn(socket, () => {
207
+ // The caller decides what to do when a connection drops.
208
+ if (opts.onClose) opts.onClose(conn);
209
+ });
210
+ if (onConnection) onConnection(conn);
211
+ } catch (e) {
212
+ // A connection that fails to construct must not kill the process; log it.
213
+ try { fs.appendFileSync(path.join(os.tmpdir(), 'hncode-web-ws.log'), '[ws] onConnection threw: ' + (e && e.message) + '\n'); } catch { /* best-effort */ }
214
+ }
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Convenience: is the incoming `req` a WebSocket upgrade for our path? Lets an
220
+ * http server's normal request handler skip it (the upgrade event is handled
221
+ * separately, so this is informational).
222
+ */
223
+ export function isUpgradeRequest(req) {
224
+ const up = String(req.headers.upgrade || '').toLowerCase();
225
+ return up === 'websocket';
226
+ }
package/src/web.js ADDED
@@ -0,0 +1,380 @@
1
+ // Local web UI server (/web) — serves the browser client and bridges it to a
2
+ // running session through the SessionHub.
3
+ //
4
+ // Zero dependencies, like the rest of the project: node:http, a hand-written
5
+ // router, Server-Sent Events for the server->browser direction and small JSON
6
+ // POSTs for the other. SSE rather than WebSockets because the traffic is
7
+ // one-directional and heavy (streamed tokens, tool output), it survives proxies,
8
+ // and it needs no upgrade handshake or framing code.
9
+ //
10
+ // AUTH is enforced in ONE place — `handle()` — so a new route cannot accidentally
11
+ // ship unauthenticated. The only unauthenticated routes are the login page itself
12
+ // and the health check (which reveals nothing but "a server is here").
13
+
14
+ import http from 'node:http';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { newToken, isAuthed, isLoopback, sameOriginOk, safeEqual } from './web-auth.js';
19
+ import { t, LANGUAGES, DEFAULT_LANG } from './i18n.js';
20
+ // The session directory lives in web-daemon.js (it is the daemon that serves the
21
+ // landing page listing every session). The session page needs the SAME list for
22
+ // its left rail, so it imports the reader rather than growing a second one.
23
+ import { listSavedSessions } from './web-daemon.js';
24
+ import { attachWebSocket } from './web-ws.js';
25
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
26
+ const PUBLIC_DIR = path.join(HERE, 'web-public');
27
+ const COOKIE = 'hncode_web';
28
+ const MAX_BODY = 1 << 20; // 1 MiB — a prompt, not a file upload
29
+ const LOGIN_DELAY_MS = 400; // slows online guessing; costs a human nothing
30
+
31
+ // Actions a browser may invoke. Shared by the HTTP /api/action handler AND the
32
+ // WebSocket command channel, so registering a hub handler is never enough on its
33
+ // own to expose it, and neither transport can drift from the other.
34
+ const ALLOWED_ACTIONS = [
35
+ // conversation
36
+ 'submit', 'dispatch', 'interrupt', 'approve', 'answerQuestion', 'stopTask',
37
+ 'setMode', 'shell', 'steer', 'editQueued', 'dropQueued',
38
+ // session settings
39
+ 'setTitle', 'getConfig', 'setConfig',
40
+ // providers and models
41
+ 'listProviders', 'addProvider', 'removeProvider', 'discoverModels',
42
+ 'listKnownProviders', 'importKnownProvider',
43
+ ];
44
+
45
+ /**
46
+ * Pick a language for a server-rendered string. The browser's own choice lives in
47
+ * localStorage (client side), so the server cannot read it; `Accept-Language` is
48
+ * the next best signal and is what a curl client or a direct visit sends. An
49
+ * explicit `?lang=` wins, so a link can pin the language.
50
+ */
51
+ function pickLang(req, url) {
52
+ const q = url && url.searchParams.get('lang');
53
+ if (q && LANGUAGES.some((l) => l.id === q)) return q;
54
+ const header = String(req.headers['accept-language'] || '');
55
+ for (const part of header.split(',')) {
56
+ const id = part.split(';')[0].trim().toLowerCase().split('-')[0];
57
+ if (LANGUAGES.some((l) => l.id === id)) return id;
58
+ }
59
+ return DEFAULT_LANG;
60
+ }
61
+
62
+ /** Read a static asset from web-public/. Returns null when missing/unsafe. */
63
+ function readAsset(urlPath) {
64
+ // Strip the query, decode, and REFUSE anything that escapes the directory.
65
+ // Serving `../src/tui.js` here would hand out the source to an authed client —
66
+ // not catastrophic, but a path traversal is a bug wherever it appears.
67
+ let rel = decodeURIComponent(String(urlPath || '/').split('?')[0]);
68
+ if (rel === '/' || rel === '') rel = '/index.html';
69
+ const full = path.resolve(PUBLIC_DIR, '.' + rel);
70
+ if (!full.startsWith(PUBLIC_DIR + path.sep)) return null;
71
+ try {
72
+ const st = fs.statSync(full);
73
+ if (!st.isFile()) return null;
74
+ return { body: fs.readFileSync(full), type: contentType(full), mtime: st.mtimeMs };
75
+ } catch { return null; }
76
+ }
77
+
78
+ function contentType(file) {
79
+ const ext = path.extname(file).toLowerCase();
80
+ return ({
81
+ '.html': 'text/html; charset=utf-8',
82
+ '.css': 'text/css; charset=utf-8',
83
+ '.js': 'text/javascript; charset=utf-8',
84
+ '.json': 'application/json; charset=utf-8',
85
+ '.svg': 'image/svg+xml',
86
+ '.ico': 'image/x-icon',
87
+ // The self-hosted faces under web-public/fonts. Without these the browser
88
+ // receives `application/octet-stream` and refuses to apply the font.
89
+ '.woff2': 'font/woff2',
90
+ '.woff': 'font/woff',
91
+ '.ttf': 'font/ttf',
92
+ })[ext] || 'application/octet-stream';
93
+ }
94
+
95
+ function send(res, code, body, headers = {}) {
96
+ const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
97
+ res.writeHead(code, { 'content-length': buf.length, ...headers });
98
+ res.end(buf);
99
+ }
100
+
101
+ function json(res, code, obj) {
102
+ send(res, code, JSON.stringify(obj), { 'content-type': 'application/json; charset=utf-8' });
103
+ }
104
+
105
+ async function readBody(req) {
106
+ return new Promise((resolve, reject) => {
107
+ let size = 0;
108
+ const chunks = [];
109
+ req.on('data', (d) => {
110
+ size += d.length;
111
+ if (size > MAX_BODY) { reject(new Error('body too large')); req.destroy(); return; }
112
+ chunks.push(d);
113
+ });
114
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
115
+ req.on('error', reject);
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Start the web server.
121
+ *
122
+ * hub SessionHub — the shared session state
123
+ * bindIp string — address to listen on (default 127.0.0.1)
124
+ * port number — 0 asks the OS for a free port
125
+ * token string — pre-set token (tests); otherwise generated
126
+ * storeDir string — session store for /api/sessions (default: the user's)
127
+ * onNotice (msg, kind) => void — surface startup info in the TUI transcript
128
+ * @returns {Promise<{server, port, host, url, token, close}>}
129
+ */
130
+ export async function startWebServer(opts = {}) {
131
+ const hub = opts.hub;
132
+ if (!hub) throw new Error('startWebServer: hub is required');
133
+ const bindIp = opts.bindIp || '127.0.0.1';
134
+ const token = opts.token || newToken();
135
+ const loopback = isLoopback(bindIp);
136
+ // Hosts we accept in Origin. A non-loopback bind may be reached under any name
137
+ // the client used, so the listen port is matched rather than a fixed hostname.
138
+ let expectedHosts = [];
139
+ const onNotice = typeof opts.onNotice === 'function' ? opts.onNotice : () => {};
140
+
141
+ const server = http.createServer((req, res) => {
142
+ handle(req, res).catch((e) => {
143
+ try { json(res, 500, { ok: false, error: (e && e.message) || String(e) }); } catch { /* headers sent */ }
144
+ });
145
+ });
146
+
147
+ // Live events over WebSocket (RFC 6455, zero deps). A client that upgrades to
148
+ // /api/ws gets the current snapshot, then every hub event pushed as a JSON
149
+ // line. Auth is checked in onUpgrade (cookie), so an unauthorised handshake is
150
+ // rejected before a connection is made.
151
+ const wsTargets = new Set();
152
+ const wsBroadcast = (obj) => {
153
+ const line = JSON.stringify(obj);
154
+ for (const conn of wsTargets) { try { conn.send(line); } catch { /* closing */ } }
155
+ };
156
+ const wsOff = hub.subscribe(wsBroadcast);
157
+ attachWebSocket(server, {
158
+ onUpgrade: (req) => {
159
+ // The browser carries the session cookie (standalone /web), while the
160
+ // daemon reverse-proxy injects `Authorization: Bearer <session token>` —
161
+ // isAuthed reads both, so a handshake through either path is accepted.
162
+ if (!isAuthed(req, token, COOKIE)) return 'unauthorized';
163
+ return null;
164
+ },
165
+ onConnection: (conn) => {
166
+ wsTargets.add(conn);
167
+ conn.send(JSON.stringify({ type: 'snapshot', ...hub.snapshot() }));
168
+ // Bidirectional: a client text frame carries {action, args} and is run
169
+ // through the SAME hub dispatch the HTTP /api/action path uses. This is
170
+ // how the UI sends a message WITHOUT waiting on a blocking HTTP round
171
+ // trip. The handler is run side-by-side (not awaited here) so the socket
172
+ // never stalls on a long action; the result, if the caller wants one,
173
+ // is pushed back as a matching frame below.
174
+ conn.onMessage = (text) => {
175
+ let req;
176
+ try { req = JSON.parse(text); } catch { return; }
177
+ const name = String((req && req.action) || '');
178
+ if (!name) return;
179
+ // Same allowlist as the HTTP path: a hub handler is not exposed just by
180
+ // existing.
181
+ if (!ALLOWED_ACTIONS.includes(name)) {
182
+ try { conn.send(JSON.stringify({ type: 'ack', action: name, ok: false, error: `unknown action: ${name}` })); } catch {}
183
+ return;
184
+ }
185
+ const args = Array.isArray(req && req.args) ? req.args : [];
186
+ Promise.resolve(hub.call(name, ...args))
187
+ .then((r) => {
188
+ try { conn.send(JSON.stringify({ type: 'ack', action: name, ...(r || {}) })); } catch {}
189
+ })
190
+ .catch((err) => {
191
+ try { conn.send(JSON.stringify({ type: 'ack', action: name, ok: false, error: (err && err.message) || String(err) })); } catch {}
192
+ });
193
+ };
194
+ },
195
+ onClose: (conn) => {
196
+ wsTargets.delete(conn);
197
+ },
198
+ });
199
+ async function handle(req, res) {
200
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
201
+ const p = url.pathname;
202
+ const method = (req.method || 'GET').toUpperCase();
203
+
204
+ // --- unauthenticated ------------------------------------------------------
205
+ // The login page and its assets must load before a token exists, and the login
206
+ // script imports the message table — so i18n.js is served here too. NONE of
207
+ // these files contain session data: they are the page, its styles, its script
208
+ // and the UI strings.
209
+ if (p === '/login' || p === '/login.html' || p === '/login.css'
210
+ || p === '/login.js' || p === '/i18n.js'
211
+ // The self-hosted faces. Neither file carries session data, and the login
212
+ // page cannot render without them.
213
+ || p === '/fonts.css' || p.startsWith('/fonts/')) {
214
+ const asset = readAsset(p === '/login' ? '/login.html' : p);
215
+ if (!asset) return send(res, 404, t(pickLang(req, url), 'http.notFound'));
216
+ return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
217
+ }
218
+ // Health check: reveals only that a server exists. Used by the TUI to confirm
219
+ // the bind worked, and by the user to test reachability from another machine.
220
+ if (p === '/healthz') return json(res, 200, { ok: true, service: 'hncode-web' });
221
+
222
+
223
+ // --- token exchange ------------------------------------------------------
224
+ // POST /api/login { token } -> sets an HttpOnly session cookie. The token is
225
+ // accepted in the BODY, never the query string, so it does not land in the
226
+ // server log or the browser history.
227
+ if (p === '/api/login' && method === 'POST') {
228
+ let body = {};
229
+ try { body = JSON.parse(await readBody(req) || '{}'); } catch { /* handled below */ }
230
+ if (!safeEqual(String(body.token || ''), token)) {
231
+ await new Promise((r) => setTimeout(r, LOGIN_DELAY_MS));
232
+ return json(res, 401, { ok: false, error: 'invalid token' });
233
+ }
234
+ const secure = !loopback && String(req.headers['x-forwarded-proto'] || '') === 'https';
235
+ const cookie = [
236
+ `${COOKIE}=${encodeURIComponent(token)}`,
237
+ 'HttpOnly',
238
+ 'SameSite=Strict',
239
+ 'Path=/',
240
+ // Not `Secure` on loopback: the browser would refuse to send it over http
241
+ // and login would appear to succeed then fail on every later request.
242
+ secure ? 'Secure' : '',
243
+ `Max-Age=${60 * 60 * 12}`,
244
+ ].filter(Boolean).join('; ');
245
+ return send(res, 200, JSON.stringify({ ok: true }), {
246
+ 'content-type': 'application/json; charset=utf-8',
247
+ 'set-cookie': cookie,
248
+ });
249
+ }
250
+
251
+ // --- everything below requires the token ---------------------------------
252
+ if (!isAuthed(req, token, COOKIE)) {
253
+ if (p.startsWith('/api/')) return json(res, 401, { ok: false, error: 'unauthorized' });
254
+ // An unauthenticated page request goes to the login form, not a 401 body.
255
+ if (method === 'GET') {
256
+ res.writeHead(302, { location: '/login' });
257
+ return res.end();
258
+ }
259
+ return send(res, 401, t(pickLang(req, url), 'http.unauthorized'));
260
+ }
261
+
262
+ // CSRF: a state-changing request must come from a page we served. Same-origin
263
+ // requests carry a matching Origin; a missing Origin means a non-browser
264
+ // client (curl, a test) that already proved it holds the token.
265
+ if (method !== 'GET' && method !== 'HEAD') {
266
+ const ok = sameOriginOk(req, expectedHosts);
267
+ if (ok === false) return json(res, 403, { ok: false, error: 'cross-origin request refused' });
268
+ }
269
+
270
+ // --- API ------------------------------------------------------------------
271
+ if (p === '/api/state') return json(res, 200, { ok: true, ...hub.snapshot() });
272
+
273
+ // The session list for the left rail. `listSavedSessions` already powers the
274
+ // daemon's landing page, so a session page shows the same names in the same
275
+ // order whether it was opened standalone or through the daemon. Entries are
276
+ // metadata only — no transcript — so this stays cheap to poll.
277
+ if (p === '/api/sessions') {
278
+ const saved = listSavedSessions(opts.storeDir);
279
+ // Which row is THIS page. It may not be on disk yet (a brand-new session
280
+ // that has not been saved), so the current id is reported separately
281
+ // instead of being inferred from the list.
282
+ const current = String((hub.snapshot().status || {}).session || '');
283
+ return json(res, 200, { ok: true, current, sessions: saved });
284
+ }
285
+
286
+
287
+ if (p === '/api/events') return sse(req, res, hub);
288
+
289
+ if (p === '/api/action' && method === 'POST') {
290
+ let body = {};
291
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch {
292
+ return json(res, 400, { ok: false, error: 'invalid JSON body' });
293
+ }
294
+ const name = String(body.action || '');
295
+ const args = Array.isArray(body.args) ? body.args : [];
296
+ // The security boundary: only these may be invoked from a browser. Kept as
297
+ // an explicit list rather than derived from `hub.actions`, so registering a
298
+ // handler on the hub is never enough on its own to expose it over HTTP.
299
+ const ALLOWED = ALLOWED_ACTIONS;
300
+ if (!ALLOWED.includes(name)) return json(res, 400, { ok: false, error: `unknown action: ${name}` });
301
+ const result = await hub.call(name, ...args);
302
+ return json(res, result.ok ? 200 : 409, result);
303
+ }
304
+
305
+ // A standalone /web serves exactly ONE session (this process's). The left
306
+ // rail still links to `/s/<id>/` so the SAME page markup works behind the
307
+ // daemon; standalone those links have no route and 404'd. Redirect them back
308
+ // to the root, which is this session's page.
309
+ if (p.startsWith('/s/')) {
310
+ res.writeHead(302, { location: '/' });
311
+ return res.end();
312
+ }
313
+
314
+ // --- static client ---------------------------------------------------------
315
+ if (method === 'GET' || method === 'HEAD') {
316
+ const asset = readAsset(p);
317
+ if (!asset) return send(res, 404, t(pickLang(req, url), 'http.notFound'));
318
+ return send(res, 200, method === 'HEAD' ? '' : asset.body, {
319
+ 'content-type': asset.type,
320
+ // The client is a handful of files served from disk; a no-store keeps the
321
+ // browser from running a stale UI against a newer server (the mismatch
322
+ // that makes a UI look broken after an update).
323
+ 'cache-control': 'no-store',
324
+ });
325
+ }
326
+ return send(res, 405, t(pickLang(req, url), 'http.badMethod'));
327
+ }
328
+
329
+ // --- Server-Sent Events -----------------------------------------------------
330
+ function sse(req, res, hubRef) {
331
+ res.writeHead(200, {
332
+ 'content-type': 'text/event-stream; charset=utf-8',
333
+ 'cache-control': 'no-store',
334
+ connection: 'keep-alive',
335
+ // Without this a proxy may buffer the whole stream and the UI looks frozen.
336
+ 'x-accel-buffering': 'no',
337
+ });
338
+ const write = (obj) => {
339
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch { /* closing */ }
340
+ };
341
+ // A late subscriber needs the current view FIRST, then the live stream.
342
+ write({ type: 'snapshot', ...hubRef.snapshot() });
343
+ const off = hubRef.subscribe(write);
344
+ // Comment frames keep the connection alive through idle proxies and let the
345
+ // server notice a dead peer on its own.
346
+ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* closing */ } }, 20000);
347
+ const done = () => { clearInterval(ping); off(); try { res.end(); } catch { /* already gone */ } };
348
+ req.on('close', done);
349
+ req.on('error', done);
350
+ }
351
+
352
+ // --- listen -----------------------------------------------------------------
353
+ const port = await new Promise((resolve, reject) => {
354
+ server.once('error', reject);
355
+ server.listen(opts.port == null ? 0 : opts.port, bindIp, () => resolve(server.address().port));
356
+ }).catch((e) => {
357
+ throw new Error(`could not bind ${bindIp}:${opts.port || 0} — ${e.message}`);
358
+ });
359
+
360
+ const hostForUrl = bindIp.includes(':') ? `[${bindIp}]` : bindIp;
361
+ const url = `http://${hostForUrl}:${port}/`;
362
+ expectedHosts = [`${bindIp}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`];
363
+ if (bindIp === '0.0.0.0' || bindIp === '::') expectedHosts.push(`0.0.0.0:${port}`);
364
+
365
+ return {
366
+ server,
367
+ port,
368
+ host: bindIp,
369
+ url,
370
+ token,
371
+ loopback,
372
+ close() {
373
+ if (typeof wsOff === 'function') { try { wsOff(); } catch { /* best-effort */ } }
374
+ for (const conn of wsTargets) { try { conn.close(); } catch { /* already gone */ } }
375
+ try { server.close(); } catch { /* already closed */ }
376
+ },
377
+ };
378
+ }
379
+
380
+ export { PUBLIC_DIR };