@hncode/hncode 0.4.2 → 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 (60) hide show
  1. package/README.md +1 -15
  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 +611 -58
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +31 -13
  34. package/src/remote.js +20 -2
  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/teleport.js +260 -0
  46. package/src/term-caps.js +411 -0
  47. package/src/term.js +283 -8
  48. package/src/tools/agent-control.js +98 -0
  49. package/src/tools/agent-swarm.js +11 -2
  50. package/src/tools/agent.js +35 -1
  51. package/src/tools/diagnostics.js +175 -0
  52. package/src/tools/edit.js +25 -10
  53. package/src/tools/grep.js +62 -20
  54. package/src/tools/ignore.js +38 -14
  55. package/src/tools/index.js +10 -0
  56. package/src/tools/notebook.js +113 -0
  57. package/src/tools/read-media-file.js +10 -3
  58. package/src/tools/shell.js +34 -6
  59. package/src/tui.js +5219 -792
  60. package/src/web-public/app.js +7 -0
@@ -0,0 +1,294 @@
1
+ // Scheduled prompts (/schedule) — a cron-like entry that queues a prompt on a
2
+ // running session. Mirrors cline's `schedule` command: a recurring prompt for work
3
+ // that happens on a clock rather than because the user asked.
4
+ //
5
+ // The cron parser is written here rather than pulled in as a dependency, because the
6
+ // whole point of the feature is one line of config and a timer — five fields of
7
+ // numbers and ranges do not justify a package. It supports exactly the cron subset
8
+ // that is useful for a coding session and REJECTS anything it does not understand
9
+ // instead of guessing: a schedule that silently never fires is worse than one that
10
+ // fails loudly at parse time.
11
+ //
12
+ // Supported syntax, per field:
13
+ // * any value
14
+ // 5 that value
15
+ // 1,3,5 a list
16
+ // 1-5 a range
17
+ // */15 a step over the field's range
18
+ // 1-30/10 a step over a range
19
+ //
20
+ // Field order and ranges are the standard five-field form:
21
+ // minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, day-of-week 0-6 (0 = Sun)
22
+ //
23
+ // Day-of-month and day-of-week are combined with OR when BOTH are restricted, which
24
+ // is what cron does (and is the standard source of confusion — a `0 9 1 * 1` entry
25
+ // fires on the 1st OR on Mondays, not only on Mondays that are the 1st). That
26
+ // behaviour is implemented, and stated in the help text rather than left as folklore.
27
+
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+
31
+ const FIELDS = [
32
+ { name: 'minute', min: 0, max: 59 },
33
+ { name: 'hour', min: 0, max: 23 },
34
+ { name: 'dom', min: 1, max: 31 },
35
+ { name: 'month', min: 1, max: 12 },
36
+ { name: 'dow', min: 0, max: 6 },
37
+ ];
38
+
39
+ /** Compile one field into a Set of allowed numbers. Throws on anything invalid. */
40
+ function parseField(spec, field) {
41
+ const out = new Set();
42
+ for (const part of String(spec).split(',')) {
43
+ const piece = part.trim();
44
+ if (!piece) throw new Error(`empty ${field.name} entry`);
45
+ // `base/step` — the base may be `*`, a single value or a range.
46
+ const [base, stepRaw] = piece.split('/');
47
+ let step = 1;
48
+ if (stepRaw !== undefined) {
49
+ step = Number(stepRaw);
50
+ if (!Number.isInteger(step) || step < 1) throw new Error(`bad step "${stepRaw}" in ${field.name}`);
51
+ }
52
+ let lo;
53
+ let hi;
54
+ if (base === '*') { lo = field.min; hi = field.max; }
55
+ else if (base.includes('-')) {
56
+ const [a, b] = base.split('-');
57
+ lo = Number(a);
58
+ hi = Number(b);
59
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) throw new Error(`bad range "${base}" in ${field.name}`);
60
+ } else {
61
+ lo = Number(base);
62
+ hi = lo;
63
+ if (!Number.isInteger(lo)) throw new Error(`bad value "${base}" in ${field.name}`);
64
+ }
65
+ // A DOW of 7 means Sunday in several cron implementations. Folding it to 0 must
66
+ // happen BEFORE the range tests: folding `hi` first made the single value `7`
67
+ // satisfy `hi < lo` (0 < 7) and be rejected as a backwards range.
68
+ const isDow = field.name === 'dow';
69
+ const foldDow = (v) => (isDow && v === 7 ? 0 : v);
70
+ if (isDow) {
71
+ // Validate against the ALLOWED range (0-7) before folding, so `8` is still
72
+ // rejected while `7` is accepted.
73
+ if (lo < field.min || lo > field.max + 1 || hi > field.max + 1) {
74
+ throw new Error(`${field.name} "${piece}" is out of range ${field.min}-${field.max}`);
75
+ }
76
+ } else if (lo < field.min || hi > field.max) {
77
+ throw new Error(`${field.name} "${piece}" is out of range ${field.min}-${field.max}`);
78
+ }
79
+ if (hi < lo) throw new Error(`${field.name} range "${base}" runs backwards`);
80
+ // A range ENDING at 7 covers Sunday, so the fold cannot just remap both ends to
81
+ // 0 — `0-7` would become `0-0` and lose six days. Iterate the original range and
82
+ // fold each VALUE instead.
83
+ for (let v = lo; v <= hi; v += step) out.add(foldDow(v));
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Compile a cron expression. Returns a matcher with
90
+ * `{ fields, matches(date), describe(), restricted }`, or throws with a message
91
+ * naming the field that is wrong.
92
+ */
93
+ export function parseCron(expr) {
94
+ const parts = String(expr == null ? '' : expr).trim().split(/\s+/).filter(Boolean);
95
+ if (parts.length !== 5) {
96
+ throw new Error(`expected 5 fields (minute hour day-of-month month day-of-week), got ${parts.length}`);
97
+ }
98
+ const fields = {};
99
+ for (let i = 0; i < FIELDS.length; i++) {
100
+ fields[FIELDS[i].name] = parseField(parts[i], FIELDS[i]);
101
+ }
102
+ // Which fields were written as `*`: the OR rule for dom/dow only applies when BOTH
103
+ // are restricted, so the distinction has to survive parsing.
104
+ const restricted = {
105
+ dom: parts[2].trim() !== '*',
106
+ dow: parts[4].trim() !== '*',
107
+ };
108
+ return {
109
+ fields,
110
+ restricted,
111
+ expr: parts.join(' '),
112
+ matches(date) {
113
+ const d = date || new Date();
114
+ if (!fields.minute.has(d.getMinutes())) return false;
115
+ if (!fields.hour.has(d.getHours())) return false;
116
+ if (!fields.month.has(d.getMonth() + 1)) return false;
117
+ const domHit = fields.dom.has(d.getDate());
118
+ const dowHit = fields.dow.has(d.getDay());
119
+ // Standard cron: when both day fields are restricted, either may match.
120
+ if (restricted.dom && restricted.dow) return domHit || dowHit;
121
+ if (restricted.dom) return domHit;
122
+ if (restricted.dow) return dowHit;
123
+ return true;
124
+ },
125
+ };
126
+ }
127
+
128
+ /** `0 9 * * 1-5` -> `weekdays at 09:00`. A readable gloss when the shape is known. */
129
+ export function describeCron(expr) {
130
+ const text = String(expr || '').trim();
131
+ const named = {
132
+ '0 9 * * 1-5': 'weekdays at 09:00',
133
+ '0 18 * * 1-5': 'weekdays at 18:00',
134
+ '*/30 * * * *': 'every 30 minutes',
135
+ '0 * * * *': 'every hour, on the hour',
136
+ '0 0 * * *': 'daily at midnight',
137
+ };
138
+ return named[text] || text;
139
+ }
140
+
141
+ /** Validate a schedule entry's fields. Returns { ok, error }. */
142
+ export function validateEntry(entry) {
143
+ if (!entry || typeof entry !== 'object') return { ok: false, error: 'not a table' };
144
+ if (!String(entry.prompt || '').trim()) return { ok: false, error: 'prompt is required' };
145
+ try {
146
+ parseCron(entry.cron || entry.at);
147
+ } catch (e) {
148
+ return { ok: false, error: e.message };
149
+ }
150
+ return { ok: true, error: '' };
151
+ }
152
+
153
+ /** Normalise the config table into a list with stable ids. */
154
+ export function listSchedules(schedule) {
155
+ const table = schedule && typeof schedule === 'object' ? schedule : {};
156
+ return Object.entries(table).map(([id, e]) => ({
157
+ id,
158
+ cron: String((e && (e.cron || e.at)) || '').trim(),
159
+ prompt: String((e && e.prompt) || ''),
160
+ enabled: !(e && e.enabled === false),
161
+ cwd: (e && e.cwd) || '',
162
+ lastRun: (e && e.last_run) || null,
163
+ valid: validateEntry(e).ok,
164
+ error: validateEntry(e).error,
165
+ }));
166
+ }
167
+
168
+ /**
169
+ * Which entries are DUE now.
170
+ *
171
+ * The rule is "the schedule matches this minute and it has not already run in this
172
+ * minute". Both halves matter: a timer that ticks twice within one minute (which
173
+ * happens when the process was busy, or when the tick lands on a minute boundary)
174
+ * must not fire the same entry twice, and a session that was closed over the due
175
+ * minute must not fire a burst of catch-up prompts on the next launch — a schedule
176
+ * is not a queue of missed work.
177
+ */
178
+ export function dueSchedules(schedule, now) {
179
+ const d = now || new Date();
180
+ const minuteKey = `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}T${d.getHours()}:${d.getMinutes()}`;
181
+ const out = [];
182
+ for (const e of listSchedules(schedule)) {
183
+ if (!e.enabled || !e.valid) continue;
184
+ let matcher;
185
+ try { matcher = parseCron(e.cron); } catch { continue; }
186
+ if (!matcher.matches(d)) continue;
187
+ if (e.lastRun === minuteKey) continue;
188
+ out.push({ ...e, minuteKey });
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * The schedule table with the given entries marked as run in this minute.
195
+ *
196
+ * Kept for callers that keep their history IN the config (the panel shows it either
197
+ * way); the TUI uses the side file (see writeHistory) because rewriting the user's
198
+ * whole config.toml once a minute is not acceptable.
199
+ */
200
+ export function markRun(schedule, ids, minuteKey) {
201
+ const out = { ...(schedule && typeof schedule === 'object' ? schedule : {}) };
202
+ for (const id of ids) {
203
+ if (!out[id]) continue;
204
+ out[id] = { ...out[id], last_run: minuteKey };
205
+ }
206
+ return out;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Run history
211
+ // ---------------------------------------------------------------------------
212
+ // `last_run` cannot live in config.toml: the schedules are HAND-WRITTEN tables, and
213
+ // rewriting that file on every fire is both risky (it holds the user's whole
214
+ // configuration) and wrong in kind (a run record is state, not configuration). So the
215
+ // history is a separate small JSON file beside the config, keyed by entry id.
216
+ //
217
+ // It is also what makes "once per due minute" survive a RESTART: without a record on
218
+ // disk, a session closed and reopened within the same minute would fire the entry a
219
+ // second time.
220
+
221
+ /** Where the run history lives — beside the config, so a test HOME gets its own. */
222
+ export function historyFile(configFile) {
223
+ return path.join(path.dirname(String(configFile)), 'schedule-state.json');
224
+ }
225
+
226
+ /** Read the run history. A missing or corrupt file reads as empty; never throws. */
227
+ export function readHistory(configFile) {
228
+ try {
229
+ const parsed = JSON.parse(fs.readFileSync(historyFile(configFile), 'utf8'));
230
+ return parsed && typeof parsed === 'object' ? parsed : {};
231
+ } catch { return {}; }
232
+ }
233
+
234
+ /** Write the run history. Best-effort: a failed write must not drop the work. */
235
+ export function writeHistory(configFile, history) {
236
+ try {
237
+ fs.mkdirSync(path.dirname(historyFile(configFile)), { recursive: true });
238
+ fs.writeFileSync(historyFile(configFile), JSON.stringify(history, null, 2) + '\n', 'utf8');
239
+ return true;
240
+ } catch { return false; }
241
+ }
242
+
243
+ /** `2026-9-30T9:5` — LOCAL time, since a cron expression is local by definition. */
244
+ export function minuteKeyOf(d) {
245
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}T${d.getHours()}:${d.getMinutes()}`;
246
+ }
247
+
248
+ /**
249
+ * Which entries are due, consulting BOTH the config and the persisted history.
250
+ *
251
+ * Kept beside `dueSchedules` rather than replacing it, so the pure matching rule stays
252
+ * testable without a filesystem.
253
+ */
254
+ export function dueWithHistory(schedule, history, now) {
255
+ const d = now || new Date();
256
+ const minuteKey = minuteKeyOf(d);
257
+ const out = [];
258
+ for (const e of listSchedules(schedule)) {
259
+ if (!e.enabled || !e.valid) continue;
260
+ let matcher;
261
+ try { matcher = parseCron(e.cron); } catch { continue; }
262
+ if (!matcher.matches(d)) continue;
263
+ const last = (history && history[e.id]) || e.lastRun || '';
264
+ if (last === minuteKey) continue;
265
+ out.push({ ...e, minuteKey });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /** Panel lines for /schedule — history included when it is available. */
271
+ export function describeSchedules(schedule, history) {
272
+ const rows = listSchedules(schedule);
273
+ if (!rows.length) {
274
+ return [
275
+ 'No schedules. Add one to config.toml:',
276
+ '',
277
+ ' [schedule.morning]',
278
+ ' cron = "0 9 * * 1-5" # weekdays at 09:00',
279
+ ' prompt = "Check the open PRs and post a summary"',
280
+ ' enabled = true # optional, default true',
281
+ ].join('\n');
282
+ }
283
+ return rows.map((e) => {
284
+ const state = !e.enabled ? 'off' : e.valid ? 'on ' : 'BAD';
285
+ const when = e.valid ? describeCron(e.cron) : `INVALID: ${e.error}`;
286
+ const last = (history && history[e.id]) || e.lastRun || '';
287
+ return `${state} ${e.id.padEnd(16)} ${when.padEnd(26)} ${e.prompt.slice(0, 44)}${last ? ` last ${last}` : ''}`;
288
+ }).join('\n');
289
+ }
290
+
291
+ export default {
292
+ parseCron, describeCron, validateEntry, listSchedules, dueSchedules, markRun,
293
+ describeSchedules, dueWithHistory, readHistory, writeHistory, historyFile, minuteKeyOf,
294
+ };
@@ -0,0 +1,86 @@
1
+ // Session approval memory — "always allow this, for now".
2
+ //
3
+ // The permission RULES in config.toml are the durable answer ("allow npm test
4
+ // forever"). This module is the transient one: the user is mid-task, a prompt
5
+ // appears for something they already decided about five minutes ago, and they want
6
+ // it gone without editing a file. kimi has exactly this as one of its permission
7
+ // policies (`session-approval-history`), evaluated AFTER the configured rules so a
8
+ // rule always wins.
9
+ //
10
+ // Scope rules, all of which exist to keep the memory from being a surprise:
11
+ // * The entry lives on the SESSION object, not on config — it dies with the
12
+ // session. A persisted grant would silently apply to next week's work.
13
+ // * It is keyed by the SHAPE of the call, not the call: for a command, the
14
+ // PROGRAM plus its first argument (`npm test`), so `npm test --watch` is
15
+ // covered but `npm publish` is not.
16
+ // * It never covers a destructive command, whatever the user approved. Ctrl+A on
17
+ // `rm -rf /build` must not pre-approve the next `rm -rf /`. This is the one
18
+ // place the module refuses to be literal about what was approved.
19
+ // * It cannot override a `deny` rule: the rule check runs first (see the caller).
20
+
21
+ /**
22
+ * The key an approval is remembered under.
23
+ *
24
+ * For a shell command: the program and its first non-flag argument. That is the
25
+ * narrowest unit that is still useful — `npm test` covers `npm test --watch` and
26
+ * `npm test src/a.test.ts`, while `npm run` stays separate from `npm test`.
27
+ *
28
+ * For anything else: the tool name alone. A file tool's approval is about the TOOL
29
+ * (the path guard still applies per call), and keying by path would produce a new
30
+ * entry per file with no benefit.
31
+ */
32
+ export function approvalKey(toolName, args) {
33
+ const tool = String(toolName || '');
34
+ if (tool !== 'Bash') return tool;
35
+ const cmd = String((args && (args.command || args.cmd)) || '').trim();
36
+ if (!cmd) return tool;
37
+ const words = cmd.split(/\s+/).filter(Boolean);
38
+ // Skip a leading assignment (`FOO=1 npm test`) — it is not the program.
39
+ const prog = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) || '';
40
+ if (!prog) return tool;
41
+ const sub = words.slice(words.indexOf(prog) + 1).find((w) => !w.startsWith('-')) || '';
42
+ return sub ? `${tool}:${prog} ${sub}` : `${tool}:${prog}`;
43
+ }
44
+
45
+ /** A label for the transcript, e.g. `Bash: npm test` or `Write`. */
46
+ export function sessionApprovalLabel(toolName, args) {
47
+ return approvalKey(toolName, args);
48
+ }
49
+
50
+ /**
51
+ * Was this call approved for the session? `destructive` is the caller's verdict on
52
+ * the command; a true value always returns false, so the memory cannot pre-approve
53
+ * something that was judged too dangerous to run unattended.
54
+ */
55
+ export function isApprovedForSession(list, toolName, args, destructive = false) {
56
+ if (destructive) return false;
57
+ const key = approvalKey(toolName, args);
58
+ if (!key) return false;
59
+ return Array.isArray(list) && list.includes(key);
60
+ }
61
+
62
+ /** Add a key to the list (returning a new array; no duplicates). */
63
+ export function rememberApproval(list, toolName, args) {
64
+ const key = approvalKey(toolName, args);
65
+ const cur = Array.isArray(list) ? list : [];
66
+ if (!key || cur.includes(key)) return cur.slice();
67
+ return [...cur, key];
68
+ }
69
+
70
+ /** Drop one key. */
71
+ export function forgetApproval(list, key) {
72
+ return (Array.isArray(list) ? list : []).filter((k) => k !== key);
73
+ }
74
+
75
+ /** Panel lines for /permissions, so the grants are visible and revocable. */
76
+ export function describeSessionApprovals(list) {
77
+ const arr = Array.isArray(list) ? list : [];
78
+ if (!arr.length) return 'No session approvals. Ctrl+A on a prompt remembers one.';
79
+ return ['Approved for this session (cleared when the session ends):']
80
+ .concat(arr.map((k) => ' ' + k)).join('\n');
81
+ }
82
+
83
+ export default {
84
+ approvalKey, sessionApprovalLabel, isApprovedForSession,
85
+ rememberApproval, forgetApproval, describeSessionApprovals,
86
+ };
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.