@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,98 @@
1
+ // Authentication for the local web UI.
2
+ //
3
+ // THREAT MODEL, stated plainly because it decides every choice here: the server
4
+ // binds to a local address by default and serves one user's coding session — the
5
+ // transcript, the file contents it read, and the ability to RUN COMMANDS as that
6
+ // user. So the bar is not "keep out a determined attacker"; it is:
7
+ // * a page the user visits in another tab must not be able to talk to it
8
+ // (same-origin rules do not apply to `fetch` from a random site without CORS,
9
+ // but a form POST or a `<img>`/websocket still reaches it),
10
+ // * another user on the same machine must not read the transcript,
11
+ // * a leaked URL pasted into a chat must not be enough on its own.
12
+ //
13
+ // What that buys:
14
+ // * a 256-bit TOKEN, generated per process, required on every request;
15
+ // * constant-time comparison, so a token cannot be guessed byte by byte;
16
+ // * a same-origin check on state-changing requests (Origin/Referer must match
17
+ // the bound host), which is the standard defence against a cross-site POST;
18
+ // * HttpOnly + SameSite=Strict session cookie after a one-time token exchange,
19
+ // so the token does not have to sit in the page's JS or in a URL.
20
+ //
21
+ // What it deliberately does NOT do: TLS (the transport is loopback), user
22
+ // accounts, or rate limiting beyond a small login delay. Binding to a non-loopback
23
+ // address is allowed (/web 0.0.0.0) but is opt-in and warned about, because then
24
+ // the token is the ONLY thing between the network and a shell.
25
+
26
+ import crypto from 'node:crypto';
27
+
28
+ // 32 bytes of entropy, base64url. Long enough that guessing is hopeless, short
29
+ // enough to paste from a terminal.
30
+ export function newToken() {
31
+ return crypto.randomBytes(32).toString('base64url');
32
+ }
33
+
34
+ /**
35
+ * Constant-time string compare. `crypto.timingSafeEqual` throws on a length
36
+ * mismatch, which would itself leak the length — so both sides are hashed first,
37
+ * making every comparison the same length.
38
+ */
39
+ export function safeEqual(a, b) {
40
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
41
+ const ha = crypto.createHash('sha256').update(a).digest();
42
+ const hb = crypto.createHash('sha256').update(b).digest();
43
+ return crypto.timingSafeEqual(ha, hb);
44
+ }
45
+
46
+ /** Loopback check — decides whether binding needs a warning. */
47
+ export function isLoopback(addr) {
48
+ const a = String(addr || '').trim().toLowerCase();
49
+ if (a === 'localhost' || a === '::1' || a === '[::1]') return true;
50
+ if (/^127(\.\d{1,3}){3}$/.test(a)) return true;
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Is this request allowed to CHANGE something? A browser sends `Origin` on every
56
+ * state-changing cross-origin request (and on same-origin POSTs too), so a
57
+ * mismatch means the request came from a page we do not control. Requests with no
58
+ * Origin at all are allowed only when they carry the bearer token — that is a
59
+ * script (curl), not a browser being abused by another site.
60
+ */
61
+ export function sameOriginOk(req, expectedHosts) {
62
+ const origin = req.headers.origin || req.headers.referer;
63
+ if (!origin) return null; // caller decides (token-only scripts)
64
+ let host;
65
+ try { host = new URL(origin).host; } catch { return false; }
66
+ return expectedHosts.includes(host);
67
+ }
68
+
69
+ /** Parse `Cookie: a=1; b=2`. Returns {} for anything malformed. */
70
+ export function parseCookies(header) {
71
+ const out = {};
72
+ const s = String(header || '');
73
+ if (!s) return out;
74
+ for (const part of s.split(';')) {
75
+ const i = part.indexOf('=');
76
+ if (i < 0) continue;
77
+ const k = part.slice(0, i).trim();
78
+ if (!k) continue;
79
+ out[k] = decodeURIComponent(part.slice(i + 1).trim());
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * The request's credential, from either place:
86
+ * * `Authorization: Bearer <token>` — scripts, curl, the CLI;
87
+ * * the `hncode_web` cookie — the browser, set by POST /api/login.
88
+ */
89
+ export function requestSecret(req, cookieName = 'hncode_web') {
90
+ const auth = String(req.headers.authorization || '');
91
+ if (auth.toLowerCase().startsWith('bearer ')) return auth.slice(7).trim();
92
+ const cookies = parseCookies(req.headers.cookie);
93
+ return cookies[cookieName] || '';
94
+ }
95
+
96
+ export function isAuthed(req, token, cookieName) {
97
+ return safeEqual(requestSecret(req, cookieName), token);
98
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // Entry point for the global web UI daemon.
3
+ //
4
+ // Normally spawned by web-daemon-client.js from a TUI session; it can also be
5
+ // run by hand (`node src/web-daemon-cli.js`) to keep the UI up without a TUI.
6
+ //
7
+ // Everything it would have printed to a terminal goes to the log file instead,
8
+ // because a daemon started detached has no terminal to report to — that log is
9
+ // the only place a bind failure can be read afterwards.
10
+
11
+ import { startWebDaemon, defaultDaemonPipe } from './web-daemon.js';
12
+ import { resolveConfig } from './config.js';
13
+
14
+ const cfg = resolveConfig();
15
+
16
+ async function main() {
17
+ const host = process.env.HNCODE_WEB_HOST || cfg.webHost || '127.0.0.1';
18
+ const port = Number(process.env.HNCODE_WEB_PORT ?? (cfg.webPort == null ? 8765 : cfg.webPort));
19
+ const pipePath = defaultDaemonPipe();
20
+
21
+ let daemon;
22
+ try {
23
+ daemon = await startWebDaemon({ host, port, pipePath });
24
+ } catch (e) {
25
+ // The port is the usual failure: something else already holds it. Say WHICH
26
+ // address and how to change it, because a detached daemon cannot ask.
27
+ if (e && (e.code === 'EADDRINUSE' || /EADDRINUSE/.test(String(e.message)))) {
28
+ process.stderr.write(
29
+ `[web-daemon] port ${port} on ${host} is already in use.\n`
30
+ + '[web-daemon] Choose another in config.toml: web_port = 8766\n'
31
+ + '[web-daemon] or for this run: HNCODE_WEB_PORT=8766\n',
32
+ );
33
+ process.exit(1);
34
+ }
35
+ throw e;
36
+ }
37
+
38
+ if (daemon.pipeError) {
39
+ // Another daemon already owns the pipe. Not an error for the caller — this
40
+ // one simply has nothing to add, so it exits and the TUI keeps using the
41
+ // existing daemon.
42
+ process.stderr.write(`[web-daemon] another daemon already owns ${pipePath}; exiting\n`);
43
+ daemon.close();
44
+ process.exit(0);
45
+ }
46
+
47
+ process.stdout.write(
48
+ `[web-daemon] listening on ${daemon.url} (pid ${process.pid})\n`
49
+ + `[web-daemon] pipe: ${daemon.pipePath}\n`,
50
+ );
51
+
52
+ const shutdown = () => {
53
+ try { daemon.close(); } catch { /* already closed */ }
54
+ process.exit(0);
55
+ };
56
+ process.on('SIGINT', shutdown);
57
+ process.on('SIGTERM', shutdown);
58
+ }
59
+
60
+ main().catch((e) => {
61
+ process.stderr.write(`[web-daemon] fatal: ${(e && e.stack) || e}\n`);
62
+ process.exit(1);
63
+ });
@@ -0,0 +1,203 @@
1
+ // TUI side of the global web UI.
2
+ //
3
+ // The user's model is: "/web" opens ONE web UI for this machine. If a daemon is
4
+ // already running, this session simply registers with it and does NOT start a
5
+ // second server — the existing one already holds the fixed port and token, and a
6
+ // second one could not bind that port anyway.
7
+ //
8
+ // So the flow is: probe -> (spawn if absent) -> register -> heartbeat. The
9
+ // session's own per-session server (web.js) still exists, because that is where
10
+ // the transcript and the actions live; the daemon only routes to it.
11
+
12
+ import net from 'node:net';
13
+ import path from 'node:path';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import cp from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { resolveConfig } from './config.js';
19
+ import { HEARTBEAT_MS, defaultDaemonPipe } from './web-daemon.js';
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+
23
+ /** Send one line-JSON request over the daemon pipe. Never throws. */
24
+ export function daemonCall(req, opts = {}) {
25
+ const pipePath = opts.pipePath || defaultDaemonPipe();
26
+ const timeoutMs = opts.timeoutMs || 2000;
27
+ return new Promise((resolve) => {
28
+ let done = false, sock = null;
29
+ const finish = (r) => {
30
+ if (done) return;
31
+ done = true;
32
+ clearTimeout(timer);
33
+ try { if (sock) sock.destroy(); } catch { /* already gone */ }
34
+ resolve(r);
35
+ };
36
+ const timer = setTimeout(() => finish({ ok: false, error: 'timeout' }), timeoutMs);
37
+ try { sock = net.connect(pipePath); }
38
+ catch (e) { finish({ ok: false, error: e.message }); return; }
39
+ let buf = '';
40
+ sock.on('connect', () => {
41
+ try { sock.write(JSON.stringify(req) + '\n'); }
42
+ catch (e) { finish({ ok: false, error: e.message }); }
43
+ });
44
+ sock.on('data', (d) => {
45
+ buf += d.toString('utf8');
46
+ const i = buf.indexOf('\n');
47
+ if (i < 0) return;
48
+ let parsed = null;
49
+ try { parsed = JSON.parse(buf.slice(0, i)); } catch { /* malformed */ }
50
+ finish(parsed || { ok: false, error: 'bad reply' });
51
+ });
52
+ sock.on('error', (e) => finish({ ok: false, error: e.message }));
53
+ });
54
+ }
55
+
56
+ /** Is a daemon already listening on the pipe? */
57
+ export async function daemonAlive(opts = {}) {
58
+ const r = await daemonCall({ op: 'ping' }, opts);
59
+ return r && r.ok === true;
60
+ }
61
+
62
+ /**
63
+ * The daemon's own address, as it reports it. Use this instead of assuming the
64
+ * configured port: when the config says `web_port = 0` the OS picks one, and a
65
+ * guessed URL would point nowhere.
66
+ */
67
+ export async function daemonInfo(opts = {}) {
68
+ const r = await daemonCall({ op: 'ping' }, opts);
69
+ if (!r || !r.ok) return null;
70
+ const host = r.host || '127.0.0.1';
71
+ return { host, port: r.port, sessions: r.sessions, url: `http://${host}:${r.port}/` };
72
+ }
73
+
74
+ /**
75
+ * Ask the daemon to shut down and release its port.
76
+ *
77
+ * Needed because the daemon is started DETACHED on purpose — `child.kill()` from
78
+ * the spawning process does not reach it once that process has moved on (which is
79
+ * what left orphaned daemons behind in testing). This is the supported way to
80
+ * stop one from outside.
81
+ */
82
+ export async function stopDaemon(opts = {}) {
83
+ const r = await daemonCall({ op: 'shutdown' }, opts);
84
+ return !!(r && r.ok);
85
+ }
86
+
87
+ /**
88
+ * Start the daemon as a DETACHED process.
89
+ *
90
+ * Detached and `unref`ed on purpose: the daemon must outlive the terminal that
91
+ * happened to start it. Its own idle rule (exit once the last session
92
+ * deregisters) is what stops it from lingering forever.
93
+ *
94
+ * Returns once it answers on the pipe, or throws with what went wrong — a silent
95
+ * failure here would leave /web reporting success with nothing serving it.
96
+ */
97
+ export async function spawnDaemon(opts = {}) {
98
+ const cfg = resolveConfig();
99
+ const pipePath = opts.pipePath || defaultDaemonPipe();
100
+ // An explicit host (from `/web <ip>`) wins over the config value, so the same
101
+ // command that asks for a network-reachable session server also binds the
102
+ // daemon to the network. Without this the daemon kept 127.0.0.1 and the
103
+ // browser's connection to the listed URL died with "no data sent".
104
+ const daemonHost = opts.host || cfg.webHost || '127.0.0.1';
105
+ const logFile = path.join(os.homedir(), '.hncode', 'web-daemon.log');
106
+ try { fs.mkdirSync(path.dirname(logFile), { recursive: true }); } catch { /* exists */ }
107
+ let out = 'ignore';
108
+ try { out = fs.openSync(logFile, 'a'); } catch { out = 'ignore'; }
109
+
110
+ const entry = path.join(HERE, 'web-daemon-cli.js');
111
+ if (!fs.existsSync(entry)) throw new Error(`daemon entry not found: ${entry}`);
112
+
113
+ const child = cp.spawn(process.execPath, [entry], {
114
+ detached: true,
115
+ stdio: ['ignore', out, out],
116
+ windowsHide: true,
117
+ env: {
118
+ ...process.env,
119
+ HNCODE_WEB_PIPE: pipePath,
120
+ HNCODE_WEB_HOST: daemonHost,
121
+ HNCODE_WEB_PORT: String(cfg.webPort == null ? 8765 : cfg.webPort),
122
+ },
123
+ });
124
+ child.unref();
125
+
126
+ // Wait for it to answer. A bind failure (port taken by something else) shows up
127
+ // here as a timeout, which is the honest report: the daemon is not serving.
128
+ const deadline = Date.now() + (opts.waitMs || 8000);
129
+ let lastErr = 'did not start';
130
+ while (Date.now() < deadline) {
131
+ await new Promise((r) => setTimeout(r, 120));
132
+ const ping = await daemonCall({ op: 'ping' }, { pipePath, timeoutMs: 800 });
133
+ if (ping && ping.ok) return { spawned: true, child, pipePath, logFile };
134
+ if (ping && ping.error && ping.error !== 'timeout') lastErr = ping.error;
135
+ }
136
+ throw new Error(`web daemon did not start (${lastErr}); see ${logFile}`);
137
+ }
138
+
139
+ /**
140
+ * Attach this session to the global daemon.
141
+ *
142
+ * @param {object} info { id, port, token, title, workspace }
143
+ * `port`/`token` describe THIS session's own web.js server, which the daemon
144
+ * proxies to. Without them the daemon can only serve the session read-only.
145
+ * @returns {Promise<{ ok, spawned, reused, pipePath, heartbeat }>}
146
+ */
147
+ export async function attachToDaemon(info, opts = {}) {
148
+ const pipePath = opts.pipePath || defaultDaemonPipe();
149
+ let spawned = false;
150
+
151
+ if (!(await daemonAlive({ pipePath }))) {
152
+ await spawnDaemon({ pipePath, waitMs: opts.waitMs, host: info.host });
153
+ spawned = true;
154
+ }
155
+
156
+ const reg = await daemonCall({
157
+ op: 'register',
158
+ id: info.id,
159
+ port: info.port,
160
+ token: info.token,
161
+ title: info.title || '',
162
+ workspace: info.workspace || '',
163
+ pid: process.pid,
164
+ }, { pipePath, timeoutMs: 4000 });
165
+
166
+ if (!reg || !reg.ok) {
167
+ throw new Error(`could not register with the web daemon: ${(reg && reg.error) || 'no reply'}`);
168
+ }
169
+
170
+ // Heartbeat so a killed process is not shown as live forever. `unref` keeps the
171
+ // timer from holding the event loop open on its own.
172
+ const hb = setInterval(() => {
173
+ daemonCall({ op: 'touch', id: info.id }, { pipePath, timeoutMs: 1500 }).catch(() => {});
174
+ }, Math.max(5000, HEARTBEAT_MS));
175
+ if (hb.unref) hb.unref();
176
+
177
+ const detach = () => {
178
+ clearInterval(hb);
179
+ daemonCall({ op: 'unregister', id: info.id }, { pipePath, timeoutMs: 1500 }).catch(() => {});
180
+ };
181
+
182
+ // Ask the daemon where it actually is, rather than trusting the configured
183
+ // port: `web_port = 0` means the OS chose one.
184
+ const addr = await daemonInfo({ pipePath, timeoutMs: 2000 });
185
+ const host = (addr && addr.host) || '127.0.0.1';
186
+ const port = (addr && addr.port) != null ? addr.port : null;
187
+ const url = port == null ? null : `http://${host}:${port}/s/${encodeURIComponent(info.id)}/`;
188
+
189
+ return {
190
+ ok: true,
191
+ spawned,
192
+ reused: !spawned,
193
+ pipePath,
194
+ host,
195
+ port,
196
+ // The per-SESSION URL the user should open. The daemon's root lists every
197
+ // session, so both are reported and the TUI can print whichever it wants.
198
+ url,
199
+ rootUrl: port == null ? null : `http://${host}:${port}/`,
200
+ heartbeat: hb,
201
+ detach,
202
+ };
203
+ }