@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/rollout.js ADDED
@@ -0,0 +1,151 @@
1
+ // Session replay (/replay) — play a recorded conversation back, one step at a time.
2
+ //
3
+ // Why this exists: a session file records WHAT happened but not how long it took or
4
+ // in what ORDER relative to the tools, and reading the raw JSON to understand a run
5
+ // is unpleasant. More importantly, when something went wrong two hours ago, the
6
+ // question is "what did the model see when it made that bad call" — and the answer is
7
+ // the transcript AS IT STOOD at that step, which is not the same as the final
8
+ // transcript.
9
+ //
10
+ // So the replay is a step index over the session's messages, with the state at each
11
+ // step reconstructable. It is deliberately read-only: nothing here writes a file or
12
+ // resumes execution. A replay that could act would be a footgun.
13
+ //
14
+ // Steps are the unit the user cares about — a user message, an assistant reply, a
15
+ // tool call, a tool result — not raw JSON entries, because one assistant message can
16
+ // carry several tool calls and the interesting question is "which tool ran, in what
17
+ // order".
18
+
19
+ /** The kinds of step a replay can stop on, in the order they can occur. */
20
+ export const STEP_KINDS = ['user', 'assistant', 'thinking', 'tool', 'system'];
21
+
22
+ /**
23
+ * Turn a session's messages into replay steps.
24
+ *
25
+ * Returns an array of
26
+ * `{ index, kind, role, label, text, toolName, args, at }`. `at` is only present
27
+ * when the recording kept a timestamp, which older sessions do not.
28
+ */
29
+ export function buildSteps(messages) {
30
+ const list = Array.isArray(messages) ? messages : [];
31
+ const steps = [];
32
+ for (const m of list) {
33
+ if (!m || typeof m !== 'object') continue;
34
+ const role = String(m.role || '');
35
+ const at = m.at || m.timestamp || null;
36
+ if (role === 'assistant') {
37
+ // Split one assistant message into its reasoning, its prose, and each tool
38
+ // call: the tool calls are the steps a debugging reader wants on their own.
39
+ const thinking = typeof m.reasoning === 'string' ? m.reasoning : '';
40
+ if (thinking.trim()) {
41
+ steps.push({ kind: 'thinking', role, label: 'thinking', text: thinking, at });
42
+ }
43
+ const text = typeof m.content === 'string' ? m.content : '';
44
+ if (text.trim()) {
45
+ steps.push({ kind: 'assistant', role, label: 'reply', text, at });
46
+ }
47
+ const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
48
+ for (const c of calls) {
49
+ const name = (c && (c.name || (c.function && c.function.name))) || 'tool';
50
+ steps.push({
51
+ kind: 'tool', role, label: name, toolName: name,
52
+ text: '', args: c && (c.args || c.arguments || (c.function && c.function.arguments)) || null,
53
+ at,
54
+ });
55
+ }
56
+ if (!thinking.trim() && !text.trim() && !calls.length) {
57
+ steps.push({ kind: 'assistant', role, label: 'reply', text: '', at });
58
+ }
59
+ continue;
60
+ }
61
+ if (role === 'tool') {
62
+ const name = m.toolName || m.name || 'tool';
63
+ steps.push({ kind: 'tool', role, label: `${name} result`, toolName: name, text: typeof m.content === 'string' ? m.content : '', at });
64
+ continue;
65
+ }
66
+ if (role === 'user' || role === 'system') {
67
+ steps.push({ kind: role, role, label: role === 'user' ? 'prompt' : 'system', text: typeof m.content === 'string' ? m.content : '', at });
68
+ continue;
69
+ }
70
+ // Anything else (a plugin-injected role, a future one) is shown as itself rather
71
+ // than dropped: a replay that silently omits steps is worse than an ugly label.
72
+ steps.push({ kind: 'other', role, label: role || 'entry', text: typeof m.content === 'string' ? m.content : '', at });
73
+ }
74
+ return steps.map((s, i) => ({ ...s, index: i }));
75
+ }
76
+
77
+ /**
78
+ * The state at step `i`: which prompt is being answered and what has been seen.
79
+ *
80
+ * This is the "as it stood" view — the whole reason to replay rather than scroll.
81
+ */
82
+ export function stateAtStep(steps, i) {
83
+ const list = Array.isArray(steps) ? steps : [];
84
+ const at = Math.max(0, Math.min(list.length - 1, i | 0));
85
+ let lastPrompt = '';
86
+ let tools = 0;
87
+ for (let n = 0; n <= at; n++) {
88
+ const s = list[n];
89
+ if (!s) continue;
90
+ if (s.kind === 'user') lastPrompt = s.text;
91
+ if (s.kind === 'tool') tools++;
92
+ }
93
+ return {
94
+ index: at,
95
+ lastPrompt,
96
+ tools,
97
+ // The turn number the step belongs to, counting prompts: 1-based for display.
98
+ turn: list.slice(0, at + 1).filter((s) => s && s.kind === 'user').length || 1,
99
+ };
100
+ }
101
+
102
+ /** A one-line summary per step, for the scrubber list. */
103
+ export function stepLine(step, width = 72) {
104
+ if (!step) return '';
105
+ const t = String(step.text || '').replace(/\s+/g, ' ').trim();
106
+ const arg = step.args == null ? ''
107
+ : (typeof step.args === 'string' ? step.args : JSON.stringify(step.args)).replace(/\s+/g, ' ');
108
+ const body = t || arg;
109
+ const cut = body.length > width ? body.slice(0, width - 1) + '…' : body;
110
+ return cut;
111
+ }
112
+
113
+ /**
114
+ * The frame for one step: a header plus the step's body, wrapped.
115
+ *
116
+ * Wrapping is done here rather than by the caller so a replay view and a test agree
117
+ * on the exact rows.
118
+ */
119
+ export function renderStep(steps, i, opts = {}) {
120
+ const list = Array.isArray(steps) ? steps : [];
121
+ if (!list.length) return ['(this session has no steps to replay)'];
122
+ const at = Math.max(0, Math.min(list.length - 1, i | 0));
123
+ const s = list[at];
124
+ const st = stateAtStep(list, at);
125
+ const width = Math.max(20, opts.width || 76);
126
+ const out = [];
127
+ out.push(`step ${at + 1}/${list.length} turn ${st.turn} ${s.kind}${s.toolName ? ' ' + s.toolName : ''}`);
128
+ if (st.lastPrompt) out.push(` answering: ${st.lastPrompt.split('\n')[0].slice(0, width - 12)}`);
129
+ if (s.at) out.push(` at: ${s.at}`);
130
+ out.push('');
131
+ const body = [String(s.text || ''), s.args == null ? '' : (typeof s.args === 'string' ? s.args : JSON.stringify(s.args, null, 2))]
132
+ .filter((x) => x && String(x).trim()).join('\n');
133
+ const rows = body ? String(body).split('\n') : ['(no content)'];
134
+ for (const r of rows) {
135
+ if (r.length <= width) { out.push(r); continue; }
136
+ for (let k = 0; k < r.length; k += width) out.push(r.slice(k, k + width));
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** Totals for the header: how many steps of each kind, and how many tools ran. */
142
+ export function replaySummary(steps) {
143
+ const list = Array.isArray(steps) ? steps : [];
144
+ const counts = {};
145
+ for (const s of list) counts[s.kind] = (counts[s.kind] || 0) + 1;
146
+ const calls = list.filter((s) => s.kind === 'tool' && s.role === 'assistant').length;
147
+ const results = list.filter((s) => s.kind === 'tool' && s.role === 'tool').length;
148
+ return { total: list.length, counts, calls, results };
149
+ }
150
+
151
+ export default { buildSteps, stateAtStep, stepLine, renderStep, replaySummary, STEP_KINDS };
@@ -0,0 +1,143 @@
1
+ // A shared, bounded cache of rendered transcript rows.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // ---------------
5
+ // Wrapping and markdown-rendering a message is the expensive half of every frame, and the
6
+ // result depends only on (message text, width, colours) — so it is cacheable. The TUI
7
+ // already cached it, but on the message object itself, which makes the cache a property of
8
+ // the TRANSCRIPT rather than of the renderer:
9
+ //
10
+ // * It was dropped by position, not by size. `CACHE_KEEP` counted messages from the
11
+ // tail, so a 4000-message session threw away the caches of the 3700 oldest messages
12
+ // while keeping the caches of 300 tiny ones — the window tracked message COUNT, not
13
+ // memory. Scrolling back into a long session re-rendered from scratch every time.
14
+ // * It could not survive a session switch, because it lived on messages that
15
+ // `reconstructChat` throws away and rebuilds from the log. Every `--continue` and
16
+ // every `/sessions` resume started with a cold cache, re-wrapping the whole
17
+ // transcript before the first frame.
18
+ // * It had two independent mechanisms — `m._cache` for the row list, `m._mdCache` for
19
+ // the markdown blocks — with separate lifetimes and no bound between them.
20
+ //
21
+ // A `Map` keyed by a string, with entries ordered by use, fixes all three: the bound is
22
+ // real, the key survives any rebuild of the transcript, and both caches share one budget.
23
+ //
24
+ // THE KEY IS THE WHOLE POINT
25
+ // --------------------------
26
+ // A key that omitted a component of the layout would serve a row rendered for a different
27
+ // width or colour — which is not a stale cache, it is a MISDRAWN frame. So the key spells
28
+ // out everything the render depends on, and `makeRowKey` is the single place that decides
29
+ // what "the same" means. `evictIfStale` is the second half of the same contract: a streamed
30
+ // message's text grows every frame, so an entry whose text no longer matches is not a
31
+ // reuse, and the cache has to notice rather than serve it.
32
+
33
+ /** How many messages keep their rows. Chosen by measurement, not by feel: the rows for a
34
+ * typical assistant message are a few hundred bytes each, so 1500 covers a long session's
35
+ * visible history while staying in the low megabytes. */
36
+ export const ROW_CACHE_LIMIT = 1500;
37
+
38
+ /**
39
+ * A cache key that identifies a rendered message.
40
+ *
41
+ * Every input the render reads goes in, so "the same key" means "the same rows":
42
+ * `id` a stable identity for the message. Index-based ids break the moment a message
43
+ * is inserted or removed, which is exactly what compaction and /undo do.
44
+ * `text` the content. Compared by VALUE, not by reference: a streamed message's
45
+ * `text` is replaced on every delta, and a reference check would miss every
46
+ * reuse after the first frame.
47
+ * `w` the wrap width, `fg`/`codeFg` the colours, `expanded` the plugin-log toggle,
48
+ * `raw` the raw-mode flag, `pending`/`failed` the live-state flags, and `spin` the
49
+ * spinner key.
50
+ */
51
+ export function makeRowKey(parts) {
52
+ return [
53
+ parts.id,
54
+ parts.text == null ? '' : parts.text,
55
+ parts.w,
56
+ parts.fg,
57
+ parts.codeFg,
58
+ parts.expanded ? 1 : 0,
59
+ parts.raw ? 1 : 0,
60
+ parts.pending ? 1 : 0,
61
+ parts.failed ? 1 : 0,
62
+ parts.spin,
63
+ ].join('\x1f');
64
+ }
65
+
66
+ /**
67
+ * The shared store.
68
+ *
69
+ * A single `Map` with insertion order: `Map` iterates in insertion order and `set` on an
70
+ * existing key does NOT move it, so "touch on every read" has to be an explicit
71
+ * delete-then-set. That is what makes it an LRU rather than a FIFO — and the difference
72
+ * matters here, because the entries worth keeping are the ones being read, not the ones
73
+ * written most recently.
74
+ */
75
+ const store = new Map();
76
+
77
+ /** Hits and misses, so a caller can tell "the cache is not helping" from "there is
78
+ * nothing to cache" without instrumenting the renderer. */
79
+ const stats = { hits: 0, misses: 0, evictions: 0 };
80
+
81
+ export function cacheStats() {
82
+ return { ...stats, size: store.size };
83
+ }
84
+
85
+ /** For tests: a clean slate. */
86
+ export function resetRowCache() {
87
+ store.clear();
88
+ stats.hits = 0;
89
+ stats.misses = 0;
90
+ stats.evictions = 0;
91
+ }
92
+
93
+ /**
94
+ * Look up `key`, marking it as recently used.
95
+ *
96
+ * @returns the cached value, or undefined on a miss. Callers treat `undefined` as "render
97
+ * it" — a cache that cannot say "no" is not a cache.
98
+ */
99
+ export function rowCacheGet(key) {
100
+ if (!store.has(key)) { stats.misses++; return undefined; }
101
+ const v = store.get(key);
102
+ // Touch: re-insert so this key moves to the young end of the Map's iteration order.
103
+ store.delete(key);
104
+ store.set(key, v);
105
+ stats.hits++;
106
+ return v;
107
+ }
108
+
109
+ /**
110
+ * Store `value` under `key` and enforce the bound.
111
+ *
112
+ * Eviction is oldest-first, and it is done HERE rather than by the caller counting
113
+ * messages: the caller cannot know what the entries cost, and a message-count window
114
+ * keeps a lot of large messages while discarding a lot of small ones.
115
+ */
116
+ export function rowCacheSet(key, value) {
117
+ // Re-inserting an existing key would leave the old entry in place, so drop it first —
118
+ // otherwise the bound could be evictions short of what it looks like.
119
+ store.delete(key);
120
+ store.set(key, value);
121
+ while (store.size > ROW_CACHE_LIMIT) {
122
+ // Map iteration is insertion order, so the first key is the least recently used.
123
+ const oldest = store.keys().next();
124
+ if (oldest.done) break;
125
+ store.delete(oldest.value);
126
+ stats.evictions++;
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /**
132
+ * Is `cached` still valid for `key`'s message?
133
+ *
134
+ * A separate question from `rowCacheGet`, because a message can change without its key
135
+ * changing: a streamed reply's text is the key's own `text` component, so its key DOES
136
+ * change — but the row COUNT memo does not carry the text, and that one has to be asked
137
+ * whether the content moved. Kept as a function so the check is named at the call site
138
+ * rather than being an open-coded `===` someone later widens by accident.
139
+ */
140
+ export function evictIfStale(entry, key) {
141
+ if (!entry) return true;
142
+ return entry.key !== key;
143
+ }
@@ -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
+ };