@hncode/hncode 0.7.1 → 0.7.2

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 CHANGED
@@ -1,6 +1,10 @@
1
- # hncode — AI Coding Agent TUI
1
+ ![HNCode](imgs/HNCode.png)
2
2
 
3
- English | [简体中文](https://github.com/NiceHello666/hncode/blob/main/README_CN.md) | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)
3
+ # HNCode
4
+
5
+ ---
6
+
7
+ English | [简体中文](https://github.com/NiceHello666/HNCode/blob/main/README_CN.md) | [Wiki](https://github.com/NiceHello666/HNCode/blob/main/README_WIKI.md)
4
8
 
5
9
  > **This is a product of my vibe coding session. I'm trying my best to make it perfect, so if you have any suggestions, feel free to open an issue.**
6
10
 
@@ -8,7 +12,7 @@ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](h
8
12
 
9
13
  ---
10
14
 
11
- ## 🌟 Features
15
+ ## Features
12
16
 
13
17
  - **Cyan-blue TUI**: Raw terminal renderer for perfect Windows Terminal compatibility
14
18
  - **Dual Protocol Support**: Works with both OpenAI-compatible APIs and Anthropic
@@ -64,7 +68,7 @@ hncode control interrupt
64
68
 
65
69
  ---
66
70
 
67
- ## 🚀 Installation
71
+ ## Installation
68
72
 
69
73
  ### Global Installation (Recommended)
70
74
 
@@ -85,27 +89,27 @@ npx @hncode/hncode
85
89
 
86
90
  ---
87
91
 
88
- ## 📖 Documentation
92
+ ## Documentation
89
93
 
90
94
  This file is a quick overview. For the **full documentation** — every command, config option, architecture notes, and the plugin/development guide — see the Wiki:
91
95
 
92
- - **[Wiki (English)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)**
93
- - **[Wiki (简体中文)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)**
96
+ - **[Wiki (English)](https://github.com/NiceHello666/HNCode/blob/main/README_WIKI.md)**
97
+ - **[Wiki (简体中文)](https://github.com/NiceHello666/HNCode/blob/main/README_WIKI_CN.md)**
94
98
 
95
99
  ---
96
100
 
97
- ## 🤝 Contributing
101
+ ## Contributing
98
102
 
99
103
  **Feel free to:**
100
104
  - Submit issues for bugs or suggestions
101
105
  - Fork and create pull requests
102
- - Share your experience using hncode
106
+ - Share your experience using HNCode
103
107
 
104
108
  ---
105
109
 
106
- ## 📄 License
110
+ ## License
107
111
 
108
- This project is licensed under the [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
112
+ This project is licensed under the [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/HNCode/blob/main/LICENSE).
109
113
 
110
114
  **In short:**
111
115
  1. ✅ Free for noncommercial use — personal projects, learning, research, hobby work, and noncommercial organizations
@@ -116,4 +120,4 @@ This project is licensed under the [PolyForm Noncommercial License 1.0.0](https:
116
120
  ---
117
121
 
118
122
  **NPM Package**: [@hncode/hncode](https://www.npmjs.com/package/@hncode/hncode)
119
- **GitHub**: [NiceHello666/hncode](https://github.com/NiceHello666/hncode)
123
+ **GitHub**: [NiceHello666/HNCode](https://github.com/NiceHello666/HNCode)
package/README_CN.md CHANGED
@@ -1,6 +1,10 @@
1
- # hncode — AI 编程助手 TUI
1
+ ![HNCode](imgs/HNCode.png)
2
2
 
3
- [English](https://github.com/NiceHello666/hncode/blob/main/README.md) | 简体中文 | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)
3
+ # HNCode
4
+
5
+ ---
6
+
7
+ [English](https://github.com/NiceHello666/HNCode/blob/main/README.md) | 简体中文 | [Wiki](https://github.com/NiceHello666/HNCode/blob/main/README_WIKI_CN.md)
4
8
 
5
9
  > **这是我vibe coding的产物. 我在尽全力让它变的完美, 所以如果你有何建议, 欢迎开一个issue.**
6
10
 
@@ -8,7 +12,7 @@
8
12
 
9
13
  ---
10
14
 
11
- ## 🌟 核心特性
15
+ ## 核心特性
12
16
 
13
17
  - **原生 TUI 渲染**: 使用原始终端序列渲染,完美兼容 Windows Terminal,解决 blessed 等库在 Windows 上的兼容性问题
14
18
  - **双协议支持**: 同时支持 OpenAI-compatible API 和 Anthropic API
@@ -20,7 +24,7 @@
20
24
 
21
25
  ---
22
26
 
23
- ## 🚀 安装
27
+ ## 安装
24
28
 
25
29
  ### 全局安装(推荐)
26
30
 
@@ -41,27 +45,27 @@ npx @hncode/hncode
41
45
 
42
46
  ---
43
47
 
44
- ## 📖 文档
48
+ ## 文档
45
49
 
46
50
  本文件只是简要介绍。**完整文档**(所有命令、配置项、架构说明、插件与开发指南)请见 Wiki:
47
51
 
48
- - **[Wiki(英文)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)**
49
- - **[Wiki(简体中文)](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)**
52
+ - **[Wiki(英文)](https://github.com/NiceHello666/HNCode/blob/main/README_WIKI.md)**
53
+ - **[Wiki(简体中文)](https://github.com/NiceHello666/HNCode/blob/main/README_WIKI_CN.md)**
50
54
 
51
55
  ---
52
56
 
53
- ## 🤝 贡献
57
+ ## 贡献
54
58
 
55
59
  **欢迎:**
56
60
  - 提交问题报告 bug 或提出建议
57
61
  - Fork 并创建拉取请求
58
- - 分享您使用 hncode 的体验
62
+ - 分享您使用 HNCode 的体验
59
63
 
60
64
  ---
61
65
 
62
- ## 📄 许可证
66
+ ## 许可证
63
67
 
64
- 本项目的许可受 [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
68
+ 本项目的许可受 [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/HNCode/blob/main/LICENSE) 保护。
65
69
 
66
70
  **简单来说:**
67
71
  1. ✅ 非商业用途免费——个人项目、学习、研究、业余爱好,以及非商业组织
@@ -72,4 +76,4 @@ npx @hncode/hncode
72
76
  ---
73
77
 
74
78
  **NPM 包**: [@hncode/hncode](https://www.npmjs.com/package/@hncode/hncode)
75
- **GitHub**: [NiceHello666/hncode](https://github.com/NiceHello666/hncode)
79
+ **GitHub**: [NiceHello666/HNCode](https://github.com/NiceHello666/HNCode)
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin/",
12
12
  "src/",
13
+ "imgs/",
13
14
  "LICENSE",
14
15
  "README.md",
15
16
  "README_CN.md",
package/src/ptc.js CHANGED
@@ -10,15 +10,43 @@
10
10
  import { Worker } from 'node:worker_threads';
11
11
  import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
+ import { pluginTools } from './plugin.js';
13
14
 
14
15
  const here = path.dirname(fileURLToPath(import.meta.url));
15
16
 
16
- /** Tools whose results are safe and useful to hand to code, and whose invocation needs no UI. */
17
- const CALLABLE = new Set([
17
+
18
+ /**
19
+ * The tools this program ships that are safe to call from generated code.
20
+ *
21
+ * A hardcoded list is right for the built-ins — whether `Write` belongs in a sandbox is a decision about THIS
22
+ * program, and it should be made once, here, and not by whoever writes the next tool.
23
+ *
24
+ * It is the wrong shape for a PLUGIN, though: a plugin already knows whether its own tool touches anything, and
25
+ * the core cannot keep a list of names it has never heard of. A plugin spec declaring `programmatic: true` joins
26
+ * this set at call time — see `callableTools()`.
27
+ */
28
+ const BUILTIN_CALLABLE = [
18
29
  'Read', 'FileLines', 'Glob', 'Grep', 'Git', 'FetchURL', 'WebSearch',
19
30
  'TodoList', 'TaskList', 'TaskOutput', 'Memory', 'ListTeammates', 'TeamTaskList',
20
31
  'ActivateSkill', 'Goal', 'Diagnostics',
21
- ]);
32
+ ];
33
+
34
+ /**
35
+ * Which tools `callTool` may reach right now.
36
+ *
37
+ * Read fresh on every run rather than captured at import: a plugin loaded after this module still gets in, and a
38
+ * plugin that unloaded takes its tools with it.
39
+ */
40
+ export function callableTools() {
41
+ const names = new Set(BUILTIN_CALLABLE);
42
+ for (const t of pluginTools) {
43
+ if (t && t.programmatic === true && t.name) names.add(t.name);
44
+ }
45
+ return [...names];
46
+ }
47
+
48
+ /** Kept for callers that want the built-in set only. */
49
+ export const CALLABLE_TOOLS = [...BUILTIN_CALLABLE];
22
50
 
23
51
  export const DEFAULT_LIMITS = {
24
52
  // A code body that orchestrates twenty calls is already unusual; five hundred is a loop that has gone wrong.
@@ -28,8 +56,6 @@ export const DEFAULT_LIMITS = {
28
56
  timeoutMs: 120000,
29
57
  };
30
58
 
31
- export const CALLABLE_TOOLS = [...CALLABLE];
32
-
33
59
  /**
34
60
  * Run `code` in a sandbox, letting it call the tools in `CALLABLE`.
35
61
  *
@@ -55,8 +81,11 @@ export function runProgrammatic(code, ctx, limits = {}) {
55
81
  resolve(result);
56
82
  };
57
83
 
84
+ // Resolved ONCE per run, so the list the model is told about and the list the guard enforces are the same one
85
+ // even if a plugin loads while the code is running.
86
+ const callable = new Set(callableTools());
58
87
  const worker = new Worker(path.join(here, 'ptc-worker.js'), {
59
- workerData: { toolNames: [...CALLABLE], timeoutMs: lim.timeoutMs },
88
+ workerData: { toolNames: [...callable], timeoutMs: lim.timeoutMs },
60
89
  // The sandbox needs no stdio and no shared memory.
61
90
  stdout: true,
62
91
  stderr: true,
@@ -77,7 +106,7 @@ export function runProgrammatic(code, ctx, limits = {}) {
77
106
  return;
78
107
  }
79
108
  const name = String(m.name || '');
80
- if (!CALLABLE.has(name)) {
109
+ if (!callable.has(name)) {
81
110
  worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `${name} is not callable from code` });
82
111
  return;
83
112
  }
package/src/remote.js CHANGED
@@ -60,7 +60,7 @@ async function tlsFetch(url, opts = {}) {
60
60
  }
61
61
 
62
62
  // The canonical source. Overridable so a fork (or a test) can point elsewhere.
63
- export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/hncode';
63
+ export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/HNCode';
64
64
  export const DEFAULT_REF = process.env.HNCODE_REPO_REF || 'main';
65
65
 
66
66
  // Where remote plugins land. `plugin.js` loads *.js/*.mjs from this directory.
package/src/tools/glob.js CHANGED
@@ -5,9 +5,26 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { resolvePath } from './utils.js';
8
- import { walkFiles } from './ignore.js';
8
+ import { walkFilesAsync } from './ignore.js';
9
9
  import { globToRegexSource, expandBraces } from './matchers.js';
10
10
 
11
+ /**
12
+ * Entries directly inside `dir` — no recursion.
13
+ *
14
+ * This is what a pattern with no separator asks for, and answering it with a full tree walk is how a one-line
15
+ * lookup turned into a 43-second scan of a drive.
16
+ */
17
+ function shallowEntries(dir, includeDirs) {
18
+ let entries;
19
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
20
+ const out = [];
21
+ for (const ent of entries) {
22
+ if (!includeDirs && !ent.isFile()) continue;
23
+ out.push(path.join(dir, ent.name));
24
+ }
25
+ return out;
26
+ }
27
+
11
28
  export const spec = {
12
29
  name: 'Glob',
13
30
  description: 'Find files by glob pattern. Honors ignore files unless include_ignored. Up to 100 matches, newest first.',
@@ -46,13 +63,34 @@ const alts = expandBraces(args.pattern);
46
63
  const re = new RegExp('^(?:' + alts.map((a) => globToRegexSource(a)).join('|') + ')$');
47
64
 
48
65
 
49
- const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: !!args.include_dirs });
66
+ // HOW DEEP TO WALK IS DECIDED BY THE PATTERN, not by the matcher.
67
+ //
68
+ // `*.java` contains no slash, so it describes entries DIRECTLY inside `path`. Matching it against every
69
+ // file's basename made it hit the whole subtree — which is why one call over a drive root took 43 seconds and
70
+ // still reported "no files matched", having walked hundreds of thousands of entries to reject them all.
71
+ //
72
+ // A pattern with a separator (`src/**/*.ts`, `**/*.java`) is asking to descend, and gets the full walk. A bare
73
+ // one is answered by a single readdir.
74
+ const wantsDeep = alts.some((a) => a.includes('/') || a.includes(path.sep));
75
+ let candidates;
76
+ if (wantsDeep) {
77
+ // ASYNC: a synchronous walk over a large tree freezes the loop it runs on — no paint, no keystroke, no
78
+ // timer, for as long as it takes.
79
+ candidates = await walkFilesAsync(base, {
80
+ includeIgnored: !!args.include_ignored,
81
+ includeDirs: !!args.include_dirs,
82
+ });
83
+ } else {
84
+ candidates = shallowEntries(base, !!args.include_dirs);
85
+ }
86
+
50
87
  const matches = [];
51
- for (const abs of files) {
88
+ for (const abs of candidates) {
52
89
  const rel = path.relative(base, abs).split(path.sep).join('/');
53
- const winRel = abs.replace(/\\/g, '/');
54
- const base_ = path.basename(abs);
55
- if (re.test(rel) || re.test(winRel) || re.test(base_)) matches.push(abs);
90
+ // Match the RELATIVE PATH only. The absolute path and the bare basename were both accepted before, and
91
+ // both were wrong: an absolute path lets `tmp/*.log` match a directory that merely appears in the middle of
92
+ // it, and the basename turns a pattern that names no directory into a recursive search.
93
+ if (re.test(rel)) matches.push(abs);
56
94
  }
57
95
  if (matches.length > 100) matches.length = 100;
58
96
  if (matches.length === 0) return `No files matched \`${args.pattern}\` in ${fromDir}`;
package/src/tools/grep.js CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import cp from 'node:child_process';
8
8
  import { isProbablyTextFile, truncateBuf, resolvePath } from './utils.js';
9
- import { walkFiles } from './ignore.js';
9
+ import { walkFilesAsync } from './ignore.js';
10
10
  import { matchGlob } from './matchers.js';
11
11
 
12
12
  export const spec = {
@@ -40,7 +40,7 @@ export const spec = {
40
40
  try { base = resolvePath(args.path || '.', ctx); } catch (e) { return `Error: ${e.message}`; }
41
41
  if (!fs.existsSync(base)) return `Error: path does not exist: ${args.path}`;
42
42
  if (!args.multiline && hasRg()) return await grepRg(args, base, ctx);
43
- return grepJs(args, base, ctx);
43
+ return await grepJs(args, base, ctx);
44
44
  },
45
45
  };
46
46
 
@@ -90,7 +90,7 @@ function grepRg(args, base, ctx) {
90
90
  try {
91
91
  child = cp.spawn('rg', buildRgArgs(args, base), { cwd: ctx.cwd || ctx.workspace });
92
92
  } catch {
93
- resolve(grepJs(args, base, ctx));
93
+ grepJs(args, base, ctx).then((r) => resolve(r), (e) => resolve(`Error: ${e.message}`));
94
94
  return;
95
95
  }
96
96
  let stdout = '';
@@ -103,7 +103,7 @@ function grepRg(args, base, ctx) {
103
103
  child.stderr.on('data', (d) => { stderr += d; });
104
104
  // spawn failure (ENOENT, etc.): fall back to the JS walker rather than
105
105
  // reporting an empty result as "No matches found".
106
- child.on('error', () => done(() => resolve(grepJs(args, base, ctx))));
106
+ child.on('error', () => done(() => grepJs(args, base, ctx).then((r) => resolve(r), (e) => resolve(`Error: ${e.message}`))));
107
107
  child.on('close', (code) => done(() => {
108
108
  if (code > 1 && stderr) resolve(`rg error: ${stderr.trim()}`);
109
109
  else if (!stdout.trim() && args.pattern.includes('$')) {
@@ -111,7 +111,7 @@ function grepRg(args, base, ctx) {
111
111
  // which rg cannot treat as line breaks at all. The JS walker handles every
112
112
  // ending, so it gets the final word — only for `$` patterns that found
113
113
  // nothing, so ordinary searches never pay for the extra pass.
114
- resolve(grepJs(args, base, ctx));
114
+ grepJs(args, base, ctx).then((r) => resolve(r), (e) => resolve(`Error: ${e.message}`));
115
115
  }
116
116
  else resolve(paginate(stdout, args));
117
117
  }));
@@ -124,10 +124,12 @@ function grepRg(args, base, ctx) {
124
124
  });
125
125
  }
126
126
 
127
- function grepJs(args, base, ctx) {
127
+ async function grepJs(args, base, ctx) {
128
128
  const flags = 'g' + (args['-i'] ? 'i' : '') + (args.multiline ? 's' : '');
129
129
  const re = new RegExp(args.pattern, flags);
130
- const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false, limit: 0 });
130
+ // ASYNC, for the same reason as Glob: a synchronous walk over a large tree freezes everything else that shares
131
+ // the process.
132
+ const files = await walkFilesAsync(base, { includeIgnored: !!args.include_ignored, includeDirs: false, limit: 0 });
131
133
  const filtered = files.filter((abs) => {
132
134
  if (args.glob) {
133
135
  const rel = path.relative(base, abs).split(path.sep).join('/');
@@ -135,3 +135,72 @@ function _walk(dir, relPrefix, parentPatterns, found, opts) {
135
135
  }
136
136
  }
137
137
  }
138
+
139
+ /**
140
+ * The same walk, WITHOUT freezing the process it runs in.
141
+ *
142
+ * `walkFiles` is synchronous end to end (`readdirSync`, `statSync`). That is fine for a script with nothing else
143
+ * to do, and fatal for an editor: the event loop cannot paint, read a keystroke or answer a timer while the walk
144
+ * holds it. Measured on a drive root, one Glob took 43 seconds — during which the interface was simply dead,
145
+ * because nothing else could run.
146
+ *
147
+ * This version uses `fs.promises` and yields every so often, so the loop stays alive. The RESULT is identical;
148
+ * only the schedule differs.
149
+ *
150
+ * @param {string} base
151
+ * @param {{includeIgnored?: boolean, includeDirs?: boolean, limit?: number}} [opts]
152
+ */
153
+ export async function walkFilesAsync(base, opts = {}) {
154
+ const { includeIgnored = false, includeDirs = false, limit = 1000 } = opts;
155
+ const baseAbs = path.resolve(base);
156
+ const found = [];
157
+ // A counter, not a timer: yielding on a chunk of FILES keeps the pause proportional to the work rather than to
158
+ // wall-clock, so a fast directory costs nothing and a slow one still yields regularly.
159
+ const state = { sinceYield: 0 };
160
+ await _walkAsync(baseAbs, '', [], found, { includeIgnored, includeDirs }, state);
161
+ found.sort((a, b) => b.mtime - a.mtime);
162
+ if (limit > 0 && found.length > limit) found.length = limit;
163
+ return found.filter((e) => includeDirs || e.isFile).map((e) => e.path);
164
+ }
165
+
166
+ const YIELD_EVERY = 200;
167
+
168
+ async function _walkAsync(dir, relPrefix, parentPatterns, found, opts, state) {
169
+ const { includeIgnored, includeDirs } = opts;
170
+ let entries;
171
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; }
172
+ const acc = parentPatterns.concat(ignorePatternsFor(dir));
173
+ for (const ent of entries) {
174
+ const rel = relPrefix ? relPrefix + '/' + ent.name : ent.name;
175
+ const abs = path.join(dir, ent.name);
176
+ let isDir = !!ent.isDirectory();
177
+ if (isDir && (PRUNED_DIRS.has(ent.name)
178
+ || (!includeIgnored && PRUNED_UNLESS_IGNORED.has(ent.name)))) continue;
179
+ let ignored = false;
180
+ for (const pat of acc) {
181
+ if (matchPattern(pat, rel, isDir)) ignored = !pat.negate;
182
+ }
183
+ if (ignored && !includeIgnored) continue;
184
+
185
+ if (isDir) {
186
+ if (includeDirs) {
187
+ let mtime = 0;
188
+ try { mtime = (await fs.promises.stat(abs)).mtimeMs; } catch { /* keep 0 */ }
189
+ found.push({ path: abs, mtime, isFile: false });
190
+ }
191
+ await _walkAsync(abs, rel, acc, found, opts, state);
192
+ } else {
193
+ let mtime = 0;
194
+ try { mtime = (await fs.promises.stat(abs)).mtimeMs; } catch { /* keep 0 */ }
195
+ found.push({ path: abs, mtime, isFile: true });
196
+ }
197
+
198
+ state.sinceYield++;
199
+ if (state.sinceYield >= YIELD_EVERY) {
200
+ state.sinceYield = 0;
201
+ // Hand the loop a turn. `setImmediate` rather than `setTimeout(0)`: it keeps the walk's own ordering and
202
+ // does not add a timer per chunk.
203
+ await new Promise((r) => setImmediate(r));
204
+ }
205
+ }
206
+ }
package/src/tools/ptc.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * and the code can only reach the ones on the allowlist. See src/ptc.js for that list, and ptc-worker.js for the
14
14
  * sandbox itself.
15
15
  */
16
- import { runProgrammatic, CALLABLE_TOOLS } from '../ptc.js';
16
+ import { runProgrammatic, callableTools } from '../ptc.js';
17
17
 
18
18
  const DESCRIPTION = [
19
19
  'Orchestrate tools with JavaScript instead of calling them one at a time.',
@@ -41,13 +41,18 @@ const DESCRIPTION = [
41
41
  'print(hits.join(\'\\n\'));',
42
42
  '```',
43
43
  '',
44
- `CALLABLE TOOLS: ${CALLABLE_TOOLS.join(', ')}.`,
45
- 'Anything else is refused — a shell or a write would escape the sandbox\'s purpose.',
46
- ].join('\n');
44
+ // NOT a fixed list: a plugin can contribute tools at any time, and a description built once at import would
45
+ // name a set the model cannot call. `description` is a getter for that reason.
46
+ ];
47
+ const BASE_DESCRIPTION = DESCRIPTION.join('\n');
47
48
 
48
49
  export const spec = {
49
50
  name: 'ProgrammaticToolCall',
50
- description: DESCRIPTION,
51
+ // Built per read so a plugin that loaded after this module is named in the list the model is given.
52
+ get description() {
53
+ return BASE_DESCRIPTION + '\n\nCALLABLE TOOLS: ' + callableTools().join(', ') + '.'
54
+ + '\nAnything else is refused — a shell or a write would escape the sandbox\'s purpose.';
55
+ },
51
56
  parameters: {
52
57
  type: 'object',
53
58
  properties: {
package/src/tui.js CHANGED
@@ -259,7 +259,7 @@ export const COMMANDS = [
259
259
  { name: 'reload-config', desc: 'Reload config.toml settings only', priority: 60 },
260
260
  { name: 'plugins', desc: 'Open the plugin manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name> | reload]' },
261
261
  { name: 'skills', desc: 'Open the skill manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name>]' },
262
- { name: 'import-skill', desc: 'Add a skill from a Markdown file, or install every skill in a GitHub repository (owner/repo)', cooldown: false },
262
+ { name: 'import-skill', desc: 'Add a skill from a Markdown file, or install every skill in a GitHub repository (owner/repo)', cooldown: false },
263
263
  { name: 'hooks', desc: 'Show configured shell hooks, or test one', priority: 60, argumentHint: '[list | test <event>]' },
264
264
  { name: 'git', desc: 'Show repository status (branch, changes, remote)', priority: 62 },
265
265
  { name: 'commit', desc: 'Stage and commit with a message (or let the agent draft one)', priority: 62, argumentHint: '[message]' },
@@ -2332,34 +2332,28 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2332
2332
  // Renders above the input box: a rule, a "Todo" heading, up to 5 rows, and a
2333
2333
  // "+N more · ctrl+t to expand" hint. Ctrl+T expands the full list.
2334
2334
  const TODO_MAX_VISIBLE = 5;
2335
- export function selectVisibleTodos(todos) {
2336
- if (todos.length <= TODO_MAX_VISIBLE) return { rows: todos, hidden: 0, counts: {} };
2337
- const inProgress = [], pending = [], done = [];
2338
- todos.forEach((t, i) => {
2339
- if (t.status === 'in_progress') inProgress.push(i);
2340
- else if (t.status === 'pending') pending.push(i);
2341
- else done.push(i);
2342
- });
2343
- const picked = new Set(inProgress.slice(0, TODO_MAX_VISIBLE));
2344
- if (picked.size < TODO_MAX_VISIBLE) {
2345
- const doneC = [...done].reverse();
2346
- const pendC = pending;
2347
- const remaining = TODO_MAX_VISIBLE - picked.size;
2348
- let doneCount, pendCount;
2349
- if (!doneC.length) { doneCount = 0; pendCount = Math.min(remaining, pendC.length); }
2350
- else if (!pendC.length) { pendCount = 0; doneCount = Math.min(remaining, doneC.length); }
2351
- else {
2352
- doneCount = 1;
2353
- pendCount = Math.min(remaining - 1, pendC.length);
2354
- if (pendCount < remaining - 1) doneCount = Math.min(doneC.length, remaining - pendCount);
2355
- }
2356
- for (let i = 0; i < doneCount; i++) picked.add(doneC[i]);
2357
- for (let i = 0; i < pendCount; i++) picked.add(pendC[i]);
2358
- }
2359
- const idx = [...picked].sort((a, b) => a - b);
2335
+
2336
+ /**
2337
+ * The first `max` entries, in the order they were written.
2338
+ *
2339
+ * This used to RANK: in-progress first, then one done item, then pending. On a status change the panel therefore
2340
+ * reshuffled — the item being worked on jumped to the top and a finished one followed it — and the list stopped
2341
+ * describing the order the work was planned in. A task list is a sequence; a view that reorders it is showing
2342
+ * something other than what was written.
2343
+ *
2344
+ * `counts` still reports what the window is hiding, so the "+N more" hint stays informative.
2345
+ */
2346
+ export function selectVisibleTodos(todos, max = TODO_MAX_VISIBLE) {
2347
+ const list = Array.isArray(todos) ? todos : [];
2348
+ const limit = Number.isFinite(max) ? Math.max(0, Math.round(max)) : TODO_MAX_VISIBLE;
2349
+ if (list.length <= limit) return { rows: list, hidden: 0, counts: {} };
2350
+ const rows = list.slice(0, limit);
2360
2351
  const counts = { done: 0, in_progress: 0, pending: 0 };
2361
- todos.forEach((t, i) => { if (!picked.has(i)) counts[t.status] = (counts[t.status] || 0) + 1; });
2362
- return { rows: idx.map((i) => todos[i]), hidden: todos.length - idx.length, counts };
2352
+ for (let i = limit; i < list.length; i++) {
2353
+ const s = list[i] && list[i].status;
2354
+ counts[s] = (counts[s] || 0) + 1;
2355
+ }
2356
+ return { rows, hidden: list.length - rows.length, counts };
2363
2357
  }
2364
2358
 
2365
2359
  function todoRow(todo, w) {
@@ -2386,16 +2380,16 @@ function renderTodoPanel(state, w, hoverTop, dragTop) {
2386
2380
  out.push(col('─'.repeat(w), ruleColor));
2387
2381
  out.push(col(' Todo', C.cyan + C.bold));
2388
2382
  const want = todoRowCount(state);
2389
- // Keep the automatic pick ORDER (so the in-progress item keeps its priority) and
2390
- // top it up from the remaining items until `want` rows are shown.
2391
- const ordered = [];
2392
- const seen = new Set();
2393
- for (const t of selectVisibleTodos(todos).rows) { ordered.push(t); seen.add(t); }
2394
- for (const t of todos) {
2395
- if (ordered.length >= want) break;
2396
- if (!seen.has(t)) { ordered.push(t); seen.add(t); }
2397
- }
2398
- const rows = ordered.slice(0, want);
2383
+ // CREATION ORDER, ALWAYS.
2384
+ //
2385
+ // The panel used to draw `selectVisibleTodos` first — which ranks in-progress ahead of pending and pulls done
2386
+ // items up — and then top up from the original list. The result read as a reshuffle on every status change: the
2387
+ // item being worked on jumped to the top and a finished one followed it, so the list no longer described the
2388
+ // order the work was planned in. A task list is a SEQUENCE; showing it in a different sequence than it was
2389
+ // written in is what made it hard to follow.
2390
+ //
2391
+ // Which rows fit is still the panel's decision — it takes the first `want` in order.
2392
+ const rows = todos.slice(0, want);
2399
2393
  for (const t of rows) out.push(todoRow(t, w));
2400
2394
  const hidden = todos.length - rows.length;
2401
2395
  if (hidden > 0) {
@@ -12988,7 +12982,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
12988
12982
  // The body is EXACTLY what the user typed. Nothing is appended — a report
12989
12983
  // the user did not write (an environment footer, a template) reads as noise
12990
12984
  // in the issue form and has to be deleted before submitting.
12991
- const url = 'https://github.com/NiceHello666/hncode/issues/new'
12985
+ const url = 'https://github.com/NiceHello666/HNCode/issues/new'
12992
12986
  + `?title=${encodeURIComponent(title)}`
12993
12987
  + (body ? `&body=${encodeURIComponent(body)}` : '');
12994
12988
  sayPanel(`Open a GitHub issue:\n ${url}`);
@@ -14191,6 +14185,16 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14191
14185
  // Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
14192
14186
  // `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
14193
14187
  // approval in yolo — the one mode whose point is not asking.
14188
+ // A PLUGIN'S TOOL ANSWERS FOR ITSELF.
14189
+ //
14190
+ // The list below covers the tools this program ships. A plugin's tools cannot be added to it from outside,
14191
+ // and `isInsideWorkspace` does not know them — so every one of them asked for approval whatever it did. A
14192
+ // spec declaring `autoApprove: true` now says so itself, which is the only place that knows whether the
14193
+ // tool touches anything.
14194
+ if (rule !== 'ask') {
14195
+ const own = getTool(toolName);
14196
+ if (own && own.autoApprove === true) return true;
14197
+ }
14194
14198
  const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'ActivateSkill', 'ProgrammaticToolCall', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
14195
14199
  if (rule !== 'ask' && safeTools.includes(toolName)) return true;
14196
14200
  // A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
@@ -14203,27 +14207,33 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14203
14207
  }
14204
14208
 
14205
14209
 
14206
- // Yolo: anything that stays INSIDE the workspace runs
14207
- // without asking — edits, writes and commands alike. Only work that touches
14208
- // a path OUTSIDE the workspace (or a command whose target we cannot prove is
14209
- // inside) needs the user.
14210
+ // ASK: an ordinary action runs, anything with a consequence asks.
14210
14211
  //
14211
- // BASH is the exception: a shell command's paths cannot be proven either way
14212
- // (variables, pipes, subshells), so the old path heuristic flagged ordinary
14213
- // commands like `ls /tmp` and asked about them. In yolo the user has already
14214
- // said "run routine work", so Bash runs without the workspace test — only an
14215
- // obviously destructive command still asks.
14212
+ // This is the only mode that gates FILE work, which is the point of it — the other two exist for people who
14213
+ // have decided not to be asked.
14214
+ if (cur === 'ask' && rule !== 'ask') {
14215
+ // A read-only shell command is as safe as a read-only tool: there is no decision to make about `git log`.
14216
+ if (toolName === 'Bash') {
14217
+ const c = String((args && (args.command || args.cmd)) || '');
14218
+ if (isReadOnlyCommand(c) && !isDestructiveCommand(c)) return true;
14219
+ }
14220
+ }
14221
+
14222
+ // YOLO: routine work runs. FILES ARE NOT A REASON TO ASK.
14223
+ //
14224
+ // A user in yolo has said they do not want to confirm edits, and a path outside the workspace is still just a
14225
+ // path. What still asks is DESTRUCTION — a command that deletes, rewrites history or kills a process — because
14226
+ // that is the one class of action nobody means to authorise wholesale.
14216
14227
  if (cur === 'yolo' && rule !== 'ask') {
14217
14228
  if (toolName === 'Bash') {
14218
14229
  const cmd = String((args && (args.command || args.cmd)) || '');
14219
14230
  if (!isDestructiveCommand(cmd)) return true;
14220
- } else if (isInsideWorkspace(state, toolName, args)) {
14231
+ } else {
14232
+ // Every non-shell tool: no path test. `isInsideWorkspace` was the thing asking about `/goal` and about a
14233
+ // Read elsewhere, neither of which is a decision.
14221
14234
  return true;
14222
14235
  }
14223
14236
  }
14224
-
14225
-
14226
-
14227
14237
  return new Promise((resolve) => {
14228
14238
  let desc = '';
14229
14239
  if (args && typeof args === 'object') {