@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/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>
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|