@hncode/hncode 0.4.1 → 0.5.0
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 +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](https://github.com/NiceHello666/hncode/blob/main/README_CN.md) | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI.md)
|
|
4
4
|
|
|
5
|
-
> **This is a product of my
|
|
5
|
+
> **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
6
|
|
|
7
7
|
A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](https://github.com/MoonshotAI/kimi-code), with OpenAI + Anthropic support, an MCP-style toolbelt, and intelligent context management.
|
|
8
8
|
|
|
@@ -16,7 +16,8 @@ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](h
|
|
|
16
16
|
- **Prompt Caching**: Explicit cache breakpoints for the stable prefix (`/cache`)
|
|
17
17
|
- **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
|
|
18
18
|
- **MCP Client**: Connect real Model Context Protocol servers (stdio + HTTP) and use their tools
|
|
19
|
-
- **Skills**: Package reusable prompts as Markdown (`/import-skill`, `/skill:<name>` with tab-completion)
|
|
19
|
+
- **Skills**: Package reusable prompts as Markdown (`/import-skill`, `/skill:<name>` with tab-completion, plus a manager that installs them from the repo)
|
|
20
|
+
- **Skills & plugins manager**: `/skills` and `/plugins` browse what is installed *and* what the repo offers, with descriptions, and install/remove in place
|
|
20
21
|
- **Shell Hooks**: Run your own commands at lifecycle events (`PreToolUse`, `PostToolUse`, …)
|
|
21
22
|
- **Git Workflow**: `/git`, `/commit`, `/branch`, `/worktree`, `/pr` plus a `Git` tool for the agent
|
|
22
23
|
- **Plan Review**: Review what a plan will touch, edit it, then approve before any file changes
|
|
@@ -95,8 +96,6 @@ This file is a quick overview. For the **full documentation** — every command,
|
|
|
95
96
|
|
|
96
97
|
## 🤝 Contributing
|
|
97
98
|
|
|
98
|
-
This project was created through Vibe Coding. While it may not be perfect, I welcome your feedback!
|
|
99
|
-
|
|
100
99
|
**Feel free to:**
|
|
101
100
|
- Submit issues for bugs or suggestions
|
|
102
101
|
- Fork and create pull requests
|
|
@@ -104,18 +103,6 @@ This project was created through Vibe Coding. While it may not be perfect, I wel
|
|
|
104
103
|
|
|
105
104
|
---
|
|
106
105
|
|
|
107
|
-
## 🙏 Acknowledgments
|
|
108
|
-
|
|
109
|
-
hncode draws inspiration from several excellent projects:
|
|
110
|
-
|
|
111
|
-
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
|
|
112
|
-
- **[Claude Code](https://github.com/anthropics/claude-code)** - The conversation flow and tool interaction patterns
|
|
113
|
-
- **[Codex](https://github.com/openai/codex)** - Several behaviours were shaped by reading its open-source implementation: the AGENTS.md loading spec, the rule against spawning subagents unprompted, the plan-tool state machine, the review rubric, and parts of the system prompt
|
|
114
|
-
|
|
115
|
-
Special thanks to the communities behind these projects for pushing the boundaries of AI-assisted coding.
|
|
116
|
-
|
|
117
|
-
---
|
|
118
|
-
|
|
119
106
|
## 📄 License
|
|
120
107
|
|
|
121
108
|
This project is licensed under the [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE).
|
package/README_CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](https://github.com/NiceHello666/hncode/blob/main/README.md) | 简体中文 | [Wiki](https://github.com/NiceHello666/hncode/blob/main/README_WIKI_CN.md)
|
|
4
4
|
|
|
5
|
-
> **这是我
|
|
5
|
+
> **这是我vibe coding的产物. 我在尽全力让它变的完美, 所以如果你有何建议, 欢迎开一个issue.**
|
|
6
6
|
|
|
7
7
|
一个基于终端用户界面(TUI)的 AI 编程助手,灵感源自 [Kimi Code](https://github.com/MoonshotAI/kimi-code)。它提供青色蓝调 (Cyan-blue) 的 TUI 界面,支持 OpenAI 和 Anthropic 双协议,配备 MCP 风格的工具集和智能上下文管理。
|
|
8
8
|
|
|
@@ -52,8 +52,6 @@ npx @hncode/hncode
|
|
|
52
52
|
|
|
53
53
|
## 🤝 贡献
|
|
54
54
|
|
|
55
|
-
这个项目是通过 Vibe Coding 创建的。虽然它可能不够完美,但我欢迎您的反馈!
|
|
56
|
-
|
|
57
55
|
**欢迎:**
|
|
58
56
|
- 提交问题报告 bug 或提出建议
|
|
59
57
|
- Fork 并创建拉取请求
|
|
@@ -61,18 +59,6 @@ npx @hncode/hncode
|
|
|
61
59
|
|
|
62
60
|
---
|
|
63
61
|
|
|
64
|
-
## 🙏 致谢
|
|
65
|
-
|
|
66
|
-
hncode 从以下几个优秀项目中汲取了灵感:
|
|
67
|
-
|
|
68
|
-
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
69
|
-
- **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
|
|
70
|
-
- **[Codex](https://github.com/openai/codex)** - 通过阅读其开源实现,本项目确定了几处行为:AGENTS.md 的加载规则、未获明确许可不得开启子代理、计划工具的状态流转约束、代码审查的判定标准,以及系统提示词的部分内容
|
|
71
|
-
|
|
72
|
-
特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
62
|
## 📄 许可证
|
|
77
63
|
|
|
78
64
|
本项目的许可受 [PolyForm Noncommercial License 1.0.0](https://github.com/NiceHello666/hncode/blob/main/LICENSE) 保护。
|
package/bin/hncode
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// hncode CLI entry point.
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hncode CLI entry point.
|
|
3
|
+
//
|
|
4
|
+
// ORDER MATTERS HERE. bootstrap() installs the source-patch loader and loads
|
|
5
|
+
// plugins BEFORE index.js (and therefore agent.js / llm.js / tools) is imported.
|
|
6
|
+
// A module that is imported before the loader is installed cannot be patched, so
|
|
7
|
+
// the plugin step has to run first. See src/bootstrap.js for the details.
|
|
8
|
+
//
|
|
9
|
+
// bootstrap() never throws (a missing Node API or a broken plugin dir degrades
|
|
10
|
+
// to "no patches/plugins"), and we swallow even that here: nothing in the plugin
|
|
11
|
+
// subsystem may stop hncode itself from launching.
|
|
12
|
+
import('../src/bootstrap.js')
|
|
13
|
+
.then((b) => b.bootstrap())
|
|
14
|
+
.catch((err) => { console.error('[hncode] bootstrap failed, starting without plugins:', err); })
|
|
15
|
+
.then(() => import('../src/index.js'))
|
|
16
|
+
.then((mod) => mod.main(process.argv.slice(2)))
|
|
17
|
+
.then((code) => process.exit(code ?? 0))
|
|
18
|
+
.catch((err) => {
|
|
19
|
+
console.error(err);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
|
22
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"preinstall": "node bin/preinstall.cjs",
|
|
20
20
|
"install": "node bin/install.cjs",
|
|
21
21
|
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
-
"i18n:check": "node tools/sync-i18n.mjs --check"
|
|
22
|
+
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
+
"test": "node --test"
|
|
23
24
|
},
|
|
24
25
|
"engines": {
|
|
25
26
|
"node": ">=20"
|
package/src/agent.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
6
|
import { tools, getTool, llmTools } from './tools/index.js';
|
|
7
7
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
|
-
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens } from './term.js';
|
|
9
|
-
import { runHooks } from './plugin.js';
|
|
8
|
+
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
|
|
9
|
+
import { runHooks, runPatch, runPatchSync } from './plugin.js';
|
|
10
10
|
import { loadHooks, runShellHooks } from './hooks.js';
|
|
11
11
|
import {
|
|
12
12
|
shapeToolResult, trimToolResults, DEFAULT_TRIM_THRESHOLD, DEFAULT_TRIM_KEEP_RATIO,
|
|
@@ -21,6 +21,27 @@ function wrapSystemReminder(content) {
|
|
|
21
21
|
return `<system-reminder>\n${String(content).trim()}\n</system-reminder>`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// ---- token accounting -------------------------------------------------------
|
|
25
|
+
// The provider reports the numbers; this folds one report into a running total.
|
|
26
|
+
// A field the provider did not send is left as it was, because "not reported" and
|
|
27
|
+
// "zero" are different facts: summing undefined into 0 would claim a model never
|
|
28
|
+
// used a cache it simply does not measure.
|
|
29
|
+
export function addUsage(total, u) {
|
|
30
|
+
const out = { ...(total || {}) };
|
|
31
|
+
if (!u) return out;
|
|
32
|
+
for (const key of ['input', 'output', 'cached', 'cacheWrite', 'reasoning']) {
|
|
33
|
+
if (typeof u[key] === 'number' && Number.isFinite(u[key])) out[key] = (out[key] || 0) + u[key];
|
|
34
|
+
}
|
|
35
|
+
// How many REQUESTS contributed, so a reader can tell one big call from many
|
|
36
|
+
// small ones with the same token count. Counted on the completion side, which
|
|
37
|
+
// every protocol reports exactly once per request — Anthropic splits one
|
|
38
|
+
// request into two usage events (input at message_start, output at
|
|
39
|
+
// message_delta), so counting events would report double.
|
|
40
|
+
if (u.output !== undefined) out.calls = (out.calls || 0) + 1;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
24
45
|
const COMPACTION_SUMMARY_PREFIX = [
|
|
25
46
|
'The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over.',
|
|
26
47
|
'Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it.',
|
|
@@ -194,12 +215,14 @@ Tools:
|
|
|
194
215
|
- If a tool fails, read the error and fix your approach. After 2 failed attempts on the same goal, stop and report the blocker.
|
|
195
216
|
- Independent tool calls may run in parallel; never parallelize dependent ones.
|
|
196
217
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
- Everything outside
|
|
201
|
-
did and what you found. Never repeat or summarise your reasoning there.
|
|
202
|
-
- Keep the tags exactly as written:
|
|
218
|
+
- Put ALL of your internal reasoning inside <|thinking|>...<|/thinking|> tags, and always emit BOTH the
|
|
219
|
+
opening and the closing tag. Do not omit the tags, do not abbreviate them, and do not leave the block
|
|
220
|
+
unclosed.
|
|
221
|
+
- Everything outside <|thinking|>...<|/thinking|> is what the user reads: keep it to the answer itself —
|
|
222
|
+
what you did and what you found. Never repeat or summarise your reasoning there.
|
|
223
|
+
- Keep the tags exactly as written: <|thinking|> and <|/thinking|>. Do not emit any other variant. The
|
|
224
|
+
bars matter: a plain <think> is ordinary text that may appear in code or prose, and a bare
|
|
225
|
+
"thinking" is a word this very instruction uses.
|
|
203
226
|
Acting:
|
|
204
227
|
- Unless the user is explicitly asking for a plan, asking a question about the code, or brainstorming,
|
|
205
228
|
assume they want the work DONE. Say less and do more: implement the change rather than describing it.
|
|
@@ -260,11 +283,23 @@ export class Agent {
|
|
|
260
283
|
const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
261
284
|
? tools.filter((t) => cfg.toolFilter.includes(t.name))
|
|
262
285
|
: tools
|
|
263
|
-
)
|
|
286
|
+
)
|
|
287
|
+
.filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)))
|
|
288
|
+
// AgentSwarm is gated behind swarm mode, like Agent is gated behind the user
|
|
289
|
+
// having asked for delegation. It is not enough to describe the mode in the
|
|
290
|
+
// system prompt: the tool was offered on EVERY turn, so the model could fan a
|
|
291
|
+
// task out to 128 subagents in a session where the user never opted in — and
|
|
292
|
+
// the prompt's own rule ("never call this on your own initiative") was not
|
|
293
|
+
// backed by anything. Off unless /swarm turned the mode on; the Agent tool
|
|
294
|
+
// stays available for a single, deliberate delegation.
|
|
295
|
+
.filter((t) => !(t.name === 'AgentSwarm' && !cfg.swarm));
|
|
264
296
|
setToolsList(filtered);
|
|
265
297
|
// A block for the direct-call path too: the model can still emit a tool call
|
|
266
298
|
// for a tool that is not exposed, so refuse it here rather than at execution.
|
|
267
299
|
this.noDelegation = !!cfg.noDelegation;
|
|
300
|
+
// Mirrors the filter above for the direct-call path — a tool the model was told
|
|
301
|
+
// about on an earlier turn can still be emitted after /swarm goes off.
|
|
302
|
+
this.swarmMode = !!cfg.swarm;
|
|
268
303
|
// AbortSignal handed to every tool so a long-running one (Bash) is killed
|
|
269
304
|
// when the user presses Esc / Ctrl-C — otherwise the command keeps running
|
|
270
305
|
// after the turn was interrupted.
|
|
@@ -314,7 +349,10 @@ export class Agent {
|
|
|
314
349
|
// Auto-compaction is allowed at most once per TURN (not per step). Reset here so
|
|
315
350
|
// a caller that reuses one Agent across turns still gets a fresh allowance.
|
|
316
351
|
this._compactedThisTurn = false;
|
|
317
|
-
|
|
352
|
+
// Rolling context-token total for `this.messages` (see usedTokens). It holds no
|
|
353
|
+
// messages of its own, only the count of what it has already priced, so a
|
|
354
|
+
// compaction that replaces the array simply falls out of the fast path.
|
|
355
|
+
this._ledger = new TokenLedger();
|
|
318
356
|
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
319
357
|
// Shell hooks (Claude Code-style): user-configured commands run at lifecycle
|
|
320
358
|
// events. ALWAYS stored as a `{ hooks: {Event: [...]} }` config, which is the
|
|
@@ -357,8 +395,12 @@ export class Agent {
|
|
|
357
395
|
// later), so counting it both double-counted the system text and measured the
|
|
358
396
|
// wrong figure — while being the only input that decides whether to compact.
|
|
359
397
|
usedTokens() {
|
|
360
|
-
|
|
361
|
-
|
|
398
|
+
// A rolling total rather than a fresh walk of the whole history. This is called at
|
|
399
|
+
// least three times per model round (before the request, after compaction, after a
|
|
400
|
+
// trim) and once more per step for the gauge, so on a long session the old full
|
|
401
|
+
// rescan priced the entire transcript — Read results included — several times per
|
|
402
|
+
// step for a number that changes by one message.
|
|
403
|
+
let total = this._ledger.sync(this.messages, null);
|
|
362
404
|
if (Array.isArray(this.cfg.toolFilter)) total += this.cfg.toolFilter.length * 8;
|
|
363
405
|
return total;
|
|
364
406
|
}
|
|
@@ -388,7 +430,11 @@ export class Agent {
|
|
|
388
430
|
let bestSplit;
|
|
389
431
|
for (; recent < msgs.length; recent++) {
|
|
390
432
|
const split = msgs.length - recent;
|
|
391
|
-
|
|
433
|
+
// `messageTokens(m)`, not `estimateMessagesTokens([m])`: the latter allocated a
|
|
434
|
+
// throwaway single-element array for every message in the transcript, on every
|
|
435
|
+
// call. It is also the memoised form, so repeating this walk after a large tool
|
|
436
|
+
// result is a WeakMap hit rather than a re-measure.
|
|
437
|
+
size += messageTokens(msgs[split], null);
|
|
392
438
|
if (split > floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
|
|
393
439
|
if (size >= maxSize && bestSplit !== undefined) break;
|
|
394
440
|
}
|
|
@@ -396,6 +442,14 @@ export class Agent {
|
|
|
396
442
|
}
|
|
397
443
|
|
|
398
444
|
async run() {
|
|
445
|
+
// Clear any interrupt flag left from a previous (interrupted) turn. This runs at
|
|
446
|
+
// the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
|
|
447
|
+
// never suppress auto-compaction on the next message. (The constructor also
|
|
448
|
+
// initializes it, but a caller that reuses one Agent across turns must get a
|
|
449
|
+
// fresh allowance here — without this, the live block would blink again with no
|
|
450
|
+
// compaction behind it.)
|
|
451
|
+
this.stopRequested = false;
|
|
452
|
+
this._compactedThisTurn = false;
|
|
399
453
|
// Plugin lifecycle: `onTurnStart` fires here and `onTurnEnd` in the finally
|
|
400
454
|
// block at the end of this method. Both are awaited and both swallow plugin
|
|
401
455
|
// errors (see runHooks), so a broken plugin cannot abort a turn.
|
|
@@ -442,6 +496,14 @@ export class Agent {
|
|
|
442
496
|
// branch), so a half-written answer is never presented as a finished one.
|
|
443
497
|
this._truncated = true;
|
|
444
498
|
break;
|
|
499
|
+
case 'usage':
|
|
500
|
+
// Real token accounting from the provider, as opposed to the estimates
|
|
501
|
+
// usedTokens() produces. Accumulated on the AGENT (so it survives across
|
|
502
|
+
// the steps of one turn and can be read by anything holding the agent)
|
|
503
|
+
// and forwarded for the TUI to fold into the session totals.
|
|
504
|
+
this.usage = addUsage(this.usage, e.usage);
|
|
505
|
+
this.onEvent({ type: 'usage', usage: e.usage, total: this.usage });
|
|
506
|
+
break;
|
|
445
507
|
case 'tool_start':
|
|
446
508
|
toolCalls.push({ id: e.id, name: e.name, args: {}, argsJson: '' });
|
|
447
509
|
this.onEvent({ type: 'tool_start', id: e.id, name: e.name });
|
|
@@ -613,9 +675,14 @@ export class Agent {
|
|
|
613
675
|
: `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
|
|
614
676
|
this.messages = [
|
|
615
677
|
...keep,
|
|
616
|
-
{ role: 'user', content: wrapSystemReminder(summaryText) },
|
|
617
|
-
{ role: 'user', content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
678
|
+
{ role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
|
|
679
|
+
{ role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
618
680
|
];
|
|
681
|
+
// The array was REPLACED, so every count the ledger held describes messages
|
|
682
|
+
// that no longer exist. `sync` would notice on its own (the first/last
|
|
683
|
+
// identities no longer match), but resetting here states the intent at the
|
|
684
|
+
// point of mutation instead of relying on that check to fire.
|
|
685
|
+
this._ledger.reset();
|
|
619
686
|
usedNow = this.usedTokens();
|
|
620
687
|
this.onEvent({
|
|
621
688
|
type: 'compacted', before, after: usedNow, kept: keep.length,
|
|
@@ -650,6 +717,12 @@ export class Agent {
|
|
|
650
717
|
mustKeep: this._lastToolResultIndex,
|
|
651
718
|
});
|
|
652
719
|
if (res && res.elided > 0) {
|
|
720
|
+
// `trimToolResults` rewrites message CONTENT IN PLACE, so the array is the
|
|
721
|
+
// same one the ledger already counted and its identity check cannot see the
|
|
722
|
+
// change. Without this reset the gauge would keep reporting the PRE-trim
|
|
723
|
+
// total: the trim would look like it freed nothing, and the very next step
|
|
724
|
+
// would compact a history that had already been trimmed.
|
|
725
|
+
this._ledger.reset();
|
|
653
726
|
const after = this.usedTokens();
|
|
654
727
|
this.onEvent({
|
|
655
728
|
type: 'results_trimmed', elided: res.elided, elidedBytes: res.elidedBytes,
|
|
@@ -697,6 +770,8 @@ export class Agent {
|
|
|
697
770
|
content: hasText ? text : null,
|
|
698
771
|
toolCalls: hasCalls ? toolCalls : undefined,
|
|
699
772
|
};
|
|
773
|
+
// Mixin seam: llmResponse - plugins may rewrite the assistant text before it is saved.
|
|
774
|
+
try { const out = await runPatch('llmResponse', { content: assistantMsg.content, toolCalls: assistantMsg.toolCalls }, (c) => c); if (out) { if (out.content !== undefined) assistantMsg.content = out.content; if (out.toolCalls !== undefined) assistantMsg.toolCalls = out.toolCalls; } } catch (e) { console.error('[hncode-plugin] llmResponse error:', e.message); }
|
|
700
775
|
this.messages.push(assistantMsg);
|
|
701
776
|
// Plugin hook: one per assistant message committed to the history. Fired
|
|
702
777
|
// here (the single point where the model's reply lands) rather than at each
|
|
@@ -734,6 +809,10 @@ export class Agent {
|
|
|
734
809
|
truncationRecoveries++;
|
|
735
810
|
this.messages.push({
|
|
736
811
|
role: 'user',
|
|
812
|
+
// `_harness`: this is the harness talking, not the user. /undo and its
|
|
813
|
+
// picker skip these, and beginTurn does not run for them, so the two
|
|
814
|
+
// stay aligned on what a "prompt" is.
|
|
815
|
+
_harness: true,
|
|
737
816
|
content: 'Your previous message was cut off because it reached the output token limit. Continue from exactly where it stopped — do not repeat what you already wrote, and do not start over.',
|
|
738
817
|
});
|
|
739
818
|
this.onEvent({ type: 'truncation_recovery', attempt: truncationRecoveries });
|
|
@@ -774,6 +853,40 @@ export class Agent {
|
|
|
774
853
|
// right "Using …" row (a turn can run several tools in sequence).
|
|
775
854
|
this._currentToolId = tc.id;
|
|
776
855
|
|
|
856
|
+
// MODE GATING COMES FIRST, BEFORE THE APPROVAL PROMPT.
|
|
857
|
+
//
|
|
858
|
+
// A tool the current mode does not allow must be refused WITHOUT asking the user.
|
|
859
|
+
// Asking first was the bug: in Plan mode the tool list is filtered to
|
|
860
|
+
// ['Read','Grep','Glob'], so Bash is not offered — but the model can still emit a
|
|
861
|
+
// call for a tool it saw on an earlier turn, and the approval prompt ran before
|
|
862
|
+
// this check. The user was asked "run this shell command?" in a mode where the
|
|
863
|
+
// answer is always no, and the prompt's own wording offered a choice that did not
|
|
864
|
+
// exist. It also meant a stray call could be APPROVED, and only then refused, which
|
|
865
|
+
// is the worst of both: a decision recorded for something that never ran.
|
|
866
|
+
//
|
|
867
|
+
// These three checks used to sit after the prompt. They all reject outright, so all
|
|
868
|
+
// three belong here — before anything with a side effect, including the session
|
|
869
|
+
// approval that a Ctrl+A on the prompt would have written.
|
|
870
|
+
if (this.noDelegation && DELEGATION_TOOLS.includes(tc.name)) {
|
|
871
|
+
const result = `You CAN'T use ${tc.name}: subagents cannot delegate. Do the work yourself and report the result to the agent that started you.`;
|
|
872
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
873
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
874
|
+
}
|
|
875
|
+
// The list above does not expose AgentSwarm outside swarm mode, but the system
|
|
876
|
+
// prompt describes only ONE shape for it — so refuse a stray call with the fix in
|
|
877
|
+
// the message rather than letting 128 subagents run in a session where the user
|
|
878
|
+
// never opted in.
|
|
879
|
+
if (this.swarmMode !== true && tc.name === 'AgentSwarm') {
|
|
880
|
+
const result = "You CAN'T use AgentSwarm: swarm mode is OFF. For one or two jobs use the Agent tool; for a real fan-out, ask the user to enable it with /swarm.";
|
|
881
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
882
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
883
|
+
}
|
|
884
|
+
if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
|
|
885
|
+
const result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
|
|
886
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
887
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
888
|
+
}
|
|
889
|
+
|
|
777
890
|
// Permission check before executing each tool
|
|
778
891
|
let result = null;
|
|
779
892
|
let ok = true;
|
|
@@ -786,24 +899,13 @@ export class Agent {
|
|
|
786
899
|
}
|
|
787
900
|
}
|
|
788
901
|
|
|
902
|
+
|
|
789
903
|
const tool = getTool(tc.name);
|
|
790
|
-
// A subagent may not delegate. The tool is not exposed to it, but the model
|
|
791
|
-
// can still emit a call for a tool it was told about earlier, so refuse it
|
|
792
|
-
// here with an actionable message instead of running a nested agent.
|
|
793
|
-
if (this.noDelegation && DELEGATION_TOOLS.includes(tc.name)) {
|
|
794
|
-
result = `You CAN'T use ${tc.name}: subagents cannot delegate. Do the work yourself and report the result to the agent that started you.`;
|
|
795
|
-
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
796
|
-
return { ok: false, result, editDiff: null, media: false };
|
|
797
|
-
}
|
|
798
|
-
if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
|
|
799
|
-
result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
|
|
800
|
-
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
801
|
-
return { ok: false, result, editDiff: null, media: false };
|
|
802
|
-
}
|
|
803
904
|
if (!tool) {
|
|
804
905
|
result = `Error: unknown tool "${tc.name}".`;
|
|
805
906
|
return { ok: false, result, editDiff: null, media: false };
|
|
806
907
|
}
|
|
908
|
+
|
|
807
909
|
// SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
|
|
808
910
|
// message goes back to the model in place of the tool result — that is
|
|
809
911
|
// the whole point of a pre-hook (e.g. refuse a write that fails a lint
|
|
@@ -823,14 +925,21 @@ export class Agent {
|
|
|
823
925
|
// are swallowed by runHooks — a broken plugin must not break a turn.
|
|
824
926
|
await runHooks('onToolExecute', tc.name, tc.args, this.ctx);
|
|
825
927
|
try {
|
|
826
|
-
|
|
928
|
+
// Mixin seam: toolDispatch - plugins may intercept/modify tool calls before execution.
|
|
929
|
+
let _args = tc.args; let _skip = false;
|
|
930
|
+
try { const out = await runPatch('toolDispatch', { name: tc.name, args: tc.args, ctx: this.ctx }, (c) => c); if (out) { _args = out.args !== undefined ? out.args : tc.args; if (out.skip) _skip = true; } } catch (e) { console.error('[hncode-plugin] toolDispatch error:', e.message); }
|
|
931
|
+
if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
|
|
932
|
+
else result = await tool.execute(_args, this.ctx);
|
|
827
933
|
} catch (err) {
|
|
828
934
|
result = `Error running ${tc.name}: ${err.message}`;
|
|
829
935
|
ok = false;
|
|
830
936
|
}
|
|
831
937
|
this.ctx.lastResult = result;
|
|
832
|
-
// Plugin hook: onToolResult fires with the tool's output.
|
|
833
|
-
|
|
938
|
+
// Plugin hook: onToolResult fires with the tool's output. A hook may return a
|
|
939
|
+
// (redacted) replacement string — e.g. secret-guard masking keys in a Read
|
|
940
|
+
// result — which is then what the model and transcript receive.
|
|
941
|
+
const hookOut = await runHooks('onToolResult', tc.name, result, this.ctx);
|
|
942
|
+
if (typeof hookOut === 'string') result = hookOut;
|
|
834
943
|
// SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
|
|
835
944
|
// failure is logged. This is where "format after every edit" lives.
|
|
836
945
|
await runShellHooks(this.hookConfig, 'PostToolUse',
|
|
@@ -877,7 +986,10 @@ export class Agent {
|
|
|
877
986
|
const shaped = toolResultLimitsEnabled()
|
|
878
987
|
? shapeToolResult(rawForModel, { sessionId: this.cfg.sessionId, toolCallId: tc.id })
|
|
879
988
|
: { content: rawForModel, spilled: false };
|
|
880
|
-
|
|
989
|
+
let _toolContent = shaped.content;
|
|
990
|
+
// Mixin seam: toolResult (sync) - plugins may rewrite what the model receives for this call.
|
|
991
|
+
try { const out = runPatchSync('toolResult', { name: tc.name, content: shaped.content }, (c) => c); if (out && out.content !== undefined) _toolContent = out.content; } catch (e) { console.error('[hncode-plugin] toolResult error:', e.message); }
|
|
992
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: _toolContent });
|
|
881
993
|
// Remember where the newest tool result landed: a trim must never elide the
|
|
882
994
|
// output the model was handed in the step it is about to reason about.
|
|
883
995
|
this._lastToolResultIndex = this.messages.length - 1;
|
|
@@ -947,4 +1059,4 @@ export class Agent {
|
|
|
947
1059
|
await runShellHooks(this.hookConfig, 'Stop',
|
|
948
1060
|
{ messages: this.messages, stopped: this.stopRequested, prompt: this._turnPrompt }, this.cfg.workspace);
|
|
949
1061
|
}
|
|
950
|
-
}
|
|
1062
|
+
}
|
package/src/bootstrap.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Bootstrap: install the patch loader and load plugins BEFORE the rest of
|
|
2
|
+
// hncode's modules are imported.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SEPARATE MODULE
|
|
5
|
+
// ---------------------
|
|
6
|
+
// Symbol-level patching (patch.js) rewrites a target module's source as it is
|
|
7
|
+
// loaded, so the loader MUST be installed and the plugins MUST have registered
|
|
8
|
+
// their targets before those modules are imported. If any patched module is
|
|
9
|
+
// imported first, it is served from cache and the patch is silently skipped.
|
|
10
|
+
//
|
|
11
|
+
// hncode's real work lives in modules (agent.js, llm.js, tools/*) that index.js
|
|
12
|
+
// imports at the top, so index.js cannot load plugins itself — by the time it ran
|
|
13
|
+
// loadPlugins(), those modules would already be cached. This module runs first
|
|
14
|
+
// (see bin/hncode) and does the ordering that makes patching possible.
|
|
15
|
+
//
|
|
16
|
+
// It is deliberately tiny and dependency-light: it only needs config (for the
|
|
17
|
+
// plugin directory) plus plugin.js / patch.js. Everything else stays lazy.
|
|
18
|
+
//
|
|
19
|
+
// FAILURE POSTURE: bootstrap must NEVER stop hncode from starting. A missing
|
|
20
|
+
// Node API, a malformed config, or a broken plugin directory degrades to "no
|
|
21
|
+
// patches / no plugins", never to "hncode won't launch".
|
|
22
|
+
|
|
23
|
+
// Set once plugins have been loaded (by bootstrap or by index.js), so index.js
|
|
24
|
+
// does not load them a second time and trip "tool already registered".
|
|
25
|
+
let pluginsLoaded = false;
|
|
26
|
+
export function pluginsAlreadyLoaded() { return pluginsLoaded; }
|
|
27
|
+
export function markPluginsLoaded() { pluginsLoaded = true; }
|
|
28
|
+
|
|
29
|
+
export async function bootstrap({ loadPlugins = true } = {}) {
|
|
30
|
+
// Patch loader first, but never let it break startup: installPatchLoader is
|
|
31
|
+
// already non-throwing, and we belt-and-brace with try/catch here.
|
|
32
|
+
try {
|
|
33
|
+
const { installPatchLoader } = await import('./patch.js');
|
|
34
|
+
installPatchLoader();
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error('[hncode] patch loader unavailable, continuing without it:', e && e.message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!loadPlugins) return { pluginDir: null, loaded: [] };
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const { resolveConfig } = await import('./config.js');
|
|
43
|
+
const cfg = resolveConfig();
|
|
44
|
+
if (!cfg.pluginDir) return { pluginDir: null, loaded: [] };
|
|
45
|
+
|
|
46
|
+
const { loadPlugins: load } = await import('./plugin.js');
|
|
47
|
+
const loaded = await load(cfg.pluginDir);
|
|
48
|
+
markPluginsLoaded();
|
|
49
|
+
return { pluginDir: cfg.pluginDir, loaded };
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// A broken plugin dir/config must not stop hncode. Report and carry on.
|
|
52
|
+
console.error('[hncode] plugin bootstrap failed, continuing without plugins:', e && e.message);
|
|
53
|
+
markPluginsLoaded();
|
|
54
|
+
return { pluginDir: null, loaded: [], error: e };
|
|
55
|
+
}
|
|
56
|
+
}
|