@hncode/hncode 0.4.1 → 0.5.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 (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. package/src/web-public/login.css +1 -1
package/src/session.js CHANGED
@@ -92,6 +92,13 @@ export function convRowFor(m) {
92
92
  // `"messages"` key, so a session whose updatedAt sits after the transcript
93
93
  // would report updatedAt = 0 and the session list / --continue would sort
94
94
  // wrongly (or pick the wrong session). Key order here is authoritative.
95
+ //
96
+ // `transcript` goes LAST — after `messages`, not merely after the metadata block.
97
+ // It is the one value here with no size limit: every persisted reasoning block is
98
+ // stored whole, and the head reader gives up after META_MAX_BYTES. Written ahead of
99
+ // `messages` a multi-megabyte `transcript` pushed the key out of reach and the session
100
+ // became unlistable, which is what made intact conversations vanish from /sessions.
101
+ // Behind `messages` the reader never has to cross it.
95
102
  function serializableSession(session) {
96
103
  const out = {};
97
104
  out.id = session.id;
@@ -100,15 +107,16 @@ function serializableSession(session) {
100
107
  out.model = session.model || '';
101
108
  out.createdAt = session.createdAt || Date.now();
102
109
  out.updatedAt = Date.now();
103
- // Everything else (messages, rounds, steps, plan, focus, swarm, effort, …)
104
- // is copied AFTER updatedAt. `messages` in particular must come after the
105
- // metadata block.
110
+ // Everything else (rounds, steps, plan, focus, swarm, effort, …) is copied AFTER
111
+ // updatedAt. `messages` and `transcript` in particular must come after the metadata
112
+ // block, and they are appended below in that order.
106
113
  for (const k of Object.keys(session)) {
107
114
  if (k in out) continue;
108
- if (k === 'messages') continue; // handled below, last
115
+ if (k === 'messages' || k === 'transcript') continue;
109
116
  out[k] = session[k];
110
117
  }
111
118
  out.messages = Array.isArray(session.messages) ? session.messages : [];
119
+ if (Array.isArray(session.transcript)) out.transcript = session.transcript;
112
120
  return out;
113
121
  }
114
122
 
@@ -122,23 +130,43 @@ export function saveSession(session, storeDir) {
122
130
  // FIX: unique temp name so two concurrent saves of the same session cannot
123
131
  // clobber each other's temp file mid-write.
124
132
  const tmp = `${target}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
125
- const json = JSON.stringify(serializableSession(session), null, 2);
133
+ // Compact JSON, no indentation. `null, 2` made the file ~30% larger for a
134
+ // human who never reads it (the reader is JSON.parse), and the pretty-printer
135
+ // itself costs real time on a large transcript — measured ~22ms for 5.7MB, all
136
+ // of it spent inserting whitespace. Saving is on the per-turn path.
137
+ const json = JSON.stringify(serializableSession(session));
126
138
  fs.writeFileSync(tmp, json, 'utf8');
127
139
  // Atomic replace. If this throws (EXDEV etc.) the temp is cleaned up below.
128
140
  fs.renameSync(tmp, target);
129
- // The saved transcript changed, so its cached metadata head is stale. Drop
130
- // just this entry; the next scan re-reads the file (cheap, one file).
131
141
  cacheFor(storeDir, false)?.delete(sessionFile(session.id, storeDir));
142
+ // A save that worked clears any earlier failure, so /save-report speaks about the
143
+ // CURRENT state rather than a problem the user already resolved by being told.
144
+ saveFailure = null;
132
145
  } catch (e) {
133
146
  // FIX: never fail silently. Previously an empty catch {} in the TUI meant a
134
147
  // serialization error (circular ref, disk full, permissions) lost the turn
135
148
  // with no signal at all.
136
- logSessionError('save', `${session.id}: ${e && e.message ? e.message : String(e)}`);
149
+ const detail = e && e.message ? e.message : String(e);
150
+ logSessionError('save', `${session.id}: ${detail}`);
151
+ // The log alone was not enough. A session whose transcript had grown past the V8
152
+ // string limit failed EVERY save for an hour and a half — the turn was lost each
153
+ // time and all the file recorded was `Invalid string length`, in a log nobody opens.
154
+ // The flag is what lets the TUI say so out loud; this module never reaches into the UI.
155
+ if (!saveFailure) saveFailure = { at: Date.now(), id: session.id, detail };
137
156
  return session;
138
157
  }
139
158
  return session;
140
159
  }
141
160
 
161
+ // The most recent save failure, or null. Set once per failure so a caller can report it
162
+ // (the TUI shows a warning) without this module reaching into the UI.
163
+ let saveFailure = null;
164
+
165
+ /** The last save failure `{ at, id, detail }`, or null when saving has been clean. */
166
+ export function lastSaveFailure() { return saveFailure; }
167
+ /** Clear the recorded failure, after a save has succeeded. */
168
+ export function clearSaveFailure() { saveFailure = null; }
169
+
142
170
  export function loadSession(id, storeDir) {
143
171
  const file = sessionFile(id, storeDir);
144
172
  let text;
@@ -288,6 +316,21 @@ function _readMetaOnce(file) {
288
316
  delete m.messages;
289
317
  return { meta: m, hasMessages: nonEmpty };
290
318
  } catch {
319
+ // Neither parse can work here, and the reason is structural rather than
320
+ // corrupt: the cap was hit before the `"messages"` key, so `text` stops
321
+ // inside some earlier value. That is what a session carrying a large
322
+ // `transcript` looked like while `serializableSession` wrote it ahead of
323
+ // `messages` — and returning null for it made the file UNLISTABLE, so a
324
+ // perfectly intact session silently disappeared from /sessions and from
325
+ // `--continue`. The metadata is all at the front and every field a listing
326
+ // needs is a top-level scalar, so read those directly instead of giving up.
327
+ const meta = scanMetaHead(text);
328
+ // `hasMessages` is taken as true here on purpose. Reaching the cap without
329
+ // seeing the `"messages"` key means some earlier value was megabytes long, and
330
+ // the only value that grows like that is a transcript — which a session only
331
+ // accumulates by having had a conversation. Reading it as empty instead would
332
+ // make `--continue --skipEmpty` pass over a session full of real work.
333
+ if (meta) return { meta, hasMessages: true };
291
334
  return null;
292
335
  }
293
336
  }
@@ -311,6 +354,133 @@ function findMessagesValueStart(text) {
311
354
  return i; // may point at `[` (or be end-of-text; caller handles both)
312
355
  }
313
356
 
357
+ // The metadata a LISTING needs, read out of a head that stopped mid-value.
358
+ //
359
+ // Only top-level keys are taken, and only while the scanner is at depth 1: a `"title"`
360
+ // nested inside a todo or a transcript row must never be mistaken for the session's own.
361
+ // The values pulled out are strings and numbers, so the scan can step over an oversized
362
+ // value with `skipJsonValue` instead of trying to read it.
363
+ const HEAD_SCALAR_KEYS = new Set([
364
+ 'id', 'title', 'workspace', 'model', 'createdAt', 'updatedAt',
365
+ 'rounds', 'steps', 'lastTurnMs', 'forkedFrom',
366
+ ]);
367
+
368
+ function scanMetaHead(text) {
369
+ const out = {};
370
+ let depth = 0;
371
+ let i = 0;
372
+ while (i < text.length) {
373
+ const c = text[i];
374
+ if (c === '{' || c === '[') { depth++; i++; continue; }
375
+ if (c === '}' || c === ']') { depth--; i++; continue; }
376
+ if (c !== '"') { i++; continue; }
377
+ const key = readJsonString(text, i);
378
+ if (!key) return Object.keys(out).length ? out : null;
379
+ i = key.end;
380
+ // Only a KEY of the root object counts, and only when a colon follows.
381
+ if (depth !== 1) {
382
+ const skip = skipJsonValue(text, i);
383
+ if (skip < 0) return Object.keys(out).length ? out : null;
384
+ i = skip;
385
+ continue;
386
+ }
387
+ let j = i;
388
+ while (j < text.length && /\s/.test(text[j])) j++;
389
+ if (text[j] !== ':') {
390
+ const skip = skipJsonValue(text, i);
391
+ if (skip < 0) return Object.keys(out).length ? out : null;
392
+ i = skip;
393
+ continue;
394
+ }
395
+ j++;
396
+ while (j < text.length && /\s/.test(text[j])) j++;
397
+ if (HEAD_SCALAR_KEYS.has(key.value)) {
398
+ if (text[j] === '"') {
399
+ const s = readJsonString(text, j);
400
+ if (!s) return Object.keys(out).length ? out : null;
401
+ out[key.value] = s.value;
402
+ i = s.end;
403
+ } else {
404
+ let k = j;
405
+ while (k < text.length && !',}]\n'.includes(text[k])) k++;
406
+ const n = Number(text.slice(j, k).trim());
407
+ if (Number.isFinite(n)) out[key.value] = n;
408
+ i = k;
409
+ }
410
+ continue;
411
+ }
412
+ const skip = skipJsonValue(text, j);
413
+ if (skip < 0) return Object.keys(out).length ? out : null;
414
+ i = skip;
415
+ }
416
+ return Object.keys(out).length ? out : null;
417
+ }
418
+
419
+ // A JSON string literal starting at `at` (which must be the opening quote), decoded.
420
+ // Returns { value, end } with `end` past the closing quote, or null when the literal
421
+ // runs off the end of `text` — the normal case for a value the head cap cut in half.
422
+ function readJsonString(text, at) {
423
+ let i = at + 1;
424
+ let raw = '';
425
+ while (i < text.length) {
426
+ const c = text[i];
427
+ if (c === '\\') {
428
+ const n = text[i + 1];
429
+ if (n === undefined) return null;
430
+ if (n === 'u') {
431
+ const hex = text.slice(i + 2, i + 6);
432
+ if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) return null;
433
+ raw += String.fromCharCode(parseInt(hex, 16));
434
+ i += 6;
435
+ continue;
436
+ }
437
+ raw += n === 'n' ? '\n' : n === 't' ? '\t' : n === 'r' ? '\r' : n;
438
+ i += 2;
439
+ continue;
440
+ }
441
+ if (c === '"') return { value: raw, end: i + 1 };
442
+ raw += c;
443
+ i++;
444
+ }
445
+ return null;
446
+ }
447
+
448
+ // Step over one JSON value starting at `at`, returning the index just past it, or -1
449
+ // when the value is not complete in `text`. Strings and containers are both handled, so
450
+ // an oversized array is crossed without building it.
451
+ function skipJsonValue(text, at) {
452
+ let i = at;
453
+ while (i < text.length && /\s/.test(text[i])) i++;
454
+ if (i >= text.length) return -1;
455
+ const c = text[i];
456
+ if (c === '"') {
457
+ const s = readJsonString(text, i);
458
+ return s ? s.end : -1;
459
+ }
460
+ if (c === '{' || c === '[') {
461
+ let depth = 0;
462
+ for (; i < text.length; i++) {
463
+ const ch = text[i];
464
+ if (ch === '"') {
465
+ const s = readJsonString(text, i);
466
+ if (!s) return -1;
467
+ i = s.end - 1;
468
+ continue;
469
+ }
470
+ if (ch === '{' || ch === '[') depth++;
471
+ else if (ch === '}' || ch === ']') {
472
+ depth--;
473
+ if (depth === 0) return i + 1;
474
+ }
475
+ }
476
+ return -1;
477
+ }
478
+ // A literal (number, true/false/null): up to the next delimiter.
479
+ let k = i;
480
+ while (k < text.length && !',}]'.includes(text[k])) k++;
481
+ return k > i ? k : -1;
482
+ }
483
+
314
484
  // Non-empty detection now lives in _readMetaOnce (it reads the file head once
315
485
  // for both the metadata and the emptiness rule), so the old separate
316
486
  // hasMessages() scan is gone — latestSession no longer reopens each candidate.