@hncode/hncode 0.2.0 → 0.3.1
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 +1 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +101 -8
- package/src/colors.js +14 -0
- package/src/config.js +191 -10
- package/src/index.js +31 -9
- package/src/llm.js +60 -11
- package/src/plugin.js +25 -2
- package/src/prompt-presets.js +429 -0
- package/src/session.js +257 -24
- 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 +208 -0
- package/src/tools/agent.js +342 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +53 -29
- package/src/tools/edit.js +122 -11
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +17 -5
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +19 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +141 -126
- package/src/tools/todo.js +29 -2
- package/src/tools/utils.js +11 -1
- package/src/tui.js +2264 -337
- package/src/update.js +95 -0
- package/src/version.js +20 -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
|
@@ -67,6 +67,7 @@ hncode 从以下几个优秀项目中汲取了灵感:
|
|
|
67
67
|
|
|
68
68
|
- **[Kimi Code](https://github.com/MoonshotAI/kimi-code)** - 原始的青色蓝调终端界面 AI 编程助手,启发了本项目的设计和理念
|
|
69
69
|
- **[Claude Code](https://github.com/anthropics/claude-code)** - 对话流程和工具交互模式
|
|
70
|
+
- **[Codex](https://github.com/openai/codex)** - 通过阅读其开源实现,本项目确定了几处行为:AGENTS.md 的加载规则、未获明确许可不得开启子代理、计划工具的状态流转约束、代码审查的判定标准,以及系统提示词的部分内容
|
|
70
71
|
|
|
71
72
|
特别感谢这些项目背后的社区,它们推动了 AI 辅助编程的边界。
|
|
72
73
|
|
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,13 @@ export class Agent {
|
|
|
360
445
|
});
|
|
361
446
|
}
|
|
362
447
|
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
|
448
|
+
// The turn is over, so the inline-reasoning block it may have left open is
|
|
449
|
+
// abandoned: the NEXT user turn starts a fresh one. Without this, a model that
|
|
450
|
+
// never wrote its closing tag would leave the following turn's answer classified
|
|
451
|
+
// as reasoning.
|
|
452
|
+
if (typeof this.llm.resetThink === 'function') this.llm.resetThink();
|
|
453
|
+
// Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
|
|
454
|
+
// In a finally-equivalent position — every exit path above reaches here.
|
|
455
|
+
await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
|
|
363
456
|
}
|
|
364
457
|
}
|
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.
|