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