@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 +16 -12
- package/README_CN.md +16 -12
- package/imgs/HNCode.png +0 -0
- package/package.json +2 -1
- package/src/ptc.js +36 -7
- package/src/remote.js +1 -1
- package/src/tools/glob.js +44 -6
- package/src/tools/grep.js +9 -7
- package/src/tools/ignore.js +69 -0
- package/src/tools/ptc.js +10 -5
- package/src/tui.js +62 -52
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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/
|
|
93
|
-
- **[Wiki (简体中文)](https://github.com/NiceHello666/
|
|
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
|
-
##
|
|
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
|
|
106
|
+
- Share your experience using HNCode
|
|
103
107
|
|
|
104
108
|
---
|
|
105
109
|
|
|
106
|
-
##
|
|
110
|
+
## License
|
|
107
111
|
|
|
108
|
-
This project is licensed under the [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/
|
|
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/
|
|
123
|
+
**GitHub**: [NiceHello666/HNCode](https://github.com/NiceHello666/HNCode)
|
package/README_CN.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
|
|
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/
|
|
49
|
-
- **[Wiki(简体中文)](https://github.com/NiceHello666/
|
|
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
|
-
- 分享您使用
|
|
62
|
+
- 分享您使用 HNCode 的体验
|
|
59
63
|
|
|
60
64
|
---
|
|
61
65
|
|
|
62
|
-
##
|
|
66
|
+
## 许可证
|
|
63
67
|
|
|
64
|
-
本项目的许可受 [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/
|
|
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/
|
|
79
|
+
**GitHub**: [NiceHello666/HNCode](https://github.com/NiceHello666/HNCode)
|
package/imgs/HNCode.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.7.
|
|
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
|
-
|
|
17
|
-
|
|
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: [...
|
|
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 (!
|
|
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/
|
|
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 {
|
|
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
|
-
|
|
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
|
|
88
|
+
for (const abs of candidates) {
|
|
52
89
|
const rel = path.relative(base, abs).split(path.sep).join('/');
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 {
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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('/');
|
package/src/tools/ignore.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
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
|
-
|
|
2362
|
-
|
|
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
|
-
//
|
|
2390
|
-
//
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const rows =
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
//
|
|
14212
|
-
//
|
|
14213
|
-
|
|
14214
|
-
|
|
14215
|
-
|
|
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
|
|
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') {
|