@hncode/hncode 0.4.1 → 0.5.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/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Output styles, loaded from FILES — the way Claude Code's `.claude/output-styles/`
|
|
2
|
+
// works (a file's NAME is the style, its BODY is the instructions).
|
|
3
|
+
//
|
|
4
|
+
// Why files rather than another picker entry: an output style is a paragraph of
|
|
5
|
+
// prompting that a user iterates on. Editing a TOML string inside config.toml for
|
|
6
|
+
// that is unpleasant, and it makes the style unreviewable in a diff. A directory of
|
|
7
|
+
// Markdown files means the style is editable with $EDITOR, diffable, and shareable
|
|
8
|
+
// by copying one file.
|
|
9
|
+
//
|
|
10
|
+
// File format (all parts optional except the body):
|
|
11
|
+
//
|
|
12
|
+
// ---
|
|
13
|
+
// name: Terse reviewer
|
|
14
|
+
// description: Reviews code, states findings, no praise
|
|
15
|
+
// ---
|
|
16
|
+
// You answer only in short declarative sentences. Never open with praise…
|
|
17
|
+
//
|
|
18
|
+
// A file with no frontmatter is valid: the name then comes from the filename
|
|
19
|
+
// (`terse-reviewer.md` -> `Terse Reviewer`), which is what makes ad-hoc styles
|
|
20
|
+
// cheap to add.
|
|
21
|
+
//
|
|
22
|
+
// The style is INJECTED as a system-ish reminder on each request rather than baked
|
|
23
|
+
// into the system prompt, so switching styles does not invalidate the prompt cache
|
|
24
|
+
// and reverting is a matter of clearing a flag.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where styles are read from, most specific first.
|
|
32
|
+
*
|
|
33
|
+
* `home` is the `.hncode` DIRECTORY, matching how config.js resolves every other
|
|
34
|
+
* per-user path (`path.join(homeDir(), '.hncode', …)`). Deriving it independently
|
|
35
|
+
* here — `process.env.HNCODE_HOME || ~/.hncode` — got it wrong under HNCODE_HOME,
|
|
36
|
+
* which points at the PARENT of `.hncode`: styles then lived in
|
|
37
|
+
* `<HNCODE_HOME>/output-styles` while the rest of the app looked in
|
|
38
|
+
* `<HNCODE_HOME>/.hncode/…`, so a user with HNCODE_HOME set could never see a style.
|
|
39
|
+
* The caller passes `config.hncodeDir()`, so both agree by construction.
|
|
40
|
+
*/
|
|
41
|
+
export function styleDirs(cwd, home) {
|
|
42
|
+
const base = home || defaultHncodeDir();
|
|
43
|
+
const dirs = [path.join(base, 'output-styles')];
|
|
44
|
+
// Project-level styles override the personal ones of the same name, so a repo can
|
|
45
|
+
// pin its own style for everyone working in it.
|
|
46
|
+
if (cwd) dirs.push(path.join(cwd, '.hncode', 'output-styles'));
|
|
47
|
+
return dirs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The per-user .hncode directory, resolved exactly as config.js does it:
|
|
51
|
+
// path.join(HNCODE_HOME || os.homedir(), '.hncode')
|
|
52
|
+
// HNCODE_HOME stands in for the HOME directory — it is how a test or a user keeping
|
|
53
|
+
// config on another drive relocates the whole thing — so `.hncode` is appended to
|
|
54
|
+
// it. Appending it to a value that ALREADY contained `.hncode` put the styles one
|
|
55
|
+
// directory too deep and made them invisible.
|
|
56
|
+
function defaultHncodeDir() {
|
|
57
|
+
return path.join(process.env.HNCODE_HOME || os.homedir(), '.hncode');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `terse-reviewer` -> `Terse Reviewer`. The fallback name when a file has none. */
|
|
61
|
+
export function nameFromFile(file) {
|
|
62
|
+
const base = path.basename(String(file || ''), '.md');
|
|
63
|
+
return base.split(/[-_]+/).filter(Boolean)
|
|
64
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
65
|
+
.join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split a style file into { meta, body }.
|
|
70
|
+
*
|
|
71
|
+
* Only a leading `---` fence is treated as frontmatter — the same rule as Jekyll's,
|
|
72
|
+
* chosen because a style BODY will often contain `---` (a horizontal rule in
|
|
73
|
+
* Markdown), and treating a later one as a fence would swallow the instructions.
|
|
74
|
+
*/
|
|
75
|
+
export function parseStyle(raw, file = '') {
|
|
76
|
+
const text = String(raw == null ? '' : raw).replace(/\r\n/g, '\n');
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
const meta = {};
|
|
79
|
+
let body = text;
|
|
80
|
+
if (lines[0] !== undefined && lines[0].trim() === '---') {
|
|
81
|
+
// Find the CLOSING fence; everything between is metadata.
|
|
82
|
+
let end = -1;
|
|
83
|
+
for (let i = 1; i < lines.length; i++) {
|
|
84
|
+
if (lines[i].trim() === '---') { end = i; break; }
|
|
85
|
+
}
|
|
86
|
+
if (end > 0) {
|
|
87
|
+
for (let i = 1; i < end; i++) {
|
|
88
|
+
const m = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
|
|
89
|
+
if (!m) continue;
|
|
90
|
+
// Strip one layer of quotes if present; a description with a colon is
|
|
91
|
+
// otherwise fine because only the FIRST colon separates the key.
|
|
92
|
+
meta[m[1].toLowerCase()] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
93
|
+
}
|
|
94
|
+
body = lines.slice(end + 1).join('\n');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const name = meta.name || nameFromFile(file);
|
|
98
|
+
return { name, description: meta.description || '', body: body.trim(), meta };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every style readable right now, sorted by name. Missing directories are not an error. */
|
|
102
|
+
export function listStyles(cwd, home) {
|
|
103
|
+
const found = new Map(); // name (lowercased) -> style
|
|
104
|
+
for (const dir of styleDirs(cwd, home)) {
|
|
105
|
+
let entries = [];
|
|
106
|
+
try { entries = fs.readdirSync(dir); } catch { continue; }
|
|
107
|
+
for (const f of entries.sort()) {
|
|
108
|
+
if (!f.endsWith('.md')) continue;
|
|
109
|
+
let raw = '';
|
|
110
|
+
try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
111
|
+
const style = parseStyle(raw, f);
|
|
112
|
+
if (!style.body) continue; // a style with no body does nothing
|
|
113
|
+
// The LATER directory wins (project over personal): this loop runs home first.
|
|
114
|
+
found.set(style.name.toLowerCase(), { ...style, file: path.join(dir, f), dir });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One style by name (case-insensitive), or null. */
|
|
121
|
+
export function findStyle(name, cwd, home) {
|
|
122
|
+
// A trailing `.md` is dropped, since `ls` shows the extension and typing it is the
|
|
123
|
+
// obvious thing to do after looking at the directory.
|
|
124
|
+
const want = String(name == null ? '' : name).trim().toLowerCase().replace(/\.md$/, '');
|
|
125
|
+
if (!want) return null;
|
|
126
|
+
const all = listStyles(cwd, home);
|
|
127
|
+
// Every spelling a user might type, most exact first:
|
|
128
|
+
// 1. the display name (`Terse Reviewer`)
|
|
129
|
+
// 2. the FILE name without .md (`terse-reviewer`) — this is what `ls` shows and
|
|
130
|
+
// what a user types after seeing the directory, and without it the lookup
|
|
131
|
+
// failed for the very style they just created.
|
|
132
|
+
// 3. a prefix of either, so `/output-style terse` is enough to disambiguate.
|
|
133
|
+
const fileOf = (s) => String(s.file || '').replace(/^.*[\\/]/, '').replace(/\.md$/, '').toLowerCase();
|
|
134
|
+
return all.find((s) => s.name.toLowerCase() === want)
|
|
135
|
+
|| all.find((s) => fileOf(s) === want)
|
|
136
|
+
|| all.find((s) => s.name.toLowerCase().startsWith(want))
|
|
137
|
+
|| all.find((s) => fileOf(s).startsWith(want))
|
|
138
|
+
|| null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The reminder text that carries a style into a request.
|
|
143
|
+
*
|
|
144
|
+
* Deliberately wrapped in the same `<system-reminder>` envelope the harness uses
|
|
145
|
+
* elsewhere, and placed so the model reads it as an instruction about FORM rather
|
|
146
|
+
* than as content to answer. It says the style overrides earlier formatting
|
|
147
|
+
* requests, because a system prompt already contains output rules and the later
|
|
148
|
+
* instruction is the one a model follows.
|
|
149
|
+
*/
|
|
150
|
+
export function styleReminder(style) {
|
|
151
|
+
if (!style || !style.body) return '';
|
|
152
|
+
return [
|
|
153
|
+
'<system-reminder>',
|
|
154
|
+
`The user has selected the "${style.name}" output style. It governs the FORM of`,
|
|
155
|
+
'your replies and takes precedence over any earlier formatting instruction.',
|
|
156
|
+
'',
|
|
157
|
+
style.body,
|
|
158
|
+
'</system-reminder>',
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Create a starter file so /output-style new gives the user something to edit. */
|
|
163
|
+
export function writeStyleTemplate(dir, name) {
|
|
164
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
165
|
+
const slug = String(name || 'my-style').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-style';
|
|
166
|
+
const file = path.join(dir, `${slug}.md`);
|
|
167
|
+
if (fs.existsSync(file)) return { file, created: false };
|
|
168
|
+
const body = [
|
|
169
|
+
'---',
|
|
170
|
+
`name: ${nameFromFile(file)}`,
|
|
171
|
+
'description: what this style is for',
|
|
172
|
+
'---',
|
|
173
|
+
'',
|
|
174
|
+
'Write your style instructions here. They describe the FORM of every reply:',
|
|
175
|
+
'length, tone, structure, what to leave out. They are added to each request as',
|
|
176
|
+
'a system reminder, so they take precedence over the default formatting rules.',
|
|
177
|
+
'',
|
|
178
|
+
].join('\n');
|
|
179
|
+
fs.writeFileSync(file, body, 'utf8');
|
|
180
|
+
return { file, created: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default { styleDirs, listStyles, findStyle, parseStyle, styleReminder, nameFromFile, writeStyleTemplate };
|
package/src/patch.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Symbol-level patching — let a plugin wrap ANY exported function or class method
|
|
2
|
+
// in hncode's own source, without editing that source.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// --------------
|
|
6
|
+
// The original `api.patch(seam, fn)` only exposed nine hand-picked "seams"
|
|
7
|
+
// (systemPrompt, llmRequest, …). Everything else — Agent.send, LLM.chat, any of
|
|
8
|
+
// the ~340 exported functions — was unreachable from a plugin. This module makes
|
|
9
|
+
// every symbol patchable by rewriting the target module's SOURCE at load time.
|
|
10
|
+
//
|
|
11
|
+
// HOW IT WORKS (and why it is safe)
|
|
12
|
+
// ---------------------------------
|
|
13
|
+
// ESM exports are read-only bindings, so a plugin cannot monkey-patch them at
|
|
14
|
+
// runtime (verified: `Cannot assign to read only property`). But a module CAN
|
|
15
|
+
// reassign its OWN bindings, and Node's `module.registerHooks({ load })` lets us
|
|
16
|
+
// rewrite a module's source as it is loaded. So for each requested target we:
|
|
17
|
+
//
|
|
18
|
+
// 1. leave the original declaration untouched (no body parsing at all), and
|
|
19
|
+
// 2. append a small footer that reassigns the symbol through our wrapper:
|
|
20
|
+
// foo = __hncodePatch("file.js#foo", foo);
|
|
21
|
+
// Klass.prototype.m = __hncodePatch("file.js#Klass.m", Klass.prototype.m);
|
|
22
|
+
//
|
|
23
|
+
// For an `export const` target we also rewrite that one declaration line
|
|
24
|
+
// (`const` → `let`) so it can be reassigned — still a single-line change, still
|
|
25
|
+
// no function-body analysis. This was chosen over an AST rewriter (acorn) to keep
|
|
26
|
+
// hncode's zero-runtime-dependency promise; it relies only on the fact that the
|
|
27
|
+
// target's declaration line is well-formed, which holds for every export in src/.
|
|
28
|
+
//
|
|
29
|
+
// ORDERING (important)
|
|
30
|
+
// --------------------
|
|
31
|
+
// The load hook only sees modules that are imported AFTER it is registered;
|
|
32
|
+
// already-cached modules are left alone (verified). hncode's entry therefore
|
|
33
|
+
// registers this loader and loads plugins BEFORE importing the business modules
|
|
34
|
+
// (see src/bootstrap.js). A module that has already been imported cannot be
|
|
35
|
+
// patched — patching it is reported as a skipped target, not a silent no-op.
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import module from 'node:module';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
// Registry: { "file.js#symbol": [handler, …] }. The loader reads it per module.
|
|
43
|
+
const registry = new Map();
|
|
44
|
+
|
|
45
|
+
// The nine legacy abstract seams, kept so `api.patch('systemPrompt', fn)` from an
|
|
46
|
+
// existing plugin keeps working. These are handled by plugin.js's chain runner,
|
|
47
|
+
// NOT by this module; listed here so api.patch can tell the two target kinds apart.
|
|
48
|
+
export const LEGACY_SEAMS = new Set([
|
|
49
|
+
'systemPrompt', 'llmRequest', 'llmHeaders', 'llmBody', 'llmResponse',
|
|
50
|
+
'toolDispatch', 'toolResult', 'beforeRender', 'afterRender',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
let loaderInstalled = false;
|
|
54
|
+
let loaderActive = false;
|
|
55
|
+
const patchedFiles = new Set();
|
|
56
|
+
|
|
57
|
+
// A target is "file.js#symbol" (a plain export) or "file.js#Class.method".
|
|
58
|
+
const TARGET_RE = /^([^#]+)#([A-Za-z_$][\w$]*)(?:\.([A-Za-z_$][\w$]*))?$/;
|
|
59
|
+
|
|
60
|
+
/** Normalise a user-supplied target into { file, base, method|null } or null. */
|
|
61
|
+
export function parseTarget(target) {
|
|
62
|
+
const m = TARGET_RE.exec(String(target || '').trim());
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return { file: m[1], base: m[2], method: m[3] || null, id: `${m[1]}#${m[2]}${m[3] ? '.' + m[3] : ''}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Register one handler for a symbol target. Returns an unregister function. */
|
|
68
|
+
export function addPatch(target, handler) {
|
|
69
|
+
const info = parseTarget(target);
|
|
70
|
+
if (!info) throw new Error(`patch: bad target "${target}" (want "file.js#symbol" or "file.js#Class.method")`);
|
|
71
|
+
if (!handler || typeof handler !== 'object') throw new Error('patch: handler must be an object like { before, after, around }');
|
|
72
|
+
if (!info.method && patchedFiles.has(info.file) && !registry.get(info.id)) {
|
|
73
|
+
// Allow it anyway; the loader may not have run yet for this file.
|
|
74
|
+
}
|
|
75
|
+
const list = registry.get(info.id) || [];
|
|
76
|
+
list.push(handler);
|
|
77
|
+
registry.set(info.id, list);
|
|
78
|
+
return () => {
|
|
79
|
+
const arr = registry.get(info.id);
|
|
80
|
+
if (!arr) return;
|
|
81
|
+
const i = arr.indexOf(handler);
|
|
82
|
+
if (i >= 0) arr.splice(i, 1);
|
|
83
|
+
if (!arr.length) registry.delete(info.id);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** All registered target ids (for /plugins-style introspection). */
|
|
88
|
+
export function listPatchTargets() { return [...registry.keys()]; }
|
|
89
|
+
|
|
90
|
+
/** Remove every handler (used by _resetPlugins and tests). */
|
|
91
|
+
export function clearPatches() { registry.clear(); }
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// The runtime wrapper applied in the module footer.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wrap `fn` so every handler registered for `id` runs around it.
|
|
99
|
+
* - before(...args): if it returns an array, that replaces the argument list.
|
|
100
|
+
* - after(result, args): if it returns non-undefined, that replaces the result.
|
|
101
|
+
* - around(original, args, thisArg): full control; its return value is the result.
|
|
102
|
+
* Handler `this` is the call's `this` (so a class method can read its instance).
|
|
103
|
+
*/
|
|
104
|
+
export function applyPatches(id, fn) {
|
|
105
|
+
const handlers = registry.get(id);
|
|
106
|
+
if (!handlers || !handlers.length) return fn;
|
|
107
|
+
|
|
108
|
+
function wrapped(...args) {
|
|
109
|
+
let finalArgs = args;
|
|
110
|
+
for (const h of handlers) {
|
|
111
|
+
if (typeof h.before === 'function') {
|
|
112
|
+
const r = h.before.apply(this, finalArgs);
|
|
113
|
+
if (Array.isArray(r)) finalArgs = r;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `around` takes over the call entirely; otherwise call the original.
|
|
118
|
+
const aroundHandler = handlers.filter((h) => typeof h.around === 'function').pop();
|
|
119
|
+
const aroundFn = aroundHandler && aroundHandler.around;
|
|
120
|
+
const invoke = () => (aroundFn ? aroundFn.call(this, fn, finalArgs, this) : fn.apply(this, finalArgs));
|
|
121
|
+
|
|
122
|
+
const finish = (result) => {
|
|
123
|
+
let out = result;
|
|
124
|
+
for (const h of handlers) {
|
|
125
|
+
if (typeof h.after === 'function') {
|
|
126
|
+
const r = h.after.call(this, out, finalArgs);
|
|
127
|
+
if (r !== undefined) out = r;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const r = invoke();
|
|
134
|
+
// Preserve asyncness: only then-ables get the after-chain deferred.
|
|
135
|
+
if (r && typeof r.then === 'function') return r.then(finish);
|
|
136
|
+
return finish(r);
|
|
137
|
+
}
|
|
138
|
+
return wrapped;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Expose to rewritten modules (they run in their own scope and cannot import us
|
|
142
|
+
// without a cycle). One global, set once.
|
|
143
|
+
function installGlobal() {
|
|
144
|
+
if (!globalThis.__hncodePatch) {
|
|
145
|
+
globalThis.__hncodePatch = (id, fn) => applyPatches(id, fn);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// The load hook: rewrite a module's source to append the patch footers.
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** Build the footer source for the targets whose file matches `base`. */
|
|
154
|
+
function footerFor(base, ids) {
|
|
155
|
+
const lines = ['', '/* hncode:patch-footer (auto-generated) */', ';(() => {', ' const P = globalThis.__hncodePatch;', ' if (!P) return;'];
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
const info = parseTarget(id);
|
|
158
|
+
if (!info) continue;
|
|
159
|
+
const ref = info.method ? `${info.base}.prototype.${info.method}` : info.base;
|
|
160
|
+
lines.push(` try { ${ref} = P(${JSON.stringify(id)}, ${ref}); } catch (e) { console.error('[hncode-patch] ${id}:', e.message); }`);
|
|
161
|
+
}
|
|
162
|
+
lines.push('})();', '');
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* In `src`, make target `file#symbol` reassignable:
|
|
168
|
+
* - `export const NAME = …` → `export let NAME = …` (only for a plain target)
|
|
169
|
+
* The declaration line is matched literally; function bodies are never touched.
|
|
170
|
+
*/
|
|
171
|
+
function makeReassignable(src, info) {
|
|
172
|
+
if (info.method) return src; // class methods are patched via the prototype
|
|
173
|
+
const re = new RegExp(`^(\\s*)export const (${info.base})\\b`, 'm');
|
|
174
|
+
return src.replace(re, '$1export let $2');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Rewrite one module's source, or return it unchanged when it has no targets. */
|
|
178
|
+
export function rewriteSource(filename, src) {
|
|
179
|
+
const base = filename.split(/[\\/]/).pop();
|
|
180
|
+
const ids = [...registry.keys()].filter((id) => parseTarget(id)?.file === base);
|
|
181
|
+
if (!ids.length) return null;
|
|
182
|
+
|
|
183
|
+
let out = src;
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const info = parseTarget(id);
|
|
186
|
+
// A plain target declares a top-level function/const; make it mutable if const.
|
|
187
|
+
out = makeReassignable(out, info);
|
|
188
|
+
}
|
|
189
|
+
return out + footerFor(base, ids);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Register the Node load hook. Idempotent and NON-FATAL: if the runtime has no
|
|
194
|
+
* `module.registerHooks` (added in Node 22.15; hncode still declares engines
|
|
195
|
+
* >=20) or registration throws, symbol patching is simply disabled. It must
|
|
196
|
+
* never throw — a throw here would propagate through bootstrap and stop hncode
|
|
197
|
+
* from starting at all, which is far worse than a missing optional feature.
|
|
198
|
+
* Returns true when the loader is active.
|
|
199
|
+
*/
|
|
200
|
+
export function installPatchLoader() {
|
|
201
|
+
installGlobal();
|
|
202
|
+
if (loaderInstalled) return loaderActive;
|
|
203
|
+
loaderInstalled = true;
|
|
204
|
+
|
|
205
|
+
if (typeof module.registerHooks !== 'function') {
|
|
206
|
+
// Older Node: patching is unavailable. Everything else keeps working.
|
|
207
|
+
console.error('[hncode-patch] module.registerHooks is unavailable on this Node — symbol patching disabled.');
|
|
208
|
+
return (loaderActive = false);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let srcDir;
|
|
212
|
+
try { srcDir = path.dirname(fileURLToPath(import.meta.url)); } catch { return (loaderActive = false); }
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
module.registerHooks({
|
|
216
|
+
load(url, context, nextLoad) {
|
|
217
|
+
const r = nextLoad(url, context);
|
|
218
|
+
// Only hncode's own src/*.js files, and only when something targets them.
|
|
219
|
+
if (!url.startsWith('file:') || !url.includes('/src/') || typeof r.source === 'undefined') return r;
|
|
220
|
+
let file;
|
|
221
|
+
try { file = fileURLToPath(url); } catch { return r; }
|
|
222
|
+
if (path.dirname(file) !== srcDir) return r;
|
|
223
|
+
try {
|
|
224
|
+
const s = typeof r.source === 'string' ? r.source : Buffer.from(r.source).toString('utf8');
|
|
225
|
+
const rewritten = rewriteSource(file, s);
|
|
226
|
+
if (rewritten == null) return r;
|
|
227
|
+
patchedFiles.add(path.basename(file));
|
|
228
|
+
return { ...r, source: rewritten, format: r.format || 'module' };
|
|
229
|
+
} catch (e) {
|
|
230
|
+
// A rewrite failure must not break the module load — return it as-is
|
|
231
|
+
// and report, rather than taking hncode down.
|
|
232
|
+
console.error(`[hncode-patch] failed to rewrite ${url}:`, e && e.message);
|
|
233
|
+
return r;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
loaderActive = true;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error('[hncode-patch] could not install the load hook — symbol patching disabled:', e && e.message);
|
|
240
|
+
loaderActive = false;
|
|
241
|
+
}
|
|
242
|
+
return loaderActive;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Whether the load hook is actually active (installed AND usable). index.js uses
|
|
246
|
+
* this to decide whether plugins still need loading — if patching is disabled,
|
|
247
|
+
* bootstrap already loaded them, so it must NOT be treated as "installed". */
|
|
248
|
+
export function isLoaderInstalled() { return loaderActive; }
|
package/src/permissions.js
CHANGED
|
@@ -30,9 +30,19 @@ const PATH_ARGS = {
|
|
|
30
30
|
Grep: ['path', 'pattern'],
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/** Strip a trailing `# …` comment. ` #` (whitespace before the hash) is the
|
|
34
|
+
* delimiter, so a `#` inside an argument — `Bash(echo a#b)` — is left alone. */
|
|
35
|
+
export function stripRuleComment(text) {
|
|
36
|
+
const m = /(^|\s)#/.exec(String(text == null ? '' : text));
|
|
37
|
+
return m ? String(text).slice(0, m.index + m[1].length).trim() : String(text == null ? '' : text).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
// `Bash(npm run test)` / `Read(./src/**)` -> { tool, arg }
|
|
34
41
|
export function parseRule(rule) {
|
|
35
|
-
|
|
42
|
+
// The comment is stripped FIRST. Without this a rule carrying `# match: …`
|
|
43
|
+
// examples parsed as null (the text no longer ends with `)`) and the rule
|
|
44
|
+
// silently stopped matching anything — so annotating a rule broke it.
|
|
45
|
+
const text = stripRuleComment(rule);
|
|
36
46
|
if (!text) return null;
|
|
37
47
|
const open = text.indexOf('(');
|
|
38
48
|
if (open === -1) return { tool: text, arg: null };
|
|
@@ -118,3 +128,122 @@ export function describeRules(rules) {
|
|
|
118
128
|
}
|
|
119
129
|
return lines.join('\n');
|
|
120
130
|
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Rule self-tests
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// A rule that silently matches NOTHING is worse than no rule at all: the user
|
|
136
|
+
// believes `npm test` is allowed, the prompt appears anyway, and nothing says why.
|
|
137
|
+
// A typo in a glob (`./src/*` vs `./src/**`) is invisible until the prompt shows up
|
|
138
|
+
// at the wrong moment.
|
|
139
|
+
//
|
|
140
|
+
// So a rule may carry its own examples, checked when the rules are read:
|
|
141
|
+
//
|
|
142
|
+
// Bash(npm run test *) # match: npm run test foo | not_match: npm run tests
|
|
143
|
+
//
|
|
144
|
+
// This mirrors codex's `prefix_rule(match=[...], not_match=[...])`, which validates
|
|
145
|
+
// its examples at load time. The examples live on the SAME line as the rule, as a
|
|
146
|
+
// trailing comment, so a rule and its test cannot drift into two places and the
|
|
147
|
+
// line-based config format does not have to change.
|
|
148
|
+
//
|
|
149
|
+
// Several examples are separated by `|`. An example is the ARGUMENT the rule
|
|
150
|
+
// describes — the command for Bash, the path for a file tool — so no quoting is
|
|
151
|
+
// needed for the common case.
|
|
152
|
+
|
|
153
|
+
/** Split one rule line into `{ rule, match, not_match }`. */
|
|
154
|
+
export function parseRuleLine(line) {
|
|
155
|
+
const text = String(line == null ? '' : line);
|
|
156
|
+
// A ` #` delimiter, so a `#` inside an argument stays part of the rule.
|
|
157
|
+
const delim = /(^|\s)#/.exec(text);
|
|
158
|
+
const ruleText = delim ? text.slice(0, delim.index + delim[1].length).trim() : text.trim();
|
|
159
|
+
const comment = delim ? text.slice(delim.index + delim[0].length).trim() : '';
|
|
160
|
+
const out = { rule: ruleText, match: [], not_match: [] };
|
|
161
|
+
if (!comment) return out;
|
|
162
|
+
// Scan for the markers rather than splitting on them: `split` with a LOOKAHEAD
|
|
163
|
+
// of `\bnot[_-]?match` splits `not-match` into `not-` and `match:` (the boundary
|
|
164
|
+
// before `match` matched first), which produced two unusable halves. An explicit
|
|
165
|
+
// scan takes the longest marker at each position and cannot do that.
|
|
166
|
+
const marker = /(not[_-]?match|match)\s*:/gi;
|
|
167
|
+
const found = [];
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = marker.exec(comment)) !== null) {
|
|
170
|
+
found.push({ key: /^not/i.test(m[1]) ? 'not_match' : 'match', from: m.index, bodyStart: m.index + m[0].length });
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < found.length; i++) {
|
|
173
|
+
const end = i + 1 < found.length ? found[i + 1].from : comment.length;
|
|
174
|
+
const body = comment.slice(found[i].bodyStart, end);
|
|
175
|
+
for (const ex of body.split('|')) {
|
|
176
|
+
const t = ex.trim();
|
|
177
|
+
if (t) out[found[i].key].push(t);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** An example string -> the argument object `ruleMatches` expects. */
|
|
184
|
+
export function exampleArgs(toolName, example) {
|
|
185
|
+
const ex = String(example == null ? '' : example).trim();
|
|
186
|
+
if (toolName === 'Bash') return { command: ex };
|
|
187
|
+
return { path: ex, file_path: ex, pattern: ex };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check every rule's examples. Returns `{ checks, failed }`; a failed check is
|
|
192
|
+
* `{ rule, kind: 'match'|'not_match', example, list }`.
|
|
193
|
+
*
|
|
194
|
+
* A rule with NO examples yields no checks: the test is opt-in, so adding this to an
|
|
195
|
+
* existing config cannot start reporting errors for rules nobody annotated.
|
|
196
|
+
*/
|
|
197
|
+
export function selfTestRules(rules) {
|
|
198
|
+
const list = rules || {};
|
|
199
|
+
const checks = [];
|
|
200
|
+
const failed = [];
|
|
201
|
+
// `deny` is included on purpose: an over-broad deny is the MOST damaging typo of
|
|
202
|
+
// the three, because it can lock the agent out of the work it was asked to do.
|
|
203
|
+
for (const key of ['allow', 'ask', 'deny']) {
|
|
204
|
+
const arr = Array.isArray(list[key]) ? list[key] : [];
|
|
205
|
+
for (const raw of arr) {
|
|
206
|
+
const { rule, match, not_match } = parseRuleLine(raw);
|
|
207
|
+
if (!rule) continue;
|
|
208
|
+
const parsed = parseRule(rule);
|
|
209
|
+
if (!parsed) {
|
|
210
|
+
failed.push({ rule, kind: 'parse', example: '', list: key });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
for (const ex of match) {
|
|
214
|
+
const pass = ruleMatches(parsed, parsed.tool, exampleArgs(parsed.tool, ex));
|
|
215
|
+
checks.push({ rule, kind: 'match', example: ex, pass, list: key });
|
|
216
|
+
if (!pass) failed.push({ rule, kind: 'match', example: ex, list: key });
|
|
217
|
+
}
|
|
218
|
+
for (const ex of not_match) {
|
|
219
|
+
const pass = !ruleMatches(parsed, parsed.tool, exampleArgs(parsed.tool, ex));
|
|
220
|
+
checks.push({ rule, kind: 'not_match', example: ex, pass, list: key });
|
|
221
|
+
if (!pass) failed.push({ rule, kind: 'not_match', example: ex, list: key });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { checks, failed };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The self-test block for the /permissions panel. */
|
|
229
|
+
export function describeSelfTest(result) {
|
|
230
|
+
if (!result || !result.checks.length) {
|
|
231
|
+
return result && result.failed.length
|
|
232
|
+
? `${result.failed.length} rule(s) could not be parsed:\n`
|
|
233
|
+
+ result.failed.map((f) => ` FAIL ${f.list}: ${f.rule}`).join('\n')
|
|
234
|
+
: 'No rule has examples yet. Add them on the rule line:\n'
|
|
235
|
+
+ ' Bash(git push *) # match: git push | not_match: git pull';
|
|
236
|
+
}
|
|
237
|
+
const out = [`${result.checks.length} example(s) checked, ${result.failed.length} failed:`];
|
|
238
|
+
for (const c of result.checks) {
|
|
239
|
+
const want = c.kind === 'match' ? 'should match ' : 'should NOT match';
|
|
240
|
+
out.push(` ${c.pass ? 'ok ' : 'FAIL'} ${want} ${c.list}: ${c.rule} <- ${c.example}`);
|
|
241
|
+
}
|
|
242
|
+
return out.join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export default {
|
|
246
|
+
parseRule, ruleMatches, decideFromRules, describeRules,
|
|
247
|
+
parseRuleLine, selfTestRules, describeSelfTest, exampleArgs,
|
|
248
|
+
};
|
|
249
|
+
|
package/src/plan.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Plan review helpers — turning a model's
|
|
1
|
+
// Plan review helpers — turning a model's plan block into something the user
|
|
2
2
|
// can actually review before any file is touched.
|
|
3
3
|
//
|
|
4
|
-
// Plan mode already asks the model for a
|
|
4
|
+
// Plan mode already asks the model for a <|plan|>…</|plan|> block and prompts to
|
|
5
|
+
// approve it. What this module adds is the review material and the persistence:
|
|
5
6
|
// approve it. What this module adds is the review material and the persistence:
|
|
6
7
|
//
|
|
7
8
|
// * filesReferenced(plan) — which paths the plan says it will touch, so the
|