@hncode/hncode 0.7.0 → 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.0",
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/config.js CHANGED
@@ -370,6 +370,27 @@ const b = baseUrl.replace(/\/+$/, '');
370
370
  || root.max_output_size
371
371
  || (modelMaxOutput > 0 ? modelMaxOutput : DEFAULTS.maxOutputTokens),
372
372
  );
373
+ // A NUMBER OR NOTHING: an unparseable value falls back to "not set", which means the provider decides.
374
+ const numOrNull = (v, min, max) => {
375
+ if (v == null || v === '') return null;
376
+ const n = Number(v);
377
+ if (!Number.isFinite(n)) return null;
378
+ return Math.max(min, Math.min(max, n));
379
+ };
380
+ // `sampling` gates the whole group. A provider that rejects `temperature` outright — several refuse it for
381
+ // reasoning models — needs a switch, not a magic value nobody can find.
382
+ const samplingOn = process.env.HNCODE_SAMPLING
383
+ ? !/^(0|false|no|off)$/i.test(process.env.HNCODE_SAMPLING)
384
+ : root.sampling !== false;
385
+ const temperature = samplingOn
386
+ ? numOrNull(process.env.HNCODE_TEMPERATURE ?? root.temperature, 0, 2)
387
+ : null;
388
+ const topP = samplingOn
389
+ ? numOrNull(process.env.HNCODE_TOP_P ?? root.top_p, 0, 1)
390
+ : null;
391
+ const presencePenalty = samplingOn
392
+ ? numOrNull(process.env.HNCODE_PRESENCE_PENALTY ?? root.presence_penalty, -2, 2)
393
+ : null;
373
394
  const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
374
395
  const allowExternal = process.env.HNCODE_ALLOW_EXTERNAL === '1' || !!root.tool_allow_external_paths;
375
396
 
@@ -377,6 +398,11 @@ const b = baseUrl.replace(/\/+$/, '');
377
398
  model, provider: providerName, innerModel,
378
399
  baseUrl: b, endpoint, apiKey, protocol,
379
400
  maxContextTokens: maxContext, maxOutputTokens: maxOutput, reasoning,
401
+ // Sampling. `undefined`/null means "do not send it", which is what lets a provider apply its own
402
+ // default — and what keeps a rejection from becoming a hard failure.
403
+ temperature: temperature == null ? undefined : temperature,
404
+ topP: topP == null ? undefined : topP,
405
+ presencePenalty: presencePenalty == null ? undefined : presencePenalty,
380
406
  workspace: process.env.HNCODE_WORKSPACE || root.workspace || DEFAULTS.workspace,
381
407
  allowExternal,
382
408
  // Standing permission rules (see permissions.js). `[permissions]` in config.toml:
package/src/llm.js CHANGED
@@ -233,6 +233,8 @@ function authHeaders(cfg) {
233
233
  function buildBody(cfg, messages, streaming = true, opts = {}) {
234
234
  const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
235
235
  if (cfg.temperature != null) common.temperature = cfg.temperature;
236
+ if (cfg.topP != null) common.top_p = cfg.topP;
237
+ if (cfg.presencePenalty != null) common.presence_penalty = cfg.presencePenalty;
236
238
  // Ask for the token accounting on the FINAL stream frame. Without this the
237
239
  // OpenAI-compatible stream carries no `usage` at all, so the only token numbers
238
240
  // available were our own estimates (see /usage, which divided the transcript's
@@ -265,6 +267,9 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
265
267
  ...common, ...wire,
266
268
  };
267
269
  if (!body.tools || !body.tools.length) delete body.tools;
270
+ // NO PRESENCE PENALTY ON THIS PROTOCOL: it has no equivalent, and an unknown field is an error
271
+ // here rather than something quietly ignored.
272
+ delete body.presence_penalty;
268
273
  // Prompt caching: mark the stable prefix (tools + system + conversation head)
269
274
  // so the provider bills it as a cache READ instead of fresh input. The key is
270
275
  // the SESSION id, so it stays identical across turns and never thrashes.
@@ -287,6 +292,8 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
287
292
  };
288
293
  if (instructions) rbody.instructions = instructions;
289
294
  if (cfg.temperature != null) rbody.temperature = cfg.temperature;
295
+ if (cfg.topP != null) rbody.top_p = cfg.topP;
296
+ // No presence penalty on this protocol: sending it is an unknown-parameter error, not a hint.
290
297
  if (!opts.noTools) {
291
298
  const defsR = Object.prototype.hasOwnProperty.call(opts, 'tools')
292
299
  ? responsesToolDefs(opts.tools) : responsesToolDefs(llmToolsList);
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.
@@ -125,6 +125,18 @@ export const SETTINGS_SCHEMA = [
125
125
  ui: { tab: 'model', group: 'Thinking', label: 'Context window', hint: 'Override the model window when the catalog is wrong; 0 means use it' } },
126
126
  { key: 'prompt_cache', ...bool(true),
127
127
  ui: { tab: 'model', group: 'Provider', label: 'Prompt caching', hint: 'Ask the provider to cache the request prefix' } },
128
+ { key: 'sampling', ...bool(true),
129
+ label: 'Send sampling parameters',
130
+ help: 'Off sends no temperature/top_p at all, for a provider that rejects them (several do, for reasoning models).' },
131
+ { key: 'temperature', ...str(''),
132
+ label: 'Temperature',
133
+ help: '0 is deterministic, 2 is wild. Empty leaves the provider default. Accepts 0-2 with one decimal.' },
134
+ { key: 'top_p', ...str(''),
135
+ label: 'Top P',
136
+ help: 'Nucleus sampling 0-1. Empty leaves the provider default. Usually set INSTEAD of temperature, not both.' },
137
+ { key: 'presence_penalty', ...str(''),
138
+ label: 'Presence penalty',
139
+ help: 'OpenAI-compatible only: -2 to 2, positive discourages repeating topics. Anthropic has no equivalent and never receives it.' },
128
140
 
129
141
  // ---- context ------------------------------------------------------------
130
142
  { key: 'auto_compact', ...bool(true),
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
@@ -187,6 +187,7 @@ export const COMMANDS = [
187
187
  { name: 'permission', desc: 'Select permission mode', priority: 100 },
188
188
  { name: 'settings', aliases: ['config'], desc: 'Browse and change every setting, grouped by area', priority: 100, argumentHint: '[tab|filter]' },
189
189
  { name: 'set', desc: 'Set one config value by key, or list what differs from the default', priority: 100, argumentHint: '[<key> [value]]' },
190
+ { name: 'sampling', desc: 'Show or set temperature, top_p and presence_penalty', priority: 100, argumentHint: '[on|off|temperature <n>|top_p <n>|presence_penalty <n>]' },
190
191
  { name: 'plan', desc: 'Toggle plan mode', priority: 100, argumentHint: '[on|off|clear]' },
191
192
  { name: 'websearch-api', desc: 'Edit the WebSearch API keys (one per line, tried in order)', priority: 96 },
192
193
  { name: 'focus', desc: 'Toggle Focus mode (minimal tools first, full tools after)', priority: 98, argumentHint: '[on|off]' },
@@ -258,7 +259,7 @@ export const COMMANDS = [
258
259
  { name: 'reload-config', desc: 'Reload config.toml settings only', priority: 60 },
259
260
  { name: 'plugins', desc: 'Open the plugin manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name> | reload]' },
260
261
  { name: 'skills', desc: 'Open the skill manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name>]' },
261
- { 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 },
262
263
  { name: 'hooks', desc: 'Show configured shell hooks, or test one', priority: 60, argumentHint: '[list | test <event>]' },
263
264
  { name: 'git', desc: 'Show repository status (branch, changes, remote)', priority: 62 },
264
265
  { name: 'commit', desc: 'Stage and commit with a message (or let the agent draft one)', priority: 62, argumentHint: '[message]' },
@@ -2331,34 +2332,28 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, ex
2331
2332
  // Renders above the input box: a rule, a "Todo" heading, up to 5 rows, and a
2332
2333
  // "+N more · ctrl+t to expand" hint. Ctrl+T expands the full list.
2333
2334
  const TODO_MAX_VISIBLE = 5;
2334
- export function selectVisibleTodos(todos) {
2335
- if (todos.length <= TODO_MAX_VISIBLE) return { rows: todos, hidden: 0, counts: {} };
2336
- const inProgress = [], pending = [], done = [];
2337
- todos.forEach((t, i) => {
2338
- if (t.status === 'in_progress') inProgress.push(i);
2339
- else if (t.status === 'pending') pending.push(i);
2340
- else done.push(i);
2341
- });
2342
- const picked = new Set(inProgress.slice(0, TODO_MAX_VISIBLE));
2343
- if (picked.size < TODO_MAX_VISIBLE) {
2344
- const doneC = [...done].reverse();
2345
- const pendC = pending;
2346
- const remaining = TODO_MAX_VISIBLE - picked.size;
2347
- let doneCount, pendCount;
2348
- if (!doneC.length) { doneCount = 0; pendCount = Math.min(remaining, pendC.length); }
2349
- else if (!pendC.length) { pendCount = 0; doneCount = Math.min(remaining, doneC.length); }
2350
- else {
2351
- doneCount = 1;
2352
- pendCount = Math.min(remaining - 1, pendC.length);
2353
- if (pendCount < remaining - 1) doneCount = Math.min(doneC.length, remaining - pendCount);
2354
- }
2355
- for (let i = 0; i < doneCount; i++) picked.add(doneC[i]);
2356
- for (let i = 0; i < pendCount; i++) picked.add(pendC[i]);
2357
- }
2358
- 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);
2359
2351
  const counts = { done: 0, in_progress: 0, pending: 0 };
2360
- todos.forEach((t, i) => { if (!picked.has(i)) counts[t.status] = (counts[t.status] || 0) + 1; });
2361
- 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 };
2362
2357
  }
2363
2358
 
2364
2359
  function todoRow(todo, w) {
@@ -2385,16 +2380,16 @@ function renderTodoPanel(state, w, hoverTop, dragTop) {
2385
2380
  out.push(col('─'.repeat(w), ruleColor));
2386
2381
  out.push(col(' Todo', C.cyan + C.bold));
2387
2382
  const want = todoRowCount(state);
2388
- // Keep the automatic pick ORDER (so the in-progress item keeps its priority) and
2389
- // top it up from the remaining items until `want` rows are shown.
2390
- const ordered = [];
2391
- const seen = new Set();
2392
- for (const t of selectVisibleTodos(todos).rows) { ordered.push(t); seen.add(t); }
2393
- for (const t of todos) {
2394
- if (ordered.length >= want) break;
2395
- if (!seen.has(t)) { ordered.push(t); seen.add(t); }
2396
- }
2397
- 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);
2398
2393
  for (const t of rows) out.push(todoRow(t, w));
2399
2394
  const hidden = todos.length - rows.length;
2400
2395
  if (hidden > 0) {
@@ -8688,12 +8683,26 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8688
8683
  if (key === 'calm_mode') cfg.calmMode = v;
8689
8684
  if (key === 'swarm_mode') cfg.swarm = v;
8690
8685
  if (key === 'tool_allow_external_paths') cfg.allowExternal = v;
8686
+ if (key === 'sampling' && !v) {
8687
+ // Off means NONE of them is sent — not their defaults, which is what a provider rejecting them
8688
+ // would receive otherwise.
8689
+ cfg.temperature = undefined; cfg.topP = undefined; cfg.presencePenalty = undefined;
8690
+ }
8691
8691
  } else {
8692
8692
  cfg[key] = raw; cfg.raw[key] = raw;
8693
8693
  if (key === 'compact_threshold') cfg.compactThreshold = Number(raw) / 100;
8694
8694
  if (key === 'compact_keep_ratio') cfg.compactKeepRatio = Number(raw) / 100;
8695
8695
  if (key === 'trim_threshold') cfg.trimThreshold = Number(raw) / 100;
8696
8696
  if (key === 'trim_keep_ratio') cfg.trimKeepRatio = Number(raw) / 100;
8697
+ // SAMPLING: a NUMBER, or gone. The generic writer stores the typed string, and `llm.js` sends it
8698
+ // straight into the request — a provider given `"0.3"` rejects it or ignores it.
8699
+ if (key === 'temperature' || key === 'top_p' || key === 'presence_penalty') {
8700
+ const field = { temperature: 'temperature', top_p: 'topP', presence_penalty: 'presencePenalty' }[key];
8701
+ const n = raw === '' ? null : Number(raw);
8702
+ // Clearing the field removes the parameter, so the provider applies its own default — which is not
8703
+ // the same thing as sending 0.
8704
+ cfg[field] = n == null || !Number.isFinite(n) ? undefined : n;
8705
+ }
8697
8706
  if (key === 'max_context_tokens') cfg.maxContextTokens = Number(raw) || cfg.maxContextTokens;
8698
8707
  if (key === 'provider') cfg.provider = raw;
8699
8708
  if (key === 'model') { cfg.model = raw; state.modelLabel = raw; }
@@ -8942,6 +8951,42 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8942
8951
  }
8943
8952
 
8944
8953
 
8954
+
8955
+ case 'sampling': {
8956
+ const parts = String(raw || '').trim().split(/\s+/).filter(Boolean);
8957
+ const show = () => {
8958
+ const row = (label, v, note) => ` ${label.padEnd(18)} ${v == null ? '(provider default)' : v}${note ? ' ' + note : ''}`;
8959
+ sayPanel([
8960
+ 'Sampling parameters',
8961
+ '',
8962
+ row('send them', cfg.sampling === false ? 'off' : 'on', cfg.sampling === false ? 'nothing is sent' : ''),
8963
+ row('temperature', cfg.temperature),
8964
+ row('top_p', cfg.topP),
8965
+ row('presence_penalty', cfg.presencePenalty, 'OpenAI-compatible only'),
8966
+ '',
8967
+ 'Set with /sampling temperature 0.3, /sampling top_p 0.9, /sampling off',
8968
+ ].join('\n'));
8969
+ };
8970
+ if (!parts.length) { show(); return; }
8971
+ if (parts[0] === 'on' || parts[0] === 'off') {
8972
+ applySetting('sampling', parts[0] === 'on' ? 'true' : 'false');
8973
+ app(`Sampling: ${parts[0]}`);
8974
+ show();
8975
+ return;
8976
+ }
8977
+ const key = parts[0] === 'top-p' ? 'top_p' : parts[0] === 'presence-penalty' ? 'presence_penalty' : parts[0];
8978
+ if (!['temperature', 'top_p', 'presence_penalty'].includes(key)) {
8979
+ appErr('Usage: /sampling [on|off] | temperature <0-2> | top_p <0-1> | presence_penalty <-2-2>');
8980
+ return;
8981
+ }
8982
+ if (parts.length === 1) { show(); return; }
8983
+ // An empty value clears it: the field means "let the provider decide", and `0` is a real temperature.
8984
+ const value = parts.slice(1).join(' ').trim();
8985
+ if (!applySetting(key, value)) return;
8986
+ app(value === '' ? `${key} cleared` : `${key} = ${value}`);
8987
+ show();
8988
+ return;
8989
+ }
8945
8990
  case 'set': {
8946
8991
  // `/set <key> <value>` — the direct path, so any setting is reachable without
8947
8992
  // remembering which command owns it. `/set` alone lists what is changed.
@@ -12937,7 +12982,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
12937
12982
  // The body is EXACTLY what the user typed. Nothing is appended — a report
12938
12983
  // the user did not write (an environment footer, a template) reads as noise
12939
12984
  // in the issue form and has to be deleted before submitting.
12940
- const url = 'https://github.com/NiceHello666/hncode/issues/new'
12985
+ const url = 'https://github.com/NiceHello666/HNCode/issues/new'
12941
12986
  + `?title=${encodeURIComponent(title)}`
12942
12987
  + (body ? `&body=${encodeURIComponent(body)}` : '');
12943
12988
  sayPanel(`Open a GitHub issue:\n ${url}`);
@@ -14140,6 +14185,16 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14140
14185
  // Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
14141
14186
  // `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
14142
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
+ }
14143
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'];
14144
14199
  if (rule !== 'ask' && safeTools.includes(toolName)) return true;
14145
14200
  // A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
@@ -14152,27 +14207,33 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
14152
14207
  }
14153
14208
 
14154
14209
 
14155
- // Yolo: anything that stays INSIDE the workspace runs
14156
- // without asking — edits, writes and commands alike. Only work that touches
14157
- // a path OUTSIDE the workspace (or a command whose target we cannot prove is
14158
- // inside) needs the user.
14210
+ // ASK: an ordinary action runs, anything with a consequence asks.
14159
14211
  //
14160
- // BASH is the exception: a shell command's paths cannot be proven either way
14161
- // (variables, pipes, subshells), so the old path heuristic flagged ordinary
14162
- // commands like `ls /tmp` and asked about them. In yolo the user has already
14163
- // said "run routine work", so Bash runs without the workspace test — only an
14164
- // 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.
14165
14227
  if (cur === 'yolo' && rule !== 'ask') {
14166
14228
  if (toolName === 'Bash') {
14167
14229
  const cmd = String((args && (args.command || args.cmd)) || '');
14168
14230
  if (!isDestructiveCommand(cmd)) return true;
14169
- } 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.
14170
14234
  return true;
14171
14235
  }
14172
14236
  }
14173
-
14174
-
14175
-
14176
14237
  return new Promise((resolve) => {
14177
14238
  let desc = '';
14178
14239
  if (args && typeof args === 'object') {
@@ -20567,6 +20628,7 @@ function wheelStep(state, family) {
20567
20628
  hub.actions.setConfig = async (key, value) => {
20568
20629
  const ALLOWED = new Set([
20569
20630
  'calm_mode', 'auto_update', 'auto_compact', 'prompt_cache',
20631
+ 'temperature', 'top_p', 'presence_penalty', 'sampling',
20570
20632
  'tool_allow_external_paths', 'subagent_model', 'swarm_mode', 'theme',
20571
20633
  ]);
20572
20634
  const k = String(key || '');