@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.
Files changed (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  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 +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -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/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
package/src/doctor.js ADDED
@@ -0,0 +1,518 @@
1
+ // /doctor — report the environment this session is running in, and flag the things that
2
+ // break it. Modelled on Claude Code's and cline's `doctor` commands.
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ---------------
6
+ // Every failure this project has hit in practice was visible in the environment BEFORE
7
+ // it bit, and none of it was visible from the TUI:
8
+ //
9
+ // * a session file grew past the V8 string limit, so saving silently stopped working
10
+ // for an hour and a half (only a line in ~/.hncode/session-errors.log);
11
+ // * three intact conversations became unlistable because their transcript pushed the
12
+ // metadata key out of the head reader's reach;
13
+ // * an MCP server that never connected, so its tools simply were not there;
14
+ // * an API key that was absent or a base URL that was malformed, which surfaces as a
15
+ // provider error on the first real turn and looks like a model problem.
16
+ //
17
+ // A diagnosis is a LIST OF CHECKS, each with a severity, so it can be tested: the checks
18
+ // that read the filesystem take their paths from this module's own resolution functions
19
+ // (the same ones the rest of the program uses), which is what makes a report trustworthy
20
+ // rather than a second, divergent opinion about where things live.
21
+ //
22
+ // READ-ONLY BY CONSTRUCTION. Nothing here writes, repairs or deletes: a diagnostic that
23
+ // "fixes" something is a diagnostic nobody can run on a machine they care about. The
24
+ // only outbound work is an optional reachability probe, which the caller opts into.
25
+
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+
30
+ /** Severities, worst first. `ok` is the only one that means "nothing to do". */
31
+ export const SEVERITY_ORDER = ['error', 'warn', 'ok', 'info'];
32
+
33
+ const MARK = { error: '✖', warn: '!', ok: '✔', info: '·' };
34
+
35
+ /** One check's outcome. `fix` is the actionable half — a finding with no remedy is noise. */
36
+ function check(name, severity, detail, fix) {
37
+ return { name, severity, detail: String(detail == null ? '' : detail), fix: fix || '' };
38
+ }
39
+
40
+ // ---- individual checks -----------------------------------------------------
41
+
42
+ /** The config file is where every provider and key lives; a malformed one is fatal. */
43
+ function checkConfig(cfg, files) {
44
+ const file = files.config;
45
+ if (!fs.existsSync(file)) {
46
+ return [check('config file', 'warn', `${file} does not exist`,
47
+ 'Run hncode once to create it, or /model to pick a provider.')];
48
+ }
49
+ let text;
50
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
51
+ return [check('config file', 'error', `unreadable: ${e.message}`, `Check permissions on ${file}`)];
52
+ }
53
+ const out = [check('config file', 'ok', file)];
54
+ // A TOML file the parser rejects would have failed before this point, so the useful
55
+ // signal here is the SHAPE: a config with no provider cannot make a request.
56
+ if (!cfg.provider) {
57
+ out.push(check('provider', 'error', 'no provider configured',
58
+ 'Run /model to choose one, or /provider add <name> <base-url>.'));
59
+ }
60
+ if (!cfg.model) {
61
+ out.push(check('model', 'error', 'no model selected', 'Run /model.'));
62
+ }
63
+ if (!cfg.apiKey) {
64
+ // A local endpoint (ollama, llama.cpp, lmstudio) legitimately has no key.
65
+ const local = /localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/.test(String(cfg.endpoint || ''));
66
+ out.push(local
67
+ ? check('api key', 'info', 'none set, and the endpoint is local', '')
68
+ : check('api key', 'error', 'not set', 'Set it in config.toml, or via /provider.'));
69
+ }
70
+ if (cfg.endpoint && !/^https?:\/\//i.test(String(cfg.endpoint))) {
71
+ out.push(check('endpoint', 'error', `does not look like a URL: ${cfg.endpoint}`,
72
+ 'It must start with http:// or https://.'));
73
+ }
74
+ if (!text.includes('[' ) && !text.includes('=')) {
75
+ out.push(check('config shape', 'warn', 'the file has no keys in it', `Open ${file} and check it.`));
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** The session directory. A store that cannot be listed is a store whose work is invisible.
81
+ *
82
+ * Async because it profiles the largest session — see the memory breakdown below. */
83
+ async function checkSessions(files, sessionsDir, limit = 4) {
84
+ const out = [];
85
+ if (!fs.existsSync(sessionsDir)) {
86
+ return [check('session store', 'warn', `${sessionsDir} does not exist yet`,
87
+ 'It is created on the first save.')];
88
+ }
89
+ let names;
90
+ try {
91
+ names = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
92
+ } catch (e) {
93
+ return [check('session store', 'error', `cannot list ${sessionsDir}: ${e.message}`,
94
+ 'Check that the directory exists and is readable.')];
95
+ }
96
+ out.push(check('session store', 'ok', `${names.length} session file(s) in ${sessionsDir}`));
97
+
98
+ // Writability is the property that matters: a read-only store loses every turn while
99
+ // the TUI looks completely normal. Tested with a create-and-remove, not a mode bit,
100
+ // because the mode bit lies on Windows and on a mounted volume.
101
+ const probe = path.join(sessionsDir, `.doctor-${process.pid}.tmp`);
102
+ try {
103
+ fs.writeFileSync(probe, 'x');
104
+ fs.rmSync(probe, { force: true });
105
+ out.push(check('session store writable', 'ok', 'yes'));
106
+ } catch (e) {
107
+ out.push(check('session store writable', 'error', `no: ${e.message}`,
108
+ 'Every turn will fail to save. Fix permissions, or point HNCODE_SESSIONS_DIR somewhere writable.'));
109
+ }
110
+
111
+ // A file big enough to approach the V8 string limit. This is the failure this project
112
+ // actually hit: past ~512 MB `JSON.stringify` throws, and every save of that session
113
+ // then fails until the transcript is trimmed.
114
+ const V8_LIMIT = 512 * 1024 * 1024;
115
+ // Sizes of every session, so the memory profile can pick the largest regardless of how
116
+ // large it is. The `big` list below is filtered to the ones worth WARNING about; the
117
+ // profiler wants the largest whatever its size, because "this session holds 0.66 MB of
118
+ // tool arguments" is useful at 4 MB and not only at 500.
119
+ const sizes = [];
120
+ for (const f of names) {
121
+ try { sizes.push({ f, size: fs.statSync(path.join(sessionsDir, f)).size }); } catch { /* raced away */ }
122
+ }
123
+ sizes.sort((a, b) => b.size - a.size);
124
+ const big = sizes.filter((x) => x.size > 8 * 1024 * 1024);
125
+
126
+ for (const b of big.slice(0, limit)) {
127
+ const mb = (b.size / 1048576).toFixed(1);
128
+ const sev = b.size > V8_LIMIT * 0.75 ? 'error' : 'warn';
129
+ out.push(check('oversized session', sev, `${mb} MB ${b.f}`,
130
+ b.size > V8_LIMIT * 0.75
131
+ ? 'Approaching the ~512 MB string limit, past which saving FAILS. Trim the reasoning transcript — see tools/repair-transcripts.mjs.'
132
+ : 'Large; loading and saving this session will be slow.'));
133
+ }
134
+
135
+ // WHERE THE BYTES ARE in the biggest session, as opposed to how big the file is.
136
+ //
137
+ // A file size says nothing actionable: the same 2.5 MB can be a conversation or a
138
+ // runaway transcript, and only one of those is worth fixing. The breakdown also explains
139
+ // the file being LARGER than the content it holds — measured on a real session, the
140
+ // serialised JSON was 2.29 MB while text + tool results + tool arguments summed to
141
+ // 1.27 MB, so structure and escaping are as expensive as the text.
142
+ //
143
+ // Skipped below 1 MB: loading and measuring a session to learn it is small is itself
144
+ // work, and the finding would be "nothing to see". Deliberately NOT gated on `big` —
145
+ // that list only holds sessions big enough to warn about, and the breakdown is most
146
+ // useful on an ordinary one: it is where "half your memory is tool arguments" shows up.
147
+ if (sizes.length && sizes[0].size > 1024 * 1024) {
148
+ const top = sizes[0];
149
+ try {
150
+ const { loadSession } = await import('./session.js');
151
+ const { profileMemory, memoryHeadline } = await import('./memory-profile.js');
152
+ const loaded = loadSession(top.f.replace(/\.json$/, ''));
153
+ if (loaded) {
154
+ const profile = profileMemory({ session: loaded });
155
+ out.push(check('largest session memory', 'info',
156
+ `${memoryHeadline(profile)} (${top.f})`,
157
+ 'Text, tool results and tool ARGUMENTS are counted separately; run /memory-profile for the full breakdown.'));
158
+ }
159
+ } catch (e) {
160
+ out.push(check('largest session memory', 'warn', `could not profile ${top.f}: ${e.message}`,
161
+ 'The file was readable by size but would not parse.'));
162
+ }
163
+ }
164
+
165
+ // Leftover temp files from an interrupted save.
166
+
167
+ // Leftover temp files from an interrupted save.
168
+ let tmp = [];
169
+ try { tmp = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.tmp')); } catch { /* listed above */ }
170
+ if (tmp.length) {
171
+ out.push(check('interrupted save', 'warn', `${tmp.length} .tmp file(s) left behind`,
172
+ `The write did not finish; the session itself is fine. Delete ${sessionsDir}\\*.tmp`));
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** The error log. Its very existence usually means something failed silently. */
178
+ function checkErrorLog(files) {
179
+ if (!fs.existsSync(files.errorLog)) return [];
180
+ let text = '';
181
+ try { text = fs.readFileSync(files.errorLog, 'utf8'); } catch { return []; }
182
+ const lines = text.split(/\r?\n/).filter(Boolean);
183
+ if (!lines.length) return [];
184
+ const tail = lines.slice(-3);
185
+ const sev = lines.length > 20 ? 'warn' : 'info';
186
+ return [check('error log', sev, `${lines.length} entr(ies), latest: ${tail[tail.length - 1].slice(0, 90)}`,
187
+ `Read ${files.errorLog}. A repeated message there is a bug worth reporting.`)];
188
+ }
189
+
190
+ /** MCP servers: configured but not connected is the silent case. */
191
+ function checkMcp(servers, connections) {
192
+ const names = Object.keys(servers || {});
193
+ if (!names.length) return [check('MCP servers', 'info', 'none configured', '')];
194
+ const byName = new Map((connections || []).map((c) => [c.name, c]));
195
+ const out = [];
196
+ for (const n of names) {
197
+ const conn = byName.get(n);
198
+ if (!conn) out.push(check(`MCP ${n}`, 'warn', 'configured but not started', 'Restart, or check the server command.'));
199
+ else if (!conn.ok) out.push(check(`MCP ${n}`, 'error', `failed: ${conn.error}`, 'Fix the command/URL in /mcp-config.'));
200
+ else out.push(check(`MCP ${n}`, 'ok', `${conn.toolCount} tool(s)`));
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /** Hooks: a malformed hooks.json silently never fires. */
206
+ function checkHooks(files, workspace) {
207
+ const candidates = [files.hooks, path.join(workspace || process.cwd(), '.hncode', 'hooks.json')];
208
+ const out = [];
209
+ for (const f of candidates) {
210
+ if (!fs.existsSync(f)) continue;
211
+ try {
212
+ const doc = JSON.parse(fs.readFileSync(f, 'utf8'));
213
+ const events = Object.keys(doc.hooks || {});
214
+ out.push(check('hooks', 'ok', `${events.length} event(s) configured in ${f}`));
215
+ } catch (e) {
216
+ out.push(check('hooks', 'error', `${f} is not valid JSON: ${e.message}`,
217
+ 'A malformed hooks file makes every hook in it silently never run.'));
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** The workspace: tools resolve paths against it, so it has to exist and be writable. */
224
+ function checkWorkspace(workspace) {
225
+ if (!workspace) return [check('workspace', 'error', 'not set', 'Run /move, or start hncode in a directory.')];
226
+ if (!fs.existsSync(workspace)) {
227
+ return [check('workspace', 'error', `${workspace} does not exist`,
228
+ 'Every file tool will fail. /move to a directory that exists.')];
229
+ }
230
+ const probe = path.join(workspace, `.doctor-${process.pid}.tmp`);
231
+ try {
232
+ fs.writeFileSync(probe, 'x');
233
+ fs.rmSync(probe, { force: true });
234
+ return [check('workspace', 'ok', `${workspace} (writable)`)];
235
+ } catch {
236
+ return [check('workspace', 'warn', `${workspace} (NOT writable)`,
237
+ 'Reads work; Edit, Write and a background command that writes will fail.')];
238
+ }
239
+ }
240
+
241
+ /** Skills and plugins: a directory that exists but holds nothing is worth knowing about. */
242
+ function checkExtensions(files) {
243
+ const out = [];
244
+ for (const [label, dir] of [['skills', files.skills], ['plugins', files.plugins]]) {
245
+ if (!fs.existsSync(dir)) continue;
246
+ let n = 0;
247
+ try { n = fs.readdirSync(dir).length; } catch { /* unreadable */ }
248
+ out.push(check(label, 'info', `${n} entr(ies) in ${dir}`, ''));
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * Free space where the sessions live.
255
+ *
256
+ * A store that fills up is a store that loses work, and the failure is not obviously about
257
+ * disk: the write fails, the turn is lost, and the error text says ENOENT or EACCES. The
258
+ * session directory held 608 MB on this machine at one point, which is the shape that walks
259
+ * a disk to full without anyone noticing.
260
+ *
261
+ * `statfs` is used where the platform has it (Node 18.15+). Where it does not, the check is
262
+ * SKIPPED rather than guessed — a wrong number here is worse than no number.
263
+ */
264
+ function checkDisk(dir) {
265
+ if (typeof fs.statfsSync !== 'function') {
266
+ return [check('disk space', 'info', 'not measurable on this Node/platform', '')];
267
+ }
268
+ try {
269
+ const st = fs.statfsSync(dir);
270
+ const freeBytes = Number(st.bavail) * Number(st.bsize);
271
+ const totalBytes = Number(st.blocks) * Number(st.bsize);
272
+ const freeGb = freeBytes / 1024 ** 3;
273
+ const totalGb = totalBytes / 1024 ** 3;
274
+ // Thresholds are deliberately far apart: below 1 GB is an error (a long session can
275
+ // write hundreds of megabytes), below 5 GB is a warning.
276
+ const sev = freeGb < 1 ? 'error' : freeGb < 5 ? 'warn' : 'ok';
277
+ return [check('disk space', sev, `${freeGb.toFixed(1)} GB free of ${totalGb.toFixed(0)} GB`,
278
+ sev === 'ok' ? ''
279
+ : 'Saving a session writes the whole transcript, which has reached hundreds of MB. Free space, or point HNCODE_SESSIONS_DIR at another volume.')];
280
+ } catch (e) {
281
+ return [check('disk space', 'info', `could not measure: ${e.message}`, '')];
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The workspace's git state.
287
+ *
288
+ * Two things here actually break work: a missing `user.name`/`user.email` makes every
289
+ * commit fail, and an unfinished merge or rebase means the tree is not what the agent
290
+ * thinks it is. Both are cheap to check and neither surfaced anywhere before.
291
+ */
292
+ function checkGit(workspace, cp) {
293
+ if (!workspace) return [];
294
+ if (!cp) return [check('git', 'info', 'the git module is unavailable', '')];
295
+ const run = (args) => {
296
+ try {
297
+ const r = cp.spawnSync('git', args, { cwd: workspace, encoding: 'utf8', timeout: 5000, windowsHide: true });
298
+ return r.status === 0 ? String(r.stdout || '').trim() : null;
299
+ } catch { return null; }
300
+ };
301
+ // A workspace that is not a repo is normal, and says nothing about health.
302
+ const top = run(['rev-parse', '--show-toplevel']);
303
+ if (!top) return [check('git', 'info', 'not a git repository', '')];
304
+
305
+ const out = [];
306
+ const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
307
+ out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
308
+
309
+ for (const key of ['user.name', 'user.email']) {
310
+ if (!run(['config', '--get', key])) {
311
+ out.push(check(`git ${key}`, 'warn', 'not set',
312
+ `Every commit fails without it. Run: git config --global ${key} "…"`));
313
+ }
314
+ }
315
+ // A half-finished operation changes what the agent is looking at.
316
+ const gitDir = run(['rev-parse', '--git-dir']);
317
+ if (gitDir) {
318
+ const abs = path.isAbsolute(gitDir) ? gitDir : path.join(workspace, gitDir);
319
+ for (const [file, what] of [['MERGE_HEAD', 'a merge'], ['REBASE_HEAD', 'a rebase'], ['CHERRY_PICK_HEAD', 'a cherry-pick']]) {
320
+ if (fs.existsSync(path.join(abs, file))) {
321
+ out.push(check('git in progress', 'warn', `${what} is unfinished`,
322
+ 'The working tree is mid-operation; resolve it before trusting a diff.'));
323
+ }
324
+ }
325
+ }
326
+ return out;
327
+ return out;
328
+ }
329
+
330
+ /**
331
+ * A LIVE end-to-end probe: can this configuration actually complete a turn?
332
+ *
333
+ * This is the check a static report cannot make. Everything above reads files; a key that
334
+ * exists and an endpoint that parses still say nothing about whether a request SUCCEEDS, and
335
+ * the failures that matter — a revoked key, a wrong base URL, a model the account cannot
336
+ * reach, a provider that rejects `reasoning_effort` — are all invisible until a real request
337
+ * is made.
338
+ *
339
+ * It costs one request and a handful of tokens, so it is OPT-IN (`/doctor --probe`) and never
340
+ * part of a plain run. Modelled on jcode's provider-doctor, whose AUTH_CREDENTIAL_LOADED,
341
+ * NON_STREAMING_CHAT_COMPLETION and STREAMING_CHAT_COMPLETION checks do the same thing.
342
+ *
343
+ * The steps are reported as they happen, so a failure names the step that broke rather than
344
+ * only saying the whole thing did not work.
345
+ */
346
+ async function probeProvider(cfg, input = {}) {
347
+ const out = [];
348
+ const send = input.probeRequest; // injected by a test; see the note below
349
+ if (!cfg.provider || !cfg.model) {
350
+ return [check('probe', 'info', 'skipped: no provider/model configured', '')];
351
+ }
352
+ if (!cfg.endpoint) {
353
+ return [check('probe', 'info', 'skipped: no endpoint', '')];
354
+ }
355
+ // A test supplies its own transport. Without one, the real LLM class is used — lazily, so
356
+ // a plain /doctor never pays to import the provider stack.
357
+ const doRequest = send || (async () => {
358
+ const { LLM } = await import('./llm.js');
359
+ const llm = new LLM(cfg);
360
+ let text = '';
361
+ let sawError = null;
362
+ let usage = null;
363
+ await llm.request(
364
+ [{ role: 'user', content: 'Reply with the single word: ok' }],
365
+ (e) => {
366
+ if (e.type === 'data') text += e.text;
367
+ else if (e.type === 'error') sawError = e.error;
368
+ else if (e.type === 'usage') usage = e.usage;
369
+ },
370
+ // One turn, no tools, no streaming: the smallest request that proves the pipeline.
371
+ { noTools: true, streaming: false },
372
+ );
373
+ return { text, error: sawError, usage };
374
+ });
375
+
376
+ const t0 = Date.now();
377
+ let res;
378
+ try {
379
+ res = await doRequest();
380
+ } catch (e) {
381
+ return [check('probe', 'error', `request threw: ${(e && e.message) || e}`,
382
+ 'The configuration parses but a request does not complete. Check the key, the endpoint and the network.')];
383
+ }
384
+ const ms = Date.now() - t0;
385
+
386
+ if (res && res.error) {
387
+ return [check('probe', 'error', `provider error: ${String(res.error).slice(0, 160)}`,
388
+ 'The key may be revoked, the model unavailable, or the provider rejecting a field in the request.')];
389
+ }
390
+ const text = String((res && res.text) || '').trim();
391
+ if (!text) {
392
+ // A 200 with no text is the failure mode that looks like success: a reasoning model can
393
+ // spend its whole budget on `reasoning_content` and return an empty `content`.
394
+ return [check('probe', 'error', `empty reply in ${ms} ms`,
395
+ 'The request completed but returned no text. If this model always reasons, it may be spending the whole output budget on reasoning.')];
396
+ }
397
+ // `usage` comes off the RESULT, not out of the closure that built it — reading the local
398
+ // inside `doRequest` from here was a ReferenceError on every successful probe.
399
+ const usage = res && res.usage;
400
+ out.push(check('probe', 'ok', `replied "${text.slice(0, 20)}" in ${ms} ms`,
401
+ usage ? `provider reported ${JSON.stringify(usage)}` : ''));
402
+ return out;
403
+ return out;
404
+ }
405
+
406
+
407
+ /** The terminal. An unknown size or a dumb TERM explains a broken-looking UI. */
408
+ function checkTerminal(env, dims) {
409
+ const out = [];
410
+ const term = env.TERM || '';
411
+ if (env.NO_COLOR) out.push(check('colour', 'info', 'NO_COLOR is set', ''));
412
+ else if (!term || term === 'dumb') {
413
+ out.push(check('TERM', 'warn', term ? `"${term}"` : 'not set',
414
+ 'Colour and cursor control may not work. Set TERM=xterm-256color.'));
415
+ } else {
416
+ out.push(check('TERM', 'ok', `${term}${env.COLORTERM ? ` / ${env.COLORTERM}` : ''}`));
417
+ }
418
+ // Named distinctly from the TERM check: both used to be called "TERM", so a report with a
419
+ // small terminal showed the same name twice and read as a duplicated finding.
420
+ if (dims && (dims.cols < 60 || dims.rows < 15)) {
421
+ out.push(check('terminal size', 'warn', `${dims.cols}x${dims.rows}`,
422
+ 'Below 60x15 the layout cannot fit; widen the window.'));
423
+ }
424
+ return out;
425
+ }
426
+
427
+ /** Runtime facts, so a bug report carries them. */
428
+ function checkRuntime(node, platform, dirs) {
429
+ return [
430
+ check('node', 'ok', node),
431
+ check('platform', 'ok', platform),
432
+ check('hncode home', 'info', dirs.home, ''),
433
+ check('cwd', 'info', dirs.cwd, ''),
434
+ ];
435
+ }
436
+
437
+ // ---- the report ------------------------------------------------------------
438
+
439
+ /**
440
+ * Run every check and return the results.
441
+ *
442
+ * @param {object} input
443
+ * @param {object} input.cfg resolved config (provider/model/endpoint/apiKey)
444
+ * @param {object} [input.files] path overrides, for tests
445
+ * @param {string} [input.workspace]
446
+ * @param {object} [input.mcpServers] config.servers
447
+ * @param {Array} [input.mcpConnections]
448
+ * @param {object} [input.env]
449
+ * @param {object} [input.dims] { cols, rows }
450
+ * @param {string} [input.node] process.version, for tests
451
+ * @returns {Array<{name:string,severity:string,detail:string,fix:string}>}
452
+ */
453
+ export async function runDoctor(input = {}) {
454
+ const cfg = input.cfg || {};
455
+ const env = input.env || process.env;
456
+ const workspace = input.workspace || cfg.workspace || process.cwd();
457
+ const sessionsDir = input.sessionsDir || path.join(env.HOME || os.homedir(), '.hncode', 'sessions');
458
+ const files = {
459
+ config: path.join(env.HOME || os.homedir(), '.hncode', 'config.toml'),
460
+ errorLog: path.join(env.HOME || os.homedir(), '.hncode', 'session-errors.log'),
461
+ hooks: path.join(env.HOME || os.homedir(), '.hncode', 'hooks.json'),
462
+ skills: path.join(env.HOME || os.homedir(), '.hncode', 'skills'),
463
+ plugins: path.join(env.HOME || os.homedir(), '.hncode', 'plugins'),
464
+ ...(input.files || {}),
465
+ };
466
+
467
+ const results = [
468
+ ...checkRuntime(input.node || process.version, input.platform || process.platform, {
469
+ home: path.join(env.HOME || os.homedir(), '.hncode'),
470
+ cwd: process.cwd(),
471
+ }),
472
+ ...checkConfig(cfg, files),
473
+ ...checkWorkspace(workspace),
474
+ ...(await checkSessions(files, sessionsDir)),
475
+ ...checkErrorLog(files),
476
+ ...checkMcp(input.mcpServers, input.mcpConnections),
477
+ ...checkHooks(files, workspace),
478
+ ...checkExtensions(files),
479
+ ...checkDisk(sessionsDir),
480
+ ...checkGit(workspace, input.childProcess),
481
+ ...checkTerminal(env, input.dims),
482
+ ];
483
+ // The live probe is OPT-IN: it spends a request and a few tokens, so it must never run
484
+ // as part of a plain /doctor.
485
+ if (input.probe) results.push(...await probeProvider(cfg, input));
486
+ return results;
487
+ }
488
+
489
+ /** Counts by severity, for a one-line summary. */
490
+ export function summarize(results) {
491
+ const n = { error: 0, warn: 0, ok: 0, info: 0 };
492
+ for (const r of results || []) if (n[r.severity] != null) n[r.severity] += 1;
493
+ return n;
494
+ }
495
+
496
+ /** Render the report as a list of lines, worst first. Pure, so a test can assert on it. */
497
+ export function formatDoctor(results, opts = {}) {
498
+ const list = Array.isArray(results) ? results : [];
499
+ const showOk = opts.showOk !== false;
500
+ const rank = (s) => { const i = SEVERITY_ORDER.indexOf(s); return i < 0 ? 99 : i; };
501
+ const sorted = list.slice().sort((a, b) => rank(a.severity) - rank(b.severity));
502
+ const n = summarize(list);
503
+ const head = `Doctor — ${n.error} error(s), ${n.warn} warning(s)`
504
+ + (opts.version ? ` hncode v${opts.version}` : '');
505
+ const lines = [head, ''];
506
+ for (const r of sorted) {
507
+ if (!showOk && (r.severity === 'ok' || r.severity === 'info')) continue;
508
+ const mark = MARK[r.severity] || '·';
509
+ lines.push(`${mark} ${r.name.padEnd(24)} ${r.detail}`);
510
+ if (r.fix && (r.severity === 'error' || r.severity === 'warn')) lines.push(` ${' '.repeat(24)} → ${r.fix}`);
511
+ }
512
+ if (!n.error && !n.warn) {
513
+ lines.push('', 'No problems found.');
514
+ } else {
515
+ lines.push('', 'Run /doctor --all to include the passing checks.');
516
+ }
517
+ return lines;
518
+ }
@@ -0,0 +1,122 @@
1
+ // Feature flags for work that is not finished enough to be on for everyone.
2
+ //
3
+ // Why this exists instead of "just leave it off in the code": an unfinished feature
4
+ // needs to be EXERCISED, by the person building it and by anyone willing to help,
5
+ // against real sessions. Commenting it out means it is only ever tested on the
6
+ // branch where it was written. A named flag also makes the risk legible — `/experiments`
7
+ // lists what is on, and `hncode.experiments` records it, so a bug report can say
8
+ // which combination produced it.
9
+ //
10
+ // Scope rules:
11
+ // * OFF unless turned on. A flag whose default is "on" is not an experiment, it
12
+ // is a release.
13
+ // * A flag that no code reads is a bug, not a no-op: `pruneUnknown()` exists so
14
+ // the list cannot silently rot, and the test asserts every declared flag is
15
+ // referenced somewhere in src/.
16
+ // * Turning a flag off must leave no residue. `applyExperiments()` is therefore
17
+ // called on every reload and is idempotent.
18
+
19
+ /**
20
+ * The registry. Each entry is a flag with a one-line reason it exists — a flag with
21
+ * no explanation is indistinguishable from a mistake, and this list is what
22
+ * `/experiments` shows.
23
+ */
24
+ export const EXPERIMENTS = {
25
+ 'parallel-tools': {
26
+ default: false,
27
+ desc: 'Run independent tool calls from one model turn concurrently',
28
+ since: '0.5.0',
29
+ },
30
+ 'prompt-cache-hints': {
31
+ default: false,
32
+ desc: 'Send cache_control breakpoints on long stable prefixes (Anthropic)',
33
+ since: '0.5.0',
34
+ },
35
+ 'shell-parser-strict': {
36
+ default: false,
37
+ desc: 'Refuse to run a shell command the classifier cannot fully parse',
38
+ since: '0.5.0',
39
+ },
40
+ };
41
+
42
+ /** Is `name` a declared flag? */
43
+ export function isExperiment(name) {
44
+ return Object.prototype.hasOwnProperty.call(EXPERIMENTS, String(name == null ? '' : name).trim());
45
+ }
46
+
47
+ /**
48
+ * The flag state from config plus the environment.
49
+ *
50
+ * `HNCODE_EXPERIMENTS=a,b` enables without touching config.toml — the escape hatch a
51
+ * test or a one-off run needs. Config wins when both are present, because config is
52
+ * the thing the user edited deliberately.
53
+ */
54
+ export function experimentState(cfg, env = process.env) {
55
+ const out = {};
56
+ for (const [name, spec] of Object.entries(EXPERIMENTS)) out[name] = !!spec.default;
57
+ const fromEnv = String(env.HNCODE_EXPERIMENTS || '')
58
+ .split(',').map((s) => s.trim()).filter(Boolean);
59
+ for (const name of fromEnv) if (isExperiment(name)) out[name] = true;
60
+ const raw = (cfg && ((cfg.raw && cfg.raw.experiments) ?? cfg.experiments)) || {};
61
+ if (raw && typeof raw === 'object') {
62
+ for (const [name, v] of Object.entries(raw)) {
63
+ if (isExperiment(name)) out[name] = v === true || v === 'true' || v === 'on';
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** Just the enabled names, sorted — what a panel or a log line shows. */
70
+ export function enabledExperiments(cfg, env) {
71
+ const st = experimentState(cfg, env);
72
+ return Object.keys(st).filter((k) => st[k]).sort();
73
+ }
74
+
75
+ /**
76
+ * Merge new flag values into a config's raw table, returning the table to persist.
77
+ *
78
+ * Written as a pure function of (current, changes) so the caller does not have to
79
+ * know how the config is shaped, and so the "turn one off" direction is testable:
80
+ * an explicit `false` is KEPT (it records a deliberate choice), while an unknown
81
+ * name is dropped rather than written into the file.
82
+ */
83
+ export function mergeExperiments(current, changes) {
84
+ const out = { ...(current && typeof current === 'object' ? current : {}) };
85
+ for (const [name, v] of Object.entries(changes || {})) {
86
+ if (!isExperiment(name)) continue;
87
+ if (v === undefined) delete out[name];
88
+ else out[name] = v === true || v === 'true' || v === 'on';
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Drop names that are no longer flags, so a config does not accumulate entries for
95
+ * features that were removed or renamed. Returns { kept, dropped }.
96
+ */
97
+ export function pruneUnknown(current) {
98
+ const kept = {};
99
+ const dropped = [];
100
+ for (const [name, v] of Object.entries(current && typeof current === 'object' ? current : {})) {
101
+ if (isExperiment(name)) kept[name] = v;
102
+ else dropped.push(name);
103
+ }
104
+ return { kept, dropped };
105
+ }
106
+
107
+ /** Panel lines for `/experiments`. */
108
+ export function describeExperiments(state) {
109
+ const rows = [];
110
+ for (const [name, spec] of Object.entries(EXPERIMENTS)) {
111
+ rows.push({
112
+ name,
113
+ on: !!(state && state[name]),
114
+ desc: spec.desc,
115
+ since: spec.since,
116
+ defaultOn: !!spec.default,
117
+ });
118
+ }
119
+ return rows;
120
+ }
121
+
122
+ export default { EXPERIMENTS, experimentState, enabledExperiments, mergeExperiments, pruneUnknown, describeExperiments, isExperiment };