@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.
- package/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/permissions.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 ===
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
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;
|
package/src/registry-browser.js
CHANGED
|
@@ -126,15 +126,30 @@ export function rowsForTab(state, tabId) {
|
|
|
126
126
|
}));
|
|
127
127
|
}
|
|
128
128
|
if (tabId === 'ondisk') {
|
|
129
|
-
|
|
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:
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|