@hncode/hncode 0.3.2 → 0.3.3
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 +43 -0
- package/package.json +1 -1
- package/src/agent.js +75 -0
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +7 -0
- package/src/git.js +332 -0
- package/src/hooks.js +247 -0
- package/src/index.js +116 -16
- package/src/llm.js +14 -2
- package/src/mcp.js +376 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +988 -100
package/README.md
CHANGED
|
@@ -13,10 +13,53 @@ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](h
|
|
|
13
13
|
- **Cyan-blue TUI**: Raw terminal renderer for perfect Windows Terminal compatibility
|
|
14
14
|
- **Dual Protocol Support**: Works with both OpenAI-compatible APIs and Anthropic
|
|
15
15
|
- **Smart Context Management**: Auto-compaction at 85% usage, AI-summarized history retention
|
|
16
|
+
- **Prompt Caching**: Explicit cache breakpoints for the stable prefix (`/cache`)
|
|
16
17
|
- **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
|
|
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)
|
|
20
|
+
- **Shell Hooks**: Run your own commands at lifecycle events (`PreToolUse`, `PostToolUse`, …)
|
|
21
|
+
- **Git Workflow**: `/git`, `/commit`, `/branch`, `/worktree`, `/pr` plus a `Git` tool for the agent
|
|
22
|
+
- **Plan Review**: Review what a plan will touch, edit it, then approve before any file changes
|
|
17
23
|
- **Plugin System**: Extend with custom tools, commands, and hooks
|
|
18
24
|
- **Session Persistence**: Resume conversations across sessions
|
|
19
25
|
- **Permission Modes**: Ask, YOLO, or Auto modes for different workflows
|
|
26
|
+
- **Shell Passthrough**: type `!` on an empty prompt to switch the composer into shell mode — no model round-trip
|
|
27
|
+
- **CI / Scripting**: Headless `-p` with `json` / `stream-json` output and outcome-based exit codes
|
|
28
|
+
- **Remote Control**: `--control` exposes a local socket to prompt/status/interrupt a running session
|
|
29
|
+
|
|
30
|
+
### CI usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# One prompt, machine-readable result, exit code reflects the outcome.
|
|
34
|
+
hncode -p "fix the failing tests" --output-format json
|
|
35
|
+
# -> {"type":"result","ok":true,"reply":"…","toolCalls":["Bash"],"files":["src/a.js"],…}
|
|
36
|
+
|
|
37
|
+
# Exit codes: 0 ok · 1 failure · 2 config · 3 no answer · 4 interrupted
|
|
38
|
+
hncode -p "run the linter and fix everything" --output-format stream-json | jq -c 'select(.type=="result")'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Shell passthrough
|
|
42
|
+
|
|
43
|
+
Type `!` on an **empty** prompt to switch the composer into shell mode — the `!`
|
|
44
|
+
becomes the prompt symbol and the line runs directly, with no model involved:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
! git add -A && git push
|
|
48
|
+
! npm test
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `!` is a mode marker, not part of what you type, so it appears exactly once.
|
|
52
|
+
Press `Esc` or `Backspace` on an empty line to leave the mode. Output appears in
|
|
53
|
+
the transcript as a `↳` receipt.
|
|
54
|
+
|
|
55
|
+
### Remote control
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
hncode --control # in one terminal (TUI)
|
|
59
|
+
hncode control status # in another
|
|
60
|
+
hncode control prompt "run the tests" # queue a prompt on the running session
|
|
61
|
+
hncode control interrupt
|
|
62
|
+
```
|
|
20
63
|
|
|
21
64
|
---
|
|
22
65
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -7,6 +7,22 @@ import { tools, getTool, llmTools } from './tools/index.js';
|
|
|
7
7
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
8
|
import { estimateMessagesTokens } from './term.js';
|
|
9
9
|
import { runHooks } from './plugin.js';
|
|
10
|
+
import { loadHooks, runShellHooks } from './hooks.js';
|
|
11
|
+
|
|
12
|
+
// A short, stable key for the prompt cache, derived from the conversation's
|
|
13
|
+
// first user message. Stability is the whole point: the same conversation must
|
|
14
|
+
// produce the same key on every turn, so a cheap deterministic hash beats a
|
|
15
|
+
// random id. (Different conversations may collide — that is harmless, it only
|
|
16
|
+
// means the provider's routing key is shared.)
|
|
17
|
+
function hashKey(s) {
|
|
18
|
+
let h = 0x811c9dc5;
|
|
19
|
+
const str = String(s || '');
|
|
20
|
+
for (let i = 0; i < str.length; i++) {
|
|
21
|
+
h ^= str.charCodeAt(i);
|
|
22
|
+
h = (h * 0x01000193) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
return h.toString(36) + '-' + str.length.toString(36);
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
// ---- completion guard --------------------------------------------------------
|
|
12
28
|
// A turn is only complete when the model ends it with an actual answer. Models
|
|
@@ -118,9 +134,28 @@ export class Agent {
|
|
|
118
134
|
// tool a live getter over this agent's own messages instead of a copy, so the
|
|
119
135
|
// snapshot is taken at spawn time and reflects everything up to that point.
|
|
120
136
|
this.ctx._parentMessages = this.messages;
|
|
137
|
+
// Prompt-cache key: identify the SESSION, not the request, so the cached
|
|
138
|
+
// prefix is reused across turns instead of thrashing. The caller may pass one
|
|
139
|
+
// (the TUI knows the session id); otherwise derive a stable value from the
|
|
140
|
+
// first user message so repeated runs of the same conversation still share a
|
|
141
|
+
// prefix. A subagent inherits the parent's key (same conversation family).
|
|
142
|
+
if (cfg.sessionId == null) {
|
|
143
|
+
const firstUser = (Array.isArray(messages) ? messages : []).find((m) => m.role === 'user' && typeof m.content === 'string');
|
|
144
|
+
cfg = { ...cfg, sessionId: firstUser ? hashKey(firstUser.content) : 'default' };
|
|
145
|
+
this.cfg = cfg;
|
|
146
|
+
}
|
|
121
147
|
this.llm = new LLM(cfg);
|
|
122
148
|
this.stopRequested = false;
|
|
123
149
|
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
150
|
+
// Shell hooks (Claude Code-style): user-configured commands run at lifecycle
|
|
151
|
+
// events. ALWAYS stored as a `{ hooks: {Event: [...]} }` config, which is the
|
|
152
|
+
// shape runShellHooks() reads. A caller may inject one (tests / the TUI,
|
|
153
|
+
// which reloads per turn); otherwise it is read from disk for this workspace.
|
|
154
|
+
this.hookConfig = cfg.hookConfig
|
|
155
|
+
? (cfg.hookConfig.hooks ? cfg.hookConfig : { hooks: cfg.hookConfig })
|
|
156
|
+
: { hooks: loadHooks(cfg.workspace).hooks };
|
|
157
|
+
// The turn's user prompt, for the UserPromptSubmit hook. Set by run().
|
|
158
|
+
this._turnPrompt = '';
|
|
124
159
|
// Messages the user types while the agent is streaming. They are injected
|
|
125
160
|
// as user messages right AFTER the current round of tool calls, so the
|
|
126
161
|
// model sees "tool callback / user steer" together on the next request —
|
|
@@ -147,6 +182,19 @@ export class Agent {
|
|
|
147
182
|
// block at the end of this method. Both are awaited and both swallow plugin
|
|
148
183
|
// errors (see runHooks), so a broken plugin cannot abort a turn.
|
|
149
184
|
await runHooks('onTurnStart', { messages: this.messages });
|
|
185
|
+
// SHELL HOOK: SessionStart fires once per agent run (the first turn of a
|
|
186
|
+
// session). A hook failure never blocks — it is a convenience signal.
|
|
187
|
+
await runShellHooks(this.hookConfig, 'SessionStart', { messages: this.messages }, this.cfg.workspace);
|
|
188
|
+
// SHELL HOOK: UserPromptSubmit fires with the latest user message. It cannot
|
|
189
|
+
// block (Claude Code's can add context; blocking the user's own prompt is a
|
|
190
|
+
// worse failure than a missing hook), so the result is only logged.
|
|
191
|
+
{
|
|
192
|
+
const lastUser = [...this.messages].reverse().find((m) => m.role === 'user' && typeof m.content === 'string');
|
|
193
|
+
if (lastUser) {
|
|
194
|
+
this._turnPrompt = lastUser.content;
|
|
195
|
+
await runShellHooks(this.hookConfig, 'UserPromptSubmit', { prompt: lastUser.content }, this.cfg.workspace);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
150
198
|
let text = '';
|
|
151
199
|
let toolCalls = [];
|
|
152
200
|
let emittedEnd = false;
|
|
@@ -232,6 +280,10 @@ export class Agent {
|
|
|
232
280
|
let usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
233
281
|
if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
234
282
|
const before = usedNow;
|
|
283
|
+
// SHELL HOOK: PreCompact — a chance to back up the transcript before the
|
|
284
|
+
// older half is summarized away. Never blocks (the trim must go ahead).
|
|
285
|
+
await runShellHooks(this.hookConfig, 'PreCompact',
|
|
286
|
+
{ tokens: usedNow, messages: this.messages }, this.cfg.workspace);
|
|
235
287
|
const keepN = Math.max(1, Math.ceil(this.messages.length * 0.2));
|
|
236
288
|
const keep = this.messages.slice(-keepN);
|
|
237
289
|
const dropped = this.messages.slice(0, this.messages.length - keepN);
|
|
@@ -389,6 +441,21 @@ export class Agent {
|
|
|
389
441
|
result = `Error: unknown tool "${tc.name}".`;
|
|
390
442
|
allOk = false;
|
|
391
443
|
} else {
|
|
444
|
+
// SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
|
|
445
|
+
// message goes back to the model in place of the tool result — that is
|
|
446
|
+
// the whole point of a pre-hook (e.g. refuse a write that fails a lint
|
|
447
|
+
// gate). Checked BEFORE approving/executing so a blocked call never
|
|
448
|
+
// touches the filesystem.
|
|
449
|
+
const pre = await runShellHooks(this.hookConfig, 'PreToolUse',
|
|
450
|
+
{ toolName: tc.name, toolArgs: tc.args }, this.ctx.cwd || this.cfg.workspace);
|
|
451
|
+
if (pre.blocked) {
|
|
452
|
+
result = `Blocked by a PreToolUse hook: ${pre.reason}`;
|
|
453
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
454
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
455
|
+
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
456
|
+
allOk = false;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
392
459
|
if (this.onToolStart) this.onToolStart(tc.name, tc.args);
|
|
393
460
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
394
461
|
// Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
|
|
@@ -404,6 +471,10 @@ export class Agent {
|
|
|
404
471
|
this.ctx.lastResult = result;
|
|
405
472
|
// Plugin hook: onToolResult fires with the tool's output.
|
|
406
473
|
await runHooks('onToolResult', tc.name, result, this.ctx);
|
|
474
|
+
// SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
|
|
475
|
+
// failure is logged. This is where "format after every edit" lives.
|
|
476
|
+
await runShellHooks(this.hookConfig, 'PostToolUse',
|
|
477
|
+
{ toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
|
|
407
478
|
// Surface the live TODO list so the TUI can render its panel.
|
|
408
479
|
if (tc.name === 'TodoList') {
|
|
409
480
|
this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
|
|
@@ -453,5 +524,9 @@ export class Agent {
|
|
|
453
524
|
// Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
|
|
454
525
|
// In a finally-equivalent position — every exit path above reaches here.
|
|
455
526
|
await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
|
|
527
|
+
// SHELL HOOK: Stop fires when the turn ends, whether it finished or was
|
|
528
|
+
// interrupted. Never blocks.
|
|
529
|
+
await runShellHooks(this.hookConfig, 'Stop',
|
|
530
|
+
{ messages: this.messages, stopped: this.stopRequested, prompt: this._turnPrompt }, this.cfg.workspace);
|
|
456
531
|
}
|
|
457
532
|
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Prompt caching — explicit cache breakpoints for long, stable prefixes.
|
|
2
|
+
//
|
|
3
|
+
// Why this is worth doing: an agent resends the whole conversation every turn,
|
|
4
|
+
// and the prefix (system prompt + tool definitions + early messages) is
|
|
5
|
+
// byte-identical across turns. Providers that support prompt caching bill those
|
|
6
|
+
// tokens at a fraction of the input price and skip the prefill compute. Without
|
|
7
|
+
// explicit breakpoints only the providers that cache automatically benefit.
|
|
8
|
+
//
|
|
9
|
+
// Protocol differences:
|
|
10
|
+
// * anthropic — explicit: mark up to N breakpoints with
|
|
11
|
+
// `cache_control: { type: "ephemeral" }`. Anthropic caches everything UP TO
|
|
12
|
+
// and INCLUDING a marked block, so the LAST breakpoint should sit at the end
|
|
13
|
+
// of the stable prefix. The minimum cacheable prefix is model-dependent
|
|
14
|
+
// (~1024 tokens); marking a shorter prefix is a no-op, not an error.
|
|
15
|
+
// * openai — automatic for prompts over ~1024 tokens on supporting models. A
|
|
16
|
+
// stable `prompt_cache_key` improves routing; keys must not vary per request.
|
|
17
|
+
// * everything else — no-op. Unknown providers are left untouched: sending an
|
|
18
|
+
// unsupported `cache_control` field can 400 some gateways.
|
|
19
|
+
//
|
|
20
|
+
// The cache is only useful when the prefix is STABLE, so the helpers here take
|
|
21
|
+
// care to mark the same positions every turn for the same conversation.
|
|
22
|
+
|
|
23
|
+
import { estimateTokens } from './term.js';
|
|
24
|
+
|
|
25
|
+
// Anthropic allows at most 4 cache breakpoints per request.
|
|
26
|
+
export const MAX_BREAKPOINTS = 4;
|
|
27
|
+
// Below this, a breakpoint is pointless: most providers will not cache a prefix
|
|
28
|
+
// this short, and the marker is wasted budget.
|
|
29
|
+
export const MIN_CACHEABLE_TOKENS = 1024;
|
|
30
|
+
|
|
31
|
+
export function supportsCaching(protocol) {
|
|
32
|
+
return protocol === 'anthropic' || protocol === 'openai';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Index (into `messages`) of the last message that belongs to the STABLE prefix.
|
|
36
|
+
//
|
|
37
|
+
// The prefix is only cacheable while it cannot change between turns. The system
|
|
38
|
+
// prompt and tool list are fixed for a session; the conversation then grows by
|
|
39
|
+
// APPENDING. So the prefix is "everything except the recent tail", and the tail
|
|
40
|
+
// must be excluded because it is what changes next turn.
|
|
41
|
+
//
|
|
42
|
+
// We keep the last `keepTail` messages out of the cached region: a tool result
|
|
43
|
+
// appended this turn is not part of the stable prefix next turn until it is
|
|
44
|
+
// itself superseded. Returns -1 when there is nothing worth marking.
|
|
45
|
+
export function stablePrefixEnd(messages, keepTail = 2) {
|
|
46
|
+
const n = Array.isArray(messages) ? messages.length : 0;
|
|
47
|
+
if (n === 0) return -1;
|
|
48
|
+
// Never mark the very last message: it is the newest content and is the most
|
|
49
|
+
// likely to be replaced/regenerated.
|
|
50
|
+
const end = Math.min(n - 1, n - 1 - Math.max(1, keepTail));
|
|
51
|
+
return end >= 0 ? end : -1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Attach Anthropic `cache_control` breakpoints to a request body IN PLACE-safe
|
|
55
|
+
// fashion (returns a new body). Breakpoints are placed:
|
|
56
|
+
// 1. on the LAST tool definition (caches the whole tool block),
|
|
57
|
+
// 2. on the system prompt (caches tools + system together),
|
|
58
|
+
// 3. on the last message of the stable prefix (caches the conversation head).
|
|
59
|
+
// Marking the tail first and working backwards keeps the total <= MAX_BREAKPOINTS.
|
|
60
|
+
export function withAnthropicCache(body, opts = {}) {
|
|
61
|
+
if (!body || typeof body !== 'object') return body;
|
|
62
|
+
const minTokens = opts.minTokens || MIN_CACHEABLE_TOKENS;
|
|
63
|
+
const out = { ...body };
|
|
64
|
+
const marker = { type: 'ephemeral' };
|
|
65
|
+
|
|
66
|
+
// Tools: marking the last one caches the entire tools array.
|
|
67
|
+
if (Array.isArray(out.tools) && out.tools.length) {
|
|
68
|
+
const tools = out.tools.slice();
|
|
69
|
+
tools[tools.length - 1] = { ...tools[tools.length - 1], cache_control: marker };
|
|
70
|
+
out.tools = tools;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// System: a plain string must become a text block to carry cache_control.
|
|
74
|
+
if (typeof out.system === 'string' && out.system.trim()) {
|
|
75
|
+
out.system = [{ type: 'text', text: out.system, cache_control: marker }];
|
|
76
|
+
} else if (Array.isArray(out.system) && out.system.length) {
|
|
77
|
+
const sys = out.system.slice();
|
|
78
|
+
const last = sys.length - 1;
|
|
79
|
+
sys[last] = { ...(typeof sys[last] === 'string' ? { type: 'text', text: sys[last] } : sys[last]), cache_control: marker };
|
|
80
|
+
out.system = sys;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Conversation head: mark the stable prefix's last message.
|
|
84
|
+
if (Array.isArray(out.messages) && out.messages.length) {
|
|
85
|
+
const end = stablePrefixEnd(out.messages, opts.keepTail);
|
|
86
|
+
if (end >= 0 && end < out.messages.length) {
|
|
87
|
+
// Only worth a breakpoint once the prefix is long enough to cache.
|
|
88
|
+
const prefixText = out.messages.slice(0, end + 1)
|
|
89
|
+
.map((m) => (typeof m.content === 'string' ? m.content
|
|
90
|
+
: Array.isArray(m.content) ? m.content.map((b) => b.text || '').join('') : ''))
|
|
91
|
+
.join('\n');
|
|
92
|
+
if (estimateTokens(prefixText) >= minTokens) {
|
|
93
|
+
const msgs = out.messages.slice();
|
|
94
|
+
const msg = { ...msgs[end] };
|
|
95
|
+
if (typeof msg.content === 'string') {
|
|
96
|
+
msg.content = [{ type: 'text', text: msg.content }];
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(msg.content) && msg.content.length) {
|
|
99
|
+
const c = msg.content.slice();
|
|
100
|
+
const li = c.length - 1;
|
|
101
|
+
c[li] = { ...c[li], cache_control: marker };
|
|
102
|
+
msg.content = c;
|
|
103
|
+
}
|
|
104
|
+
msgs[end] = msg;
|
|
105
|
+
out.messages = msgs;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// OpenAI-compatible: most providers cache automatically. A STABLE
|
|
113
|
+
// `prompt_cache_key` helps routing on the providers that accept it, and must not
|
|
114
|
+
// change between turns of the same conversation or the cache thrashes.
|
|
115
|
+
// `seed` should therefore identify the SESSION, not the request.
|
|
116
|
+
export function withOpenAiCache(body, opts = {}) {
|
|
117
|
+
if (!body || typeof body !== 'object') return body;
|
|
118
|
+
const key = opts.sessionKey;
|
|
119
|
+
if (!key) return body;
|
|
120
|
+
return { ...body, prompt_cache_key: String(key) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Apply the right caching strategy for `protocol`. Returns the body unchanged
|
|
124
|
+
// for protocols with no explicit support.
|
|
125
|
+
export function applyPromptCache(protocol, body, opts = {}) {
|
|
126
|
+
if (!opts.enabled) return body;
|
|
127
|
+
if (protocol === 'anthropic') return withAnthropicCache(body, opts);
|
|
128
|
+
if (protocol === 'openai') return withOpenAiCache(body, opts);
|
|
129
|
+
return body;
|
|
130
|
+
}
|
package/src/ci.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// CI / scripting integration — turning a headless run into something a pipeline
|
|
2
|
+
// can act on.
|
|
3
|
+
//
|
|
4
|
+
// `hncode -p "…"` already prints the reply. What a CI job additionally needs is:
|
|
5
|
+
// * a machine-readable final result (did it succeed? what changed?),
|
|
6
|
+
// * an exit code that reflects the OUTCOME, not just "the process started",
|
|
7
|
+
// * a stable JSON envelope it can parse without scraping prose.
|
|
8
|
+
//
|
|
9
|
+
// The formats (chosen to match what CI systems and Claude-Code-style tools
|
|
10
|
+
// already understand):
|
|
11
|
+
// text — the assistant's reply, as before (default)
|
|
12
|
+
// json — ONE object at the end: { ok, reply, toolCalls, files, error }
|
|
13
|
+
// stream-json — one JSON event per line as the run progresses (existing), now
|
|
14
|
+
// terminated by a final `result` event so a reader can stop.
|
|
15
|
+
//
|
|
16
|
+
// Exit codes (see EXIT):
|
|
17
|
+
// 0 the run completed and produced an answer
|
|
18
|
+
// 1 generic failure (LLM error, bad config)
|
|
19
|
+
// 2 configuration problem (no API key)
|
|
20
|
+
// 3 the model stopped without an answer / reported a blocker
|
|
21
|
+
// 4 interrupted
|
|
22
|
+
//
|
|
23
|
+
// Nothing here writes to stdout directly except through the helpers, so the
|
|
24
|
+
// caller stays in control of framing.
|
|
25
|
+
|
|
26
|
+
import net from 'node:net';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
export const EXIT = {
|
|
32
|
+
OK: 0,
|
|
33
|
+
FAILURE: 1,
|
|
34
|
+
CONFIG: 2,
|
|
35
|
+
NO_ANSWER: 3,
|
|
36
|
+
INTERRUPTED: 4,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const FORMATS = ['text', 'json', 'stream-json'];
|
|
40
|
+
|
|
41
|
+
export function isValidFormat(f) {
|
|
42
|
+
return f == null || FORMATS.includes(f);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build the structured result for a finished run.
|
|
46
|
+
// ok — completed with a usable answer
|
|
47
|
+
// reply — the assistant's final text
|
|
48
|
+
// toolCalls — names of tools invoked, in order
|
|
49
|
+
// files — files the run wrote/edited (from tool ARGS, best-effort)
|
|
50
|
+
// error — a fatal error message, when the run failed outright
|
|
51
|
+
// stopped — the turn was interrupted
|
|
52
|
+
export function buildResult({ reply, toolCalls, files, error, stopped, durationMs, model, sessionId }) {
|
|
53
|
+
const hasReply = typeof reply === 'string' && reply.trim().length > 0;
|
|
54
|
+
const ok = !error && !stopped && hasReply;
|
|
55
|
+
const result = {
|
|
56
|
+
ok,
|
|
57
|
+
reply: reply || '',
|
|
58
|
+
toolCalls: toolCalls || [],
|
|
59
|
+
files: files || [],
|
|
60
|
+
durationMs: durationMs || 0,
|
|
61
|
+
model: model || '',
|
|
62
|
+
sessionId: sessionId || '',
|
|
63
|
+
};
|
|
64
|
+
if (error) result.error = String(error);
|
|
65
|
+
if (stopped) result.stopped = true;
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Map a result to a process exit code.
|
|
70
|
+
export function exitCodeFor(result, hadConfigError) {
|
|
71
|
+
if (hadConfigError) return EXIT.CONFIG;
|
|
72
|
+
if (!result) return EXIT.FAILURE;
|
|
73
|
+
if (result.stopped) return EXIT.INTERRUPTED;
|
|
74
|
+
if (result.error) return EXIT.FAILURE;
|
|
75
|
+
if (!result.ok) return EXIT.NO_ANSWER;
|
|
76
|
+
return EXIT.OK;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The JSON envelope printed at the end in `json` mode. Single line so a shell
|
|
80
|
+
// can capture it with `tail -1` or `jq`.
|
|
81
|
+
export function resultLine(result) {
|
|
82
|
+
return JSON.stringify({ type: 'result', ...result });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Collect tool activity from an agent event stream. `onEvent` is called per
|
|
86
|
+
// event; this returns the mutable accumulators the caller reads at the end.
|
|
87
|
+
export function createRunTracker() {
|
|
88
|
+
return { toolCalls: [], files: [], errors: [], text: '' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Files a tool call will write, taken from its arguments. Only the mutating
|
|
92
|
+
// tools are considered: a Read of a file is not "changed by this run".
|
|
93
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit']);
|
|
94
|
+
|
|
95
|
+
export function trackEvent(tracker, e) {
|
|
96
|
+
if (!tracker || !e) return tracker;
|
|
97
|
+
if (e.type === 'data' && e.text) tracker.text += e.text;
|
|
98
|
+
if (e.type === 'tool_use' && e.name) {
|
|
99
|
+
tracker.toolCalls.push(e.name);
|
|
100
|
+
if (WRITE_TOOLS.has(e.name) && e.args && typeof e.args.path === 'string') {
|
|
101
|
+
if (!tracker.files.includes(e.args.path)) tracker.files.push(e.args.path);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (e.type === 'error') tracker.errors.push((e.error && e.error.message) || String(e.error));
|
|
105
|
+
return tracker;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The final assistant text for the run. Prefers the LAST assistant message that
|
|
109
|
+
// has text (the handoff), falling back to the streamed aggregate. Mirrors the
|
|
110
|
+
// rule subagents use, so both report the same thing.
|
|
111
|
+
export function finalReply(messages, streamedText) {
|
|
112
|
+
const msgs = Array.isArray(messages) ? messages : [];
|
|
113
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
114
|
+
const m = msgs[i];
|
|
115
|
+
if (m && m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
|
|
116
|
+
return m.content.trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return String(streamedText || '').trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- remote control ----------------------------------------------------------
|
|
123
|
+
// A running hncode TUI can expose a small control surface (a unix socket /
|
|
124
|
+
// named pipe) so another process can queue a prompt or query status. This is
|
|
125
|
+
// what "remote control" means here: not a network server by default, a local
|
|
126
|
+
// endpoint the user opts into.
|
|
127
|
+
//
|
|
128
|
+
// The protocol is newline-delimited JSON, one request per line, one response
|
|
129
|
+
// per line: {"op":"prompt","text":"…"} -> {"ok":true}
|
|
130
|
+
// {"op":"status"} -> {"ok":true,"busy":false,"session":"…"}
|
|
131
|
+
|
|
132
|
+
export function defaultControlPath() {
|
|
133
|
+
if (process.env.HNCODE_CONTROL_SOCKET) return process.env.HNCODE_CONTROL_SOCKET;
|
|
134
|
+
if (process.platform === 'win32') {
|
|
135
|
+
// Windows has no unix sockets at a filesystem path: Node exposes named pipes
|
|
136
|
+
// under \\.\pipe\. Using a normal temp path there fails with EACCES, which is
|
|
137
|
+
// exactly what happened before this branch existed.
|
|
138
|
+
return `\\\\.\\pipe\\hncode-control-${process.pid}`;
|
|
139
|
+
}
|
|
140
|
+
return path.join(os.homedir(), '.hncode', 'control.sock');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Start a control server. `handlers` is { prompt(text), status(), interrupt() }.
|
|
144
|
+
// Returns { server, path, close() }. A stale socket file is replaced; a live one
|
|
145
|
+
// makes the call fail so two TUIs cannot fight over it.
|
|
146
|
+
export function startControlServer(handlers, opts = {}) {
|
|
147
|
+
const sockPath = opts.path || defaultControlPath();
|
|
148
|
+
const isPipe = process.platform === 'win32' && sockPath.startsWith('\\\\');
|
|
149
|
+
if (!isPipe) {
|
|
150
|
+
try { fs.mkdirSync(path.dirname(sockPath), { recursive: true }); } catch { /* exists */ }
|
|
151
|
+
// Remove a stale socket (a crash leaves one behind). Windows named pipes
|
|
152
|
+
// disappear with the process and cannot be stat()ed, hence the guard above.
|
|
153
|
+
try {
|
|
154
|
+
const st = fs.statSync(sockPath);
|
|
155
|
+
if (st.isSocket() || st.isFIFO()) fs.unlinkSync(sockPath);
|
|
156
|
+
} catch { /* nothing to clean */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const server = net.createServer((conn) => {
|
|
160
|
+
let buf = '';
|
|
161
|
+
conn.on('data', (d) => {
|
|
162
|
+
buf += d.toString('utf8');
|
|
163
|
+
let i;
|
|
164
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
165
|
+
const line = buf.slice(0, i).trim();
|
|
166
|
+
buf = buf.slice(i + 1);
|
|
167
|
+
if (!line) continue;
|
|
168
|
+
handleControlLine(line, handlers).then((res) => {
|
|
169
|
+
try { conn.write(JSON.stringify(res) + '\n'); } catch { /* peer gone */ }
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
conn.on('error', () => { /* client hung up */ });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
server.on('error', (e) => resolve({ server: null, path: sockPath, error: e.message, close() {} }));
|
|
178
|
+
server.listen(sockPath, () => {
|
|
179
|
+
resolve({
|
|
180
|
+
server,
|
|
181
|
+
path: sockPath,
|
|
182
|
+
error: null,
|
|
183
|
+
close() {
|
|
184
|
+
try { server.close(); } catch { /* already closed */ }
|
|
185
|
+
if (!isPipe) { try { fs.unlinkSync(sockPath); } catch { /* already gone */ } }
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handleControlLine(line, handlers) {
|
|
193
|
+
let req;
|
|
194
|
+
try { req = JSON.parse(line); } catch { return { ok: false, error: 'invalid JSON' }; }
|
|
195
|
+
try {
|
|
196
|
+
if (req.op === 'status') return { ok: true, ...(await handlers.status()) };
|
|
197
|
+
if (req.op === 'prompt') {
|
|
198
|
+
if (typeof req.text !== 'string' || !req.text.trim()) return { ok: false, error: 'text required' };
|
|
199
|
+
await handlers.prompt(req.text);
|
|
200
|
+
return { ok: true };
|
|
201
|
+
}
|
|
202
|
+
if (req.op === 'interrupt') {
|
|
203
|
+
await handlers.interrupt();
|
|
204
|
+
return { ok: true };
|
|
205
|
+
}
|
|
206
|
+
return { ok: false, error: `unknown op: ${req.op}` };
|
|
207
|
+
} catch (e) {
|
|
208
|
+
return { ok: false, error: e.message };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Client side: send one request to a running control server. Returns the parsed
|
|
213
|
+
// response or { ok:false, error }. Never throws.
|
|
214
|
+
export async function sendControl(req, opts = {}) {
|
|
215
|
+
const sockPath = opts.path || defaultControlPath();
|
|
216
|
+
const timeoutMs = opts.timeoutMs || 10_000;
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let done = false;
|
|
219
|
+
let sock = null;
|
|
220
|
+
const finish = (r) => {
|
|
221
|
+
if (done) return;
|
|
222
|
+
done = true;
|
|
223
|
+
try { if (sock) sock.destroy(); } catch { /* already gone */ }
|
|
224
|
+
resolve(r);
|
|
225
|
+
};
|
|
226
|
+
try { sock = net.connect(sockPath); }
|
|
227
|
+
catch (e) { resolve({ ok: false, error: e.message }); return; }
|
|
228
|
+
const timer = setTimeout(() => finish({ ok: false, error: 'timeout' }), timeoutMs);
|
|
229
|
+
sock.on('connect', () => {
|
|
230
|
+
try { sock.write(JSON.stringify(req) + '\n'); }
|
|
231
|
+
catch (e) { clearTimeout(timer); finish({ ok: false, error: e.message }); }
|
|
232
|
+
});
|
|
233
|
+
let buf = '';
|
|
234
|
+
sock.on('data', (d) => {
|
|
235
|
+
buf += d.toString('utf8');
|
|
236
|
+
const i = buf.indexOf('\n');
|
|
237
|
+
if (i >= 0) {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
try { finish(JSON.parse(buf.slice(0, i))); } catch { finish({ ok: false, error: 'bad response' }); }
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
sock.on('error', (e) => { clearTimeout(timer); finish({ ok: false, error: e.message }); });
|
|
243
|
+
});
|
|
244
|
+
}
|
package/src/colors.js
CHANGED
|
@@ -88,6 +88,16 @@ export const C = {
|
|
|
88
88
|
// first attempt and read as plain GREEN — its red channel is lifted to 95 and
|
|
89
89
|
// green pins at 255, which is a spring green, not a green-tinted cyan.
|
|
90
90
|
spring: TRUECOLOR ? rgb(0, 215, 175) : '\x1b[38;5;43m',
|
|
91
|
+
// Git branch in the status line: yellow pulled about half way toward white, so
|
|
92
|
+
// it stays clearly "yellow" (not cream/ivory) while reading brighter than the
|
|
93
|
+
// plain 93 yellow on a dark background. rgb(255,219,110) keeps red at max,
|
|
94
|
+
// green near it, and blue around 43% — that ratio is what keeps the hue
|
|
95
|
+
// yellow rather than dropping into orange.
|
|
96
|
+
branch: TRUECOLOR ? rgb(255, 219, 110) : '\x1b[38;5;222m',
|
|
97
|
+
// Shell-mode (`!`) accent — violet, matching kimi-code's shellMode token so the
|
|
98
|
+
// bash-mode editor border and the `!` read as "shell", distinct from the cyan
|
|
99
|
+
// prompt and the yellow git badge. #BD93F9 dark / #7C3AED light.
|
|
100
|
+
shellMode: TRUECOLOR ? rgb(189, 147, 249) : '\x1b[38;5;141m',
|
|
91
101
|
bgRed: '\x1b[41m',
|
|
92
102
|
bgGreen: '\x1b[42m',
|
|
93
103
|
// Selection highlight (mouse text selection) and the scrollbar gutter.
|
|
@@ -168,6 +178,14 @@ export function setTheme(name) {
|
|
|
168
178
|
if (t.green) C.green = rgb(...t.green);
|
|
169
179
|
if (t.yellow) C.yellow = rgb(...t.yellow);
|
|
170
180
|
if (t.orange) C.orange = rgb(...t.orange);
|
|
181
|
+
// The git branch colour is derived per theme: the pale yellow that reads well
|
|
182
|
+
// on the dark background is nearly invisible on white, so on a light theme it
|
|
183
|
+
// is darkened (keeping the same hue) instead of being left as-is.
|
|
184
|
+
if (t.bg && t.bg[0] > 128) {
|
|
185
|
+
C.branch = rgb(146, 106, 0); // dark amber for light backgrounds
|
|
186
|
+
} else {
|
|
187
|
+
C.branch = TRUECOLOR ? rgb(255, 219, 110) : '\x1b[38;5;222m';
|
|
188
|
+
}
|
|
171
189
|
|
|
172
190
|
return true;
|
|
173
191
|
}
|
package/src/config.js
CHANGED
|
@@ -286,6 +286,13 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
286
286
|
autoUpdate: process.env.HNCODE_AUTO_UPDATE
|
|
287
287
|
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
|
|
288
288
|
: (root.auto_update === true || root.auto_update === 'true'),
|
|
289
|
+
// PROMPT CACHE (/cache): explicit cache breakpoints for long stable prefixes.
|
|
290
|
+
// Default ON — it only ever adds provider-recognised markers, and an
|
|
291
|
+
// unsupported field on a non-caching gateway is filtered out in cache.js.
|
|
292
|
+
// Set HNCODE_PROMPT_CACHE=0 or `prompt_cache = false` to disable.
|
|
293
|
+
promptCache: process.env.HNCODE_PROMPT_CACHE
|
|
294
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_PROMPT_CACHE)
|
|
295
|
+
: (root.prompt_cache === false || root.prompt_cache === 'false' ? false : true),
|
|
289
296
|
raw: root,
|
|
290
297
|
};
|
|
291
298
|
}
|