@hncode/hncode 0.3.1 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
  }