@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/hooks.js ADDED
@@ -0,0 +1,247 @@
1
+ // Shell-command hooks — user-configured commands run at lifecycle events,
2
+ // mirroring Claude Code's `hooks` feature.
3
+ //
4
+ // Config lives in ~/.hncode/hooks.json (and an optional per-project
5
+ // <workspace>/.hncode/hooks.json that is merged on top):
6
+ //
7
+ // {
8
+ // "hooks": {
9
+ // "PreToolUse": [{ "matcher": "Edit|Write", "command": "npm run lint" }],
10
+ // "PostToolUse": [{ "matcher": "Edit", "command": "npx prettier --write $FILE" }],
11
+ // "UserPromptSubmit": [{ "command": "echo submitting" }],
12
+ // "SessionStart": [{ "command": "git fetch --quiet" }],
13
+ // "Stop": [{ "command": "echo turn done >> ~/turns.log" }]
14
+ // }
15
+ // }
16
+ //
17
+ // Each entry runs the command with the shell (bash on POSIX, pwsh on Windows).
18
+ // The event payload is passed as JSON on stdin and, for convenience, projected
19
+ // into environment variables the command can read directly:
20
+ //
21
+ // HNCODE_EVENT event name (PreToolUse, …)
22
+ // HNCODE_TOOL_NAME tool name (tool events only)
23
+ // HNCODE_TOOL_ARGS JSON of the tool args (tool events only)
24
+ // HNCODE_FILE best-effort path argument (tool events only)
25
+ // HNCODE_PROMPT the user's prompt (UserPromptSubmit)
26
+ // HNCODE_CWD working directory the command runs in
27
+ //
28
+ // Exit codes: 0 = continue. For PreToolUse, a NON-zero exit BLOCKS the tool and
29
+ // its stderr (or stdout) becomes the message the model sees. For every other
30
+ // event a failure is only logged, never fatal — a broken hook must not stop a
31
+ // turn. Hooks are best-effort and time-bounded (see HOOK_TIMEOUT_MS).
32
+
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import cp from 'node:child_process';
37
+
38
+ // Events the core emits. Kept explicit so a typo in config is reported instead
39
+ // of silently never firing.
40
+ export const HOOK_EVENTS = [
41
+ 'SessionStart',
42
+ 'UserPromptSubmit',
43
+ 'PreToolUse',
44
+ 'PostToolUse',
45
+ 'Stop',
46
+ 'PreCompact',
47
+ ];
48
+
49
+ // A hook that hangs must not hang the agent. 60s is generous for a formatter or
50
+ // a lint pass and still bounded.
51
+ export const HOOK_TIMEOUT_MS = 60 * 1000;
52
+
53
+ export function globalHooksFile() {
54
+ return process.env.HNCODE_HOOKS_FILE || path.join(os.homedir(), '.hncode', 'hooks.json');
55
+ }
56
+ export function projectHooksFile(workspace) {
57
+ return path.join(workspace || process.cwd(), '.hncode', 'hooks.json');
58
+ }
59
+
60
+ // Parse one hooks file. Returns { hooks: {event: [entry]}, errors: [string] }.
61
+ // A malformed file yields empty hooks and a recorded error, never a throw.
62
+ function readHooksFile(file) {
63
+ const out = { hooks: {}, errors: [] };
64
+ let text;
65
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
66
+ let doc;
67
+ try { doc = JSON.parse(text); } catch (e) {
68
+ out.errors.push(`${file}: invalid JSON (${e.message})`);
69
+ return out;
70
+ }
71
+ const raw = (doc && doc.hooks) || {};
72
+ for (const [event, list] of Object.entries(raw)) {
73
+ if (!HOOK_EVENTS.includes(event)) { out.errors.push(`${file}: unknown event "${event}"`); continue; }
74
+ const entries = Array.isArray(list) ? list : [list];
75
+ const clean = [];
76
+ for (const e of entries) {
77
+ if (!e || typeof e !== 'object' || typeof e.command !== 'string' || !e.command.trim()) {
78
+ out.errors.push(`${file}: ${event} entry needs a non-empty "command"`);
79
+ continue;
80
+ }
81
+ clean.push({
82
+ command: e.command,
83
+ matcher: typeof e.matcher === 'string' ? e.matcher : '',
84
+ timeoutMs: Number.isFinite(e.timeout) ? Math.max(1, e.timeout) * 1000 : HOOK_TIMEOUT_MS,
85
+ source: file,
86
+ });
87
+ }
88
+ if (clean.length) out.hooks[event] = clean;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Merge global + project hooks. Project entries run AFTER global ones so a
94
+ // project can add to (not replace) the user's setup.
95
+ export function loadHooks(workspace) {
96
+ const merged = {};
97
+ const errors = [];
98
+ const files = workspace
99
+ ? [globalHooksFile(), projectHooksFile(workspace)]
100
+ : [globalHooksFile()];
101
+ const seen = new Set();
102
+ for (const f of files) {
103
+ const abs = path.resolve(f);
104
+ if (seen.has(abs)) continue; // global === project (workspace is ~)
105
+ seen.add(abs);
106
+ const { hooks, errors: errs } = readHooksFile(abs);
107
+ errors.push(...errs);
108
+ for (const [ev, list] of Object.entries(hooks)) {
109
+ merged[ev] = (merged[ev] || []).concat(list);
110
+ }
111
+ }
112
+ return { hooks: merged, errors };
113
+ }
114
+
115
+ // Does this entry apply to `toolName`? An empty matcher matches everything; a
116
+ // non-empty one is a case-insensitive alternation of exact names (Claude Code's
117
+ // `Edit|Write` form).
118
+ export function matcherApplies(matcher, toolName) {
119
+ if (!matcher) return true;
120
+ if (!toolName) return true;
121
+ const name = String(toolName);
122
+ return matcher.split('|').map((s) => s.trim()).filter(Boolean)
123
+ .some((m) => m.toLowerCase() === name.toLowerCase());
124
+ }
125
+
126
+ // The path-ish argument of a tool call, for HNCODE_FILE. Best-effort: it only
127
+ // needs to help a formatter/linter, not to be authoritative.
128
+ function fileArgFor(args) {
129
+ if (!args || typeof args !== 'object') return '';
130
+ for (const k of ['path', 'file_path', 'filePath', 'file']) {
131
+ if (typeof args[k] === 'string' && args[k]) return args[k];
132
+ }
133
+ return '';
134
+ }
135
+
136
+ // Build the environment for a hook invocation from the event payload.
137
+ export function hookEnv(event, payload, cwd) {
138
+ const p = payload || {};
139
+ const env = { ...process.env, HNCODE_EVENT: event, HNCODE_CWD: cwd || process.cwd() };
140
+ if (p.toolName) env.HNCODE_TOOL_NAME = String(p.toolName);
141
+ if (p.toolArgs !== undefined) env.HNCODE_TOOL_ARGS = JSON.stringify(p.toolArgs);
142
+ const f = fileArgFor(p.toolArgs);
143
+ if (f) env.HNCODE_FILE = f;
144
+ if (p.prompt !== undefined) env.HNCODE_PROMPT = String(p.prompt);
145
+ return env;
146
+ }
147
+
148
+ function shellCommand() {
149
+ if (process.platform === 'win32') return { bin: 'pwsh', flag: '-Command' };
150
+ return { bin: '/bin/sh', flag: '-c' };
151
+ }
152
+
153
+ // Run ONE hook entry. Resolves to
154
+ // { ok: true, stdout, stderr, exitCode } — command exited 0
155
+ // { ok: false, blocked: bool, message, ... } — non-zero / timeout
156
+ // Never rejects: a hook failure is data, not an exception.
157
+ export function runHookCommand(entry, event, payload, cwd) {
158
+ return new Promise((resolve) => {
159
+ const { bin, flag } = shellCommand();
160
+ let child;
161
+ try {
162
+ child = cp.spawn(bin, [flag, entry.command], {
163
+ cwd: cwd || process.cwd(),
164
+ env: hookEnv(event, payload, cwd),
165
+ stdio: ['pipe', 'pipe', 'pipe'],
166
+ });
167
+ } catch (e) {
168
+ resolve({ ok: false, blocked: false, message: `could not start: ${e.message}`, exitCode: null });
169
+ return;
170
+ }
171
+ let out = '', err = '';
172
+ let done = false;
173
+ const finish = (res) => { if (!done) { done = true; resolve(res); } };
174
+ const timer = setTimeout(() => {
175
+ try { child.kill(); } catch {}
176
+ finish({ ok: false, blocked: event === 'PreToolUse', message: `timed out after ${entry.timeoutMs / 1000}s`, exitCode: null });
177
+ }, entry.timeoutMs);
178
+
179
+ if (child.stdout) child.stdout.on('data', (d) => { out += d.toString(); });
180
+ if (child.stderr) child.stderr.on('data', (d) => { err += d.toString(); });
181
+ child.on('error', (e) => { clearTimeout(timer); finish({ ok: false, blocked: false, message: e.message, exitCode: null }); });
182
+ child.on('close', (code) => {
183
+ clearTimeout(timer);
184
+ if (code === 0) finish({ ok: true, stdout: out, stderr: err, exitCode: 0 });
185
+ else finish({
186
+ ok: false,
187
+ blocked: event === 'PreToolUse',
188
+ message: (code === null ? 'terminated' : `exit ${code}`),
189
+ stdout: out, stderr: err, exitCode: code,
190
+ });
191
+ });
192
+ // Feed the payload as JSON on stdin (some hooks prefer parsing it).
193
+ try {
194
+ if (child.stdin) {
195
+ child.stdin.end(JSON.stringify({ event, ...(payload || {}) }));
196
+ }
197
+ } catch { /* stdin already gone: the env vars are still available */ }
198
+ });
199
+ }
200
+
201
+ // Run every hook for `event`. Returns
202
+ // { blocked, reason, results: [...] }
203
+ // `blocked` is true only when a PreToolUse hook failed — that is the one event
204
+ // whose exit code changes control flow (the tool must not run).
205
+ export async function runShellHooks(config, event, payload, cwd) {
206
+ const list = (config && config.hooks && config.hooks[event]) || [];
207
+ if (!list.length) return { blocked: false, reason: '', results: [] };
208
+ // Tool events respect their matcher: a `matcher: "Edit"` hook must fire only
209
+ // for Edit, for BOTH PreToolUse and PostToolUse. Applying it to PreToolUse
210
+ // alone made a PostToolUse hook run on every tool — a "format after edit" hook
211
+ // ran after reads and searches too.
212
+ const isToolEvent = event === 'PreToolUse' || event === 'PostToolUse';
213
+ const results = [];
214
+ for (const entry of list) {
215
+ if (isToolEvent && !matcherApplies(entry.matcher, payload && payload.toolName)) continue;
216
+ const r = await runHookCommand(entry, event, payload, cwd);
217
+ results.push({ entry, ...r });
218
+ if (!r.ok) {
219
+ // The message the model sees: prefer stderr, fall back to stdout.
220
+ const msg = (r.stderr || r.stdout || r.message || 'hook failed').trim();
221
+ if (event === 'PreToolUse') {
222
+ return { blocked: true, reason: msg || 'blocked by a PreToolUse hook', results };
223
+ }
224
+ // Other events: log only, keep going.
225
+ try { console.error(`[hncode-hook] ${event} (${entry.source}): ${msg}`); } catch {}
226
+ }
227
+ }
228
+ return { blocked: false, reason: '', results };
229
+ }
230
+
231
+ // Human-readable summary for /hooks.
232
+ export function describeHooks(config) {
233
+ const hooks = (config && config.hooks) || {};
234
+ const lines = [];
235
+ const total = Object.values(hooks).reduce((a, l) => a + l.length, 0);
236
+ if (!total) return ['No hooks configured.', '', `Global file: ${globalHooksFile()}`, `Project file: ${projectHooksFile(process.cwd())}`];
237
+ for (const ev of HOOK_EVENTS) {
238
+ const list = hooks[ev];
239
+ if (!list || !list.length) continue;
240
+ lines.push(`${ev}:`);
241
+ for (const e of list) {
242
+ lines.push(` ${e.matcher ? `[${e.matcher}] ` : ''}${e.command}`);
243
+ lines.push(` (${e.source})`);
244
+ }
245
+ }
246
+ return lines;
247
+ }
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { Agent, SYSTEM_PROMPT } from './agent.js';
9
9
  import { llmTools } from './tools/index.js';
10
10
  import { setToolsList } from './llm.js';
11
11
  import { localVersion } from './version.js';
12
+ import { EXIT, FORMATS, isValidFormat, buildResult, exitCodeFor, resultLine, createRunTracker, trackEvent, finalReply } from './ci.js';
12
13
 
13
14
  // The version shown by `hncode -V` and the help banner. Read from package.json
14
15
  // (via version.js) so it can never drift from the published version again.
@@ -29,7 +30,11 @@ Options:
29
30
  --auto Never ask; everything runs automatically.
30
31
  -m, --model <model> Model alias to use for this invocation.
31
32
  -p, --prompt <prompt> Run one prompt non-interactively and print the reply.
32
- --output-format <format> Output format for prompt mode: text (default) or stream-json.
33
+ --output-format <format> Prompt mode output: text (default), json, or stream-json.
34
+ json prints one result object; stream-json streams events then a result.
35
+ Exit code reflects the outcome: 0 ok, 1 failure, 2 config, 3 no answer, 4 interrupted.
36
+ --control Expose a local control socket (prompt/status/interrupt) while the TUI runs.
37
+ --control <path> Same, at an explicit socket path.
33
38
  --plan Start in plan mode (research only, no writes).
34
39
  --add-dir <dir> Add an additional workspace directory. Repeatable.
35
40
  -h, --help Show help.
@@ -40,6 +45,12 @@ Commands:
40
45
  doctor config [path] Validate a config.toml (default: merged config).
41
46
  init Create ~/.hncode/config.toml with defaults.
42
47
  export [id] Export a session to ~/.hncode/export/<id>.json.
48
+ control status Ask a running session (started with --control) for its status.
49
+ control prompt <text> Queue a prompt on a running session.
50
+ control interrupt Interrupt the running turn.
51
+ hooks list Show configured shell hooks.
52
+ skills list List installed skills.
53
+ mcp list Show configured MCP servers.
43
54
  `;
44
55
 
45
56
  function printHelp() { process.stdout.write(HELP); }
@@ -55,7 +66,7 @@ export function parseArgs(argv) {
55
66
  if (eq >= 0) { key = a.slice(2, eq); val = a.slice(eq + 1); }
56
67
  else { key = a.slice(2); val = null; }
57
68
  if (key === 'resume') key = 'session'; // `--resume <id>` is an alias for `--session <id>`
58
- if (['session'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
69
+ if (['session', 'control'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
59
70
  else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir') {
60
71
  if (val === null) {
61
72
  const nx = argv[i + 1];
@@ -177,14 +188,42 @@ const SUBCOMMANDS = {
177
188
  'doctor': { 'config': cmdDoctorConfig },
178
189
  'init': cmdInit,
179
190
  'export': cmdExport,
191
+ 'control': {
192
+ // Subcommands pass only positional[2] to their handler, so bind the op.
193
+ 'status': (rest) => cmdControl('status', rest),
194
+ 'prompt': (rest) => cmdControl('prompt', rest),
195
+ 'interrupt': (rest) => cmdControl('interrupt', rest),
196
+ },
180
197
  };
181
198
 
199
+ // ---- control (talk to a running --control session) --------------------------
200
+ // `hncode control status|prompt|interrupt` — the client half of the control
201
+ // socket the TUI opens with --control. Prints the JSON reply so a script can
202
+ // parse it; exits non-zero when the running session could not be reached.
203
+ async function cmdControl(sub, arg) {
204
+ const { sendControl } = await import('./ci.js');
205
+ const op = sub || 'status';
206
+ if (!['status', 'prompt', 'interrupt'].includes(op)) {
207
+ console.error(`unknown control op: ${op}. Use status | prompt <text> | interrupt.`);
208
+ return 1;
209
+ }
210
+ const req = { op };
211
+ if (op === 'prompt') {
212
+ const text = String(arg || '').trim();
213
+ if (!text) { console.error('control prompt requires text.'); return 1; }
214
+ req.text = text;
215
+ }
216
+ const res = await sendControl(req, { path: process.env.HNCODE_CONTROL_SOCKET || undefined });
217
+ console.log(JSON.stringify(res));
218
+ return res && res.ok ? 0 : 1;
219
+ }
220
+
182
221
  // ---- headless prompt mode ----
183
222
  async function runPrompt({ cfg, prompt, format, session, modelArg }) {
184
223
  cfg = resolveModelArg(cfg, modelArg);
185
224
  if (!cfg.apiKey) {
186
225
  console.error('hncode: no api_key configured. Set HNCODE_API_KEY or configure a provider first. Run `hncode doctor config` to check.');
187
- return 2;
226
+ return EXIT.CONFIG;
188
227
  }
189
228
  const messages = [];
190
229
  if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
@@ -195,19 +234,28 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
195
234
  if (personal) sysText += '\n\n' + personal;
196
235
  messages.push({ role: 'system', content: sysText });
197
236
  const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
237
+ const tracker = createRunTracker();
238
+ const startedAt = Date.now();
239
+ let fatal = null;
198
240
  const agent = new Agent({
199
241
  cfg,
200
242
  messages,
201
243
  // Uncapped: run until the model finishes (see agent.js).
202
244
  onEvent: (e) => {
203
- if (format === 'stream-json') {
204
- if (e.type === 'context') return; // TUI-only gauge; not part of the JSON stream
205
- const line = { type: e.type };
206
- if (e.text) line.text = e.text;
207
- if (e.name) line.name = e.name;
208
- if (e.id) line.id = e.id;
209
- if (e.content != null) line.content = e.content;
210
- process.stdout.write(JSON.stringify(line) + '\n');
245
+ trackEvent(tracker, e);
246
+ if (e.type === 'error') fatal = (e.error && e.error.message) || String(e.error);
247
+ if (format === 'stream-json' || format === 'json') {
248
+ // `json` mode keeps stdout clean for the final envelope, so events go to
249
+ // stderr there. `stream-json` is the streaming contract: one event/line.
250
+ if (format === 'stream-json') {
251
+ if (e.type === 'context') return; // TUI-only gauge; not part of the stream
252
+ const line = { type: e.type };
253
+ if (e.text) line.text = e.text;
254
+ if (e.name) line.name = e.name;
255
+ if (e.id) line.id = e.id;
256
+ if (e.content != null) line.content = e.content;
257
+ process.stdout.write(JSON.stringify(line) + '\n');
258
+ }
211
259
  } else if (e.type === 'data') {
212
260
  process.stdout.write(e.text);
213
261
  }
@@ -216,8 +264,30 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
216
264
  saved.messages = messages;
217
265
  await agent.run();
218
266
  sess.saveSession(saved);
219
- if (format !== 'stream-json') process.stdout.write('\n');
220
- return 0;
267
+
268
+ const reply = finalReply(messages, tracker.text);
269
+ const result = buildResult({
270
+ reply,
271
+ toolCalls: tracker.toolCalls,
272
+ files: tracker.files,
273
+ error: fatal || (tracker.errors.length ? tracker.errors[tracker.errors.length - 1] : null),
274
+ stopped: !!agent.stopRequested,
275
+ durationMs: Date.now() - startedAt,
276
+ model: cfg.model,
277
+ sessionId: saved.id,
278
+ });
279
+
280
+ if (format === 'json') {
281
+ // Exactly one JSON object on stdout, so `| jq` / `tail -1` works.
282
+ process.stdout.write(resultLine(result) + '\n');
283
+ } else if (format === 'stream-json') {
284
+ // A terminating event tells a streaming reader the run is over and carries
285
+ // the same envelope, so both formats end with a `result` record.
286
+ process.stdout.write(resultLine(result) + '\n');
287
+ } else {
288
+ process.stdout.write('\n');
289
+ }
290
+ return exitCodeFor(result, false);
221
291
  }
222
292
 
223
293
  // ---- TUI dispatch ----
@@ -286,17 +356,55 @@ export async function main(argv) {
286
356
  await loadPlugins(cfg.pluginDir);
287
357
  }
288
358
 
289
- // headless prompt
359
+ // Connect configured MCP servers and register their tools.
360
+ //
361
+ // This is a FUNCTION rather than an inline block because the two modes want
362
+ // different timing:
363
+ // * headless (`-p`) MUST wait — the prompt runs immediately and its model
364
+ // request has to see the MCP tools in the tool list.
365
+ // * the TUI must NOT wait — connecting can take seconds (8s per wedged
366
+ // server), and blocking the first paint for that is unacceptable. The TUI
367
+ // starts this in the background and reports the result as a notice.
368
+ // Failures are reported but never fatal: an unreachable server must not stop
369
+ // the session from starting.
370
+ const connectMcp = async () => {
371
+ if (cfg.mcp === false) return { servers: [], toolCount: 0 };
372
+ try {
373
+ const mcp = await import('./mcp.js');
374
+ const { API } = await import('./plugin.js');
375
+ const mcpCfg = mcp.loadMcpConfig(cfg.workspace);
376
+ if (!Object.keys(mcpCfg.servers).length) return { servers: [], toolCount: 0 };
377
+ const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
378
+ mcp.setLiveConnections(res.servers);
379
+ if (process.env.HNCODE_MCP_VERBOSE === '1') {
380
+ for (const s of res.servers) {
381
+ console.error(s.ok ? `[hncode-mcp] ${s.name}: ${s.toolCount} tool(s)` : `[hncode-mcp] ${s.name}: ${s.error}`);
382
+ }
383
+ }
384
+ return res;
385
+ } catch (e) {
386
+ console.error(`hncode: MCP setup failed: ${e.message}`);
387
+ return { servers: [], toolCount: 0 };
388
+ }
389
+ };
390
+
391
+ // headless prompt: MCP must be live BEFORE the request goes out.
290
392
  if (args.prompt) {
393
+ const format = args['output-format'];
394
+ if (!isValidFormat(format)) {
395
+ console.error(`hncode: unknown --output-format "${format}". Use one of: ${FORMATS.join(', ')}.`);
396
+ return EXIT.CONFIG;
397
+ }
398
+ await connectMcp();
291
399
  let session = null;
292
400
  if (args.session) session = typeof args.session === 'string' ? sess.loadSession(args.session) : sess.latestSession(undefined, undefined, { skipEmpty: true });
293
401
  else if (args.continue) session = sess.latestSession(undefined, undefined, { skipEmpty: true });
294
- return runPrompt({ cfg, prompt: args.prompt, format: args['output-format'], session, modelArg: args.model });
402
+ return runPrompt({ cfg, prompt: args.prompt, format, session, modelArg: args.model });
295
403
  }
296
404
 
297
405
  if (!process.stdout.isTTY) {
298
406
  console.error('hncode: interactive mode requires a TTY. Use `hncode -p "prompt"` for non-interactive use.');
299
- return 1;
407
+ return EXIT.FAILURE;
300
408
  }
301
409
 
302
410
  const opts = {
@@ -308,10 +416,16 @@ export async function main(argv) {
308
416
  auto: !!args.auto,
309
417
  plan: !!args.plan,
310
418
  addDirs: args['add-dir'] || [],
419
+ // Remote control: expose a local socket so a script can queue a prompt or
420
+ // query status on the running session (see ci.js).
421
+ control: args.control === true ? '' : (typeof args.control === 'string' ? args.control : null),
422
+ // MCP runs in the BACKGROUND once the TUI is up (see startTUI): connecting
423
+ // can take seconds and must not delay the first paint.
424
+ mcpConnect: connectMcp,
311
425
  session: loadOrCreateSession(args, cfg),
312
426
  };
313
427
  await startTui(opts);
314
- return 0;
428
+ return EXIT.OK;
315
429
  }
316
430
 
317
431
  // main() is invoked by bin/hncode (the CLI entry point), which imports this module
package/src/llm.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { llmTools } from './tools/index.js';
11
11
  import { effortWire } from './config.js';
12
+ import { applyPromptCache } from './cache.js';
12
13
 
13
14
  export function openAiToolDefs(tools) {
14
15
  return tools.map((t) => ({
@@ -101,9 +102,20 @@ function buildBody(cfg, messages, streaming = true) {
101
102
  const wire = effortWire(cfg, cfg.effort) || {};
102
103
  if (cfg.protocol === 'anthropic') {
103
104
  const { system, messages: am } = toAnthropic(messages);
104
- return JSON.stringify({ model: cfg.innerModel, system, messages: am, tools: anthropicToolDefs(llmToolsList), ...common, ...wire });
105
+ const body = { model: cfg.innerModel, system, messages: am, tools: anthropicToolDefs(llmToolsList), ...common, ...wire };
106
+ // Prompt caching: mark the stable prefix (tools + system + conversation head)
107
+ // so the provider bills it as a cache READ instead of fresh input. The key is
108
+ // the SESSION id, so it stays identical across turns and never thrashes.
109
+ return JSON.stringify(applyPromptCache('anthropic', body, {
110
+ enabled: cfg.promptCache !== false,
111
+ sessionKey: cfg.sessionId,
112
+ }));
105
113
  }
106
- return JSON.stringify({ model: cfg.innerModel, messages: toOpenAi(messages), tools: openAiToolDefs(llmToolsList), ...common, ...wire });
114
+ const body = { model: cfg.innerModel, messages: toOpenAi(messages), tools: openAiToolDefs(llmToolsList), ...common, ...wire };
115
+ return JSON.stringify(applyPromptCache('openai', body, {
116
+ enabled: cfg.promptCache !== false,
117
+ sessionKey: cfg.sessionId,
118
+ }));
107
119
  }
108
120
 
109
121
  // tool list used for defs (injected via setTools)