@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
package/src/plugin.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// - commands (slash-commands in the TUI)
|
|
9
9
|
// - hooks (lifecycle events: onTurnStart, onTurnEnd, onBeforeTool, etc.)
|
|
10
10
|
// - config (default config values that merge into resolveConfig)
|
|
11
|
+
// - services (named values other plugins can `inject`)
|
|
11
12
|
//
|
|
12
13
|
// Example plugin:
|
|
13
14
|
// import { Tool } from 'hncode';
|
|
@@ -21,14 +22,37 @@
|
|
|
21
22
|
// api.registerCommand({ name: 'echo', run: (arg, ctx) => console.log(arg) });
|
|
22
23
|
// }
|
|
23
24
|
// export default { name: 'echo-plugin' };
|
|
25
|
+
//
|
|
26
|
+
// SERVICES, DEPENDENCIES AND TEARDOWN
|
|
27
|
+
// -----------------------------------
|
|
28
|
+
// A plugin may export four more things:
|
|
29
|
+
// provides names of the services its install() registers with api.provide()
|
|
30
|
+
// inject names of the services it needs; its install() runs only after they
|
|
31
|
+
// are all provided, so a plugin never has to guess the load order.
|
|
32
|
+
// A plugin whose dependencies cannot be satisfied is SKIPPED with a
|
|
33
|
+
// notice rather than started half-wired.
|
|
34
|
+
// dispose dispose(api); called by unloadPlugins() BEFORE the registered
|
|
35
|
+
// things are undone, so it can flush a file or stop a timer.
|
|
36
|
+
// reload hot reload: loadPlugins(dir, { fresh: true }) re-imports each file
|
|
37
|
+
// with a cache-busting query and calls dispose + the collected
|
|
38
|
+
// unregister functions of the previous load, so an edited plugin
|
|
39
|
+
// takes effect without restarting hncode. (Exception: a `patch` on a
|
|
40
|
+
// module that is ALREADY loaded this session still needs a restart —
|
|
41
|
+
// the load hook only sees modules imported after it runs.)
|
|
42
|
+
//
|
|
43
|
+
// export const provides = ['signature-db'];
|
|
44
|
+
// export const inject = ['signature-db'];
|
|
45
|
+
// export default { name: 'x', version: '1.0.0', install, dispose };
|
|
24
46
|
|
|
25
47
|
import fs from 'node:fs';
|
|
26
48
|
import path from 'node:path';
|
|
27
49
|
import os from 'node:os';
|
|
28
50
|
import { pathToFileURL } from 'node:url';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
import { addSkillFromText, listSkills, deleteSkill } from './skills.js';
|
|
52
|
+
import { addPatch, listPatchTargets, clearPatches, LEGACY_SEAMS, installPatchLoader } from './patch.js';
|
|
53
|
+
// THEME_NAMES only. colors.js has no imports of its own, so this cannot create a cycle —
|
|
54
|
+
// which matters because this module is loaded very early, by bootstrap.
|
|
55
|
+
import { THEME_NAMES } from './colors.js';
|
|
32
56
|
|
|
33
57
|
// ---------------------------------------------------------------------------
|
|
34
58
|
// Internal registries — these are the "live" state that plugins mutate.
|
|
@@ -39,6 +63,96 @@ const registeredCommands = [];
|
|
|
39
63
|
const registeredHooks = {};
|
|
40
64
|
const registeredConfig = {};
|
|
41
65
|
const loadedPlugins = [];
|
|
66
|
+
const registeredKeybinds = [];
|
|
67
|
+
const registeredWidgets = [];
|
|
68
|
+
const registeredPatches = {}; // { seam: [fn] } mixin-style patches keyed by seam name
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Services — the composition seam between plugins.
|
|
72
|
+
//
|
|
73
|
+
// `api.provide(name, value)` publishes a value under a name and `api.inject` /
|
|
74
|
+
// `api.get(name)` reads it, so plugin B can build on plugin A without importing
|
|
75
|
+
// its file (which would break the moment A is edited or reloaded). This is what
|
|
76
|
+
// makes `inject` load ordering meaningful: the names are the graph's edges.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
const services = new Map();
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Per-plugin bookkeeping — one unit per plugin whose install() ran, in load order.
|
|
82
|
+
//
|
|
83
|
+
// Every register* call funnels its unregister function into the CURRENT unit, so
|
|
84
|
+
// a plugin author gets teardown for free instead of collecting disposers by hand
|
|
85
|
+
// and getting it wrong. unloadPlugins() then walks the units backwards.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
const pluginUnits = [];
|
|
88
|
+
let currentUnit = null; // the unit whose install() is running, if any
|
|
89
|
+
// Bumped per fresh (cache-busted) import; see loadPlugins' `opts.fresh`.
|
|
90
|
+
let loadSeq = 0;
|
|
91
|
+
/** Record a disposer against the plugin currently installing. Returns it unchanged,
|
|
92
|
+
* so a register* method can both track and return the unregister function. */
|
|
93
|
+
function track(dispose) {
|
|
94
|
+
if (currentUnit && typeof dispose === 'function') currentUnit.disposers.push(dispose);
|
|
95
|
+
return dispose;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Plugin notices — what loaded, what failed, and anything a plugin reports.
|
|
100
|
+
//
|
|
101
|
+
// A plugin author's instinct is console.log/error, and that used to be the only channel.
|
|
102
|
+
// The TUI owns the screen, so those messages went to a terminal the user cannot see while
|
|
103
|
+
// the app runs: a plugin that failed to load simply did not appear, with no explanation
|
|
104
|
+
// anywhere. Collecting every message and letting the TUI render it puts both the success
|
|
105
|
+
// and the failure where the user is already looking.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const pendingNotices = [];
|
|
108
|
+
|
|
109
|
+
/** Record one notice. `level` is 'info' | 'warn' | 'error'. */
|
|
110
|
+
function notice(level, text) {
|
|
111
|
+
pendingNotices.push({ level, text: String(text == null ? '' : text) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Take and clear the pending notices — the TUI drains this after loading. */
|
|
115
|
+
export function takePluginNotices() {
|
|
116
|
+
const out = pendingNotices.slice();
|
|
117
|
+
pendingNotices.length = 0;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fmtArgs(args) {
|
|
122
|
+
return args.map((a) => {
|
|
123
|
+
if (typeof a === 'string') return a;
|
|
124
|
+
if (a instanceof Error) return a.message;
|
|
125
|
+
try { return JSON.stringify(a); } catch { return String(a); }
|
|
126
|
+
}).join(' ');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A console-like object handed to `install(api)` as `api.log`, so a plugin can report
|
|
131
|
+
* without knowing anything about the UI. Everything reaches the chat AND mirrors to the
|
|
132
|
+
* terminal, so a plugin author watching stderr still sees it.
|
|
133
|
+
*
|
|
134
|
+
* It is CALLABLE as well as carrying methods — `api.log('x')` and `api.log.warn('x')`
|
|
135
|
+
* both work, exactly like `console`. The alternative, exposing the four methods as
|
|
136
|
+
* `api.log` / `api.info` / `api.warn` / `api.error`, was what the type definitions had
|
|
137
|
+
* claimed and the host never provided: `api.info(...)` was `undefined`, so a plugin
|
|
138
|
+
* following the docs crashed on its first log line. One object with both shapes is the
|
|
139
|
+
* one that cannot contradict itself.
|
|
140
|
+
*/
|
|
141
|
+
function makeLogger(sink) {
|
|
142
|
+
const fn = (...a) => sink.log(...a);
|
|
143
|
+
fn.log = (...a) => sink.log(...a);
|
|
144
|
+
fn.info = (...a) => sink.info(...a);
|
|
145
|
+
fn.warn = (...a) => sink.warn(...a);
|
|
146
|
+
fn.error = (...a) => sink.error(...a);
|
|
147
|
+
return fn;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const pluginLogger = makeLogger({
|
|
151
|
+
log: (...a) => { notice('info', fmtArgs(a)); console.log('[hncode-plugin]', ...a); },
|
|
152
|
+
info: (...a) => { notice('info', fmtArgs(a)); console.log('[hncode-plugin]', ...a); },
|
|
153
|
+
warn: (...a) => { notice('warn', fmtArgs(a)); console.warn('[hncode-plugin]', ...a); },
|
|
154
|
+
error: (...a) => { notice('error', fmtArgs(a)); console.error('[hncode-plugin]', ...a); },
|
|
155
|
+
});
|
|
42
156
|
|
|
43
157
|
const API = {
|
|
44
158
|
// Register a new tool (same shape as built-in tool specs).
|
|
@@ -51,10 +165,10 @@ const API = {
|
|
|
51
165
|
throw new Error(`registerTool: tool "${spec.name}" is already registered`);
|
|
52
166
|
}
|
|
53
167
|
registeredTools.push(spec);
|
|
54
|
-
return () => {
|
|
168
|
+
return track(() => {
|
|
55
169
|
const i = registeredTools.findIndex((t) => t.name === spec.name);
|
|
56
170
|
if (i >= 0) registeredTools.splice(i, 1);
|
|
57
|
-
};
|
|
171
|
+
});
|
|
58
172
|
},
|
|
59
173
|
|
|
60
174
|
// Register a slash-command.
|
|
@@ -68,39 +182,247 @@ const API = {
|
|
|
68
182
|
throw new Error(`registerCommand: command "/${cmd.name}" is already registered`);
|
|
69
183
|
}
|
|
70
184
|
registeredCommands.push(cmd);
|
|
71
|
-
return () => {
|
|
185
|
+
return track(() => {
|
|
72
186
|
const i = registeredCommands.findIndex((c) => c.name === cmd.name);
|
|
73
187
|
if (i >= 0) registeredCommands.splice(i, 1);
|
|
74
|
-
};
|
|
188
|
+
});
|
|
75
189
|
},
|
|
76
190
|
|
|
77
191
|
// Register a lifecycle hook. Supported hook names:
|
|
78
|
-
//
|
|
79
|
-
//
|
|
192
|
+
// onStartup, onShutdown, onTurnStart, onTurnEnd,
|
|
193
|
+
// onBeforeRequest, onAfterRequest,
|
|
194
|
+
// onToolExecute, onToolResult, onNewMessage, onSessionSave
|
|
195
|
+
// An unknown name is accepted (a plugin may use its own event, and hncode never
|
|
196
|
+
// fires it) — see HOOK_NAMES in the SDK for the set the host actually emits.
|
|
80
197
|
registerHook(name, fn) {
|
|
81
198
|
if (!name || typeof fn !== 'function') {
|
|
82
199
|
throw new Error('registerHook: name and fn are required');
|
|
83
200
|
}
|
|
84
201
|
(registeredHooks[name] = registeredHooks[name] || []).push(fn);
|
|
85
|
-
return () => {
|
|
202
|
+
return track(() => {
|
|
86
203
|
const arr = registeredHooks[name];
|
|
87
204
|
if (arr) {
|
|
88
205
|
const i = arr.indexOf(fn);
|
|
89
206
|
if (i >= 0) arr.splice(i, 1);
|
|
90
207
|
}
|
|
91
|
-
};
|
|
208
|
+
});
|
|
92
209
|
},
|
|
93
210
|
|
|
94
211
|
// Merge default config values. These are applied in resolveConfig after
|
|
95
|
-
// all built-in defaults and before user config overrides.
|
|
212
|
+
// all built-in defaults and before user config overrides. Returns an unregister
|
|
213
|
+
// function that drops the keys this call added — the values live in a shared
|
|
214
|
+
// object, so unloading has to remove them by key.
|
|
96
215
|
registerConfig(defaults) {
|
|
216
|
+
const keys = Object.keys(defaults || {});
|
|
97
217
|
Object.assign(registeredConfig, defaults || {});
|
|
218
|
+
if (currentUnit) currentUnit.configKeys.push(...keys);
|
|
219
|
+
return track(() => { for (const k of keys) delete registeredConfig[k]; });
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// Runtime helpers.
|
|
223
|
+
get ctx() { return _ctx; },
|
|
224
|
+
|
|
225
|
+
// ----- Services -----
|
|
226
|
+
// Publish a value other plugins can `inject` / `get`. Declare the same names in
|
|
227
|
+
// the module's `provides` export: the loader reads that (not this call) to work
|
|
228
|
+
// out the load order, because ordering must be known BEFORE any install runs.
|
|
229
|
+
// A duplicate name is an error rather than a silent overwrite — two plugins
|
|
230
|
+
// fighting over one name is the failure a dependency system exists to prevent.
|
|
231
|
+
provide(name, value) {
|
|
232
|
+
const key = String(name == null ? '' : name).trim();
|
|
233
|
+
if (!key) throw new Error('provide: a service name is required');
|
|
234
|
+
const existing = pluginUnits.find((u) => u.services.has(key));
|
|
235
|
+
if (services.has(key)) {
|
|
236
|
+
throw new Error(`provide: service "${key}" is already provided${existing ? ` by ${existing.name}` : ''}`);
|
|
237
|
+
}
|
|
238
|
+
const unit = currentUnit;
|
|
239
|
+
services.set(key, value);
|
|
240
|
+
if (unit) unit.services.add(key);
|
|
241
|
+
return track(() => {
|
|
242
|
+
// Only drop the entry if it is still the value THIS plugin published: an
|
|
243
|
+
// unregister called by hand after a reload must not delete the new load's.
|
|
244
|
+
if (services.get(key) === value) services.delete(key);
|
|
245
|
+
if (unit) unit.services.delete(key);
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
/** A provided service, or undefined. */
|
|
250
|
+
get(name) { return services.get(String(name == null ? '' : name).trim()); },
|
|
251
|
+
/** Whether a service is available right now. */
|
|
252
|
+
has(name) { return services.has(String(name == null ? '' : name).trim()); },
|
|
253
|
+
/** Like get(), but throws when the service is missing — use it in install() when
|
|
254
|
+
* the plugin cannot work at all without it. */
|
|
255
|
+
require(name) {
|
|
256
|
+
const key = String(name == null ? '' : name).trim();
|
|
257
|
+
if (!services.has(key)) throw new Error(`require: service "${key}" is not available`);
|
|
258
|
+
return services.get(key);
|
|
98
259
|
},
|
|
260
|
+
/** Names of every currently-provided service. */
|
|
261
|
+
get serviceNames() { return [...services.keys()]; },
|
|
262
|
+
|
|
263
|
+
// ----- Teardown -----
|
|
264
|
+
/** Register a cleanup function, run by unloadPlugins() before the registrations
|
|
265
|
+
* are undone. For resources the register* calls do not know about: timers,
|
|
266
|
+
* child processes, watchers, files. Returns an unregister function. */
|
|
267
|
+
onDispose(fn) {
|
|
268
|
+
if (typeof fn !== 'function') throw new Error('onDispose: a function is required');
|
|
269
|
+
const unit = currentUnit;
|
|
270
|
+
const wrapper = () => {
|
|
271
|
+
if (unit) { const i = unit.disposers.indexOf(wrapper); if (i >= 0) unit.disposers.splice(i, 1); }
|
|
272
|
+
return fn();
|
|
273
|
+
};
|
|
274
|
+
return track(wrapper);
|
|
275
|
+
},
|
|
276
|
+
|
|
99
277
|
|
|
100
278
|
// Runtime helpers.
|
|
101
279
|
get ctx() { return _ctx; },
|
|
102
280
|
|
|
103
|
-
//
|
|
281
|
+
// Register a skill from an in-memory SKILL.md string. Lets a plugin ship a skill
|
|
282
|
+
// as code instead of asking the user to /import-skill a file. `content` is the
|
|
283
|
+
// skill body; pass `name` to fix the skill name (else it is read from the
|
|
284
|
+
// front-matter `name` field). Returns { ok, name, path, replaced } or { ok:false, error }.
|
|
285
|
+
// The skill lands in the same skills directory /import-skill uses, so it shows up
|
|
286
|
+
// immediately under /skills and can be activated with /skill:<name>.
|
|
287
|
+
addSkill(content, name) {
|
|
288
|
+
return addSkillFromText(content, name);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// ----- Runtime control -----
|
|
292
|
+
sendPrompt(text, opts) { return _host.sendPrompt ? _host.sendPrompt(text, opts || {}) : null; },
|
|
293
|
+
addMessage(role, text) { return _host.addChat ? _host.addChat(role, text) : null; },
|
|
294
|
+
getMessages() { return _host.getMessages ? _host.getMessages() : []; },
|
|
295
|
+
clearSession() { if (_host.clearSession) _host.clearSession(); },
|
|
296
|
+
saveSession(s) { if (_host.saveSession) _host.saveSession(s); },
|
|
297
|
+
quit() { if (_host.quit) _host.quit(); },
|
|
298
|
+
|
|
299
|
+
// ----- UI -----
|
|
300
|
+
notice(text, kind) { if (_host.notice) _host.notice(text, kind); },
|
|
301
|
+
openPanel(content) { if (_host.openPanel) _host.openPanel(content); },
|
|
302
|
+
openEditor(opts) { if (_host.openEditor) _host.openEditor(opts); },
|
|
303
|
+
openPicker(opts) { if (_host.openPicker) _host.openPicker(opts); },
|
|
304
|
+
openForm(opts) { if (_host.openForm) _host.openForm(opts); },
|
|
305
|
+
openTasksPanel() { if (_host.openTasksPanel) _host.openTasksPanel(); },
|
|
306
|
+
openRegistryBrowser(kind) { if (_host.openRegistryBrowser) _host.openRegistryBrowser(kind); },
|
|
307
|
+
|
|
308
|
+
// The /files browser, driven from a plugin. `filter` narrows the tree the same way
|
|
309
|
+
// `/files <filter>` does. Returns whether the browser actually opened, so a plugin can
|
|
310
|
+
// tell a headless host (or an empty workspace) from a real success — a plugin that opens
|
|
311
|
+
// a panel and then waits for the user is broken if nothing appeared.
|
|
312
|
+
openFileTree(filter) { return _host.openFileTree ? !!_host.openFileTree(filter) : false; },
|
|
313
|
+
/** Close the file-tree browser if it is open. */
|
|
314
|
+
closeFileTree() { if (_host.closeFileTree) _host.closeFileTree(); },
|
|
315
|
+
|
|
316
|
+
// ----- Theme -----
|
|
317
|
+
/** Every theme name this build knows. */
|
|
318
|
+
get themeNames() { return THEME_NAMES.slice(); },
|
|
319
|
+
/** The theme in force, or the default when none was set. */
|
|
320
|
+
getTheme() { return _host.getTheme ? _host.getTheme() : null; },
|
|
321
|
+
/**
|
|
322
|
+
* Switch the colour theme. Returns false for an unknown name or a host that cannot
|
|
323
|
+
* repaint — the check is here rather than in the host so a plugin gets a straight answer
|
|
324
|
+
* instead of a screen that half-changed.
|
|
325
|
+
*/
|
|
326
|
+
setTheme(name) {
|
|
327
|
+
const n = String(name == null ? '' : name).trim();
|
|
328
|
+
if (!THEME_NAMES.includes(n)) return false;
|
|
329
|
+
return _host.setTheme ? !!_host.setTheme(n) : false;
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// ----- Subagents -----
|
|
333
|
+
/**
|
|
334
|
+
* The subagents running RIGHT NOW. A finished agent is not listed — it is no longer
|
|
335
|
+
* controllable, and its result is already in the conversation.
|
|
336
|
+
*/
|
|
337
|
+
subagents() { return _host.subagents ? _host.subagents() : []; },
|
|
338
|
+
/** Send a message to a running subagent (by agent id, run id or task id). */
|
|
339
|
+
messageSubagent(ref, text) {
|
|
340
|
+
return _host.messageSubagent ? _host.messageSubagent(ref, text) : { ok: false, message: 'not supported by this host' };
|
|
341
|
+
},
|
|
342
|
+
/** Stop one running subagent, or every one when `ref` is omitted. */
|
|
343
|
+
interruptSubagent(ref) {
|
|
344
|
+
return _host.interruptSubagent ? _host.interruptSubagent(ref) : { ok: false, message: 'not supported by this host' };
|
|
345
|
+
},
|
|
346
|
+
/** Interrupt a subagent AND drop it from the running list at once. */
|
|
347
|
+
closeSubagent(ref) {
|
|
348
|
+
return _host.closeSubagent ? _host.closeSubagent(ref) : { ok: false, message: 'not supported by this host' };
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// ----- Keybinds -----
|
|
352
|
+
/**
|
|
353
|
+
* Whether a shortcut is already claimed — by a built-in, by another plugin, or by the
|
|
354
|
+
* user's own keybindings file. `registerKeybind` silently overwrites, so a plugin that
|
|
355
|
+
* wants to be polite checks first.
|
|
356
|
+
*/
|
|
357
|
+
isKeyBound(key) { return _host.isKeyBound ? !!_host.isKeyBound(key) : false; },
|
|
358
|
+
/** Every currently-claimed shortcut token. */
|
|
359
|
+
get boundKeys() { return _host.boundKeys ? _host.boundKeys() : []; },
|
|
360
|
+
|
|
361
|
+
// ----- Config (runtime read/write) -----
|
|
362
|
+
getConfig(key) { return _host.getConfig ? _host.getConfig(key) : undefined; },
|
|
363
|
+
setConfig(key, val) { if (_host.setConfig) _host.setConfig(key, val); },
|
|
364
|
+
// Persist a config value to disk so it survives restarts. The host does the
|
|
365
|
+
// actual TOML write (plugins must not import ../src). Returns true on success.
|
|
366
|
+
persistConfig(key, val) {
|
|
367
|
+
if (_host.persistConfig) return _host.persistConfig(key, val);
|
|
368
|
+
if (_host.setConfig) { _host.setConfig(key, val); return true; } // best-effort in-memory only
|
|
369
|
+
return false;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
// ----- Filesystem helpers (encoding-aware, error-tolerant) -----
|
|
373
|
+
readFile(file, enc) { try { return fs.readFileSync(file, enc || 'utf8'); } catch { return null; } },
|
|
374
|
+
writeFile(file, data, enc) { try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, data, enc || 'utf8'); return true; } catch { return false; } },
|
|
375
|
+
readJSON(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback !== undefined ? fallback : null; } },
|
|
376
|
+
writeJSON(file, obj) { try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(obj, null, 2), 'utf8'); return true; } catch { return false; } },
|
|
377
|
+
|
|
378
|
+
// ----- Skills -----
|
|
379
|
+
get skills() { try { return listSkills(); } catch { return []; } },
|
|
380
|
+
removeSkill(name) { return deleteSkill(name); },
|
|
381
|
+
|
|
382
|
+
// ----- Keybinds & widgets -----
|
|
383
|
+
registerKeybind(key, fn) {
|
|
384
|
+
// Claim a global shortcut. The key uses the TUI's internal token format:
|
|
385
|
+
// 'c-k' for Ctrl+K, 'c-s-c' for Ctrl+Shift+C, or a plain name like 'f9'.
|
|
386
|
+
// Returns an unregister function.
|
|
387
|
+
if (!key || typeof fn !== 'function') throw new Error('registerKeybind: key and fn are required');
|
|
388
|
+
registeredKeybinds.push({ key: String(key).toLowerCase(), fn });
|
|
389
|
+
return track(() => { const i = registeredKeybinds.findIndex((k) => k.key === String(key).toLowerCase() && k.fn === fn); if (i >= 0) registeredKeybinds.splice(i, 1); });
|
|
390
|
+
},
|
|
391
|
+
registerTuiWidget(fn) {
|
|
392
|
+
if (typeof fn !== 'function') throw new Error('registerTuiWidget: fn is required');
|
|
393
|
+
registeredWidgets.push(fn);
|
|
394
|
+
return track(() => { const i = registeredWidgets.indexOf(fn); if (i >= 0) registeredWidgets.splice(i, 1); });
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
// ----- Patching (behaviour injection into hncode's own code) -----
|
|
398
|
+
// Two target kinds, one method:
|
|
399
|
+
//
|
|
400
|
+
// 1. A SYMBOL target — "file.js#name" or "file.js#Class.method". This wraps
|
|
401
|
+
// the named export/class method in hncode's src/. Works for ANY symbol;
|
|
402
|
+
// the wrapper is installed by rewriting that module's source at load time
|
|
403
|
+
// (see patch.js). The handler is { before?, after?, around? }.
|
|
404
|
+
//
|
|
405
|
+
// 2. A legacy SEAM — one of the nine abstract seams ('systemPrompt',
|
|
406
|
+
// 'llmRequest', …). The handler is the old chain function (ctx, next).
|
|
407
|
+
// Kept so plugins written against the original API keep working.
|
|
408
|
+
//
|
|
409
|
+
// Returns an unregister function.
|
|
410
|
+
patch(target, handler) {
|
|
411
|
+
if (!target) throw new Error('patch: target is required');
|
|
412
|
+
if (LEGACY_SEAMS.has(String(target))) {
|
|
413
|
+
if (typeof handler !== 'function') throw new Error(`patch: seam "${target}" needs a chain function (ctx, next)`);
|
|
414
|
+
if (!registeredPatches[target]) registeredPatches[target] = [];
|
|
415
|
+
registeredPatches[target].push(handler);
|
|
416
|
+
return track(() => { const arr = registeredPatches[target]; if (arr) { const i = arr.indexOf(handler); if (i >= 0) arr.splice(i, 1); if (!arr.length) delete registeredPatches[target]; } });
|
|
417
|
+
}
|
|
418
|
+
// Symbol target: { before, after, around }
|
|
419
|
+
return track(addPatch(String(target), handler));
|
|
420
|
+
},
|
|
421
|
+
get patchSeams() { return [...Object.keys(registeredPatches), ...listPatchTargets()]; },
|
|
422
|
+
|
|
423
|
+
log: pluginLogger,
|
|
424
|
+
|
|
425
|
+
// ----- Introspection -----
|
|
104
426
|
get tools() { return [...registeredTools]; },
|
|
105
427
|
get commands() { return [...registeredCommands]; },
|
|
106
428
|
get hooks() { return { ...registeredHooks }; },
|
|
@@ -113,42 +435,213 @@ const API = {
|
|
|
113
435
|
let _ctx = null;
|
|
114
436
|
export function setPluginContext(ctx) { _ctx = ctx; }
|
|
115
437
|
|
|
438
|
+
// The TUI/host injects its capabilities here so the API above can forward to them.
|
|
439
|
+
let _host = {};
|
|
440
|
+
export function setPluginHost(host) { _host = host || {}; }
|
|
441
|
+
|
|
116
442
|
// ---------------------------------------------------------------------------
|
|
117
443
|
// Plugin loading
|
|
118
444
|
// ---------------------------------------------------------------------------
|
|
119
445
|
|
|
446
|
+
/** Read a list-valued metadata field as a clean array of non-empty strings. */
|
|
447
|
+
function nameList(value) {
|
|
448
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' && value ? [value] : []);
|
|
449
|
+
return arr.map((s) => String(s == null ? '' : s).trim()).filter(Boolean);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Order candidates so every plugin's `inject` names are provided before it runs.
|
|
453
|
+
// This is the dependency graph the plugin system previously did not have: the
|
|
454
|
+
// order was the directory listing, so a plugin that extended another plugin's
|
|
455
|
+
// tool only worked if its name happened to sort later.
|
|
456
|
+
//
|
|
457
|
+
// Returns { ordered, skipped }, where a skipped entry is { candidate, reason }:
|
|
458
|
+
// 'missing' when nobody provides a name the plugin injected, and 'cycle' for the
|
|
459
|
+
// leftovers of the topological sort.
|
|
460
|
+
function orderByInject(candidates) {
|
|
461
|
+
const providerOf = new Map();
|
|
462
|
+
for (const c of candidates) for (const s of c.provides) providerOf.set(s, c);
|
|
463
|
+
|
|
464
|
+
// Iteratively drop whatever can never be satisfied: a name nobody provides, or a
|
|
465
|
+
// name provided by a plugin that has already been dropped. Repeat so a chain of
|
|
466
|
+
// dependents falls with its dependency instead of being reported as a cycle.
|
|
467
|
+
const skipped = [];
|
|
468
|
+
const live = new Set(candidates);
|
|
469
|
+
for (let changed = true; changed;) {
|
|
470
|
+
changed = false;
|
|
471
|
+
for (const c of candidates) {
|
|
472
|
+
if (!live.has(c)) continue;
|
|
473
|
+
const bad = c.inject.find((s) => !providerOf.has(s) || !live.has(providerOf.get(s)));
|
|
474
|
+
if (bad) {
|
|
475
|
+
live.delete(c);
|
|
476
|
+
const by = providerOf.get(bad);
|
|
477
|
+
skipped.push({
|
|
478
|
+
candidate: c,
|
|
479
|
+
reason: 'missing',
|
|
480
|
+
detail: by
|
|
481
|
+
? `"${bad}" was provided by ${by.name}, which did not start`
|
|
482
|
+
: `nothing provides "${bad}" — declare it in the other plugin's \`provides\` export`,
|
|
483
|
+
});
|
|
484
|
+
changed = true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Kahn's algorithm over the survivors. Ties keep the discovery order, so the
|
|
490
|
+
// result is deterministic instead of depending on Map iteration luck.
|
|
491
|
+
const ordered = [];
|
|
492
|
+
const done = new Set();
|
|
493
|
+
const pending = candidates.filter((c) => live.has(c));
|
|
494
|
+
while (pending.length) {
|
|
495
|
+
const i = pending.findIndex((c) => c.inject.every((s) => done.has(s)));
|
|
496
|
+
if (i < 0) {
|
|
497
|
+
// Nothing is ready and the queue is not empty: the rest is a cycle.
|
|
498
|
+
for (const c of pending) skipped.push({ candidate: c, reason: 'cycle', detail: c.inject.join(', ') });
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
const [c] = pending.splice(i, 1);
|
|
502
|
+
for (const s of c.provides) done.add(s);
|
|
503
|
+
ordered.push(c);
|
|
504
|
+
}
|
|
505
|
+
return { ordered, skipped };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Undo everything the currently loaded plugins registered, in reverse load order.
|
|
510
|
+
*
|
|
511
|
+
* Every register* call funnels its unregister function into the plugin's unit, so
|
|
512
|
+
* this normally has nothing to guess. Each plugin's own `dispose(api)` runs FIRST
|
|
513
|
+
* (while its registrations still exist, which is what lets it flush state through
|
|
514
|
+
* them), then the collected unregister functions, then the registries are cleared
|
|
515
|
+
* as a safety net — a plugin that threw mid-install leaves partial registrations
|
|
516
|
+
* that no disposer covers.
|
|
517
|
+
* Returns { unloaded, failed } for the caller to report: `unloaded` are the plugin
|
|
518
|
+
* names that were torn down, `failed` the teardown errors.
|
|
519
|
+
*/
|
|
520
|
+
export async function unloadPlugins() {
|
|
521
|
+
const failed = [];
|
|
522
|
+
const unloaded = [];
|
|
523
|
+
// Reverse order: a plugin built on another is torn down first, so its teardown
|
|
524
|
+
// cannot see a dependency that has already collapsed.
|
|
525
|
+
for (const unit of pluginUnits.slice().reverse()) {
|
|
526
|
+
unloaded.push(unit.name);
|
|
527
|
+
if (typeof unit.disposeFn === 'function') {
|
|
528
|
+
try { await unit.disposeFn(API); }
|
|
529
|
+
catch (e) { failed.push(`${unit.name}: dispose() failed: ${String((e && e.message) || e)}`); }
|
|
530
|
+
}
|
|
531
|
+
// `.slice().reverse()`: a disposer removes itself from the list, and mutating
|
|
532
|
+
// the array being walked would skip the next entry.
|
|
533
|
+
for (const d of unit.disposers.slice().reverse()) {
|
|
534
|
+
try { await d(); }
|
|
535
|
+
catch (e) { failed.push(`${unit.name}: a registered teardown failed: ${String((e && e.message) || e)}`); }
|
|
536
|
+
}
|
|
537
|
+
for (const k of unit.configKeys) delete registeredConfig[k];
|
|
538
|
+
for (const s of unit.services) services.delete(s);
|
|
539
|
+
}
|
|
540
|
+
pluginUnits.length = 0;
|
|
541
|
+
|
|
542
|
+
// The safety net. Plugins registered these through the units above, so this is
|
|
543
|
+
// normally already empty; it exists for a plugin whose install() threw after
|
|
544
|
+
// registering something, which no disposer knows about. It also clears
|
|
545
|
+
// keybinds/widgets, which a reload used to leave behind forever.
|
|
546
|
+
registeredTools.length = 0;
|
|
547
|
+
registeredCommands.length = 0;
|
|
548
|
+
for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
|
|
549
|
+
for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
|
|
550
|
+
for (const k in registeredPatches) delete registeredPatches[k];
|
|
551
|
+
registeredKeybinds.length = 0;
|
|
552
|
+
registeredWidgets.length = 0;
|
|
553
|
+
clearPatches();
|
|
554
|
+
loadedPlugins.length = 0;
|
|
555
|
+
|
|
556
|
+
return { unloaded, failed };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Names of the services plugins have provided. */
|
|
560
|
+
export function listPluginServices() { return [...services.keys()]; }
|
|
561
|
+
|
|
120
562
|
// Discover and load all plugins from the plugin directory.
|
|
121
563
|
// Each plugin is a .js/.mjs file that exports either:
|
|
122
564
|
// - `install(api)` (named export)
|
|
123
565
|
// - or a default export with an `install` method
|
|
124
|
-
|
|
566
|
+
//
|
|
567
|
+
// `opts.fresh` re-imports each file with a cache-busting query so an EDITED plugin
|
|
568
|
+
// takes effect without restarting the process — the ESM cache would otherwise hand
|
|
569
|
+
// back the module instance loaded last time. Used by the reload path only.
|
|
570
|
+
export async function loadPlugins(dir, opts = {}) {
|
|
125
571
|
const pluginDir = dir || PLUGIN_DIR;
|
|
126
572
|
if (!fs.existsSync(pluginDir)) return [];
|
|
127
573
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// and
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
574
|
+
// Node decides a .js file's module type from the NEAREST package.json, searching
|
|
575
|
+
// upward well past the plugin directory. An installed plugin has no package.json
|
|
576
|
+
// of its own, and whatever the search finds higher up cannot be touched, so the
|
|
577
|
+
// loader parses each plugin as CommonJS first, detects module syntax, re-parses it
|
|
578
|
+
// as ESM, and prints MODULE_TYPELESS_PACKAGE_JSON — once per plugin, on every
|
|
579
|
+
// launch, over the screen the TUI has not taken over yet. Plugins are ESM by
|
|
580
|
+
// definition (see the header), so state the type once at the plugin root and the
|
|
581
|
+
// loader stops guessing. An existing package.json is left alone: it may be the
|
|
582
|
+
// author's own, with fields we do not know about.
|
|
583
|
+
const pkgFile = path.join(pluginDir, 'package.json');
|
|
584
|
+
try {
|
|
585
|
+
if (!fs.existsSync(pkgFile)) fs.writeFileSync(pkgFile, '{ "type": "module" }\n');
|
|
586
|
+
} catch { /* read-only plugin dir: the warning is noise, not a failure */ }
|
|
587
|
+
|
|
588
|
+
// Notices belong to THIS run; a second load must not replay the first run's. The
|
|
589
|
+
// wipe comes BEFORE the unload so a failing teardown is reported in this run.
|
|
590
|
+
pendingNotices.length = 0;
|
|
136
591
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
592
|
+
// Tear the previous load down properly instead of just resetting the registries:
|
|
593
|
+
// that is what lets a plugin release a timer or a child process, and what makes
|
|
594
|
+
// `loadPlugins` safe to call on a live process (the reload path).
|
|
595
|
+
const { failed } = await unloadPlugins();
|
|
596
|
+
for (const f of failed) notice('warn', f);
|
|
140
597
|
|
|
141
|
-
|
|
142
|
-
|
|
598
|
+
// Two layouts, because both are in the wild and the install path only produces one:
|
|
599
|
+
// plugins/foo.js — a single-file plugin
|
|
600
|
+
// plugins/foo/index.js — a plugin directory (this is what /plugins install
|
|
601
|
+
// writes, and what a plugin with helper files needs)
|
|
602
|
+
// Only the flat form used to be scanned, so every plugin installed through the
|
|
603
|
+
// manager was written to disk correctly and then silently never loaded.
|
|
604
|
+
const files = [];
|
|
605
|
+
for (const e of fs.readdirSync(pluginDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
606
|
+
if (e.name.startsWith('.')) continue;
|
|
607
|
+
if (e.isFile() && (e.name.endsWith('.js') || e.name.endsWith('.mjs'))) {
|
|
608
|
+
files.push({ id: e.name, full: path.join(pluginDir, e.name) });
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (!e.isDirectory()) continue;
|
|
612
|
+
const dir = path.join(pluginDir, e.name);
|
|
613
|
+
// A directory's entry point: index.js/.mjs, else the first .js/.mjs beside it (a
|
|
614
|
+
// directory may legitimately be named differently from its file).
|
|
615
|
+
let entries = [];
|
|
616
|
+
try { entries = fs.readdirSync(dir); } catch { continue; }
|
|
617
|
+
const entry = ['index.js', 'index.mjs'].find((n) => entries.includes(n))
|
|
618
|
+
|| entries.filter((n) => n.endsWith('.js') || n.endsWith('.mjs')).sort()[0];
|
|
619
|
+
if (!entry) continue;
|
|
620
|
+
files.push({ id: `${e.name}/${entry}`, full: path.join(dir, entry) });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Phase 1: import every file and read its metadata. Imports come first because the
|
|
624
|
+
// dependency order is declared in that metadata — the graph cannot be built before
|
|
625
|
+
// the modules are read.
|
|
626
|
+
const candidates = [];
|
|
627
|
+
for (const { id: f, full } of files) {
|
|
143
628
|
try {
|
|
144
|
-
|
|
629
|
+
// A query string is a DIFFERENT module instance to Node, which is exactly what
|
|
630
|
+
// a hot reload wants. Only on a fresh load: the startup path keeps the plain
|
|
631
|
+
// URL so nothing else can see two instances of a plugin.
|
|
632
|
+
const url = pathToFileURL(full).href + (opts.fresh ? `?v=${++loadSeq}` : '');
|
|
633
|
+
const mod = await import(url);
|
|
634
|
+
// `install(api)` may be the named export or live on the default export.
|
|
145
635
|
let installFn;
|
|
146
636
|
if (mod && typeof mod.install === 'function') installFn = mod.install;
|
|
147
637
|
else if (mod && mod.default && typeof mod.default.install === 'function') installFn = mod.default.install;
|
|
148
638
|
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
639
|
+
if (typeof installFn !== 'function') {
|
|
640
|
+
// Report it to the chat: a file that is present but registers nothing is
|
|
641
|
+
// the most confusing failure of all — the user sees the file on disk and nothing
|
|
642
|
+
// in the UI, with no hint that the export is missing. `notice` feeds the AI
|
|
643
|
+
// output area; no console line, so it does not leak onto the terminal.
|
|
644
|
+
notice('error', `${f}: no install(api) function found — skipped`);
|
|
152
645
|
continue;
|
|
153
646
|
}
|
|
154
647
|
|
|
@@ -161,21 +654,89 @@ export async function loadPlugins(dir) {
|
|
|
161
654
|
const def = (mod && mod.default) || {};
|
|
162
655
|
const defMeta = typeof def === 'string' ? { name: def } : def;
|
|
163
656
|
const namedMeta = (mod && mod.meta) || {};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
|
|
657
|
+
// Fall back to the DIRECTORY name for a directory plugin, so a plugin at
|
|
658
|
+
// plugins/secret-guard/index.js with no metadata shows as `secret-guard`, not
|
|
659
|
+
// `secret-guard/index.js`.
|
|
660
|
+
const fallback = f.includes('/') ? f.split('/')[0] : f.replace(/\.m?js$/, '');
|
|
661
|
+
const name = defMeta.name || defMeta.meta?.name || namedMeta.name || namedMeta.meta?.name || fallback;
|
|
662
|
+
// A plugin MUST declare a version (semver, e.g. "1.0.0"). Omitting it is a
|
|
663
|
+
// hard load failure, not a silent default — an unversioned plugin cannot be
|
|
664
|
+
// told apart from a stale one, and remote update checks depend on it.
|
|
665
|
+
const version = defMeta.version || defMeta.meta?.version || namedMeta.version || namedMeta.meta?.version;
|
|
666
|
+
if (!version || typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version.trim())) {
|
|
667
|
+
notice('error', `${f}: missing or invalid \`version\` (need semver like "1.0.0") — skipped. Add \`export default { name: '${name}', version: '1.0.0' }\`.`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
// dispose() may be a named export or on the default export, like install.
|
|
671
|
+
const disposeFn = typeof mod.dispose === 'function' ? mod.dispose
|
|
672
|
+
: (def && typeof def.dispose === 'function' ? def.dispose : null);
|
|
673
|
+
// provides/inject may be named exports, on `meta`, or on the default export.
|
|
674
|
+
const provides = nameList(mod.provides || defMeta.provides || namedMeta.provides || defMeta.meta?.provides);
|
|
675
|
+
const inject = nameList(mod.inject || defMeta.inject || namedMeta.inject || defMeta.meta?.inject);
|
|
676
|
+
candidates.push({ id: f, name, version: version.trim(), installFn, disposeFn, provides, inject });
|
|
173
677
|
} catch (e) {
|
|
174
|
-
//
|
|
175
|
-
|
|
678
|
+
// Report it BOTH ways. stderr is the developer's channel (a plugin author running
|
|
679
|
+
// The TUI owns the screen, so a load failure must reach the AI output area. `notice`
|
|
680
|
+
// feeds the chat; no console line, so it does not leak onto the terminal.
|
|
681
|
+
const msg = String((e && e.message) || e);
|
|
682
|
+
notice('error', `${f} failed to load: ${msg}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Phase 2: order by `inject`, and report whatever cannot start.
|
|
687
|
+
const { ordered, skipped } = orderByInject(candidates);
|
|
688
|
+
for (const { candidate, reason, detail } of skipped) {
|
|
689
|
+
notice('error', reason === 'cycle'
|
|
690
|
+
? `${candidate.id}: circular \`inject\` dependency (${detail}) — skipped`
|
|
691
|
+
: `${candidate.id}: missing dependency — ${detail}; skipped`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Phase 3: install, dependencies first.
|
|
695
|
+
for (const c of ordered) {
|
|
696
|
+
const unit = {
|
|
697
|
+
id: c.id, name: c.name, version: c.version,
|
|
698
|
+
provides: c.provides, inject: c.inject,
|
|
699
|
+
disposeFn: c.disposeFn,
|
|
700
|
+
disposers: [], configKeys: [], services: new Set(),
|
|
701
|
+
};
|
|
702
|
+
// Set BEFORE install so every register* call inside it is attributed to this
|
|
703
|
+
// plugin, and cleared in `finally` so a throw cannot leave the next plugin's
|
|
704
|
+
// registrations recorded against this one.
|
|
705
|
+
currentUnit = unit;
|
|
706
|
+
try {
|
|
707
|
+
// AWAITED: an async install that registers after an await would otherwise race
|
|
708
|
+
// the next plugin's install, which would break the ordering this function
|
|
709
|
+
// exists to provide — and its registrations would land after the load ended.
|
|
710
|
+
await c.installFn(API);
|
|
711
|
+
pluginUnits.push(unit);
|
|
712
|
+
// install(api) FIRST, then record it. Pushing before the call reported a plugin as
|
|
713
|
+
// loaded even when its install() threw — /plugins would list it under Loaded while
|
|
714
|
+
// none of its tools existed.
|
|
715
|
+
loadedPlugins.push({ id: c.id, name: c.name, version: c.version });
|
|
716
|
+
// The notice goes to the AI output area; no console line, so it does not leak
|
|
717
|
+
// onto the terminal (the TUI owns the screen at this point).
|
|
718
|
+
notice('info', `loaded ${c.name}${c.version && c.version !== '0.0.0' ? ' v' + c.version : ''}`);
|
|
719
|
+
} catch (e) {
|
|
720
|
+
// Undo whatever this plugin managed to register BEFORE it threw, so a broken
|
|
721
|
+
// plugin cannot leave half its tools behind.
|
|
722
|
+
for (const d of unit.disposers.slice().reverse()) { try { await d(); } catch { /* already failing */ } }
|
|
723
|
+
for (const k of unit.configKeys) delete registeredConfig[k];
|
|
724
|
+
for (const s of unit.services) services.delete(s);
|
|
725
|
+
const msg = String((e && e.message) || e);
|
|
726
|
+
notice('error', `${c.id} failed to install: ${msg}`);
|
|
727
|
+
} finally {
|
|
728
|
+
currentUnit = null;
|
|
176
729
|
}
|
|
177
730
|
}
|
|
178
731
|
|
|
732
|
+
// A patch registered on a module that is ALREADY loaded cannot take effect: the
|
|
733
|
+
// load hook only rewrites modules imported after it runs. On a fresh reload that
|
|
734
|
+
// is the normal case for src/*, so say so instead of letting the plugin look like
|
|
735
|
+
// it silently did nothing.
|
|
736
|
+
if (opts.fresh && listPatchTargets().length) {
|
|
737
|
+
notice('warn', `${listPatchTargets().length} patch target(s) active — a patch on a module already loaded this session needs a restart`);
|
|
738
|
+
}
|
|
739
|
+
|
|
179
740
|
return [...loadedPlugins];
|
|
180
741
|
}
|
|
181
742
|
|
|
@@ -183,16 +744,56 @@ export async function loadPlugins(dir) {
|
|
|
183
744
|
// Hook runner — called by the core from the TUI / agent loop.
|
|
184
745
|
// ---------------------------------------------------------------------------
|
|
185
746
|
|
|
747
|
+
|
|
748
|
+
// Run all patches registered for `seam`. `ctx` is mutated by each patch; the final
|
|
749
|
+
// built-in behavior is `base`. Returns whatever the chain resolves to (a patch may
|
|
750
|
+
// short-circuit by returning without calling next).
|
|
751
|
+
export async function runPatch(seam, ctx, base) {
|
|
752
|
+
const arr = registeredPatches[seam];
|
|
753
|
+
if (!arr || !arr.length) return typeof base === 'function' ? await base(ctx) : base;
|
|
754
|
+
let i = -1;
|
|
755
|
+
async function next(c) {
|
|
756
|
+
i++;
|
|
757
|
+
if (i >= arr.length) return typeof base === 'function' ? await base(c) : base;
|
|
758
|
+
return arr[i](c, next);
|
|
759
|
+
}
|
|
760
|
+
return next(ctx);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Synchronous variant of runPatch for hot paths that cannot await (render loop,
|
|
764
|
+
// header/body construction before fetch). Same chain semantics; the last built-in
|
|
765
|
+
// behavior is `base`. Patches that register here must NOT be async or they will be
|
|
766
|
+
// skipped (a sync seam cannot await a Promise).
|
|
767
|
+
export function runPatchSync(seam, ctx, base) {
|
|
768
|
+
const arr = registeredPatches[seam];
|
|
769
|
+
if (!arr || !arr.length) return typeof base === 'function' ? base(ctx) : base;
|
|
770
|
+
let i = -1;
|
|
771
|
+
function next(c) {
|
|
772
|
+
i++;
|
|
773
|
+
if (i >= arr.length) return typeof base === 'function' ? base(c) : base;
|
|
774
|
+
return arr[i](c, next);
|
|
775
|
+
}
|
|
776
|
+
return next(ctx);
|
|
777
|
+
}
|
|
778
|
+
|
|
186
779
|
export async function runHooks(name, ...args) {
|
|
187
780
|
const arr = registeredHooks[name];
|
|
188
781
|
if (!arr || !arr.length) return;
|
|
782
|
+
// Return the last truthy value a hook produced, so an `onToolResult` hook can
|
|
783
|
+
// hand back a (redacted) replacement for the tool's output. Hooks that return
|
|
784
|
+
// nothing leave `ret` undefined; existing hook types ignore the return anyway.
|
|
785
|
+
let ret;
|
|
189
786
|
for (const fn of arr) {
|
|
190
|
-
try {
|
|
787
|
+
try {
|
|
788
|
+
const r = await fn(...args);
|
|
789
|
+
if (r !== undefined) ret = r;
|
|
790
|
+
} catch (e) {
|
|
191
791
|
// Hooks must never crash the agent.
|
|
192
792
|
// eslint-disable-next-line no-console
|
|
193
793
|
console.error(`[hncode-plugin] hook "${name}" error: ${e.message}`);
|
|
194
794
|
}
|
|
195
795
|
}
|
|
796
|
+
return ret;
|
|
196
797
|
}
|
|
197
798
|
|
|
198
799
|
// ---------------------------------------------------------------------------
|
|
@@ -200,6 +801,45 @@ export async function runHooks(name, ...args) {
|
|
|
200
801
|
// ---------------------------------------------------------------------------
|
|
201
802
|
|
|
202
803
|
export { API, registeredTools as pluginTools, registeredCommands as pluginCommands };
|
|
804
|
+
export const pluginPatches = registeredPatches;
|
|
805
|
+
// The plugins that loaded THIS run, as { id, name, version }. Exported for the
|
|
806
|
+
// registry browser's "Loaded" tab; it previously read `API.plugins`, which never
|
|
807
|
+
// existed, so opening /plugins threw "Cannot read properties of undefined" and
|
|
808
|
+
// killed the process.
|
|
809
|
+
export const pluginLoaded = loadedPlugins;
|
|
810
|
+
// Plugin-claimed global keybinds / status widgets. The TUI consumes these directly.
|
|
811
|
+
export const pluginKeybinds = registeredKeybinds;
|
|
812
|
+
export const pluginWidgets = registeredWidgets;
|
|
813
|
+
// Run every matching keybind for a pressed key; returns true if any matched.
|
|
814
|
+
export async function runKeybinds(key) {
|
|
815
|
+
const k = String(key || '').toLowerCase();
|
|
816
|
+
let hit = false;
|
|
817
|
+
for (const { key: kk, fn } of registeredKeybinds) {
|
|
818
|
+
if (kk === k) { try { await fn(); hit = true; } catch (e) { console.error(`[hncode-plugin] keybind ${kk} error: ${e.message}`); } }
|
|
819
|
+
}
|
|
820
|
+
return hit;
|
|
821
|
+
}
|
|
822
|
+
// Synchronous keybind dispatch for the TUI's sync input handler. Handlers that
|
|
823
|
+
// return a Promise are still reported as handled but run in the background.
|
|
824
|
+
export function runKeybindsSync(key) {
|
|
825
|
+
const k = String(key || '').toLowerCase();
|
|
826
|
+
let hit = false;
|
|
827
|
+
for (const { key: kk, fn } of registeredKeybinds) {
|
|
828
|
+
if (kk === k) {
|
|
829
|
+
try { fn(); hit = true; } catch (e) { console.error(`[hncode-plugin] keybind ${kk} error: ${e.message}`); }
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return hit;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Render all plugin widgets into a single status-bar fragment.
|
|
836
|
+
export function renderWidgets() {
|
|
837
|
+
const parts = [];
|
|
838
|
+
for (const fn of registeredWidgets) {
|
|
839
|
+
try { const s = fn(); if (s) parts.push(String(s)); } catch (e) { /* a bad widget must not break the bar */ }
|
|
840
|
+
}
|
|
841
|
+
return parts.join(' ');
|
|
842
|
+
}
|
|
203
843
|
// Plugin-provided config DEFAULTS. resolveConfig() must merge these BEFORE
|
|
204
844
|
// user config so a plugin can supply a value the user has not set. Previously
|
|
205
845
|
// registerConfig() stored into this object and nothing ever read it.
|
|
@@ -212,5 +852,13 @@ export function _resetPlugins() {
|
|
|
212
852
|
for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
|
|
213
853
|
for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
|
|
214
854
|
loadedPlugins.length = 0;
|
|
855
|
+
registeredKeybinds.length = 0;
|
|
856
|
+
registeredWidgets.length = 0;
|
|
857
|
+
for (const k in registeredPatches) delete registeredPatches[k];
|
|
858
|
+
clearPatches();
|
|
859
|
+
pendingNotices.length = 0;
|
|
860
|
+
pluginUnits.length = 0;
|
|
861
|
+
services.clear();
|
|
862
|
+
currentUnit = null;
|
|
215
863
|
_ctx = null;
|
|
216
864
|
}
|