@hncode/hncode 0.4.2 → 0.5.1

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 +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  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 +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -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/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -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,37 @@ 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
+ }
42
97
 
43
98
  // ---------------------------------------------------------------------------
44
99
  // Plugin notices — what loaded, what failed, and anything a plugin reports.
@@ -75,13 +130,29 @@ function fmtArgs(args) {
75
130
  * A console-like object handed to `install(api)` as `api.log`, so a plugin can report
76
131
  * without knowing anything about the UI. Everything reaches the chat AND mirrors to the
77
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.
78
140
  */
79
- export const pluginLogger = {
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({
80
151
  log: (...a) => { notice('info', fmtArgs(a)); console.log('[hncode-plugin]', ...a); },
81
152
  info: (...a) => { notice('info', fmtArgs(a)); console.log('[hncode-plugin]', ...a); },
82
153
  warn: (...a) => { notice('warn', fmtArgs(a)); console.warn('[hncode-plugin]', ...a); },
83
154
  error: (...a) => { notice('error', fmtArgs(a)); console.error('[hncode-plugin]', ...a); },
84
- };
155
+ });
85
156
 
86
157
  const API = {
87
158
  // Register a new tool (same shape as built-in tool specs).
@@ -94,10 +165,10 @@ const API = {
94
165
  throw new Error(`registerTool: tool "${spec.name}" is already registered`);
95
166
  }
96
167
  registeredTools.push(spec);
97
- return () => {
168
+ return track(() => {
98
169
  const i = registeredTools.findIndex((t) => t.name === spec.name);
99
170
  if (i >= 0) registeredTools.splice(i, 1);
100
- };
171
+ });
101
172
  },
102
173
 
103
174
  // Register a slash-command.
@@ -111,81 +182,419 @@ const API = {
111
182
  throw new Error(`registerCommand: command "/${cmd.name}" is already registered`);
112
183
  }
113
184
  registeredCommands.push(cmd);
114
- return () => {
185
+ return track(() => {
115
186
  const i = registeredCommands.findIndex((c) => c.name === cmd.name);
116
187
  if (i >= 0) registeredCommands.splice(i, 1);
117
- };
188
+ });
118
189
  },
119
190
 
120
191
  // Register a lifecycle hook. Supported hook names:
121
- // onTurnStart, onTurnEnd, onBeforeRequest, onAfterRequest,
122
- // 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.
123
197
  registerHook(name, fn) {
124
198
  if (!name || typeof fn !== 'function') {
125
199
  throw new Error('registerHook: name and fn are required');
126
200
  }
127
201
  (registeredHooks[name] = registeredHooks[name] || []).push(fn);
128
- return () => {
202
+ return track(() => {
129
203
  const arr = registeredHooks[name];
130
204
  if (arr) {
131
205
  const i = arr.indexOf(fn);
132
206
  if (i >= 0) arr.splice(i, 1);
133
207
  }
134
- };
208
+ });
135
209
  },
136
210
 
137
211
  // Merge default config values. These are applied in resolveConfig after
138
- // 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.
139
215
  registerConfig(defaults) {
216
+ const keys = Object.keys(defaults || {});
140
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);
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);
141
275
  },
142
276
 
277
+
143
278
  // Runtime helpers.
144
279
  get ctx() { return _ctx; },
145
280
 
146
- // 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 -----
147
426
  get tools() { return [...registeredTools]; },
148
427
  get commands() { return [...registeredCommands]; },
149
428
  get hooks() { return { ...registeredHooks }; },
150
429
  get config() { return { ...registeredConfig }; },
151
430
  get plugins() { return [...loadedPlugins]; },
152
-
153
- // Reporting. `api.log.info('…')` reaches the CHAT, which is the only channel a user
154
- // can actually see while the TUI owns the screen; it also mirrors to the terminal for
155
- // a developer watching it. See pluginLogger.
156
- log: pluginLogger,
157
431
  };
158
432
 
159
- // The agent context is injected lazily; plugins that need it read api.ctx
160
-
161
433
  // The agent context is injected lazily; plugins that need it read api.ctx
162
434
  // during their hook callbacks.
163
435
  let _ctx = null;
164
436
  export function setPluginContext(ctx) { _ctx = ctx; }
165
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
+
166
442
  // ---------------------------------------------------------------------------
167
443
  // Plugin loading
168
444
  // ---------------------------------------------------------------------------
169
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
+
170
562
  // Discover and load all plugins from the plugin directory.
171
563
  // Each plugin is a .js/.mjs file that exports either:
172
564
  // - `install(api)` (named export)
173
565
  // - or a default export with an `install` method
174
- 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 = {}) {
175
571
  const pluginDir = dir || PLUGIN_DIR;
176
572
  if (!fs.existsSync(pluginDir)) return [];
177
573
 
178
- // loadPlugins() may be called more than once (tests, hot reload). Reset the
179
- // registries first so a second load cannot throw "tool already registered"
180
- // and accumulate stale hooks/config over reloads.
181
- registeredTools.length = 0;
182
- registeredCommands.length = 0;
183
- for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
184
- for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
185
- loadedPlugins.length = 0;
186
- // Notices belong to THIS run too; a second load must not replay the first run's.
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.
187
590
  pendingNotices.length = 0;
188
591
 
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);
597
+
189
598
  // Two layouts, because both are in the wild and the install path only produces one:
190
599
  // plugins/foo.js — a single-file plugin
191
600
  // plugins/foo/index.js — a plugin directory (this is what /plugins install
@@ -211,21 +620,28 @@ export async function loadPlugins(dir) {
211
620
  files.push({ id: `${e.name}/${entry}`, full: path.join(dir, entry) });
212
621
  }
213
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 = [];
214
627
  for (const { id: f, full } of files) {
215
628
  try {
216
- 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);
217
634
  // `install(api)` may be the named export or live on the default export.
218
635
  let installFn;
219
636
  if (mod && typeof mod.install === 'function') installFn = mod.install;
220
637
  else if (mod && mod.default && typeof mod.default.install === 'function') installFn = mod.default.install;
221
638
 
222
639
  if (typeof installFn !== 'function') {
223
- // Report it to the chat as well: a file that is present but registers nothing is
640
+ // Report it to the chat: a file that is present but registers nothing is
224
641
  // the most confusing failure of all — the user sees the file on disk and nothing
225
- // in the UI, with no hint that the export is missing.
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.
226
644
  notice('error', `${f}: no install(api) function found — skipped`);
227
- // eslint-disable-next-line no-console
228
- console.error(`[hncode-plugin] ${f}: no install(api) function found, skipped.`);
229
645
  continue;
230
646
  }
231
647
 
@@ -242,34 +658,85 @@ if (typeof installFn !== 'function') {
242
658
  // plugins/secret-guard/index.js with no metadata shows as `secret-guard`, not
243
659
  // `secret-guard/index.js`.
244
660
  const fallback = f.includes('/') ? f.split('/')[0] : f.replace(/\.m?js$/, '');
245
- const plugin = {
246
- id: f,
247
- name: defMeta.name || defMeta.meta?.name || namedMeta.name || namedMeta.meta?.name || fallback,
248
- version: defMeta.version || defMeta.meta?.version || namedMeta.version || namedMeta.meta?.version || '0.0.0',
249
- };
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 });
677
+ } catch (e) {
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);
250
712
  // install(api) FIRST, then record it. Pushing before the call reported a plugin as
251
713
  // loaded even when its install() threw — /plugins would list it under Loaded while
252
714
  // none of its tools existed.
253
- installFn(API);
254
- loadedPlugins.push(plugin);
255
- // The notice goes to the chat; the console line stays for a developer watching a
256
- // terminal, matching the failure path below.
257
- notice('info', `loaded ${plugin.name}${plugin.version && plugin.version !== '0.0.0' ? ' v' + plugin.version : ''}`);
258
- // eslint-disable-next-line no-console
259
- console.log(`[hncode-plugin] loaded: ${plugin.name}`);
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 : ''}`);
260
719
  } catch (e) {
261
- // Report it BOTH ways. stderr is the developer's channel (a plugin author running
262
- // hncode from a terminal), but the TUI owns the screen, so a user who installs a
263
- // plugin through /plugins never sees it — their plugin simply does not appear and
264
- // nothing says why. `notice` feeds the chat; the console line stays for a developer
265
- // watching a terminal.
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);
266
725
  const msg = String((e && e.message) || e);
267
- notice('error', `${f} failed to load: ${msg}`);
268
- // eslint-disable-next-line no-console
269
- console.error(`[hncode-plugin] ${f}: ${msg}`);
726
+ notice('error', `${c.id} failed to install: ${msg}`);
727
+ } finally {
728
+ currentUnit = null;
270
729
  }
271
730
  }
272
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
+
273
740
  return [...loadedPlugins];
274
741
  }
275
742
 
@@ -277,16 +744,56 @@ if (typeof installFn !== 'function') {
277
744
  // Hook runner — called by the core from the TUI / agent loop.
278
745
  // ---------------------------------------------------------------------------
279
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
+
280
779
  export async function runHooks(name, ...args) {
281
780
  const arr = registeredHooks[name];
282
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;
283
786
  for (const fn of arr) {
284
- try { await fn(...args); } catch (e) {
787
+ try {
788
+ const r = await fn(...args);
789
+ if (r !== undefined) ret = r;
790
+ } catch (e) {
285
791
  // Hooks must never crash the agent.
286
792
  // eslint-disable-next-line no-console
287
793
  console.error(`[hncode-plugin] hook "${name}" error: ${e.message}`);
288
794
  }
289
795
  }
796
+ return ret;
290
797
  }
291
798
 
292
799
  // ---------------------------------------------------------------------------
@@ -294,6 +801,45 @@ export async function runHooks(name, ...args) {
294
801
  // ---------------------------------------------------------------------------
295
802
 
296
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
+ }
297
843
  // Plugin-provided config DEFAULTS. resolveConfig() must merge these BEFORE
298
844
  // user config so a plugin can supply a value the user has not set. Previously
299
845
  // registerConfig() stored into this object and nothing ever read it.
@@ -306,6 +852,13 @@ export function _resetPlugins() {
306
852
  for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
307
853
  for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
308
854
  loadedPlugins.length = 0;
855
+ registeredKeybinds.length = 0;
856
+ registeredWidgets.length = 0;
857
+ for (const k in registeredPatches) delete registeredPatches[k];
858
+ clearPatches();
309
859
  pendingNotices.length = 0;
860
+ pluginUnits.length = 0;
861
+ services.clear();
862
+ currentUnit = null;
310
863
  _ctx = null;
311
864
  }