@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.
- package/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- 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 (
|
|
104
|
-
//
|
|
105
|
-
//
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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.
|