@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/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
  }