@hncode/hncode 0.3.2 → 0.3.4
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-task.js +78 -0
- package/src/agent.js +150 -45
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +13 -0
- package/src/git.js +501 -0
- package/src/hooks.js +247 -0
- package/src/index.js +131 -17
- package/src/llm.js +14 -2
- package/src/mcp.js +384 -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 +1256 -121
- package/src/update.js +35 -14
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,19 @@ 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
|
+
// AUTO-COMPACTION (/auto-compact): when true, the agent summarizes older
|
|
290
|
+
// history once a request reaches 85% of the model context. Default ON. Set
|
|
291
|
+
// `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
|
|
292
|
+
autoCompact: process.env.HNCODE_AUTO_COMPACT
|
|
293
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_COMPACT)
|
|
294
|
+
: !(root.auto_compact === false || root.auto_compact === 'false'),
|
|
295
|
+
// PROMPT CACHE (/cache): explicit cache breakpoints for long stable prefixes.
|
|
296
|
+
// Default ON — it only ever adds provider-recognised markers, and an
|
|
297
|
+
// unsupported field on a non-caching gateway is filtered out in cache.js.
|
|
298
|
+
// Set HNCODE_PROMPT_CACHE=0 or `prompt_cache = false` to disable.
|
|
299
|
+
promptCache: process.env.HNCODE_PROMPT_CACHE
|
|
300
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_PROMPT_CACHE)
|
|
301
|
+
: (root.prompt_cache === false || root.prompt_cache === 'false' ? false : true),
|
|
289
302
|
raw: root,
|
|
290
303
|
};
|
|
291
304
|
}
|