@hncode/hncode 0.1.11 → 0.3.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/LICENSE +674 -219
- package/README.md +1 -0
- package/README_CN.md +2 -0
- package/bin/install.js +1 -1
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +96 -8
- package/src/colors.js +14 -0
- package/src/config.js +128 -0
- package/src/index.js +7 -2
- package/src/plugin.js +12 -2
- package/src/prompt-presets.js +429 -0
- package/src/subagent-types.js +147 -0
- package/src/swarm-progress.js +293 -0
- package/src/task-output-viewer.js +135 -0
- package/src/tasks-browser.js +279 -0
- package/src/tools/agent-swarm.js +203 -0
- package/src/tools/agent.js +335 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +46 -28
- package/src/tools/edit.js +117 -6
- package/src/tools/glob.js +8 -1
- package/src/tools/index.js +6 -0
- package/src/tools/tasks.js +140 -126
- package/src/tools/todo.js +29 -2
- package/src/tui.js +2057 -292
- package/src/update.js +107 -0
package/README.md
CHANGED
|
@@ -67,6 +67,7 @@ hncode draws inspiration from several excellent projects:
|
|
|
67
67
|
|
|
68
68
|
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - The original cyan-blue TUI coding agent that inspired this project's design and approach
|
|
69
69
|
- **[Claude Code](https://github.com/anthropics/claude-code)** - The conversation flow and tool interaction patterns
|
|
70
|
+
- **[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
|
|
70
71
|
|
|
71
72
|
Special thanks to the communities behind these projects for pushing the boundaries of AI-assisted coding.
|
|
72
73
|
|
package/README_CN.md
CHANGED
|
@@ -65,8 +65,10 @@ npx @hncode/hncode
|
|
|
65
65
|
|
|
66
66
|
hncode 从以下几个优秀项目中汲取了灵感:
|
|
67
67
|
|
|
68
|
+
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
68
69
|
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
69
70
|
- **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
|
|
71
|
+
- **[Codex](https://github.com/openai/codex)** - 通过阅读其开源实现,本项目确定了几处行为:AGENTS.md 的加载规则、未获明确许可不得开启子代理、计划工具的状态流转约束、代码审查的判定标准,以及系统提示词的部分内容
|
|
70
72
|
|
|
71
73
|
特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
|
|
72
74
|
|
package/bin/install.js
CHANGED
|
@@ -74,7 +74,7 @@ try {
|
|
|
74
74
|
// Windows: Use PowerShell to refresh environment
|
|
75
75
|
const { execSync } = require('child_process');
|
|
76
76
|
try {
|
|
77
|
-
execSync('
|
|
77
|
+
execSync('$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User");', { stdio: 'pipe' });
|
|
78
78
|
console.log('✓ Environment refreshed automatically (PowerShell)');
|
|
79
79
|
} catch (e) {
|
|
80
80
|
console.log('ℹ️ To refresh environment in PowerShell:');
|
package/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Background task registry — the single place tasks are created, listed, stopped
|
|
2
|
+
// and read. Mirrors kimi-code's task model:
|
|
3
|
+
//
|
|
4
|
+
// TaskInfo = base fields + a `kind`-discriminated payload
|
|
5
|
+
// kind 'process' -> { command, pid, exitCode } (Bash run_in_background)
|
|
6
|
+
// kind 'agent' -> { agentId, subagentType, model, thinkingEffort } (Agent)
|
|
7
|
+
// kind 'question' -> { questionCount, toolCallId } (AskUserQuestion bg)
|
|
8
|
+
//
|
|
9
|
+
// Statuses match kimi exactly so the UI can share one vocabulary:
|
|
10
|
+
// running | completed | failed | timed_out | killed | lost
|
|
11
|
+
//
|
|
12
|
+
// Bash used to keep its own ad-hoc objects ('done'/'failed', `id`, `start`), which
|
|
13
|
+
// is why the task panel could not show the same fields as kimi's. Everything now
|
|
14
|
+
// goes through `createTask`, so a task always has the same shape no matter which
|
|
15
|
+
// tool spawned it.
|
|
16
|
+
|
|
17
|
+
export const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timed_out', 'killed', 'lost']);
|
|
18
|
+
|
|
19
|
+
export function isTerminal(status) {
|
|
20
|
+
return TERMINAL_STATUSES.has(status);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `{prefix}-{8 chars}` like kimi's VALID_TASK_ID. Unique even for two tasks
|
|
24
|
+
// created in the same millisecond (a bare Date.now() collided).
|
|
25
|
+
let seq = 0;
|
|
26
|
+
export function newTaskId(prefix = 'task') {
|
|
27
|
+
seq = (seq + 1) % 1e9;
|
|
28
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
|
|
29
|
+
const s = String(seq).padStart(4, '0');
|
|
30
|
+
return `${prefix}-${(Date.now().toString(36) + s).slice(-8)}-${rand}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Decode a task's buffered chunks for display. Buffer chunks (Bash) are UTF-8;
|
|
34
|
+
// string chunks (Agent/AgentSwarm) are already text.
|
|
35
|
+
function decodeTaskOutput(chunks) {
|
|
36
|
+
const list = chunks || [];
|
|
37
|
+
if (!list.length) return '';
|
|
38
|
+
const allStrings = list.every((c) => typeof c === 'string');
|
|
39
|
+
if (allStrings) return list.join('');
|
|
40
|
+
try { return Buffer.concat(list.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c))))).toString('utf8'); }
|
|
41
|
+
catch { return list.map((c) => String(c)).join(''); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The task store lives on the agent ctx (`ctx.tasks`), so every tool and the TUI
|
|
45
|
+
// see the same map.
|
|
46
|
+
function store(ctx) {
|
|
47
|
+
if (!ctx) return {};
|
|
48
|
+
if (!ctx.tasks) ctx.tasks = {};
|
|
49
|
+
return ctx.tasks;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register a task. `kind` selects the payload group; the returned object is the
|
|
54
|
+
* live record (mutate `status`/`endedAt`/`stopReason` as it runs).
|
|
55
|
+
*
|
|
56
|
+
* options:
|
|
57
|
+
* description short human label
|
|
58
|
+
* detached true when it runs in the background (Ctrl+B or run_in_background)
|
|
59
|
+
* timeoutMs optional cap
|
|
60
|
+
* command/pid/exitCode (kind 'process')
|
|
61
|
+
* agentId/subagentType/model/thinkingEffort (kind 'agent')
|
|
62
|
+
* questionCount/toolCallId (kind 'question')
|
|
63
|
+
* output initial output text
|
|
64
|
+
*/
|
|
65
|
+
export function createTask(ctx, kind, options = {}) {
|
|
66
|
+
const tasks = store(ctx);
|
|
67
|
+
const prefix = kind === 'process' ? 'bash' : kind === 'agent' ? 'agent' : 'question';
|
|
68
|
+
const task = {
|
|
69
|
+
taskId: newTaskId(prefix),
|
|
70
|
+
kind,
|
|
71
|
+
description: String(options.description || ''),
|
|
72
|
+
status: 'running',
|
|
73
|
+
detached: options.detached !== false,
|
|
74
|
+
startedAt: Date.now(),
|
|
75
|
+
endedAt: null,
|
|
76
|
+
stopReason: undefined,
|
|
77
|
+
timeoutMs: options.timeoutMs,
|
|
78
|
+
// Output is buffered here and exposed through `output` below. Kept as chunks
|
|
79
|
+
// so the decoder can run lazily (a chatty task costs nothing until read).
|
|
80
|
+
_chunks: options._chunks || [],
|
|
81
|
+
_bytes: 0,
|
|
82
|
+
};
|
|
83
|
+
if (kind === 'process') {
|
|
84
|
+
task.command = String(options.command || '');
|
|
85
|
+
task.pid = options.pid || 0;
|
|
86
|
+
task.exitCode = null;
|
|
87
|
+
} else if (kind === 'agent') {
|
|
88
|
+
task.agentId = options.agentId;
|
|
89
|
+
task.subagentType = options.subagentType;
|
|
90
|
+
task.model = options.model;
|
|
91
|
+
task.thinkingEffort = options.thinkingEffort;
|
|
92
|
+
} else if (kind === 'question') {
|
|
93
|
+
task.questionCount = options.questionCount || 0;
|
|
94
|
+
task.toolCallId = options.toolCallId;
|
|
95
|
+
}
|
|
96
|
+
// Every task exposes its buffered output through `output`. The chunks are
|
|
97
|
+
// stored raw and decoded lazily:
|
|
98
|
+
// * a process appends Buffer chunks -> decode as UTF-8
|
|
99
|
+
// * an agent/swarm appends strings -> join
|
|
100
|
+
// Callers may override this getter when they need a different decoding.
|
|
101
|
+
Object.defineProperty(task, 'output', {
|
|
102
|
+
enumerable: true,
|
|
103
|
+
configurable: true,
|
|
104
|
+
get() { return decodeTaskOutput(this._chunks); },
|
|
105
|
+
});
|
|
106
|
+
tasks[task.taskId] = task;
|
|
107
|
+
return task;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cap retained output per task; a runaway command must not exhaust memory.
|
|
111
|
+
export const MAX_TASK_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
112
|
+
|
|
113
|
+
// Append raw bytes/chunks to a task, trimming the oldest past the cap.
|
|
114
|
+
export function appendTaskOutput(task, chunk) {
|
|
115
|
+
if (!task || chunk == null) return;
|
|
116
|
+
task._chunks.push(chunk);
|
|
117
|
+
task._bytes = (task._bytes || 0) + (chunk.length || 0);
|
|
118
|
+
while (task._bytes > MAX_TASK_OUTPUT_BYTES && task._chunks.length > 1) {
|
|
119
|
+
task._bytes -= task._chunks[0].length || 0;
|
|
120
|
+
task._chunks.shift();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Settle a task. `status` is one of the kimi statuses; a task that was already
|
|
125
|
+
// stopped keeps 'killed' (mirrors kimi: a deliberate stop must not be relabelled
|
|
126
|
+
// as a failure by the process's non-zero exit).
|
|
127
|
+
export function settleTask(task, status, extra = {}) {
|
|
128
|
+
if (!task) return;
|
|
129
|
+
// A task that already reached a TERMINAL state must not be relabelled: a
|
|
130
|
+
// deliberate stop ('killed') must not turn into 'failed' when the killed
|
|
131
|
+
// process reports a non-zero exit, and a 'completed' task must not be
|
|
132
|
+
// downgraded by a late error. Only a still-running task settles.
|
|
133
|
+
if (isTerminal(task.status)) {
|
|
134
|
+
if (extra.exitCode !== undefined && task.kind === 'process') task.exitCode = extra.exitCode;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
task.status = status;
|
|
138
|
+
task.endedAt = Date.now();
|
|
139
|
+
if (extra.stopReason !== undefined) task.stopReason = extra.stopReason;
|
|
140
|
+
if (extra.exitCode !== undefined && task.kind === 'process') task.exitCode = extra.exitCode;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function listTasks(ctx) {
|
|
144
|
+
return Object.values(store(ctx));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getTask(ctx, id) {
|
|
148
|
+
return store(ctx)[id] || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Newest-first for display (mirrors kimi's compareTasks: running first, then by
|
|
152
|
+
// most recent activity).
|
|
153
|
+
export function sortedTasks(ctx) {
|
|
154
|
+
const tasks = listTasks(ctx);
|
|
155
|
+
return tasks.slice().sort((a, b) => {
|
|
156
|
+
const at = isTerminal(a.status), bt = isTerminal(b.status);
|
|
157
|
+
if (at !== bt) return at ? 1 : -1;
|
|
158
|
+
if (!at) return a.startedAt - b.startedAt;
|
|
159
|
+
return (b.endedAt || b.startedAt) - (a.endedAt || a.startedAt);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Human label for a status, matching kimi's STATUS_LABEL.
|
|
164
|
+
export const STATUS_LABEL = {
|
|
165
|
+
running: 'running',
|
|
166
|
+
completed: 'completed',
|
|
167
|
+
failed: 'failed',
|
|
168
|
+
timed_out: 'timed out',
|
|
169
|
+
killed: 'killed',
|
|
170
|
+
lost: 'lost',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Colour token for a status (the TUI maps it to a theme colour).
|
|
174
|
+
export function statusColor(status) {
|
|
175
|
+
if (status === 'running') return 'green';
|
|
176
|
+
if (status === 'completed') return 'gray';
|
|
177
|
+
return 'red';
|
|
178
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
6
|
import { tools, getTool, llmTools } from './tools/index.js';
|
|
7
|
+
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
7
8
|
import { estimateMessagesTokens } from './term.js';
|
|
9
|
+
import { runHooks } from './plugin.js';
|
|
8
10
|
|
|
9
11
|
// ---- completion guard --------------------------------------------------------
|
|
10
12
|
// A turn is only complete when the model ends it with an actual answer. Models
|
|
@@ -33,10 +35,40 @@ Tools:
|
|
|
33
35
|
- If a tool fails, read the error and fix your approach. After 2 failed attempts on the same goal, stop and report the blocker.
|
|
34
36
|
- Independent tool calls may run in parallel; never parallelize dependent ones.
|
|
35
37
|
|
|
38
|
+
Reasoning:
|
|
39
|
+
- Put ALL of your internal reasoning inside <think>...</think> tags, and always emit BOTH the opening
|
|
40
|
+
and the closing tag. Do not omit the tags, do not abbreviate them, and do not leave the block unclosed.
|
|
41
|
+
- Everything outside <think>...</think> is what the user reads: keep it to the answer itself — what you
|
|
42
|
+
did and what you found. Never repeat or summarise your reasoning there.
|
|
43
|
+
- Keep the tags exactly as written: <think> and </think>. Do not emit any other variant.
|
|
44
|
+
Acting:
|
|
45
|
+
- Unless the user is explicitly asking for a plan, asking a question about the code, or brainstorming,
|
|
46
|
+
assume they want the work DONE. Say less and do more: implement the change rather than describing it.
|
|
47
|
+
- Carry the task through implementation and verification in this turn. Do not stop at analysis, at a
|
|
48
|
+
partial fix, or at a list of things you would do next.
|
|
49
|
+
- New project, no prior context: be ambitious, use your judgement on structure.
|
|
50
|
+
Existing codebase: be surgical. Touch only what the task needs — no renames, no reorganising,
|
|
51
|
+
no drive-by fixes.
|
|
52
|
+
- Read the neighbours before you write: match the naming, error handling and comment density you see.
|
|
53
|
+
- Do not re-read a file after editing it, and do not re-run a search you already have results for.
|
|
54
|
+
A successful edit tool call already proves it worked.
|
|
55
|
+
|
|
36
56
|
Scope:
|
|
37
57
|
- Do only what was asked. Do not refactor or "improve" unrelated code.
|
|
58
|
+
- Do not fix unrelated bugs or broken tests — mention them instead; they are not yours to change.
|
|
38
59
|
- If the request is ambiguous, ask one short clarifying question.
|
|
39
60
|
- State any assumption you make.
|
|
61
|
+
- Might be a dirty git worktree. Never revert changes you did not make. Never run a destructive command
|
|
62
|
+
(git reset --hard, git checkout --) unless the user asked for it. If you notice an unexpected change
|
|
63
|
+
you did not make, stop and ask.
|
|
64
|
+
|
|
65
|
+
Verifying:
|
|
66
|
+
- Run the narrowest check that exercises your change first, then widen.
|
|
67
|
+
- In auto/yolo mode, run tests and lint yourself without asking.
|
|
68
|
+
- In ask mode, do not burn the user's time on slow test suites mid-task: say what you would run and
|
|
69
|
+
let them confirm.
|
|
70
|
+
- Do not add tests to a codebase that has none. Do not add a formatter that the project did not configure.
|
|
71
|
+
- If you could not verify something, say so plainly. Do not imply a test passed when you did not run it.
|
|
40
72
|
|
|
41
73
|
Finishing:
|
|
42
74
|
- Never stop silently after a tool call; end every turn with a written answer in the user's language.
|
|
@@ -56,10 +88,18 @@ export class Agent {
|
|
|
56
88
|
// Ensure the LLM client knows the bare tool specs (the client wraps them per-protocol).
|
|
57
89
|
// A toolFilter (array of tool names) limits which tools are exposed — used
|
|
58
90
|
// by Plan mode (read-only subset) and Focus mode (minimal subset first).
|
|
59
|
-
|
|
91
|
+
// `cfg.noDelegation` marks this as a SUBAGENT: delegation tools are removed
|
|
92
|
+
// even if the filter would have allowed them. The catalog already excludes
|
|
93
|
+
// them from every profile; this is the hard backstop that also covers the
|
|
94
|
+
// `null` (= everything) case, so a subagent can never spawn another agent.
|
|
95
|
+
const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
60
96
|
? tools.filter((t) => cfg.toolFilter.includes(t.name))
|
|
61
|
-
: tools
|
|
97
|
+
: tools
|
|
98
|
+
).filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)));
|
|
62
99
|
setToolsList(filtered);
|
|
100
|
+
// A block for the direct-call path too: the model can still emit a tool call
|
|
101
|
+
// for a tool that is not exposed, so refuse it here rather than at execution.
|
|
102
|
+
this.noDelegation = !!cfg.noDelegation;
|
|
63
103
|
// AbortSignal handed to every tool so a long-running one (Bash) is killed
|
|
64
104
|
// when the user presses Esc / Ctrl-C — otherwise the command keeps running
|
|
65
105
|
// after the turn was interrupted.
|
|
@@ -72,6 +112,12 @@ export class Agent {
|
|
|
72
112
|
this.ctx.onOutput = (chunk) => {
|
|
73
113
|
if (chunk && this._currentToolId) this.onEvent({ type: 'tool_output', id: this._currentToolId, chunk });
|
|
74
114
|
};
|
|
115
|
+
// `fork` (the Agent tool) snapshots THIS conversation into the subagent. The
|
|
116
|
+
// tool reads `ctx._parentMessages`, which nothing ever set — so `fork: true`
|
|
117
|
+
// was accepted and then seeded the subagent with an empty history. Hand the
|
|
118
|
+
// tool a live getter over this agent's own messages instead of a copy, so the
|
|
119
|
+
// snapshot is taken at spawn time and reflects everything up to that point.
|
|
120
|
+
this.ctx._parentMessages = this.messages;
|
|
75
121
|
this.llm = new LLM(cfg);
|
|
76
122
|
this.stopRequested = false;
|
|
77
123
|
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
@@ -97,14 +143,15 @@ export class Agent {
|
|
|
97
143
|
}
|
|
98
144
|
|
|
99
145
|
async run() {
|
|
146
|
+
// Plugin lifecycle: `onTurnStart` fires here and `onTurnEnd` in the finally
|
|
147
|
+
// block at the end of this method. Both are awaited and both swallow plugin
|
|
148
|
+
// errors (see runHooks), so a broken plugin cannot abort a turn.
|
|
149
|
+
await runHooks('onTurnStart', { messages: this.messages });
|
|
100
150
|
let text = '';
|
|
101
151
|
let toolCalls = [];
|
|
102
152
|
let emittedEnd = false;
|
|
103
|
-
// Reasoning text seen in the current step. A turn that ends with reasoning
|
|
104
|
-
// but no answer is a truncated turn (see the completion guard below).
|
|
105
153
|
let thought = '';
|
|
106
154
|
let nudges = 0;
|
|
107
|
-
// True when the most recent iteration still had tool calls pending; used to
|
|
108
155
|
// decide whether running out of steps actually left the turn unfinished.
|
|
109
156
|
let hadToolCallsOnFinalStep = false;
|
|
110
157
|
|
|
@@ -225,7 +272,12 @@ export class Agent {
|
|
|
225
272
|
}
|
|
226
273
|
}
|
|
227
274
|
|
|
275
|
+
// Plugin hooks around the model round-trip. `onBeforeRequest` sees the exact
|
|
276
|
+
// message array being sent (and may mutate it); `onAfterRequest` fires once
|
|
277
|
+
// the stream has been consumed.
|
|
278
|
+
await runHooks('onBeforeRequest', this.messages, this.cfg);
|
|
228
279
|
await this.llm.request(this.messages, handle);
|
|
280
|
+
await runHooks('onAfterRequest', this.messages, this.cfg);
|
|
229
281
|
|
|
230
282
|
// An abort ends the turn: keep whatever text arrived, then stop.
|
|
231
283
|
if (this.stopRequested) {
|
|
@@ -238,11 +290,17 @@ export class Agent {
|
|
|
238
290
|
const hasText = !!(text && text.trim());
|
|
239
291
|
const hasCalls = toolCalls.length > 0;
|
|
240
292
|
if (hasText || hasCalls) {
|
|
241
|
-
|
|
293
|
+
const assistantMsg = {
|
|
242
294
|
role: 'assistant',
|
|
243
295
|
content: hasText ? text : null,
|
|
244
296
|
toolCalls: hasCalls ? toolCalls : undefined,
|
|
245
|
-
}
|
|
297
|
+
};
|
|
298
|
+
this.messages.push(assistantMsg);
|
|
299
|
+
// Plugin hook: one per assistant message committed to the history. Fired
|
|
300
|
+
// here (the single point where the model's reply lands) rather than at each
|
|
301
|
+
// of the eleven `messages.push` sites, so a plugin sees a coherent message
|
|
302
|
+
// instead of the intermediate bookkeeping pushes.
|
|
303
|
+
await runHooks('onNewMessage', assistantMsg, this.messages);
|
|
246
304
|
}
|
|
247
305
|
|
|
248
306
|
// ---- if tool calls exist, execute them ----
|
|
@@ -308,6 +366,17 @@ export class Agent {
|
|
|
308
366
|
}
|
|
309
367
|
|
|
310
368
|
const tool = getTool(tc.name);
|
|
369
|
+
// A subagent may not delegate. The tool is not exposed to it, but the model
|
|
370
|
+
// can still emit a call for a tool it was told about earlier, so refuse it
|
|
371
|
+
// here with an actionable message instead of running a nested agent.
|
|
372
|
+
if (this.noDelegation && DELEGATION_TOOLS.includes(tc.name)) {
|
|
373
|
+
result = `You CAN'T use ${tc.name}: subagents cannot delegate. Do the work yourself and report the result to the agent that started you.`;
|
|
374
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
375
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
376
|
+
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
377
|
+
allOk = false;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
311
380
|
if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
|
|
312
381
|
result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
|
|
313
382
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
@@ -322,6 +391,10 @@ export class Agent {
|
|
|
322
391
|
} else {
|
|
323
392
|
if (this.onToolStart) this.onToolStart(tc.name, tc.args);
|
|
324
393
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
394
|
+
// Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
|
|
395
|
+
// mutable args object, so a plugin can inspect or adjust a call. Errors
|
|
396
|
+
// are swallowed by runHooks — a broken plugin must not break a turn.
|
|
397
|
+
await runHooks('onToolExecute', tc.name, tc.args, this.ctx);
|
|
325
398
|
try {
|
|
326
399
|
result = await tool.execute(tc.args, this.ctx);
|
|
327
400
|
} catch (err) {
|
|
@@ -329,15 +402,27 @@ export class Agent {
|
|
|
329
402
|
allOk = false;
|
|
330
403
|
}
|
|
331
404
|
this.ctx.lastResult = result;
|
|
405
|
+
// Plugin hook: onToolResult fires with the tool's output.
|
|
406
|
+
await runHooks('onToolResult', tc.name, result, this.ctx);
|
|
332
407
|
// Surface the live TODO list so the TUI can render its panel.
|
|
333
408
|
if (tc.name === 'TodoList') {
|
|
334
409
|
this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
|
|
335
410
|
}
|
|
336
411
|
}
|
|
337
412
|
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
338
|
-
|
|
413
|
+
// An Edit reports the text it replaced on ctx (both the substring and the
|
|
414
|
+
// line-range path do). Forward it so the TUI can draw the +/- diff: the
|
|
415
|
+
// line-range mode has no `old_string` in its args, so without this the
|
|
416
|
+
// edit rendered with no diff at all. Cleared every time so a later tool
|
|
417
|
+
// cannot inherit a stale diff.
|
|
418
|
+
this.onEvent({
|
|
419
|
+
type: 'tool_result', id: tc.id, name: tc.name, content: result,
|
|
420
|
+
editDiff: this.ctx.lastEditDiff || null,
|
|
421
|
+
});
|
|
422
|
+
this.ctx.lastEditDiff = null;
|
|
339
423
|
}
|
|
340
424
|
|
|
425
|
+
|
|
341
426
|
// Did THIS iteration still have tool calls? If the loop runs out of budget
|
|
342
427
|
// right after running them, the turn really is unfinished.
|
|
343
428
|
hadToolCallsOnFinalStep = toolCalls.length > 0;
|
|
@@ -360,5 +445,8 @@ export class Agent {
|
|
|
360
445
|
});
|
|
361
446
|
}
|
|
362
447
|
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
|
448
|
+
// Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
|
|
449
|
+
// In a finally-equivalent position — every exit path above reaches here.
|
|
450
|
+
await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
|
|
363
451
|
}
|
|
364
452
|
}
|
package/src/colors.js
CHANGED
|
@@ -57,6 +57,13 @@ export const C = {
|
|
|
57
57
|
reset: '\x1b[0m',
|
|
58
58
|
bold: '\x1b[1m',
|
|
59
59
|
dim: '\x1b[2m',
|
|
60
|
+
// Italic (SGR 3). Used for the BODY of a reasoning block — the prose reads as
|
|
61
|
+
// an aside rather than as the answer. Must be closed with SGR 23 (`endItalic`),
|
|
62
|
+
// NOT with `reset`, because resetting also drops the surrounding dim/gray that
|
|
63
|
+
// the same row depends on. Terminals without italic fall back to normal text,
|
|
64
|
+
// so this is a decoration, never load-bearing.
|
|
65
|
+
italic: '\x1b[3m',
|
|
66
|
+
endItalic: '\x1b[23m',
|
|
60
67
|
muted: '\x1b[90m',
|
|
61
68
|
teal: TEAL,
|
|
62
69
|
cyan: CYAN,
|
|
@@ -74,6 +81,13 @@ export const C = {
|
|
|
74
81
|
gray: '\x1b[90m',
|
|
75
82
|
white: '\x1b[97m',
|
|
76
83
|
orange: TRUECOLOR ? rgb(255, 140, 0) : '\x1b[38;5;208m',
|
|
84
|
+
// Cyan pushed slightly toward green — used for the Swarm mode badge so it reads
|
|
85
|
+
// as its own mode next to Plan (pure cyan) and Focus (blue) while staying in the
|
|
86
|
+
// theme's blue-green family. 38;5;43 is rgb(0,215,175): red at zero and green
|
|
87
|
+
// only 40 above blue, i.e. still clearly cyan with a green cast. 38;5;85 was the
|
|
88
|
+
// first attempt and read as plain GREEN — its red channel is lifted to 95 and
|
|
89
|
+
// green pins at 255, which is a spring green, not a green-tinted cyan.
|
|
90
|
+
spring: TRUECOLOR ? rgb(0, 215, 175) : '\x1b[38;5;43m',
|
|
77
91
|
bgRed: '\x1b[41m',
|
|
78
92
|
bgGreen: '\x1b[42m',
|
|
79
93
|
// Selection highlight (mouse text selection) and the scrollbar gutter.
|
package/src/config.js
CHANGED
|
@@ -62,6 +62,124 @@ export function hncodeConfigFile() {
|
|
|
62
62
|
return process.env.HNCODE_CONFIG || path.join(home, '.hncode', 'config.toml');
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// ---- personal preferences (/personal) ----
|
|
66
|
+
// Free-form markdown the user wants applied on EVERY turn ("reply in Chinese",
|
|
67
|
+
// "I use pnpm", "never add a trailing newline", …). Two scopes are merged, the
|
|
68
|
+
// project one LAST so it can refine the global one:
|
|
69
|
+
// * global — ~/.hncode/PERSONAL.md (every workspace)
|
|
70
|
+
// * project — <workspace>/.hncode/PERSONAL.md (this workspace only)
|
|
71
|
+
// Both are OPTIONAL; a missing/empty file injects nothing. The files are read
|
|
72
|
+
// fresh on each turn, so an edit through /personal takes effect immediately.
|
|
73
|
+
export function personalPromptFile(scope, workspace) {
|
|
74
|
+
if (scope === 'global') return path.join(home, '.hncode', 'PERSONAL.md');
|
|
75
|
+
return path.join(workspace || process.cwd(), '.hncode', 'PERSONAL.md');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Header placed before the injected preferences, so the model knows where the
|
|
79
|
+
// text came from and how much authority it has.
|
|
80
|
+
export const PERSONAL_PROMPT_HEADER =
|
|
81
|
+
'USER PERSONAL PREFERENCES (persistent notes from the user; follow them unless\n'
|
|
82
|
+
+ 'the current request says otherwise):';
|
|
83
|
+
|
|
84
|
+
// Raw file content for one scope (no header) — used by /personal's editor.
|
|
85
|
+
export function readPersonalPromptRaw(scope, workspace) {
|
|
86
|
+
try { return fs.readFileSync(personalPromptFile(scope, workspace), 'utf8'); } catch { return ''; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Save one scope. Empty text REMOVES the file (so nothing is injected).
|
|
90
|
+
export function writePersonalPrompt(scope, workspace, text) {
|
|
91
|
+
const file = personalPromptFile(scope, workspace);
|
|
92
|
+
const value = String(text == null ? '' : text);
|
|
93
|
+
if (!value.trim()) {
|
|
94
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
95
|
+
return file;
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
fs.writeFileSync(file, value.replace(/\s+$/, '') + '\n', 'utf8');
|
|
99
|
+
return file;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readPersonalPrompt(workspace) {
|
|
103
|
+
const parts = [];
|
|
104
|
+
for (const scope of ['global', 'project']) {
|
|
105
|
+
try {
|
|
106
|
+
const t = fs.readFileSync(personalPromptFile(scope, workspace), 'utf8').trim();
|
|
107
|
+
if (t) parts.push(t);
|
|
108
|
+
} catch { /* no file for this scope: nothing to inject */ }
|
|
109
|
+
}
|
|
110
|
+
if (!parts.length) return '';
|
|
111
|
+
return PERSONAL_PROMPT_HEADER + '\n' + parts.join('\n\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- AGENTS.md --------------------------------------------------------------
|
|
115
|
+
// Project instruction files, mirroring codex's AGENTS.md spec
|
|
116
|
+
// (codex-rs/core/gpt_5_2_prompt.md, "AGENTS.md spec"):
|
|
117
|
+
// * a file's scope is the whole directory tree rooted where it sits;
|
|
118
|
+
// * for every file you touch you must obey the AGENTS.md whose scope covers it;
|
|
119
|
+
// * more-deeply-nested files take precedence on conflict;
|
|
120
|
+
// * direct system/user instructions outrank all of them.
|
|
121
|
+
// Files are therefore collected from the workspace root DOWN to the working
|
|
122
|
+
// directory and injected outermost-first, so the deepest one is read last and
|
|
123
|
+
// naturally wins — the same precedence, without a merge step.
|
|
124
|
+
//
|
|
125
|
+
// This closes a real gap: /init WRITES an AGENTS.md and the prompt advertises it
|
|
126
|
+
// as "smarter agent context", but nothing ever read it back.
|
|
127
|
+
const AGENTS_MAX_BYTES = 32 * 1024;
|
|
128
|
+
const AGENTS_FILENAMES = ['AGENTS.md', 'agents.md', 'AGENTS.override.md'];
|
|
129
|
+
|
|
130
|
+
export const AGENTS_PROMPT_HEADER =
|
|
131
|
+
'PROJECT INSTRUCTIONS (AGENTS.md). The scope of each file is the directory it sits in,\n'
|
|
132
|
+
+ 'so a deeper file is more specific and takes precedence over a shallower one. They apply\n'
|
|
133
|
+
+ 'to every file you touch under that directory. The current request and the user\'s direct\n'
|
|
134
|
+
+ 'instructions still outrank anything here.'
|
|
135
|
+
|
|
136
|
+
// Every applicable AGENTS.md, outermost first. `dir` keeps the scope visible.
|
|
137
|
+
export function agentsFilesFor(cwd, workspaceRoot) {
|
|
138
|
+
const start = path.resolve(cwd || process.cwd());
|
|
139
|
+
const root = path.resolve(workspaceRoot || start);
|
|
140
|
+
const dirs = [];
|
|
141
|
+
let dir = start;
|
|
142
|
+
for (;;) {
|
|
143
|
+
dirs.push(dir);
|
|
144
|
+
const parent = path.dirname(dir);
|
|
145
|
+
// Stop at the filesystem root, or once we have walked past the workspace.
|
|
146
|
+
if (parent === dir || dir === root) break;
|
|
147
|
+
dir = parent;
|
|
148
|
+
}
|
|
149
|
+
dirs.reverse(); // outermost first
|
|
150
|
+
const found = [];
|
|
151
|
+
for (const d of dirs) {
|
|
152
|
+
for (const name of AGENTS_FILENAMES) {
|
|
153
|
+
const p = path.join(d, name);
|
|
154
|
+
try {
|
|
155
|
+
if (!fs.statSync(p).isFile()) continue;
|
|
156
|
+
found.push({ path: p, dir: d });
|
|
157
|
+
break; // one instruction file per directory
|
|
158
|
+
} catch { /* not present here */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The block injected into the system prompt, or '' when the project has none.
|
|
165
|
+
export function readAgentsMd(cwd, workspaceRoot) {
|
|
166
|
+
const files = agentsFilesFor(cwd, workspaceRoot);
|
|
167
|
+
if (!files.length) return '';
|
|
168
|
+
const parts = [];
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
try {
|
|
171
|
+
let text = fs.readFileSync(f.path, 'utf8').trim();
|
|
172
|
+
if (!text) continue;
|
|
173
|
+
if (Buffer.byteLength(text, 'utf8') > AGENTS_MAX_BYTES) {
|
|
174
|
+
text = Buffer.from(text, 'utf8').subarray(0, AGENTS_MAX_BYTES).toString('utf8') + '\n[truncated]';
|
|
175
|
+
}
|
|
176
|
+
parts.push(`--- ${f.path} ---\n${text}`);
|
|
177
|
+
} catch { /* unreadable: skip rather than fail the turn */ }
|
|
178
|
+
}
|
|
179
|
+
if (!parts.length) return '';
|
|
180
|
+
return AGENTS_PROMPT_HEADER + '\n\n' + parts.join('\n\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
65
183
|
function loadBaseToml() {
|
|
66
184
|
// Only hncode's own config file participates.
|
|
67
185
|
const f = hncodeConfigFile();
|
|
@@ -146,9 +264,19 @@ export function resolveConfig() {
|
|
|
146
264
|
// Backwards-compat: read legacy cool_mode key if calm_mode is not set.
|
|
147
265
|
: (root.calm_mode === true || root.calm_mode === 'true'
|
|
148
266
|
|| root.cool_mode === true || root.cool_mode === 'true'),
|
|
267
|
+
// SUBAGENT MODEL (/swarm-sub-agent): the model each subagent runs on. Empty
|
|
268
|
+
// (the default) means "follow this session's model", which is what the Agent
|
|
269
|
+
// and AgentSwarm tools fall back to. Set from config.toml so it persists.
|
|
270
|
+
subagentModel: process.env.HNCODE_SUBAGENT_MODEL || root.subagent_model || '',
|
|
149
271
|
// Plugin directory (default: ~/.hncode/plugins). Set to "" or "false" to
|
|
150
272
|
// disable plugin loading entirely.
|
|
151
273
|
pluginDir: process.env.HNCODE_PLUGINS || root.plugins_dir || '',
|
|
274
|
+
// AUTO-UPDATE (/auto-update): when true, hncode checks npm for a newer
|
|
275
|
+
// version at startup and then every 30 minutes, installing in the background
|
|
276
|
+
// without interrupting the session.
|
|
277
|
+
autoUpdate: process.env.HNCODE_AUTO_UPDATE
|
|
278
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
|
|
279
|
+
: (root.auto_update === true || root.auto_update === 'true'),
|
|
152
280
|
raw: root,
|
|
153
281
|
};
|
|
154
282
|
}
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg } from './config.js';
|
|
6
|
+
import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg, readPersonalPrompt } from './config.js';
|
|
7
7
|
import * as sess from './session.js';
|
|
8
8
|
import { Agent, SYSTEM_PROMPT } from './agent.js';
|
|
9
9
|
import { llmTools } from './tools/index.js';
|
|
@@ -171,7 +171,12 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
|
171
171
|
}
|
|
172
172
|
const messages = [];
|
|
173
173
|
if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
|
|
174
|
-
|
|
174
|
+
// Same layering as the TUI: built-in prompt first, then the user's own
|
|
175
|
+
// system_prompt override if any, then the /personal notes, then calm mode.
|
|
176
|
+
let sysText = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
|
|
177
|
+
const personal = readPersonalPrompt(cfg.workspace);
|
|
178
|
+
if (personal) sysText += '\n\n' + personal;
|
|
179
|
+
messages.push({ role: 'system', content: sysText });
|
|
175
180
|
const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
|
|
176
181
|
const agent = new Agent({
|
|
177
182
|
cfg,
|
package/src/plugin.js
CHANGED
|
@@ -143,8 +143,18 @@ export async function loadPlugins(dir) {
|
|
|
143
143
|
continue;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
// Metadata comes from the default export. `name` there is a plain STRING
|
|
147
|
+
// (`export default { name: 'recorder' }`), so reading `meta.name` off it
|
|
148
|
+
// returned undefined and the plugin showed up as its FILENAME with version
|
|
149
|
+
// 0.0.0 — /plugins listed every plugin under the wrong name. Accept both
|
|
150
|
+
// shapes: a string name, or an object carrying { name, version }.
|
|
151
|
+
const def = (mod && mod.default) || {};
|
|
152
|
+
const meta = typeof def === 'string' ? { name: def } : def;
|
|
153
|
+
const plugin = {
|
|
154
|
+
id: f,
|
|
155
|
+
name: meta.name || meta.meta?.name || f,
|
|
156
|
+
version: meta.version || meta.meta?.version || '0.0.0',
|
|
157
|
+
};
|
|
148
158
|
loadedPlugins.push(plugin);
|
|
149
159
|
installFn(API);
|
|
150
160
|
// eslint-disable-next-line no-console
|