@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
@@ -0,0 +1,113 @@
1
+ // NotebookEdit — read and edit a Jupyter notebook by CELL.
2
+ //
3
+ // WHY A TOOL AND NOT A BRANCH IN `Edit`
4
+ // ------------------------------------
5
+ // `Edit`'s contract is "replace this text with that text", and a notebook has no text to
6
+ // match: the source lives inside a JSON array, so a `old_string` would match in one place in
7
+ // the .ipynb and mean something different in the cell. Expressing the operation as CELL
8
+ // indices removes the ambiguity entirely — there is nothing to match, so nothing to match
9
+ // wrongly.
10
+ //
11
+ // `tools/edit.js` still refuses a .ipynb, and now says to use THIS instead. That refusal is
12
+ // the safety property: a notebook can only be changed through an operation that knows its
13
+ // structure, so the corruption the refusal prevented remains impossible.
14
+ //
15
+ // Everything outside the edited `source` is preserved: outputs, metadata, cell ids, the
16
+ // top-level metadata. See src/notebook.js for why that is an invariant rather than a nicety.
17
+
18
+ import {
19
+ loadNotebook, listCells, describeCells, replaceSource, insertCell,
20
+ deleteCell, checkIndex, cellSource, saveNotebook,
21
+ } from '../notebook.js';
22
+ import { resolvePath } from './utils.js';
23
+ import { checkpointBeforeWrite } from './utils.js';
24
+
25
+ export const spec = {
26
+ name: 'NotebookEdit',
27
+ description: `Read and change a Jupyter notebook (.ipynb) by CELL. Plain Edit refuses .ipynb because a notebook is JSON — use this instead.
28
+
29
+ Actions:
30
+ - \`list\`: every cell with its index, type, line count and a one-line preview. Do this FIRST unless you already know the indices.
31
+ - \`read\`: one cell's source in full.
32
+ - \`replace\`: replace one cell's source. Everything else about the cell (its outputs, metadata, id) is preserved.
33
+ - \`insert\`: add a cell BEFORE an index; use index = cell count to append.
34
+ - \`delete\`: remove one cell.
35
+
36
+ Indices are 0-based and refer to the list \`list\` prints. Recent output is kept when a cell's source is replaced, exactly as Jupyter does when you edit without re-running.`,
37
+ parameters: {
38
+ type: 'object',
39
+ properties: {
40
+ path: { type: 'string', description: 'Path to the .ipynb file.' },
41
+ action: { type: 'string', enum: ['list', 'read', 'replace', 'insert', 'delete'], description: 'What to do.' },
42
+ index: { type: 'integer', description: '0-based cell index. Required for read/replace/insert/delete. For insert, the new cell goes BEFORE this index (cell count to append).' },
43
+ source: { type: 'string', description: 'The cell source. Required for replace and insert.' },
44
+ cell_type: { type: 'string', enum: ['code', 'markdown', 'raw'], description: 'Type for an inserted cell; defaults to code.' },
45
+ },
46
+ required: ['path', 'action'],
47
+ },
48
+
49
+ async execute(args, ctx) {
50
+ if (typeof args.path !== 'string' || !args.path.trim()) return 'Error: `path` is required.';
51
+ const action = String(args.action || '').toLowerCase();
52
+ if (!['list', 'read', 'replace', 'insert', 'delete'].includes(action)) {
53
+ return `Error: \`action\` must be one of list, read, replace, insert, delete (got ${JSON.stringify(args.action)}).`;
54
+ }
55
+ let abs;
56
+ try { abs = resolvePath(args.path, ctx); } catch (e) { return e.message; }
57
+ if (!String(abs).toLowerCase().endsWith('.ipynb')) {
58
+ return `Error: ${args.path} is not a .ipynb file. Use Edit for ordinary text files.`;
59
+ }
60
+ const loaded = loadNotebook(abs);
61
+ if (!loaded.ok) return `Error: ${loaded.error}`;
62
+
63
+ if (action === 'list') {
64
+ return describeCells(loaded.doc, args.path).join('\n');
65
+ }
66
+
67
+ const idx = checkIndex(loaded.doc, args.index);
68
+ if (!idx.ok) return `Error: ${idx.error}`;
69
+
70
+ if (action === 'read') {
71
+ const cell = loaded.doc.cells[args.index];
72
+ const type = cell.cell_type || 'unknown';
73
+ const src = cellSource(cell);
74
+ return `cell ${args.index} (${type}, ${src ? src.split('\n').length : 0} line(s)):\n\n${src}`;
75
+ }
76
+
77
+ if (action === 'replace') {
78
+ if (typeof args.source !== 'string') return 'Error: `source` is required for replace.';
79
+ const before = cellSource(loaded.doc.cells[args.index]);
80
+ const r = replaceSource(loaded.doc, args.index, args.source);
81
+ if (!r.ok) return `Error: ${r.error}`;
82
+ // A checkpoint BEFORE the write, like Edit does, so /checkpoints and /undo can put
83
+ // the previous cell content back.
84
+ try { checkpointBeforeWrite(abs, ctx); } catch { /* best effort */ }
85
+ const saved = saveNotebook(abs, loaded.doc, loaded.indent);
86
+ if (!saved.ok) return `Error: saving ${args.path}: ${saved.error}`;
87
+ const beforeLines = before ? before.split('\n').length : 0;
88
+ const afterLines = args.source ? args.source.split('\n').length : 0;
89
+ return `Replaced the source of ${args.path} cell ${args.index} (${beforeLines} -> ${afterLines} line(s)). The cell's outputs and metadata are unchanged.`;
90
+ }
91
+
92
+ if (action === 'insert') {
93
+ if (typeof args.source !== 'string') return 'Error: `source` is required for insert.';
94
+ const r = insertCell(loaded.doc, args.index, args.source, args.cell_type);
95
+ if (!r.ok) return `Error: ${r.error}`;
96
+ try { checkpointBeforeWrite(abs, ctx); } catch { /* best effort */ }
97
+ const saved = saveNotebook(abs, loaded.doc, loaded.indent);
98
+ if (!saved.ok) return `Error: saving ${args.path}: ${saved.error}`;
99
+ return `Inserted a ${r.cell.cell_type} cell at index ${args.index} in ${args.path}. The notebook now has ${loaded.doc.cells.length} cell(s).`;
100
+ }
101
+
102
+ // delete
103
+ const r = deleteCell(loaded.doc, args.index);
104
+ if (!r.ok) return `Error: ${r.error}`;
105
+ try { checkpointBeforeWrite(abs, ctx); } catch { /* best effort */ }
106
+ const saved = saveNotebook(abs, loaded.doc, loaded.indent);
107
+ if (!saved.ok) return `Error: saving ${args.path}: ${saved.error}`;
108
+ return `Deleted cell ${args.index} from ${args.path}. The notebook now has ${loaded.doc.cells.length} cell(s).`;
109
+ },
110
+ };
111
+
112
+ /** Exported for tests: the cell list without touching the filesystem. */
113
+ export { listCells };
@@ -52,8 +52,12 @@ export const spec = {
52
52
  let p;
53
53
  try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
54
54
 
55
- if (!fs.existsSync(p)) return `Error: file not found: ${args.path}`;
56
- const stat = fs.statSync(p);
55
+ // Async fs throughout. These calls used to be sync, and an image may be tens
56
+ // of MB — reading it (plus the base64 encode) blocked the event loop long
57
+ // enough to freeze the TUI while the Working spinner was animating.
58
+ let stat;
59
+ try { stat = await fs.promises.stat(p); }
60
+ catch { return `Error: file not found: ${args.path}`; }
57
61
  if (!stat.isFile()) return `Error: not a file: ${args.path}`;
58
62
 
59
63
  const ext = path.extname(p).toLowerCase();
@@ -67,7 +71,10 @@ export const spec = {
67
71
  + 'Resize it, or raise the limit with HNCODE_MEDIA_MAX_BYTES.';
68
72
  }
69
73
  const mimeType = IMAGE_MIME[ext];
70
- const data = fs.readFileSync(p).toString('base64');
74
+ let buf;
75
+ try { buf = await fs.promises.readFile(p); }
76
+ catch (e) { return `Error: could not read ${args.path}: ${e.message}`; }
77
+ const data = buf.toString('base64');
71
78
  const approxKB = Math.round(data.length / 1024);
72
79
  return {
73
80
  text: `[image] ${args.path} (${sizeKB}KB, ${mimeType})`,
@@ -15,10 +15,26 @@
15
15
  // on which one was found.
16
16
 
17
17
  import cp from 'node:child_process';
18
+ import fs from 'node:fs';
18
19
 
19
20
  // The two PowerShell families, by the name they are invoked as.
20
21
  export const SHELL_CANDIDATES = ['pwsh', 'powershell'];
21
22
 
23
+ // Last-resort Windows fallback. A Windows box always has cmd.exe (%COMSPEC%),
24
+ // so when neither PowerShell exists the Bash tool must still run SOMETHING
25
+ // rather than failing outright. `cmd` is not a PowerShell, so the syntax
26
+ // guidance and the spawn args differ (see cmdSpawnArgs).
27
+ export function findCmdExe() {
28
+ const fromEnv = process.env.COMSPEC || process.env.ComSpec;
29
+ if (fromEnv) {
30
+ try { if (fs.existsSync(fromEnv)) return fromEnv; } catch { /* fall through to the PATH probe */ }
31
+ }
32
+ if (process.platform === 'win32') {
33
+ return whichSync('cmd') || whichSync('cmd.exe') || 'cmd.exe';
34
+ }
35
+ return null;
36
+ }
37
+
22
38
  /**
23
39
  * Resolve an executable name through PATH without spawning it.
24
40
  *
@@ -42,13 +58,15 @@ function whichSync(name) {
42
58
  }
43
59
 
44
60
  /**
45
- * Find a usable PowerShell: pwsh first, then the built-in powershell.
46
- * Returns { path, name, edition } or null when neither exists.
61
+ * Find a usable shell: pwsh, then the built-in powershell, then (on Windows)
62
+ * cmd.exe as a last resort. Returns { path, name, edition } or null.
47
63
  *
48
64
  * `edition` drives syntax guidance — see the header. It is inferred from the NAME
49
65
  * rather than by running the binary (a spawn per probe is not worth it, and
50
66
  * PowerShell 6, also named `pwsh`, is EOL and not a realistic target, so `pwsh`
51
- * safely implies 7+).
67
+ * safely implies 7+). `edition: 'cmd'` means neither PowerShell exists and the
68
+ * Bash tool is running through cmd.exe: it still works, but the command syntax is
69
+ * cmd's, not PowerShell's.
52
70
  */
53
71
  export function detectShell() {
54
72
  for (const name of SHELL_CANDIDATES) {
@@ -57,6 +75,9 @@ export function detectShell() {
57
75
  return { path, name, edition: name === 'pwsh' ? 'core' : 'desktop' };
58
76
  }
59
77
  }
78
+ // No PowerShell at all: on Windows, guarantee cmd.exe so the tool is not dead.
79
+ const cmd = findCmdExe();
80
+ if (cmd) return { path: cmd, name: 'cmd', edition: 'cmd' };
60
81
  return null;
61
82
  }
62
83
 
@@ -91,16 +112,23 @@ export function resetShellCache() {
91
112
  * on every single tool call, and `-NonInteractive` stops a command that prompts
92
113
  * from hanging the tool forever with no way to answer.
93
114
  *
94
- * Throws a message naming the fix when no PowerShell is installed, instead of
95
- * letting the caller hit a bare ENOENT.
115
+ * When neither PowerShell exists, `edition === 'cmd'` selects cmd.exe args instead
116
+ * (see detectShell). Throws a message naming the fix only when no shell at all
117
+ * was found, instead of letting the caller hit a bare ENOENT.
96
118
  */
97
119
  export function shellSpawnArgs(command) {
98
120
  const shell = getShell();
99
121
  if (!shell) {
100
122
  throw new Error(
101
- 'No PowerShell found on PATH (tried pwsh, powershell). '
123
+ 'No shell found (tried pwsh, powershell, and cmd.exe). '
102
124
  + 'Install PowerShell 7+ or add it to PATH, then retry.',
103
125
  );
104
126
  }
127
+ if (shell.edition === 'cmd') {
128
+ // cmd.exe: `/d` skips AutoRun commands, `/s` makes `/c` quoting behave
129
+ // predictably. The command is passed as ONE argv element, so Node does the
130
+ // quoting and we do not wrap it ourselves.
131
+ return { bin: shell.path, args: ['/d', '/s', '/c', command], shell };
132
+ }
105
133
  return { bin: shell.path, args: ['-NoProfile', '-NonInteractive', '-Command', command], shell };
106
134
  }