@hncode/hncode 0.5.1 → 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.
Files changed (60) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +2 -2
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +279 -48
  6. package/src/cache.js +175 -130
  7. package/src/cell-buffer.js +5 -0
  8. package/src/clipboard.js +111 -8
  9. package/src/context-bar.js +46 -5
  10. package/src/doctor.js +7 -2
  11. package/src/git.js +35 -4
  12. package/src/index.js +29 -5
  13. package/src/keybindings.js +25 -3
  14. package/src/llm.js +60 -6
  15. package/src/mcp.js +419 -384
  16. package/src/permissions.js +29 -7
  17. package/src/plugin.js +95 -10
  18. package/src/registry-browser.js +21 -6
  19. package/src/remote.js +307 -289
  20. package/src/session-core.js +203 -0
  21. package/src/session-host.js +213 -0
  22. package/src/session-view.js +110 -0
  23. package/src/settings-schema.js +12 -9
  24. package/src/shell-safety.js +610 -473
  25. package/src/swarm-progress.js +348 -293
  26. package/src/tasks-browser.js +4 -1
  27. package/src/team/constants.js +20 -0
  28. package/src/team/context.js +89 -0
  29. package/src/team/mailbox.js +216 -0
  30. package/src/team/permission.js +121 -0
  31. package/src/team/protocol.js +167 -0
  32. package/src/team/runner.js +462 -0
  33. package/src/team/spawn.js +305 -0
  34. package/src/team/store.js +172 -0
  35. package/src/team/tasks.js +177 -0
  36. package/src/team/team.js +346 -0
  37. package/src/team-panel.js +624 -0
  38. package/src/term-caps.js +457 -410
  39. package/src/term.js +162 -19
  40. package/src/tool-result.js +70 -7
  41. package/src/tools/agent-swarm.js +114 -11
  42. package/src/tools/agent.js +464 -386
  43. package/src/tools/bash.js +53 -14
  44. package/src/tools/diagnostics.js +13 -0
  45. package/src/tools/edit.js +428 -50
  46. package/src/tools/git.js +15 -3
  47. package/src/tools/glob.js +5 -3
  48. package/src/tools/grep.js +19 -5
  49. package/src/tools/ignore.js +17 -3
  50. package/src/tools/index.js +19 -3
  51. package/src/tools/read.js +2 -1
  52. package/src/tools/shell.js +145 -134
  53. package/src/tools/tasks.js +214 -141
  54. package/src/tools/team.js +411 -0
  55. package/src/tools/utils.js +12 -3
  56. package/src/tools/web-search.js +53 -40
  57. package/src/tui.js +7496 -5366
  58. package/src/update.js +320 -115
  59. package/src/web-daemon.js +27 -4
  60. package/src/web-live.js +250 -0
@@ -6,6 +6,10 @@
6
6
  // and loses the prompt for genuinely dangerous commands too. A rule set lets the
7
7
  // routine work through while the rest keeps asking.
8
8
  //
9
+ // A `Bash(...)` prefix rule is matched against every segment of the line, not the
10
+ // line as a whole, so allowing one command cannot smuggle in a second one.
11
+ import { splitSegments } from './shell-safety.js';
12
+
9
13
  // Rule syntax (a subset of Claude Code's, chosen to stay readable):
10
14
  // Bash — every Bash call
11
15
  // Bash(npm run test) — that exact command
@@ -76,17 +80,35 @@ export function ruleMatches(parsed, toolName, args) {
76
80
  const a = args || {};
77
81
  // Bash: the rule names a command. A trailing `*` means "starts with"; otherwise the
78
82
  // command must match exactly, so `Bash(rm -rf /)` cannot be widened by an argument.
83
+ //
84
+ // A prefix rule applies to EVERY segment, not just the line as a whole. Comparing
85
+ // the whole string made `Bash(npm test *)` allow `npm test && rm -rf /build`,
86
+ // because the line does start with the prefix — the second command was never
87
+ // looked at. `splitSegments` is the same splitter the destructive-command check
88
+ // uses, so `;`, `|`, `&`, `&&` and `||` all separate here too.
79
89
  if (toolName === 'Bash') {
80
- const cmd = String(a.command || a.cmd || '').trim();
81
- if (parsed.arg.endsWith('*')) {
82
- const prefix = parsed.arg.slice(0, -1).trim();
83
- return cmd === prefix || cmd.startsWith(prefix + ' ');
90
+ // A rule compares WORDS, so runs of whitespace collapse before comparing. Shell words are
91
+ // separated by one or more blanks and the count means nothing, so `git status` and
92
+ // `git status` are the same command — but a raw string comparison treated them as different
93
+ // and silently skipped a rule the user had written, which is the "it still prompts even
94
+ // though I allowed it" report.
95
+ const squash = (s) => String(s).replace(/\s+/g, ' ').trim();
96
+ const cmd = squash(a.command || a.cmd || '');
97
+ const rule = squash(parsed.arg);
98
+ if (rule.endsWith('*')) {
99
+ const prefix = squash(rule.slice(0, -1));
100
+ const { segments } = splitSegments(cmd);
101
+ const parts = (segments.length ? segments : [cmd]).map(squash).filter(Boolean);
102
+ // Every segment must be covered. An empty command still falls back to the whole-string
103
+ // comparison rather than matching anything.
104
+ if (!parts.length) return cmd === prefix;
105
+ return parts.every((p) => p === prefix || p.startsWith(prefix + ' '));
84
106
  }
85
- return cmd === parsed.arg;
107
+ return cmd === rule;
86
108
  }
87
109
 
88
- // Path-taking tools: match any of the tool's path-ish arguments against the glob,
89
- // so `Read(./src/**)` works whether the model sent `path` or `file_path`.
110
+ // Path-taking tools: match any of the tool's path-ish arguments against the glob, so
111
+ // `Read(./src/**)` works whether the model sent `path` or `file_path`.
90
112
  const names = PATH_ARGS[toolName] || ['path', 'file_path', 'pattern'];
91
113
  for (const n of names) {
92
114
  const v = a[n];
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;
@@ -126,15 +126,30 @@ export function rowsForTab(state, tabId) {
126
126
  }));
127
127
  }
128
128
  if (tabId === 'ondisk') {
129
- const loadedNames = new Set((d.loaded || []).map((p) => p.name));
129
+ // Match on the DIRECTORY a plugin was loaded from, not on the name it reports.
130
+ //
131
+ // A plugin declares its own `name`, and it need not match the directory it sits in. Comparing
132
+ // names meant any plugin where the two differed showed "Need Restart" forever: the name it
133
+ // reported could never equal the directory it was found under, so restarting never helped.
134
+ // `computer-use/` declaring `hncode-cu` is the ordinary case, not an oddity.
135
+ //
136
+ // `loaded` records each plugin with an `id` of `<dirname>/<entry>` while `onDisk` reports only a
137
+ // bare `name` (the directory, or the filename for a single-file plugin). The first segment of
138
+ // the id is therefore the thing to compare, and it covers both layouts: a directory's id starts
139
+ // with its directory name, and a single file's id IS its filename.
140
+ const loadedDirs = new Set();
141
+ const loadedNames = new Set();
142
+ for (const p of d.loaded || []) {
143
+ if (p.id) loadedDirs.add(String(p.id).split('/')[0]);
144
+ if (p.name) loadedNames.add(String(p.name));
145
+ }
146
+ const isLoaded = (p) => loadedDirs.has(String(p.name)) || loadedNames.has(String(p.name));
130
147
  return (d.onDisk || []).map((p) => ({
131
148
  name: p.name,
132
149
  label: p.name + (p.kind === 'dir' ? '/' : ''),
133
- description: loadedNames.has(p.name)
134
- ? 'loaded in this session'
135
- : 'not loaded — Need Restart',
136
- status: loadedNames.has(p.name) ? 'loaded' : 'Need Restart',
137
- statusTone: loadedNames.has(p.name) ? 'ok' : 'warn',
150
+ description: isLoaded(p) ? 'loaded in this session' : 'not loaded — Need Restart',
151
+ status: isLoaded(p) ? 'loaded' : 'Need Restart',
152
+ statusTone: isLoaded(p) ? 'ok' : 'warn',
138
153
  action: 'remove-plugin',
139
154
  }));
140
155
  }