@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
@@ -0,0 +1,407 @@
1
+ // Session hub — the single source of truth shared by the TUI and the web UI.
2
+ //
3
+ // WHY THIS EXISTS: the TUI is the original owner of the transcript, the agent
4
+ // events and the input. The web UI must see exactly what the TUI sees AND be able
5
+ // to drive the same session (send prompts, run slash commands, interrupt, answer
6
+ // approvals). Without one shared object the two would drift: a web-sent prompt
7
+ // would never appear in the terminal, and a TUI-sent one would never reach the
8
+ // browser.
9
+ //
10
+ // Shape: the hub keeps the durable view (transcript rows, status, tasks, todos)
11
+ // and an append-only event log. Both front-ends are SUBSCRIBERS:
12
+ // * `emit(event)` — anything happening in the session. Broadcast to every
13
+ // subscriber; the hub also folds it into its own view so a late subscriber
14
+ // (a browser opened mid-turn) can render the whole state on connect.
15
+ // * `pushRow(row)` — a transcript row was appended by the TUI. Goes out as a
16
+ // `row` event AND into the durable view.
17
+ // Actions (the user's intent) travel the OTHER way through injected handlers, so
18
+ // the web never touches the agent directly — it calls the same functions the
19
+ // keyboard does. That is what keeps behaviour identical in both front-ends.
20
+
21
+ // How many transcript rows / events to keep for a reconnecting client. The
22
+ // transcript is bounded because a long session with big tool results would
23
+ // otherwise grow without limit in a process that may run for days; the browser
24
+ // gets the tail and can scroll back no further than that on a fresh connect.
25
+ //
26
+ // NOTE: this is a MEMORY bound for a long-running process, not a rendering one.
27
+ // The browser is sent everything up to here and the windowed list (vlist.js)
28
+ // keeps only the visible slice in the DOM, so the two limits are independent.
29
+ const MAX_ROWS = 4000;
30
+ const MAX_EVENTS = 2000;
31
+ const SNAPSHOT_ROWS = 0; // 0 = no cap; see snapshot()
32
+
33
+ /**
34
+ * Status fields that animate the Working row and change on EVERY frame a turn is
35
+ * running. They must not count as a "status change" — otherwise a long turn
36
+ * broadcasts the whole status (and the browser rebuilds every card pane) once per
37
+ * frame. The pulse is drawn client-side; these ride along when a real change
38
+ * broadcasts.
39
+ */
40
+ const ANIMATION_FIELDS = new Set(['spin', 'workMsg', 'pulseStart']);
41
+ // Which fields of a transcript row the browser actually renders. The fingerprint
42
+ // covers exactly these, so streaming text (a growing `text`) invalidates it while
43
+ // an unrelated mutation does not. Keeping the list here — rather than hashing the
44
+ // whole object — also keeps a large tool result from being stringified on every
45
+ // frame.
46
+ const ROW_FIELDS = [
47
+ 'role', 'text', 'toolName', 'pending', 'failed',
48
+ 'streamContent', 'liveOutput', 'phase', 'tokensBefore', 'tokensAfter',
49
+ 'instruction', 'card',
50
+ ];
51
+
52
+ function rowFingerprint(msg) {
53
+ let s = '';
54
+ for (const f of ROW_FIELDS) {
55
+ const v = msg[f];
56
+ if (v === undefined || v === null) { s += '|'; continue; }
57
+ if (typeof v === 'string') s += v.length + ':' + (v.length <= 64 ? v : '');
58
+ else if (typeof v === 'object') s += v === msg.card ? 'card:' + String(v.headline || '') : 'obj';
59
+ else s += String(v);
60
+ s += '|';
61
+ }
62
+ // Tool arguments drive the one-line summary, so a change there must be seen.
63
+ s += msg.toolArgs ? Object.keys(msg.toolArgs).join(',') + ':' + String(msg.toolArgs.command || msg.toolArgs.path || msg.toolArgs.pattern || '').length : '';
64
+ // The diff drives an expanded row's body.
65
+ s += '/' + (Array.isArray(msg.diff) ? msg.diff.length : 0);
66
+ return s;
67
+ }
68
+
69
+ /**
70
+ * Content equality for status fields. Scalars by value; arrays element-wise with a
71
+ * one-level object compare (the shape of a todo / task / file entry); other
72
+ * objects field-wise with a JSON fallback for anything nested deeper — exactly the
73
+ * shapes webStatus builds (`options` carries arrays of objects, `pending` carries
74
+ * option lists).
75
+ */
76
+ function sameValue(a, b) {
77
+ if (a === b) return true;
78
+ if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false;
79
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
80
+ if (Array.isArray(a)) {
81
+ if (a.length !== b.length) return false;
82
+ for (let i = 0; i < a.length; i++) {
83
+ if (a[i] === b[i]) continue;
84
+ if (!sameValue(a[i], b[i])) return false;
85
+ }
86
+ return true;
87
+ }
88
+ const ka = Object.keys(a);
89
+ const kb = Object.keys(b);
90
+ if (ka.length !== kb.length) return false;
91
+ for (const k of ka) {
92
+ const va = a[k];
93
+ const vb = b[k];
94
+ if (va === vb) continue;
95
+ if (va && vb && typeof va === 'object' && typeof vb === 'object') {
96
+ if (JSON.stringify(va) !== JSON.stringify(vb)) return false;
97
+ continue;
98
+ }
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ // The subset of a TUI message the browser needs. Built explicitly rather than
104
+ // spreading, so TUI-only bookkeeping (render caches, the arg-stream parser state,
105
+ // the hub id itself) never travels over the wire.
106
+ function snapshotRow(msg) {
107
+ const out = { role: msg.role || 'system', text: msg.text == null ? '' : String(msg.text) };
108
+ for (const k of ['toolName', 'pending', 'failed', 'streamContent', 'liveOutput',
109
+ 'phase', 'tokensBefore', 'tokensAfter', 'instruction']) {
110
+ if (msg[k] !== undefined) out[k] = msg[k];
111
+ }
112
+ // The two fields that can be unbounded. `text` is a finished tool result and
113
+ // `liveOutput` is a running command's stream; both are capped BEFORE the wire,
114
+ // because this snapshot travels as one SSE frame and the browser copies it into
115
+ // a fresh row object on every patch. 128 KiB matches the tool-result cap
116
+ // (`MAX_OUTPUT_BYTES`), so the browser sees the same content the terminal kept.
117
+ out.text = capText(out.text, MAX_ROW_TEXT_CHARS);
118
+ if (typeof out.liveOutput === 'string') {
119
+ out.liveOutput = capText(out.liveOutput, MAX_ROW_TEXT_CHARS);
120
+ }
121
+ // A tool's one-line summary needs its identifying argument, not the whole
122
+ // payload — a Write's `content` can be megabytes.
123
+ if (msg.toolArgs && typeof msg.toolArgs === 'object') {
124
+ const a = {};
125
+ for (const k of ['command', 'cmd', 'path', 'file_path', 'pattern', 'query', 'url', 'description', 'task_id']) {
126
+ if (typeof msg.toolArgs[k] === 'string') a[k] = String(msg.toolArgs[k]).slice(0, 400);
127
+ }
128
+ if (Object.keys(a).length) out.toolArgs = a;
129
+ }
130
+ if (Array.isArray(msg.diff)) out.diff = msg.diff.slice(0, 400);
131
+ if (msg.card && typeof msg.card === 'object') {
132
+ out.card = { phase: msg.card.phase, headline: msg.card.headline, detail: msg.card.detail };
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** Per-row character budget for the two fields that can grow without bound. */
138
+ const MAX_ROW_TEXT_CHARS = 128 * 1024;
139
+
140
+ /** Keep the TAIL of an over-long field — the part both front-ends show — and mark
141
+ what was dropped, so a truncated row is not mistaken for a short one. */
142
+ function capText(s, max) {
143
+ const str = String(s == null ? '' : s);
144
+ if (str.length <= max) return str;
145
+ return `...[${str.length - max} chars truncated]\n` + str.slice(str.length - max);
146
+ }
147
+
148
+
149
+
150
+ export class SessionHub {
151
+ constructor() {
152
+ this.rows = []; // durable transcript: { id, role, text, ... }
153
+ this.events = []; // ring of recent events, replayed on connect
154
+ this.subscribers = new Set();
155
+ this.seq = 0;
156
+ this.rowSeq = 0;
157
+ // hub row id -> last fingerprint, for syncChat's incremental diff.
158
+ this._rowFp = new Map();
159
+ // hub row id -> the row object in `this.rows`. syncChat runs on every paint
160
+ // frame, and looking a row up with Array.find() inside the per-message loop
161
+ // made it O(n^2): every token of a long reply walked the whole transcript. The
162
+ // index turns that lookup into a Map hit.
163
+ this._rowById = new Map();
164
+ // Filled in by the TUI once it owns these. A web action that has no handler
165
+ // reports a clear error instead of silently doing nothing.
166
+ this.actions = {
167
+ submit: null, // async (text) => void — same path as typing
168
+ dispatch: null, // async (cmd, arg) => void — slash commands
169
+ interrupt: null, // async () => void
170
+ approve: null, // async (id, ok) => void — answer an approval
171
+ answerQuestion: null,// async (id, answers) => void — AskUserQuestion
172
+ stopTask: null, // async (taskId) => void
173
+ setMode: null, // async (mode) => void
174
+ status: null, // () => object
175
+ shell: null, // async (cmd) => void — `!` passthrough
176
+ steer: null, // async (text) => void — inject into the running turn
177
+ editQueued: null, // async (text) => {text} — recall a queued msg for editing
178
+ dropQueued: null, // async (text) => void — remove a queued message
179
+ };
180
+ }
181
+
182
+ // ---- broadcasting ----------------------------------------------------------
183
+
184
+ subscribe(fn) {
185
+ this.subscribers.add(fn);
186
+ return () => this.subscribers.delete(fn);
187
+ }
188
+
189
+ /** Broadcast one event. Subscriber failures must never break the session. */
190
+ emit(event) {
191
+ const e = { ...event, seq: ++this.seq, at: Date.now() };
192
+ this.events.push(e);
193
+ if (this.events.length > MAX_EVENTS) this.events.splice(0, this.events.length - MAX_EVENTS);
194
+ for (const fn of this.subscribers) {
195
+ try { fn(e); } catch { /* one bad client must not kill the others */ }
196
+ }
197
+ return e;
198
+ }
199
+
200
+ /**
201
+ * Append a transcript row. `row` is the TUI's own message object (role/text/
202
+ * toolName/toolArgs/pending/failed/diff/…), so the two front-ends render the
203
+ * SAME data rather than two parallel models.
204
+ */
205
+ pushRow(row) {
206
+ const r = { ...row, id: row.id != null ? row.id : ++this.rowSeq };
207
+ if (row.id == null) r.id = this.rowSeq;
208
+ this.rows.push(r);
209
+ if (this.rows.length > MAX_ROWS) this.rows.splice(0, this.rows.length - MAX_ROWS);
210
+ this.emit({ type: 'row', row: r });
211
+ return r;
212
+ }
213
+
214
+ /** Mutate an existing row in place (streaming text, a tool finishing, …). */
215
+ patchRow(id, patch) {
216
+ const r = this.rows.find((x) => x.id === id);
217
+ if (!r) return null;
218
+ Object.assign(r, patch);
219
+ this.emit({ type: 'row_patch', id, patch });
220
+ return r;
221
+ }
222
+
223
+ /**
224
+ * Sync the hub's view of the transcript against the TUI's own `state.chat`.
225
+ *
226
+ * This is the bridge between the two front-ends, and it exists so the TUI does
227
+ * NOT have to announce every change. `state.chat` is mutated from half a dozen
228
+ * places (addChat plus direct pushes for streamed prose, tool rows, thinking
229
+ * blocks, the plan bubble, aborts) and one of them is a streaming hot path where
230
+ * an extra call per token would be felt. Instead the caller runs this once per
231
+ * paint: rows are matched by IDENTITY, so a pushed row is picked up on the next
232
+ * frame, and a mutated row is diffed by a cheap field fingerprint.
233
+ *
234
+ * Returns nothing; failures here must never affect rendering.
235
+ */
236
+ syncChat(chat) {
237
+ const list = Array.isArray(chat) ? chat : [];
238
+ const seen = new Set();
239
+ for (let i = 0; i < list.length; i++) {
240
+ const msg = list[i];
241
+ if (!msg || typeof msg !== 'object') continue;
242
+ // Bind the TUI's message object to a stable hub row id the first time we see
243
+ // it. A WeakMap would be cleaner but the TUI copies rows (spread) in a few
244
+ // paths, so the id lives on the message itself.
245
+ let id = msg._hubId;
246
+ if (id == null) {
247
+ id = ++this.rowSeq;
248
+ Object.defineProperty(msg, '_hubId', { value: id, enumerable: false, configurable: true, writable: true });
249
+ }
250
+ seen.add(id);
251
+ const fp = rowFingerprint(msg);
252
+ const known = this._rowFp.get(id);
253
+ if (known === undefined) {
254
+ const row = { ...snapshotRow(msg), id };
255
+ this.rows.push(row);
256
+ this._rowById.set(id, row);
257
+ this.emit({ type: 'row', row });
258
+ } else if (known !== fp) {
259
+ const target = this._rowById.get(id);
260
+ // The WHOLE row's renderable fields, not a text delta.
261
+ //
262
+ // A delta looked like an easy win — a streamed reply grows one token at a
263
+ // time, so shipping only the appended slice is O(n) instead of O(n^2) over a
264
+ // message. But it puts the sender and the receiver into a shared state
265
+ // machine: the receiver must hold exactly the text the sender assumed, and
266
+ // any divergence (a dropped frame, a reconnect mid-stream, an out-of-order
267
+ // batch) silently TRUNCATES the message instead of visibly failing. That is
268
+ // exactly what happened: replies rendered at a fraction of their real length
269
+ // in the browser while the terminal showed them whole.
270
+ //
271
+ // The full patch costs bandwidth, and this is a LOOPBACK server: the cost
272
+ // that matters is the browser's re-render, and `text` is assigned to one
273
+ // text node either way. Correctness over a micro-optimisation.
274
+ const patch = snapshotRow(msg);
275
+ if (target) Object.assign(target, patch);
276
+ this.emit({ type: 'row_patch', id, patch });
277
+ }
278
+ }
279
+ // The TUI rebuilds `state.chat` wholesale on /compact and on session resume.
280
+ // Anything it no longer holds is gone from the hub too, or the browser would
281
+ // keep showing rows the terminal has already dropped.
282
+ //
283
+ // The COUNT is compared first: it is almost always equal, so the common frame
284
+ // never walks the list. The previous version built two filtered copies of
285
+ // `rows` on every frame whether anything had been dropped or not.
286
+ if (this.rows.length !== seen.size) {
287
+ const kept = this.rows.filter((r) => seen.has(r.id));
288
+ if (kept.length !== this.rows.length) {
289
+ this.rows = kept;
290
+ this._rowById = new Map(kept.map((r) => [r.id, r]));
291
+ this.emit({ type: 'rows_reset', rows: this.rows });
292
+ }
293
+ }
294
+ if (this.rows.length > MAX_ROWS) {
295
+ const dropped = this.rows.splice(0, this.rows.length - MAX_ROWS);
296
+ for (const r of dropped) {
297
+ this._rowById.delete(r.id);
298
+ this._rowFp.delete(r.id);
299
+ }
300
+ }
301
+ }
302
+
303
+
304
+ /**
305
+ * Update non-transcript session state (context gauge, tasks, todos, model).
306
+ *
307
+ * Broadcasts ONLY when something actually changed. `setStatus` runs on EVERY TUI
308
+ * frame (~12/s), and `webStatus` rebuilds every array and the nested
309
+ * `options`/`pending` objects each time — so identical content always arrives
310
+ * behind FRESH references. A reference compare (`next[k] !== prev[k]`) therefore
311
+ * reported a change every frame, every frame was broadcast, and the browser
312
+ * re-rendered its whole status DOM — the card panes included — 12 times a second
313
+ * while nothing had changed. That idle churn is what showed up as a tab sitting
314
+ * at 800+ MB with typing that stuttered even when the model was not streaming.
315
+ *
316
+ * ANIMATION FIELDS are excluded from the change test. `spin`, `workMsg` and
317
+ * `pulseStart` drive the Working row's pulse and change on every frame while a
318
+ * turn runs, but the ONLY thing that needs them is the pulse — which the browser
319
+ * draws itself with requestAnimationFrame. Broadcasting on each of those changes
320
+ * meant a long turn rebuilt the whole status DOM (and every card pane) once per
321
+ * frame. They ride along in a broadcast a REAL change triggers, never by
322
+ * themselves.
323
+ *
324
+ * Scalars compare by value; objects compare one level deep (arrays element-wise,
325
+ * nested objects by field) with a JSON fallback for anything deeper — exactly
326
+ * the shapes webStatus builds.
327
+ */
328
+ statusChanged(next) {
329
+ const prev = this.status || {};
330
+ for (const k of Object.keys(next)) {
331
+ if (ANIMATION_FIELDS.has(k)) continue;
332
+ if (!sameValue(next[k], prev[k])) return true;
333
+ }
334
+ // A field present before but dropped from next is also a change.
335
+ for (const k of Object.keys(prev)) {
336
+ if (ANIMATION_FIELDS.has(k)) continue;
337
+ if (!(k in next)) return true;
338
+ }
339
+ return false;
340
+ }
341
+
342
+ setStatus(patch) {
343
+ const next = { ...(this.status || {}), ...patch };
344
+ if (!this.statusChanged(next)) return this.status;
345
+ this.status = next;
346
+ this.emit({ type: 'status', status: this.status });
347
+ return this.status;
348
+ }
349
+
350
+ /**
351
+ * Publish ONE status field as its own event, so a low-frequency change
352
+ * (e.g. a queued message) reaches the browser immediately instead of riding
353
+ * the next whole-status snapshot. Keeps the field in `this.status` too, so a
354
+ * freshly-connected client still gets it via snapshot().
355
+ */
356
+ pubStatusField(field, value) {
357
+ if (field == null) return this.status;
358
+ const next = { ...(this.status || {}), [field]: value };
359
+ if (!this.statusChanged(next)) return this.status;
360
+ this.status = next;
361
+ const e = { type: field, [field]: value };
362
+ this.emit(e);
363
+ return this.status;
364
+ }
365
+
366
+ /**
367
+ * Everything a freshly connected client needs to render the current view.
368
+ *
369
+ * The whole transcript is sent by default. `limit` exists for callers that want
370
+ * a bounded slice (it is a tail), but nothing forces a cap: the browser's
371
+ * windowed list renders only the visible rows, so a long transcript costs one
372
+ * payload rather than one DOM node per row. When a caller DOES cap it, `hidden`
373
+ * reports how much was left out.
374
+ */
375
+ snapshot(limit) {
376
+ const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : SNAPSHOT_ROWS;
377
+ const start = cap > 0 ? Math.max(0, this.rows.length - cap) : 0;
378
+ return {
379
+ rows: start > 0 ? this.rows.slice(start) : this.rows,
380
+ hidden: start,
381
+ totalRows: this.rows.length,
382
+ status: this.status || {},
383
+ events: this.events.slice(-200),
384
+ };
385
+ }
386
+
387
+ // ---- actions (web -> session) ---------------------------------------------
388
+ // Each returns { ok, error } so the HTTP layer can answer honestly instead of
389
+ // pretending an action ran.
390
+
391
+ async call(name, ...args) {
392
+ const fn = this.actions[name];
393
+ if (typeof fn !== 'function') {
394
+ return { ok: false, error: `action "${name}" is not available in this session` };
395
+ }
396
+ try {
397
+ // The handler's return value travels back to the caller. Most actions are
398
+ // fire-and-forget (submit, interrupt) and return undefined, but the ones
399
+ // that READ something — listProviders, getConfig — have nothing else to
400
+ // report with, and dropping it made them look like they did nothing.
401
+ const result = await fn(...args);
402
+ return { ok: true, result };
403
+ } catch (e) {
404
+ return { ok: false, error: (e && e.message) || String(e) };
405
+ }
406
+ }
407
+ }
package/src/session.js CHANGED
@@ -10,6 +10,9 @@ import crypto from 'node:crypto';
10
10
  const DEFAULT_DIR = () => path.join(os.homedir(), '.hncode', 'sessions');
11
11
  function dir(custom) { return custom || process.env.HNCODE_SESSIONS_DIR || DEFAULT_DIR(); }
12
12
 
13
+ /** The resolved session store directory. Exported so the web daemon can read it. */
14
+ export function sessionsDir(custom) { return dir(custom); }
15
+
13
16
  // ---- metadata cache --------------------------------------------------------
14
17
  // Enumerating sessions (listSessions / latestSession / --continue) re-read every
15
18
  // session file to grab its id/title/workspace/timestamps. With hundreds of
@@ -56,6 +59,34 @@ export function sessionFile(id, storeDir) {
56
59
  return path.join(dir(storeDir), `${id}.json`);
57
60
  }
58
61
 
62
+ /**
63
+ * One `session.messages` entry -> the transcript ROW(s) that mirror it.
64
+ *
65
+ * Returns an ARRAY because an assistant turn with tool calls becomes the
66
+ * assistant row PLUS one `tool` row per call. Both the TUI transcript and the
67
+ * Web UI's read-only view of a finished session render from these rows, so the
68
+ * expansion lives here — in the session data layer — rather than in either
69
+ * front-end. It also keeps the Web daemon off the TUI's import graph: the daemon
70
+ * needs this conversion but must NOT pull in the renderer, the agent and the LLM
71
+ * client to get it.
72
+ *
73
+ * Callers must FLATTEN the result (`flatMap`), not spread it into an object.
74
+ */
75
+ export function convRowFor(m) {
76
+ if (!m || typeof m !== 'object') return [];
77
+ const text = typeof m.content === 'string' ? m.content : '';
78
+ if (m.role === 'user') return [{ role: 'user', text }];
79
+ if (m.role === 'tool') return [{ role: 'tool_result', text }];
80
+ if (m.role === 'assistant') {
81
+ const rows = [{ role: 'assistant', text }];
82
+ for (const tc of (Array.isArray(m.toolCalls) ? m.toolCalls : [])) {
83
+ if (!tc || typeof tc !== 'object') continue;
84
+ rows.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
85
+ }
86
+ return rows;
87
+ }
88
+ return [];
89
+ }
59
90
  // FIX: build the serialized object with a FIXED key order so `updatedAt`
60
91
  // always lands BEFORE `messages`. readSessionMeta() stops scanning at the
61
92
  // `"messages"` key, so a session whose updatedAt sits after the transcript
package/src/term.js CHANGED
@@ -264,10 +264,15 @@ export function estimateTokens(text) {
264
264
  return Math.ceil(ascii / 4) + nonAscii;
265
265
  }
266
266
 
267
- // Estimate tokens for the whole request payload: system + tools + messages.
267
+ // Estimate tokens for the MESSAGES only. The system prompt and tool definitions are
268
+ // NOT included: `messages` usually already starts with the system prompt (the TUI
269
+ // builds `[{ role: 'system', content: sysText }, ...]`), so adding `cfg.systemPrompt`
270
+ // on top counted it twice — and the value in `cfg` is the RAW prompt, not the
271
+ // assembled `sysText` (calm/plan/swarm/AGENTS.md appended), so it was also the wrong
272
+ // number. Callers that want the whole request add it themselves via
273
+ // estimateRequestOverhead().
268
274
  export function estimateMessagesTokens(messages, cfg) {
269
- let total = estimateTokens(cfg && cfg.systemPrompt);
270
- if (Array.isArray(cfg && cfg.toolFilter)) total += cfg.toolFilter.length * 8; // rough tool-def overhead
275
+ let total = 0;
271
276
  for (const m of messages || []) {
272
277
  total += estimateTokens(m.role || '');
273
278
  const c = m.content;
@@ -279,6 +284,17 @@ export function estimateMessagesTokens(messages, cfg) {
279
284
  return total;
280
285
  }
281
286
 
287
+ // The overhead a request carries BEYOND its messages: the system prompt plus the
288
+ // tool definitions. Passed the assembled system text so the number matches what is
289
+ // actually sent.
290
+ export function estimateRequestOverhead(systemText, cfg) {
291
+ let total = estimateTokens(systemText || '');
292
+ const n = (cfg && Array.isArray(cfg.toolFilter)) ? cfg.toolFilter.length : 0;
293
+ if (n) total += n * 8; // rough per-tool-definition overhead
294
+ return total;
295
+ }
296
+
297
+
282
298
  // Expand TAB characters to spaces at 8-column tab stops. A terminal advances a
283
299
  // tab to the NEXT tab stop, but our width math (visualWidth) counts '\t' as 0,
284
300
  // so a row containing a tab is padded short and the terminal wraps it onto the