@hncode/hncode 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.js CHANGED
@@ -146,14 +146,69 @@ function makeLogger(sink) {
146
146
  fn.error = (...a) => sink.error(...a);
147
147
  return fn;
148
148
  }
149
+ /**
150
+ * Where a plugin's `log`/`warn`/`error` goes.
151
+ *
152
+ * TWO DESTINATIONS, NEVER BOTH AT ONCE.
153
+ *
154
+ * The notice queue is what the TUI drains onto its own transcript, so in the TUI those are the messages the
155
+ * user sees. `console.*` was called ALONGSIDE it, and that is wrong whenever the TUI is running: the TUI
156
+ * owns stdout and paints the whole screen itself, so a raw `console.log` lands wherever the cursor happens
157
+ * to be — mid-frame, on top of the composer, wherever. It showed up most visibly after `/reload`, because
158
+ * that is when every plugin re-runs its load-time logging in one burst.
159
+ *
160
+ * Outside the TUI there is no screen to paint and no queue being drained, so the console IS the right
161
+ * destination — headless runs and tests rely on it.
162
+ *
163
+ * `tuiActive` is set by the TUI once it takes over the screen, and cleared on exit.
164
+ */
165
+ let tuiActive = false;
166
+
167
+ /** Tell the plugin logger whether the TUI currently owns stdout. */
168
+ export function setPluginLoggerTui(active) {
169
+ tuiActive = !!active;
170
+ }
171
+
172
+ /**
173
+ * Route `console.*` to the notice queue for the duration of a function, and restore it afterwards.
174
+ *
175
+ * Plugin code runs inside `import()` and `install(api)`, where `console.log` is the natural thing to write.
176
+ * While the TUI owns stdout those writes land on the painted frame, so they are captured and re-emitted as
177
+ * notices instead — the same channel plugin errors already use.
178
+ *
179
+ * Returns the restore function; it MUST be called (a `finally` in every caller), or every later console
180
+ * write in the process would be swallowed.
181
+ */
182
+ function capturePluginConsole() {
183
+ const saved = { log: console.log, info: console.info, warn: console.warn, error: console.error };
184
+ const route = (level) => (...a) => notice(level, fmtArgs(a));
185
+ console.log = route('info');
186
+ console.info = route('info');
187
+ console.warn = route('warn');
188
+ console.error = route('error');
189
+ return () => {
190
+ console.log = saved.log;
191
+ console.info = saved.info;
192
+ console.warn = saved.warn;
193
+ console.error = saved.error;
194
+ };
195
+ }
149
196
 
150
197
  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); },
198
+ log: (...a) => { notice('info', fmtArgs(a)); if (!tuiActive) console.log('[hncode-plugin]', ...a); },
199
+ info: (...a) => { notice('info', fmtArgs(a)); if (!tuiActive) console.log('[hncode-plugin]', ...a); },
200
+ warn: (...a) => { notice('warn', fmtArgs(a)); if (!tuiActive) console.warn('[hncode-plugin]', ...a); },
201
+ error: (...a) => { notice('error', fmtArgs(a)); if (!tuiActive) console.error('[hncode-plugin]', ...a); },
155
202
  });
156
203
 
204
+ // Registration GENERATION: bumped whenever the tool surface changes (a tool
205
+ // registers or unregisters, plugins unload). The toolbelt's by-name map in
206
+ // tools/index.js keys off this, so a background MCP connect or a /reload is
207
+ // visible to `getTool` immediately instead of serving stale entries forever.
208
+ let _toolGeneration = 0;
209
+ function bumpToolGeneration() { _toolGeneration++; }
210
+ export function pluginToolGeneration() { return _toolGeneration; }
211
+
157
212
  const API = {
158
213
  // Register a new tool (same shape as built-in tool specs).
159
214
  // Returns an unregister function.
@@ -165,9 +220,11 @@ const API = {
165
220
  throw new Error(`registerTool: tool "${spec.name}" is already registered`);
166
221
  }
167
222
  registeredTools.push(spec);
223
+ bumpToolGeneration();
168
224
  return track(() => {
169
225
  const i = registeredTools.findIndex((t) => t.name === spec.name);
170
226
  if (i >= 0) registeredTools.splice(i, 1);
227
+ bumpToolGeneration();
171
228
  });
172
229
  },
173
230
 
@@ -545,6 +602,7 @@ export async function unloadPlugins() {
545
602
  // keybinds/widgets, which a reload used to leave behind forever.
546
603
  registeredTools.length = 0;
547
604
  registeredCommands.length = 0;
605
+ bumpToolGeneration();
548
606
  for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
549
607
  for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
550
608
  for (const k in registeredPatches) delete registeredPatches[k];
@@ -568,7 +626,10 @@ export function listPluginServices() { return [...services.keys()]; }
568
626
  // takes effect without restarting the process — the ESM cache would otherwise hand
569
627
  // back the module instance loaded last time. Used by the reload path only.
570
628
  export async function loadPlugins(dir, opts = {}) {
571
- const pluginDir = dir || PLUGIN_DIR;
629
+ // Falls back to the user's plugin directory. `PLUGIN_DIR` was referenced here
630
+ // but never defined — any loadPlugins() without a dir (the /reload path when
631
+ // no pluginDir is configured) died with a ReferenceError.
632
+ const pluginDir = dir || pluginsDir();
572
633
  if (!fs.existsSync(pluginDir)) return [];
573
634
 
574
635
  // Node decides a .js file's module type from the NEAREST package.json, searching
@@ -630,7 +691,23 @@ export async function loadPlugins(dir, opts = {}) {
630
691
  // a hot reload wants. Only on a fresh load: the startup path keeps the plain
631
692
  // URL so nothing else can see two instances of a plugin.
632
693
  const url = pathToFileURL(full).href + (opts.fresh ? `?v=${++loadSeq}` : '');
633
- const mod = await import(url);
694
+ // A PLUGIN'S OWN console.log MUST NOT REACH THE SCREEN.
695
+ //
696
+ // The import below runs the plugin's top-level code, and `install(api)` runs more of it. Both are
697
+ // plugin author territory, where `console.log` is the first thing anyone reaches for — and while the
698
+ // TUI owns stdout, that write lands wherever the cursor happens to be, on top of the frame. It was
699
+ // most visible after `/reload`, which re-runs every plugin's load-time logging in one burst.
700
+ //
701
+ // Redirected to `pluginLogger`, which queues a notice the TUI already renders in its own transcript.
702
+ // The real console is restored immediately afterwards, so this covers exactly the window where
703
+ // plugin code runs and nothing else.
704
+ const restoreConsole = capturePluginConsole();
705
+ let mod;
706
+ try {
707
+ mod = await import(url);
708
+ } finally {
709
+ restoreConsole();
710
+ }
634
711
  // `install(api)` may be the named export or live on the default export.
635
712
  let installFn;
636
713
  if (mod && typeof mod.install === 'function') installFn = mod.install;
@@ -704,10 +781,17 @@ if (typeof installFn !== 'function') {
704
781
  // registrations recorded against this one.
705
782
  currentUnit = unit;
706
783
  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);
784
+ // AWAITED: an async install that registers after an await would otherwise race the next plugin's
785
+ // install, which would break the ordering this function exists to provide.
786
+ //
787
+ // The console is redirected for the duration: `install(api)` is plugin code, and it is where most
788
+ // plugins do their logging.
789
+ const restoreInstallConsole = capturePluginConsole();
790
+ try {
791
+ await c.installFn(API);
792
+ } finally {
793
+ restoreInstallConsole();
794
+ }
711
795
  pluginUnits.push(unit);
712
796
  // install(api) FIRST, then record it. Pushing before the call reported a plugin as
713
797
  // loaded even when its install() threw — /plugins would list it under Loaded while
@@ -849,6 +933,7 @@ export { registeredConfig as pluginConfigDefaults };
849
933
  export function _resetPlugins() {
850
934
  registeredTools.length = 0;
851
935
  registeredCommands.length = 0;
936
+ bumpToolGeneration();
852
937
  for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
853
938
  for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
854
939
  loadedPlugins.length = 0;