@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/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 };
|
package/src/row-cache.js
ADDED
|
@@ -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
|
+
}
|
package/src/schedule.js
ADDED
|
@@ -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
|
+
};
|