@hncode/hncode 0.3.6 → 0.4.0
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/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
package/src/config.js
CHANGED
|
@@ -5,10 +5,19 @@
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
8
9
|
import { parse } from './toml.js';
|
|
9
10
|
import { pluginConfigDefaults } from './plugin.js';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
// Where the per-user .hncode directory lives. HNCODE_HOME overrides it, mirroring
|
|
13
|
+
// Claude Code's CLAUDE_CONFIG_DIR: it lets a test (or a user keeping config on
|
|
14
|
+
// another drive) relocate the whole directory instead of writing into the real
|
|
15
|
+
// home. Resolved per call, NOT captured once at module load — a test that sets the
|
|
16
|
+
// variable has already imported this module by then, and a captured value silently
|
|
17
|
+
// wrote into the developer's actual home directory.
|
|
18
|
+
function homeDir() {
|
|
19
|
+
return process.env.HNCODE_HOME || os.homedir();
|
|
20
|
+
}
|
|
12
21
|
|
|
13
22
|
export const DEFAULTS = {
|
|
14
23
|
model: '',
|
|
@@ -60,7 +69,7 @@ export async function fetchCatalog() {
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
export function hncodeConfigFile() {
|
|
63
|
-
return process.env.HNCODE_CONFIG || path.join(
|
|
72
|
+
return process.env.HNCODE_CONFIG || path.join(homeDir(), '.hncode', 'config.toml');
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
// ---- personal preferences (/personal) ----
|
|
@@ -72,10 +81,68 @@ export function hncodeConfigFile() {
|
|
|
72
81
|
// Both are OPTIONAL; a missing/empty file injects nothing. The files are read
|
|
73
82
|
// fresh on each turn, so an edit through /personal takes effect immediately.
|
|
74
83
|
export function personalPromptFile(scope, workspace) {
|
|
75
|
-
if (scope === 'global') return path.join(
|
|
84
|
+
if (scope === 'global') return path.join(homeDir(), '.hncode', 'PERSONAL.md');
|
|
76
85
|
return path.join(workspace || process.cwd(), '.hncode', 'PERSONAL.md');
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
// ---- agent memory (/memory) --------------------------------------------------
|
|
89
|
+
// Notes the AGENT writes for itself, as opposed to /personal's user-authored
|
|
90
|
+
// preferences. Same shape and same injection points, but a separate file so the
|
|
91
|
+
// two never overwrite each other: the user owns PERSONAL.md, the model owns
|
|
92
|
+
// MEMORY.md. Two scopes, project LAST so it can refine the global one.
|
|
93
|
+
// * global — ~/.hncode/MEMORY.md
|
|
94
|
+
// * project — <workspace>/.hncode/MEMORY.md
|
|
95
|
+
export function memoryFile(scope, workspace) {
|
|
96
|
+
if (scope === 'global') return path.join(homeDir(), '.hncode', 'MEMORY.md');
|
|
97
|
+
return path.join(workspace || process.cwd(), '.hncode', 'MEMORY.md');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Header for injected memory. Deliberately hedged ("notes, verify before relying")
|
|
101
|
+
// so a stale entry written several sessions ago cannot masquerade as fact.
|
|
102
|
+
export const MEMORY_PROMPT_HEADER =
|
|
103
|
+
'AGENT MEMORY (notes you wrote in earlier sessions; treat as context, not as\n'
|
|
104
|
+
+ 'verified fact — re-check anything a current task depends on):';
|
|
105
|
+
|
|
106
|
+
export function readMemoryRaw(scope, workspace) {
|
|
107
|
+
try { return fs.readFileSync(memoryFile(scope, workspace), 'utf8'); } catch { return ''; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function writeMemoryRaw(scope, workspace, text) {
|
|
111
|
+
const file = memoryFile(scope, workspace);
|
|
112
|
+
const value = String(text == null ? '' : text);
|
|
113
|
+
if (!value.trim()) {
|
|
114
|
+
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
115
|
+
return file;
|
|
116
|
+
}
|
|
117
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
|
+
fs.writeFileSync(file, value.replace(/\s+$/, '') + '\n', 'utf8');
|
|
119
|
+
return file;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Append one note to a scope's memory, creating the file if needed. Appending
|
|
123
|
+
// (rather than replacing) is what a memory tool needs: the agent records a single
|
|
124
|
+
// learning and must not have to re-emit everything it knew before.
|
|
125
|
+
export function appendMemory(scope, workspace, note) {
|
|
126
|
+
const text = String(note == null ? '' : note).trim();
|
|
127
|
+
if (!text) return memoryFile(scope, workspace);
|
|
128
|
+
const existing = readMemoryRaw(scope, workspace).trim();
|
|
129
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
130
|
+
const line = `- ${text.replace(/\s+/g, ' ')} _(${stamp})_`;
|
|
131
|
+
const body = existing ? `${existing}\n${line}` : line;
|
|
132
|
+
return writeMemoryRaw(scope, workspace, body);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readMemory(workspace) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
for (const scope of ['global', 'project']) {
|
|
138
|
+
const t = readMemoryRaw(scope, workspace).trim();
|
|
139
|
+
if (t) parts.push(t);
|
|
140
|
+
}
|
|
141
|
+
if (!parts.length) return '';
|
|
142
|
+
return MEMORY_PROMPT_HEADER + '\n' + parts.join('\n\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
79
146
|
// Header placed before the injected preferences, so the model knows where the
|
|
80
147
|
// text came from and how much authority it has.
|
|
81
148
|
export const PERSONAL_PROMPT_HEADER =
|
|
@@ -193,9 +260,34 @@ function loadBaseToml() {
|
|
|
193
260
|
return base;
|
|
194
261
|
}
|
|
195
262
|
|
|
263
|
+
|
|
264
|
+
// A TCP port from config.toml, or `fallback` when it is absent or nonsense. TOML
|
|
265
|
+
// parses `web_port = 8765` as a number but `web_port = "8765"` as a string, and a
|
|
266
|
+
// hand-edited file can hold anything, so both are accepted and everything else
|
|
267
|
+
// falls back rather than making the daemon fail to bind on a garbage port.
|
|
268
|
+
export function tomlPort(value, fallback) {
|
|
269
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
270
|
+
const n = Number(value);
|
|
271
|
+
if (!Number.isFinite(n) || n < 0 || n > 65535) return fallback;
|
|
272
|
+
return Math.floor(n);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A 0-1 ratio from config.toml / env. Accepts a number (0.85), a whole number
|
|
276
|
+
// (85 = 85%), or a percent string ("85%"). Anything outside (0,1) falls back.
|
|
277
|
+
export function clampRatio(value, fallback) {
|
|
278
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
279
|
+
const s = String(value).trim();
|
|
280
|
+
const pct = s.endsWith('%');
|
|
281
|
+
const n = parseFloat(s);
|
|
282
|
+
if (!Number.isFinite(n)) return fallback;
|
|
283
|
+
const v = pct ? n / 100 : (n > 1 ? n / 100 : n);
|
|
284
|
+
return (v > 0 && v < 1) ? v : fallback;
|
|
285
|
+
}
|
|
286
|
+
|
|
196
287
|
export function resolveConfig() {
|
|
197
288
|
const root = loadBaseToml() || {};
|
|
198
289
|
// Merge plugin-provided defaults UNDER the user's config: an explicitly set
|
|
290
|
+
// Merge plugin-provided defaults UNDER the user's config: an explicitly set
|
|
199
291
|
// user value always wins.
|
|
200
292
|
for (const [k, v] of Object.entries(pluginConfigDefaults || {})) {
|
|
201
293
|
if (!(k in root)) root[k] = v;
|
|
@@ -250,7 +342,7 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
250
342
|
// `contextLength` (discovered from the provider's /v1/models), then the
|
|
251
343
|
// default. This is what stops every model from being pinned to 128k.
|
|
252
344
|
const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
|
|
253
|
-
const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.max_context_size || 0);
|
|
345
|
+
const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.context_window || modelEntry.max_context_size || 0);
|
|
254
346
|
const maxContext = Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
|
|
255
347
|
const maxOutput = Number(process.env.HNCODE_MAX_OUTPUT || root.max_output_size || DEFAULTS.maxOutputTokens);
|
|
256
348
|
const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
|
|
@@ -262,10 +354,32 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
262
354
|
maxContextTokens: maxContext, maxOutputTokens: maxOutput, reasoning,
|
|
263
355
|
workspace: process.env.HNCODE_WORKSPACE || root.workspace || DEFAULTS.workspace,
|
|
264
356
|
allowExternal,
|
|
357
|
+
// Standing permission rules (see permissions.js). `[permissions]` in config.toml:
|
|
358
|
+
// [permissions]
|
|
359
|
+
// allow = ["Bash(npm run test *)", "Read(./src/**)"]
|
|
360
|
+
// ask = ["Bash(git push *)"]
|
|
361
|
+
// deny = ["Read(./.env)"]
|
|
362
|
+
// Checked before the mode rules, so a deny cannot be escaped by switching to
|
|
363
|
+
// Auto and an explicit ask still prompts inside it.
|
|
364
|
+
permissions: {
|
|
365
|
+
allow: Array.isArray(root.permissions && root.permissions.allow) ? root.permissions.allow.map(String) : [],
|
|
366
|
+
ask: Array.isArray(root.permissions && root.permissions.ask) ? root.permissions.ask.map(String) : [],
|
|
367
|
+
deny: Array.isArray(root.permissions && root.permissions.deny) ? root.permissions.deny.map(String) : [],
|
|
368
|
+
},
|
|
265
369
|
// A user-supplied system prompt (set via /set-system-prompt). Empty means
|
|
266
370
|
// "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
|
|
267
371
|
// across restarts.
|
|
268
372
|
systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
|
|
373
|
+
// ---- Web UI daemon -------------------------------------------------------
|
|
374
|
+
// One daemon serves EVERY hncode session on this machine: it holds the fixed
|
|
375
|
+
// port, proxies each session's requests to that session's own loopback
|
|
376
|
+
// server, and reads the sessions on disk for the ones whose TUI has exited.
|
|
377
|
+
// These live in config.toml so the address and the token stay the SAME across
|
|
378
|
+
// restarts — a browser bookmark and a saved token keep working, which is the
|
|
379
|
+
// whole point of a single global UI.
|
|
380
|
+
webHost: process.env.HNCODE_WEB_HOST || root.web_host || '127.0.0.1',
|
|
381
|
+
webPort: tomlPort(process.env.HNCODE_WEB_PORT || root.web_port, 8765),
|
|
382
|
+
webToken: process.env.HNCODE_WEB_TOKEN || root.web_token || '',
|
|
269
383
|
// CALM MODE (/calm-mode): persisted boolean. When true an instruction is
|
|
270
384
|
// appended to the system prompt to suppress narration.
|
|
271
385
|
calmMode: process.env.HNCODE_CALM_MODE
|
|
@@ -287,12 +401,43 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
287
401
|
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
|
|
288
402
|
: (root.auto_update === true || root.auto_update === 'true'),
|
|
289
403
|
// AUTO-COMPACTION (/auto-compact): when true, the agent summarizes older
|
|
290
|
-
// history once a request reaches
|
|
291
|
-
// `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
|
|
404
|
+
// history once a request reaches the trigger ratio of the model context.
|
|
405
|
+
// Default ON. Set `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
|
|
292
406
|
autoCompact: process.env.HNCODE_AUTO_COMPACT
|
|
293
407
|
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_COMPACT)
|
|
294
408
|
: !(root.auto_compact === false || root.auto_compact === 'false'),
|
|
295
|
-
//
|
|
409
|
+
// COMPACTION TUNING (config.toml):
|
|
410
|
+
// compact_threshold — fraction of the model window at which auto-compaction
|
|
411
|
+
// fires (default 0.85 = 85%).
|
|
412
|
+
// compact_keep_ratio — fraction of the CURRENT usage to KEEP after a
|
|
413
|
+
// compaction (default 0.2 = keep ~20% of what was used).
|
|
414
|
+
// Both accept a number (0-1) or a percent string ("85%"). Env overrides:
|
|
415
|
+
// HNCODE_COMPACT_THRESHOLD / HNCODE_COMPACT_KEEP_RATIO.
|
|
416
|
+
compactThreshold: clampRatio(
|
|
417
|
+
process.env.HNCODE_COMPACT_THRESHOLD || root.compact_threshold, 0.85,
|
|
418
|
+
),
|
|
419
|
+
compactKeepRatio: clampRatio(
|
|
420
|
+
process.env.HNCODE_COMPACT_KEEP_RATIO || root.compact_keep_ratio, 0.2,
|
|
421
|
+
),
|
|
422
|
+
// TOOL-RESULT TRIMMING (config.toml), the cheaper cousin of compaction: old
|
|
423
|
+
// tool outputs are elided from the REQUEST (never from the saved history) once
|
|
424
|
+
// it grows past the trigger, down to the keep fraction of the tool-result text.
|
|
425
|
+
// auto_trim — ON by default; `false` never trims automatically.
|
|
426
|
+
// trim_threshold — fire when the request reaches this fraction of the
|
|
427
|
+
// model window (default 0.5 = 50%).
|
|
428
|
+
// trim_keep_ratio — fraction of the tool-result text to KEEP (default
|
|
429
|
+
// 0.3 = keep 30%, so ~70% of it is elided).
|
|
430
|
+
// Both accept a number (0-1) or a percent string ("50%"). Env overrides:
|
|
431
|
+
// HNCODE_AUTO_TRIM / HNCODE_TRIM_THRESHOLD / HNCODE_TRIM_KEEP_RATIO.
|
|
432
|
+
autoTrim: process.env.HNCODE_AUTO_TRIM
|
|
433
|
+
? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_TRIM)
|
|
434
|
+
: !(root.auto_trim === false || root.auto_trim === 'false'),
|
|
435
|
+
trimThreshold: clampRatio(
|
|
436
|
+
process.env.HNCODE_TRIM_THRESHOLD || root.trim_threshold, 0.5,
|
|
437
|
+
),
|
|
438
|
+
trimKeepRatio: clampRatio(
|
|
439
|
+
process.env.HNCODE_TRIM_KEEP_RATIO || root.trim_keep_ratio, 0.3,
|
|
440
|
+
),
|
|
296
441
|
// Default ON — it only ever adds provider-recognised markers, and an
|
|
297
442
|
// unsupported field on a non-caching gateway is filtered out in cache.js.
|
|
298
443
|
// Set HNCODE_PROMPT_CACHE=0 or `prompt_cache = false` to disable.
|
|
@@ -405,8 +550,47 @@ export function setConfigString(key, value) {
|
|
|
405
550
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
406
551
|
fs.writeFileSync(file, out.join('\n') + (out.length ? '\n' : ''), 'utf8');
|
|
407
552
|
return file;
|
|
553
|
+
return file;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The Web UI's access token, generated ONCE and persisted to config.toml.
|
|
557
|
+
//
|
|
558
|
+
// It is deliberately not regenerated per launch: the daemon outlives individual
|
|
559
|
+
// sessions, and a token that changed on every restart would invalidate every
|
|
560
|
+
// browser's saved cookie and force a re-login each time — which defeats having a
|
|
561
|
+
// single stable UI. `HNCODE_WEB_TOKEN` overrides for scripted setups.
|
|
562
|
+
export function ensureWebToken() {
|
|
563
|
+
const fromEnv = (process.env.HNCODE_WEB_TOKEN || '').trim();
|
|
564
|
+
if (fromEnv) return fromEnv;
|
|
565
|
+
const cfg = resolveConfig();
|
|
566
|
+
if (cfg.webToken) return cfg.webToken;
|
|
567
|
+
// 32 random bytes, base64url: 43 chars, URL- and cookie-safe, ~256 bits.
|
|
568
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
569
|
+
try { setConfigString('web_token', token); }
|
|
570
|
+
catch { /* read-only config: the token lives for this run only */ }
|
|
571
|
+
return token;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Write a boolean root-level key as a TOML LITERAL (`key = true`), not a string.
|
|
575
|
+
|
|
576
|
+
// Write a boolean root-level key as a TOML LITERAL (`key = true`), not a string.
|
|
577
|
+
// setConfigString() renders every value as a quoted string, so `true` would land
|
|
578
|
+
// as `"true"` — which reads back as a truthy string rather than a boolean, and
|
|
579
|
+
// would make `key = false` truthy. Reuses setConfigString for the surgery (it
|
|
580
|
+
// already knows how to replace a key in place and where a new root key goes), then
|
|
581
|
+
// rewrites the one line it produced.
|
|
582
|
+
export function setConfigBool(key, value) {
|
|
583
|
+
const file = setConfigString(key, String(!!value));
|
|
584
|
+
let text = fs.readFileSync(file, 'utf8');
|
|
585
|
+
const wanted = `${key} = ${value ? 'true' : 'false'}`;
|
|
586
|
+
const re = new RegExp('^\\s*' + escapeRe(key) + '\\s*=\\s*.*$', 'm');
|
|
587
|
+
if (re.test(text)) text = text.replace(re, wanted);
|
|
588
|
+
else text = wanted + '\n' + text;
|
|
589
|
+
fs.writeFileSync(file, text, 'utf8');
|
|
590
|
+
return file;
|
|
408
591
|
}
|
|
409
592
|
|
|
593
|
+
|
|
410
594
|
// Render `key = <value>` as TOML. Uses a multi-line basic string when the value
|
|
411
595
|
// contains newlines (readable, and TOML-valid), a plain one otherwise.
|
|
412
596
|
//
|
|
@@ -544,9 +728,16 @@ function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
544
728
|
// Fetch the model list from a provider's API. The base URL is used as given and
|
|
545
729
|
// we append only `/models` (so `https://host/v1` → `https://host/v1/models`).
|
|
546
730
|
// Anthropic sends x-api-key + anthropic-version. Returns [{id, display}] or [].
|
|
547
|
-
export async function fetchModels({ baseUrl, apiKey, protocol }) {
|
|
731
|
+
export async function fetchModels({ baseUrl, apiKey, protocol, throwOnError = false }) {
|
|
548
732
|
const b = String(baseUrl || '').replace(/\/+$/, '');
|
|
549
|
-
if (!b)
|
|
733
|
+
if (!b) {
|
|
734
|
+
// `throwOnError` makes a failure LOUD instead of an empty list. The model
|
|
735
|
+
// pickers use the quiet form — "no models" is a normal state there — but a
|
|
736
|
+
// user who pressed "discover models" and got nothing needs to know whether
|
|
737
|
+
// the endpoint refused the key or simply has no catalogue.
|
|
738
|
+
if (throwOnError) throw new Error('no base URL');
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
550
741
|
const url = `${b}/models`;
|
|
551
742
|
const headers = { 'content-type': 'application/json' };
|
|
552
743
|
if (protocol === 'anthropic') {
|
|
@@ -559,7 +750,10 @@ export async function fetchModels({ baseUrl, apiKey, protocol }) {
|
|
|
559
750
|
const timer = setTimeout(() => ctrl.abort(), 15000);
|
|
560
751
|
try {
|
|
561
752
|
const res = await fetch(url, { headers, signal: ctrl.signal });
|
|
562
|
-
if (!res.ok)
|
|
753
|
+
if (!res.ok) {
|
|
754
|
+
if (throwOnError) throw new Error(`${res.status} ${res.statusText || ''}`.trim());
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
563
757
|
const json = await res.json();
|
|
564
758
|
// OpenAI: { data: [{ id }] } | Anthropic: { data: [{ id, display_name }] }
|
|
565
759
|
const arr = Array.isArray(json.data) ? json.data : (Array.isArray(json.models) ? json.models : []);
|
|
@@ -567,7 +761,7 @@ export async function fetchModels({ baseUrl, apiKey, protocol }) {
|
|
|
567
761
|
id: String(m.id || m.name || ''),
|
|
568
762
|
display: m.display_name || m.displayName || '',
|
|
569
763
|
// Capability hints (servers vary in what they expose).
|
|
570
|
-
contextLength: m.context_length || m.contextLength || undefined,
|
|
764
|
+
contextLength: m.context_length || m.contextLength || m.context_window || undefined,
|
|
571
765
|
maxTokens: m.max_tokens || undefined, // NEW: per-model token limit from /v1/models
|
|
572
766
|
maxOutputTokens: m.max_output_tokens || m.maxOutputTokens || undefined,
|
|
573
767
|
ownedBy: m.owned_by || undefined,
|
|
@@ -577,10 +771,14 @@ export async function fetchModels({ baseUrl, apiKey, protocol }) {
|
|
|
577
771
|
|| undefined,
|
|
578
772
|
efforts: m.reasoning_efforts || m.supported_efforts || undefined,
|
|
579
773
|
})).filter((m) => m.id);
|
|
580
|
-
} catch {
|
|
581
|
-
|
|
774
|
+
} catch (e) {
|
|
775
|
+
if (throwOnError) throw e;
|
|
776
|
+
return [];
|
|
777
|
+
} finally { clearTimeout(timer); }
|
|
582
778
|
}
|
|
583
779
|
|
|
780
|
+
// ---- thinking effort capability ----
|
|
781
|
+
|
|
584
782
|
// ---- thinking effort capability ----
|
|
585
783
|
// The set of selectable efforts for a model. Honours, in order:
|
|
586
784
|
// 1. an explicit `efforts` / `reasoning_efforts` list on the model entry
|
|
@@ -643,10 +841,12 @@ export function bareModelId(provider, key, entry) {
|
|
|
643
841
|
const e = entry || {};
|
|
644
842
|
const providerName = e.provider || provider || '';
|
|
645
843
|
const k = String(key || '');
|
|
646
|
-
// API
|
|
647
|
-
//
|
|
844
|
+
// The API wants the model id on its own (it may carry a sub-prefix like
|
|
845
|
+
// "qoder/deepseek-flash"), NOT hncode's provider prefix ("traebuddy/"), so
|
|
846
|
+
// "<provider>/" is stripped off.
|
|
648
847
|
if (providerName && k.startsWith(providerName + '/')) return k.slice(providerName.length + 1);
|
|
649
|
-
// key
|
|
848
|
+
// When the key has no provider prefix, an explicit `model` field wins;
|
|
849
|
+
// otherwise the key itself is the id.
|
|
650
850
|
const fromField = typeof e.model === 'string' ? e.model : '';
|
|
651
851
|
return fromField || k;
|
|
652
852
|
}
|
|
@@ -729,7 +929,7 @@ function contextForModel(cfg, modelName) {
|
|
|
729
929
|
const models = (root.models) || {};
|
|
730
930
|
const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
|
|
731
931
|
const entry = models[modelName] || {};
|
|
732
|
-
const modelContext = Number(entry.contextLength || entry.context_length || entry.max_context_size || 0);
|
|
932
|
+
const modelContext = Number(entry.contextLength || entry.context_length || entry.context_window || entry.max_context_size || 0);
|
|
733
933
|
return Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
|
|
734
934
|
}
|
|
735
935
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// File checkpoints for /undo.
|
|
2
|
+
//
|
|
3
|
+
// The old /undo only rewound the CONVERSATION: it truncated the transcript and
|
|
4
|
+
// said "Undid N prompts", while every Edit/Write the agent had made stayed on
|
|
5
|
+
// disk. That reads as a lie — the user believes the work was withdrawn and then
|
|
6
|
+
// finds their files still rewritten. This module makes the rewind real: the
|
|
7
|
+
// content a file had BEFORE the agent touched it is kept, and /undo puts it back
|
|
8
|
+
// (and deletes a file the agent created).
|
|
9
|
+
//
|
|
10
|
+
// The model follows Claude Code's fileHistory (src/utils/fileHistory.ts):
|
|
11
|
+
// * a backup is taken per TURN, lazily, on the first change to a file within
|
|
12
|
+
// that turn — so the recorded version is exactly the pre-turn state;
|
|
13
|
+
// * one entry per user turn is recorded even when nothing changed, so the
|
|
14
|
+
// index lines up with the user prompts /undo counts;
|
|
15
|
+
// * backups are content copies under ~/.hncode/file-history/<sessionId>/, so a
|
|
16
|
+
// rewind cannot fail on a file that has since been edited by hand (it just
|
|
17
|
+
// overwrites it back).
|
|
18
|
+
//
|
|
19
|
+
// Only files the agent itself modified are ever touched. A file the user edited
|
|
20
|
+
// in their own editor during the turn is not in the index and is left alone.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
function homeDir() {
|
|
28
|
+
return process.env.HNCODE_HOME || os.homedir();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function historyDir(sessionId) {
|
|
32
|
+
return path.join(homeDir(), '.hncode', 'file-history', String(sessionId || 'unscoped'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function indexPath(sessionId) {
|
|
36
|
+
return path.join(historyDir(sessionId), 'index.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Read the index. A missing or corrupt file yields an empty one rather than a
|
|
40
|
+
// throw: checkpoints are a safety net, and losing them must not break /undo's
|
|
41
|
+
// conversation rewind.
|
|
42
|
+
function readIndex(sessionId) {
|
|
43
|
+
try {
|
|
44
|
+
const doc = JSON.parse(fs.readFileSync(indexPath(sessionId), 'utf8'));
|
|
45
|
+
if (doc && Array.isArray(doc.turns)) {
|
|
46
|
+
for (const t of doc.turns) if (!Array.isArray(t.files)) t.files = [];
|
|
47
|
+
return doc;
|
|
48
|
+
}
|
|
49
|
+
} catch { /* first run, or the file was removed */ }
|
|
50
|
+
return { turns: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeIndex(sessionId, doc) {
|
|
54
|
+
const file = indexPath(sessionId);
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
// Same atomic-replace shape as session.js: a crash mid-write must not leave a
|
|
58
|
+
// half-written index that a later read would have to guess at.
|
|
59
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(doc), 'utf8');
|
|
61
|
+
fs.renameSync(tmp, file);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Start a new turn: append an (initially empty) entry and return its id. The id
|
|
69
|
+
// is just the turn's position, which is what /undo counts in.
|
|
70
|
+
export function beginTurn(sessionId) {
|
|
71
|
+
const doc = readIndex(sessionId);
|
|
72
|
+
doc.turns.push({ at: Date.now(), files: [] });
|
|
73
|
+
if (doc.turns.length > 200) doc.turns.splice(0, doc.turns.length - 200);
|
|
74
|
+
writeIndex(sessionId, doc);
|
|
75
|
+
return sessionId == null ? null : doc.turns.length - 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Record `filePath`'s CURRENT content as the version to restore, ONCE per turn —
|
|
79
|
+
// called immediately before the agent writes, so what is captured is the state
|
|
80
|
+
// before any of this turn's changes.
|
|
81
|
+
//
|
|
82
|
+
// Returns true when a backup was taken (or already existed for this turn).
|
|
83
|
+
export function recordBeforeWrite(sessionId, filePath) {
|
|
84
|
+
const abs = path.resolve(filePath);
|
|
85
|
+
const doc = readIndex(sessionId);
|
|
86
|
+
const turn = doc.turns[doc.turns.length - 1];
|
|
87
|
+
// No turn open (the caller forgot beginTurn): open one so the backup still has a
|
|
88
|
+
// home, rather than dropping it and silently losing the ability to rewind.
|
|
89
|
+
if (!turn) { beginTurn(sessionId); return recordBeforeWrite(sessionId, filePath); }
|
|
90
|
+
if (turn.files.some((f) => f.path === abs)) return true;
|
|
91
|
+
|
|
92
|
+
let existed = true;
|
|
93
|
+
let content = null;
|
|
94
|
+
try {
|
|
95
|
+
content = fs.readFileSync(abs);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e && e.code === 'ENOENT') existed = false;
|
|
98
|
+
else return false; // unreadable: do not claim a checkpoint we cannot honour
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let backup = '';
|
|
102
|
+
if (existed) {
|
|
103
|
+
const dir = historyDir(sessionId);
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
const name = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 16)
|
|
107
|
+
+ '-' + crypto.randomBytes(3).toString('hex') + '.bak';
|
|
108
|
+
backup = path.join(dir, name);
|
|
109
|
+
fs.writeFileSync(backup, content);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
turn.files.push({ path: abs, existed, backup });
|
|
115
|
+
return writeIndex(sessionId, doc);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// How many turns are on record.
|
|
119
|
+
export function turnCount(sessionId) {
|
|
120
|
+
return readIndex(sessionId).turns.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rewind `count` turns: put every file the agent changed back to how it was
|
|
125
|
+
* before those turns, and delete any file the agent created in them.
|
|
126
|
+
*
|
|
127
|
+
* Walks the turns from newest to oldest and, for each file, keeps the version
|
|
128
|
+
* recorded EARLIEST among the rewound turns — that is the pre-turn state. Returns
|
|
129
|
+
* { restored: [path], deleted: [path], turns: n, failed: [{path, error}] }.
|
|
130
|
+
*/
|
|
131
|
+
export function rewind(sessionId, count = 1) {
|
|
132
|
+
const doc = readIndex(sessionId);
|
|
133
|
+
const n = Math.max(0, Math.min(Number(count) || 0, doc.turns.length));
|
|
134
|
+
const out = { restored: [], deleted: [], turns: 0, failed: [] };
|
|
135
|
+
if (!n) return out;
|
|
136
|
+
|
|
137
|
+
const rewound = doc.turns.slice(doc.turns.length - n);
|
|
138
|
+
// For each file, keep the FIRST record seen walking oldest -> newest: that is
|
|
139
|
+
// the content it had before the first of the rewound turns touched it. Taking
|
|
140
|
+
// the newest instead would restore a halfway state (the version left by the
|
|
141
|
+
// previous rewound turn). A file CREATED in the oldest rewound turn records
|
|
142
|
+
// `existed: false`, which correctly wins over the copies taken in later turns.
|
|
143
|
+
const chosen = new Map();
|
|
144
|
+
for (const turn of rewound) {
|
|
145
|
+
for (const f of turn.files) if (!chosen.has(f.path)) chosen.set(f.path, f);
|
|
146
|
+
}
|
|
147
|
+
const ordered = [...chosen.entries()];
|
|
148
|
+
|
|
149
|
+
for (const [file, rec] of ordered) {
|
|
150
|
+
try {
|
|
151
|
+
if (rec.existed === false) {
|
|
152
|
+
// The agent created it in one of these turns: it did not exist before.
|
|
153
|
+
fs.rmSync(file, { force: true });
|
|
154
|
+
out.deleted.push(file);
|
|
155
|
+
} else {
|
|
156
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
157
|
+
fs.copyFileSync(rec.backup, file);
|
|
158
|
+
out.restored.push(file);
|
|
159
|
+
}
|
|
160
|
+
} catch (e) {
|
|
161
|
+
out.failed.push({ path: file, error: e && e.message ? e.message : String(e) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
doc.turns.splice(doc.turns.length - n, n);
|
|
165
|
+
writeIndex(sessionId, doc);
|
|
166
|
+
out.turns = n;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Human-readable summary for the /undo notice.
|
|
171
|
+
export function describeRewind(res) {
|
|
172
|
+
const parts = [];
|
|
173
|
+
if (res.restored.length) parts.push(`${res.restored.length} file(s) restored`);
|
|
174
|
+
if (res.deleted.length) parts.push(`${res.deleted.length} created file(s) removed`);
|
|
175
|
+
if (res.failed.length) parts.push(`${res.failed.length} could not be restored`);
|
|
176
|
+
return parts.join(', ');
|
|
177
|
+
}
|
package/src/hooks.js
CHANGED
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
// "hooks": {
|
|
9
9
|
// "PreToolUse": [{ "matcher": "Edit|Write", "command": "npm run lint" }],
|
|
10
10
|
// "PostToolUse": [{ "matcher": "Edit", "command": "npx prettier --write $FILE" }],
|
|
11
|
+
// "PostToolUseFailure": [{ "matcher": "Bash", "command": "notify-fail.sh" }],
|
|
11
12
|
// "UserPromptSubmit": [{ "command": "echo submitting" }],
|
|
12
13
|
// "SessionStart": [{ "command": "git fetch --quiet" }],
|
|
14
|
+
// "SessionEnd": [{ "command": "echo bye >> ~/sessions.log" }],
|
|
15
|
+
// "SubagentStop": [{ "command": "echo sub done" }],
|
|
16
|
+
// "PostCompact": [{ "command": "archive-summary.sh" }],
|
|
17
|
+
// "Notification": [{ "command": "notify-send hncode" }],
|
|
13
18
|
// "Stop": [{ "command": "echo turn done >> ~/turns.log" }]
|
|
14
19
|
// }
|
|
15
20
|
// }
|
|
@@ -39,11 +44,16 @@ import cp from 'node:child_process';
|
|
|
39
44
|
// of silently never firing.
|
|
40
45
|
export const HOOK_EVENTS = [
|
|
41
46
|
'SessionStart',
|
|
47
|
+
'SessionEnd',
|
|
42
48
|
'UserPromptSubmit',
|
|
43
49
|
'PreToolUse',
|
|
44
50
|
'PostToolUse',
|
|
51
|
+
'PostToolUseFailure',
|
|
45
52
|
'Stop',
|
|
53
|
+
'SubagentStop',
|
|
46
54
|
'PreCompact',
|
|
55
|
+
'PostCompact',
|
|
56
|
+
'Notification',
|
|
47
57
|
];
|
|
48
58
|
|
|
49
59
|
// A hook that hangs must not hang the agent. 60s is generous for a formatter or
|
|
@@ -209,7 +219,7 @@ export async function runShellHooks(config, event, payload, cwd) {
|
|
|
209
219
|
// for Edit, for BOTH PreToolUse and PostToolUse. Applying it to PreToolUse
|
|
210
220
|
// alone made a PostToolUse hook run on every tool — a "format after edit" hook
|
|
211
221
|
// ran after reads and searches too.
|
|
212
|
-
const isToolEvent = event === 'PreToolUse' || event === 'PostToolUse';
|
|
222
|
+
const isToolEvent = event === 'PreToolUse' || event === 'PostToolUse' || event === 'PostToolUseFailure';
|
|
213
223
|
const results = [];
|
|
214
224
|
for (const entry of list) {
|
|
215
225
|
if (isToolEvent && !matcherApplies(entry.matcher, payload && payload.toolName)) continue;
|