@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.
Files changed (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. 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
- // Default plugin directory.
31
- const PLUGIN_DIR = path.join(os.homedir(), '.hncode', 'plugins');
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
- // onTurnStart, onTurnEnd, onBeforeRequest, onAfterRequest,
79
- // onToolExecute, onToolResult, onNewMessage
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
- // Introspection.
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
- export async function loadPlugins(dir) {
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
- // loadPlugins() may be called more than once (tests, hot reload). Reset the
129
- // registries first so a second load cannot throw "tool already registered"
130
- // and accumulate stale hooks/config over reloads.
131
- registeredTools.length = 0;
132
- registeredCommands.length = 0;
133
- for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
134
- for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
135
- loadedPlugins.length = 0;
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
- const files = fs.readdirSync(pluginDir)
138
- .filter((f) => f.endsWith('.js') || f.endsWith('.mjs'))
139
- .sort();
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
- for (const f of files) {
142
- const full = path.join(pluginDir, f);
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
- const mod = await import(pathToFileURL(full).href);
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
- if (typeof installFn !== 'function') {
150
- // eslint-disable-next-line no-console
151
- console.error(`[hncode-plugin] ${f}: no install(api) function found, skipped.`);
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
- const plugin = {
165
- id: f,
166
- name: defMeta.name || defMeta.meta?.name || namedMeta.name || namedMeta.meta?.name || f,
167
- version: defMeta.version || defMeta.meta?.version || namedMeta.version || namedMeta.meta?.version || '0.0.0',
168
- };
169
- loadedPlugins.push(plugin);
170
- installFn(API);
171
- // eslint-disable-next-line no-console
172
- console.log(`[hncode-plugin] loaded: ${plugin.name}`);
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
- // eslint-disable-next-line no-console
175
- console.error(`[hncode-plugin] ${f}: ${e.message}`);
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 { await fn(...args); } catch (e) {
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
  }