@moxxy/core 0.4.0 → 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.
- package/dist/events/log.d.ts.map +1 -1
- package/dist/events/log.js +38 -1
- package/dist/events/log.js.map +1 -1
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/engine.js +80 -17
- package/dist/permissions/engine.js.map +1 -1
- package/dist/plugins/discovery.d.ts.map +1 -1
- package/dist/plugins/discovery.js +48 -7
- package/dist/plugins/discovery.js.map +1 -1
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +54 -11
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/lifecycle.d.ts.map +1 -1
- package/dist/plugins/lifecycle.js +7 -0
- package/dist/plugins/lifecycle.js.map +1 -1
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +34 -8
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +21 -2
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +3 -1
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/plugins/toposort.d.ts.map +1 -1
- package/dist/plugins/toposort.js +43 -18
- package/dist/plugins/toposort.js.map +1 -1
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +5 -4
- package/dist/preferences.js.map +1 -1
- package/dist/registries/active-backend-registry.d.ts.map +1 -1
- package/dist/registries/active-backend-registry.js +10 -1
- package/dist/registries/active-backend-registry.js.map +1 -1
- package/dist/registries/channels.d.ts +2 -1
- package/dist/registries/channels.d.ts.map +1 -1
- package/dist/registries/channels.js +30 -20
- package/dist/registries/channels.js.map +1 -1
- package/dist/registries/commands.d.ts.map +1 -1
- package/dist/registries/commands.js +27 -8
- package/dist/registries/commands.js.map +1 -1
- package/dist/registries/isolators.d.ts +27 -1
- package/dist/registries/isolators.d.ts.map +1 -1
- package/dist/registries/isolators.js +35 -1
- package/dist/registries/isolators.js.map +1 -1
- package/dist/registries/modes.d.ts +4 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +15 -4
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/providers.d.ts +8 -0
- package/dist/registries/providers.d.ts.map +1 -1
- package/dist/registries/providers.js +8 -0
- package/dist/registries/providers.js.map +1 -1
- package/dist/registries/skills.d.ts +9 -0
- package/dist/registries/skills.d.ts.map +1 -1
- package/dist/registries/skills.js +22 -0
- package/dist/registries/skills.js.map +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js +7 -1
- package/dist/requirements.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +65 -17
- package/dist/run-turn.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +25 -3
- package/dist/session.js.map +1 -1
- package/dist/sessions/persistence.d.ts +15 -0
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -13
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +11 -2
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +87 -36
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/events.js +33 -2
- package/dist/subagents/events.js.map +1 -1
- package/dist/subagents/registry.d.ts +22 -1
- package/dist/subagents/registry.d.ts.map +1 -1
- package/dist/subagents/registry.js +72 -2
- package/dist/subagents/registry.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +138 -46
- package/dist/subagents/run-child.js.map +1 -1
- package/dist/surfaces/host.d.ts +10 -0
- package/dist/surfaces/host.d.ts.map +1 -1
- package/dist/surfaces/host.js +41 -3
- package/dist/surfaces/host.js.map +1 -1
- package/dist/usage-stats.d.ts +7 -1
- package/dist/usage-stats.d.ts.map +1 -1
- package/dist/usage-stats.js +41 -15
- package/dist/usage-stats.js.map +1 -1
- package/dist/view/parse.d.ts.map +1 -1
- package/dist/view/parse.js +119 -19
- package/dist/view/parse.js.map +1 -1
- package/package.json +4 -4
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/permissions/engine.test.ts +60 -0
- package/src/permissions/engine.ts +81 -16
- package/src/plugins/discovery.test.ts +59 -1
- package/src/plugins/discovery.ts +57 -9
- package/src/plugins/host.test.ts +40 -0
- package/src/plugins/host.ts +48 -10
- package/src/plugins/lifecycle.test.ts +29 -0
- package/src/plugins/lifecycle.ts +7 -0
- package/src/plugins/loader.ts +45 -7
- package/src/plugins/registry-kinds.test.ts +53 -0
- package/src/plugins/registry-kinds.ts +25 -3
- package/src/plugins/toposort.ts +45 -17
- package/src/preferences.test.ts +52 -1
- package/src/preferences.ts +5 -4
- package/src/registries/active-backend-registry.ts +10 -1
- package/src/registries/channels.test.ts +79 -0
- package/src/registries/channels.ts +33 -18
- package/src/registries/commands.test.ts +65 -0
- package/src/registries/commands.ts +25 -8
- package/src/registries/isolators.test.ts +37 -0
- package/src/registries/isolators.ts +48 -1
- package/src/registries/modes.test.ts +21 -1
- package/src/registries/modes.ts +15 -2
- package/src/registries/providers.ts +8 -0
- package/src/registries/semantics.test.ts +33 -1
- package/src/registries/skills.ts +28 -0
- package/src/registries/transcribers.test.ts +13 -0
- package/src/requirements.test.ts +35 -0
- package/src/requirements.ts +7 -1
- package/src/run-turn.test.ts +219 -0
- package/src/run-turn.ts +68 -18
- package/src/session.test.ts +24 -0
- package/src/session.ts +24 -3
- package/src/sessions/page.test.ts +17 -0
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -30
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +125 -2
- package/src/subagents/run-child.ts +150 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- package/src/view/parse.ts +113 -18
package/src/plugins/lifecycle.ts
CHANGED
|
@@ -112,6 +112,13 @@ export class HookDispatcherImpl implements HookDispatcher {
|
|
|
112
112
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
113
|
try {
|
|
114
114
|
const p = Promise.resolve(fn() as Promise<T | undefined | void>);
|
|
115
|
+
// If the timeout wins the race, `p` is still pending and unobserved; a
|
|
116
|
+
// slow hook that later rejects would surface as an unhandledRejection
|
|
117
|
+
// (process-level crash/log spam in strict configs / the desktop main).
|
|
118
|
+
// Attach a no-op handler so a post-timeout rejection is swallowed too.
|
|
119
|
+
// (Promise.race still settles on the original `p`, so this doesn't mask
|
|
120
|
+
// the in-time error path below — that one is observed via the race.)
|
|
121
|
+
p.catch(() => undefined);
|
|
115
122
|
const timeout = new Promise<never>((_, reject) => {
|
|
116
123
|
timer = setTimeout(
|
|
117
124
|
() => reject(new Error(`Hook ${hook} on ${entry.plugin.name} timed out`)),
|
package/src/plugins/loader.ts
CHANGED
|
@@ -5,20 +5,42 @@ import type { PluginLoader } from './host.js';
|
|
|
5
5
|
|
|
6
6
|
export interface JitiLoaderOptions {
|
|
7
7
|
readonly cwd: string;
|
|
8
|
+
/**
|
|
9
|
+
* When provided, its return value is appended as a `?v=` query to the ESM
|
|
10
|
+
* import URL so `import()` re-evaluates the module. Supplying this LEAKS a
|
|
11
|
+
* module instance per call — ESM modules can't be unloaded, so every distinct
|
|
12
|
+
* URL is retained for the process lifetime. Provide it ONLY for an explicit
|
|
13
|
+
* hot-reload of a changed `.js`/`.mjs` plugin; omit it for normal loads so a
|
|
14
|
+
* long-lived runner that reloads repeatedly doesn't grow memory without
|
|
15
|
+
* bound. (`.ts` plugins go through jiti, which has its own cache control.)
|
|
16
|
+
*/
|
|
8
17
|
readonly cacheBust?: () => string;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
// Key the jiti cache by cwd: createPluginLoader is called per-session with the
|
|
21
|
+
// session's cwd, so a single process-global instance rooted at the FIRST cwd
|
|
22
|
+
// would resolve every later session's .ts plugins (and jiti's transform cache)
|
|
23
|
+
// against the wrong base directory — non-deterministically by session order.
|
|
24
|
+
// One jiti per distinct plugin root is bounded and correct.
|
|
25
|
+
const jitiByCwd = new Map<string, ((id: string) => unknown) | null>();
|
|
12
26
|
|
|
13
27
|
async function getJiti(cwd: string): Promise<((id: string) => unknown) | null> {
|
|
14
|
-
|
|
28
|
+
const cached = jitiByCwd.get(cwd);
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
15
30
|
try {
|
|
16
31
|
const mod = await import('jiti');
|
|
17
32
|
const factory = (mod as { createJiti?: (cwd: string, opts?: unknown) => (id: string) => unknown; default?: (cwd: string, opts?: unknown) => (id: string) => unknown }).createJiti ?? (mod as { default?: (cwd: string, opts?: unknown) => (id: string) => unknown }).default;
|
|
18
|
-
if (!factory)
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
if (!factory) {
|
|
34
|
+
jitiByCwd.set(cwd, null);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const instance = factory(cwd, { interopDefault: true });
|
|
38
|
+
jitiByCwd.set(cwd, instance);
|
|
39
|
+
return instance;
|
|
21
40
|
} catch {
|
|
41
|
+
// Don't cache a transient import failure as a permanent null — a missing
|
|
42
|
+
// optional `jiti` dep is the only realistic cause and is stable, but not
|
|
43
|
+
// caching here keeps a one-off failure from poisoning all later loads.
|
|
22
44
|
return null;
|
|
23
45
|
}
|
|
24
46
|
}
|
|
@@ -27,6 +49,17 @@ export function createPluginLoader(opts: JitiLoaderOptions): PluginLoader {
|
|
|
27
49
|
return {
|
|
28
50
|
async load(manifest: ResolvedPluginManifest): Promise<Plugin> {
|
|
29
51
|
const entry = path.resolve(manifest.packagePath, manifest.entry);
|
|
52
|
+
// `manifest.entry` comes from the plugin's own package.json (validated only
|
|
53
|
+
// as a non-empty string). Reject an entry that escapes the package dir
|
|
54
|
+
// (e.g. `../../sibling/internals.js` or an absolute path) so a package can
|
|
55
|
+
// only execute code from inside its own tree — cheap defense-in-depth for
|
|
56
|
+
// provenance/auditing and any future sandbox-by-path assumptions.
|
|
57
|
+
const rel = path.relative(manifest.packagePath, entry);
|
|
58
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Plugin entry escapes its package directory: ${manifest.entry} (package: ${manifest.packagePath})`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
30
63
|
const isTs = entry.endsWith('.ts') || entry.endsWith('.tsx');
|
|
31
64
|
|
|
32
65
|
let mod: unknown;
|
|
@@ -39,8 +72,13 @@ export function createPluginLoader(opts: JitiLoaderOptions): PluginLoader {
|
|
|
39
72
|
}
|
|
40
73
|
mod = jiti(entry);
|
|
41
74
|
} else {
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
// Only cache-bust on an explicit request (caller supplied cacheBust).
|
|
76
|
+
// Busting unconditionally appended a unique `?v=` on EVERY load, and
|
|
77
|
+
// since ESM never unloads a module, each load permanently retained a
|
|
78
|
+
// fresh copy of the plugin + its transitive graph — an unbounded leak
|
|
79
|
+
// across repeated reloads. The plain URL lets ESM reuse its cache.
|
|
80
|
+
const bust = opts.cacheBust?.();
|
|
81
|
+
const url = bust ? `${pathToFileURL(entry).href}?v=${bust}` : pathToFileURL(entry).href;
|
|
44
82
|
mod = await import(url);
|
|
45
83
|
}
|
|
46
84
|
|
|
@@ -159,4 +159,57 @@ describe('PluginHost register/unregister lockstep (REGISTRY_KINDS)', () => {
|
|
|
159
159
|
expect(byKind[kind.kind]!.list().length, `${kind.kind} should be empty after unload`).toBe(0);
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
it('unloading a discovered plugin whose isolator was REFUSED keeps the trusted builtin', async () => {
|
|
164
|
+
const { host, byKind } = makeHost();
|
|
165
|
+
const isolators = byKind.isolator as IsolatorRegistry;
|
|
166
|
+
// A trusted builtin owns the name "worker".
|
|
167
|
+
const trustedWorker: Isolator = { name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) };
|
|
168
|
+
host.registerStatic(definePlugin({ name: 'builtin-sec', isolators: [trustedWorker] }));
|
|
169
|
+
expect(isolators.get('worker')).toBe(trustedWorker);
|
|
170
|
+
|
|
171
|
+
// A discovered (untrusted) plugin tries to shadow "worker"; the registry
|
|
172
|
+
// refuses, so the plugin never owned it. Unloading the discovered plugin
|
|
173
|
+
// must NOT delete the trusted impl.
|
|
174
|
+
const rogueWorker: Isolator = { name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) };
|
|
175
|
+
host.registerDiscovered(
|
|
176
|
+
definePlugin({ name: 'rogue', isolators: [rogueWorker] }),
|
|
177
|
+
{ entry: './index.js', packageName: '@rogue/pkg', packageVersion: '1.0.0', packagePath: '/tmp/rogue' },
|
|
178
|
+
);
|
|
179
|
+
expect(isolators.get('worker')).toBe(trustedWorker); // refused, not shadowed
|
|
180
|
+
|
|
181
|
+
await host.unload('@rogue/pkg');
|
|
182
|
+
// The trusted worker must survive the rogue plugin's unload.
|
|
183
|
+
expect(isolators.get('worker')).toBe(trustedWorker);
|
|
184
|
+
expect(isolators.has('worker')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a mid-load collision in a discovered plugin whose isolator was REFUSED does not delete the trusted builtin', () => {
|
|
188
|
+
const { host, byKind } = makeHost();
|
|
189
|
+
const isolators = byKind.isolator as IsolatorRegistry;
|
|
190
|
+
const tools = byKind.tool as ToolRegistryImpl;
|
|
191
|
+
const trustedWorker: Isolator = { name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) };
|
|
192
|
+
host.registerStatic(definePlugin({ name: 'builtin-sec', isolators: [trustedWorker] }));
|
|
193
|
+
// Another plugin owns tool "dup".
|
|
194
|
+
host.registerStatic(
|
|
195
|
+
definePlugin({ name: 'first', tools: [defineTool({ name: 'dup', description: '', inputSchema: z.any(), handler: () => null })] }),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// A discovered plugin: its isolator "worker" is refused (untrusted shadow),
|
|
199
|
+
// then its tool "dup" collides → applyPlugin throws and rolls back. The
|
|
200
|
+
// rollback must NOT unregister "worker" (the plugin never owned it).
|
|
201
|
+
const rogue = definePlugin({
|
|
202
|
+
name: 'rogue',
|
|
203
|
+
isolators: [{ name: 'worker', strength: 'none', run: async (_c, h) => h(undefined) } as Isolator],
|
|
204
|
+
tools: [defineTool({ name: 'dup', description: '', inputSchema: z.any(), handler: () => null })],
|
|
205
|
+
});
|
|
206
|
+
expect(() =>
|
|
207
|
+
host.registerDiscovered(rogue, { entry: './index.js', packageName: '@rogue/pkg', packageVersion: '1.0.0', packagePath: '/tmp/rogue' }),
|
|
208
|
+
).toThrow(/already registered/);
|
|
209
|
+
// Trusted worker untouched; first plugin's tool untouched.
|
|
210
|
+
expect(isolators.get('worker')).toBe(trustedWorker);
|
|
211
|
+
expect(tools.has('dup')).toBe(true);
|
|
212
|
+
// No half-loaded record for the rogue.
|
|
213
|
+
expect(host.list().map((p) => p.name).sort()).toEqual(['builtin-sec', 'first']);
|
|
214
|
+
});
|
|
162
215
|
});
|
|
@@ -64,10 +64,29 @@ export interface RegistryKind<TDef> {
|
|
|
64
64
|
readonly defs: (plugin: Plugin) => ReadonlyArray<TDef>;
|
|
65
65
|
/** Extract the registry key from a def (mostly `.name`; surfaces use `.kind`). */
|
|
66
66
|
readonly nameOf: (def: TDef) => string;
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Register a single def into its registry (uses `register` or `replace`).
|
|
69
|
+
* `trusted` is true for statically-registered builtins and false for
|
|
70
|
+
* discovered (untrusted) plugins; most kinds ignore it, but the isolator
|
|
71
|
+
* registry uses it to refuse letting a discovered plugin shadow a builtin.
|
|
72
|
+
*
|
|
73
|
+
* Returns `false` when the registration was REFUSED without taking effect
|
|
74
|
+
* (currently only the isolator registry, when an untrusted plugin tries to
|
|
75
|
+
* shadow a trusted name). PluginHost must NOT track a refused registration
|
|
76
|
+
* for rollback — unregistering it on a later mid-`applyPlugin` failure would
|
|
77
|
+
* delete a name this plugin never actually owned. `void`/`true` means applied.
|
|
78
|
+
*/
|
|
79
|
+
readonly register: (opts: PluginHostOptions, def: TDef, trusted: boolean) => void | boolean;
|
|
69
80
|
/** Remove a previously-registered name from its registry. */
|
|
70
81
|
readonly unregister: (opts: PluginHostOptions, name: string) => void;
|
|
82
|
+
/**
|
|
83
|
+
* True when `register` is an override (`replace`) rather than a throw-on-
|
|
84
|
+
* duplicate insert. Such kinds are last-wins and may clobber a name seeded by
|
|
85
|
+
* core or another plugin, so PluginHost must NOT roll them back when a later
|
|
86
|
+
* kind throws mid-`applyPlugin` (unregistering would delete a def this plugin
|
|
87
|
+
* didn't actually own).
|
|
88
|
+
*/
|
|
89
|
+
readonly overrideOnRegister?: boolean;
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
/**
|
|
@@ -124,6 +143,7 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
|
|
|
124
143
|
// viewRenderers use `replace` (override-allowed) on the register side.
|
|
125
144
|
register: (o, v: ViewRendererDef) => o.viewRenderers.replace(v),
|
|
126
145
|
unregister: (o, n) => o.viewRenderers.unregister(n),
|
|
146
|
+
overrideOnRegister: true,
|
|
127
147
|
} satisfies RegistryKind<ViewRendererDef>,
|
|
128
148
|
{
|
|
129
149
|
kind: 'tunnelProvider',
|
|
@@ -133,6 +153,7 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
|
|
|
133
153
|
// tunnelProviders use `replace` (override-allowed) on the register side.
|
|
134
154
|
register: (o, t: TunnelProviderDef) => o.tunnelProviders.replace(t),
|
|
135
155
|
unregister: (o, n) => o.tunnelProviders.unregister(n),
|
|
156
|
+
overrideOnRegister: true,
|
|
136
157
|
} satisfies RegistryKind<TunnelProviderDef>,
|
|
137
158
|
{
|
|
138
159
|
kind: 'channel',
|
|
@@ -196,7 +217,8 @@ export const REGISTRY_KINDS: ReadonlyArray<RegistryKind<unknown>> = [
|
|
|
196
217
|
recordField: 'isolatorNames',
|
|
197
218
|
defs: (p) => p.isolators ?? [],
|
|
198
219
|
nameOf: (i: Isolator) => i.name,
|
|
199
|
-
register: (o, i: Isolator) =>
|
|
220
|
+
register: (o, i: Isolator, trusted): boolean =>
|
|
221
|
+
o.isolators.register(i, { trusted, logger: o.logger }),
|
|
200
222
|
unregister: (o, n) => o.isolators.unregister(n),
|
|
201
223
|
} satisfies RegistryKind<Isolator>,
|
|
202
224
|
{
|
package/src/plugins/toposort.ts
CHANGED
|
@@ -33,27 +33,55 @@ export function toposortPluginManifests(
|
|
|
33
33
|
const order: ResolvedPluginManifest[] = [];
|
|
34
34
|
const visited = new Set<string>();
|
|
35
35
|
const onStack = new Set<string>();
|
|
36
|
-
|
|
36
|
+
// Path of names from the current root, mirroring the old recursion stack so a
|
|
37
|
+
// detected cycle reports the same participating names.
|
|
38
|
+
const path: string[] = [];
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
40
|
+
// Explicit-stack iterative DFS (instead of recursion) so a pathological deep
|
|
41
|
+
// linear dependency chain — depth is plugin-author/package-controlled via
|
|
42
|
+
// package.json#moxxy.requirements — can't overflow the call stack and turn a
|
|
43
|
+
// load-time ordering concern into a process crash.
|
|
44
|
+
type Frame = { name: string; deps: ReadonlyArray<string>; next: number };
|
|
45
|
+
|
|
46
|
+
const visitRoot = (root: string): void => {
|
|
47
|
+
if (visited.has(root)) return;
|
|
48
|
+
const rootManifest = byPackage.get(root);
|
|
49
|
+
if (!rootManifest) return; // unknown dep — leave for readiness gate
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
stack.
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
const stack: Frame[] = [{ name: root, deps: pluginDeps(rootManifest.requirements), next: 0 }];
|
|
52
|
+
onStack.add(root);
|
|
53
|
+
path.push(root);
|
|
54
|
+
|
|
55
|
+
while (stack.length > 0) {
|
|
56
|
+
const frame = stack[stack.length - 1];
|
|
57
|
+
if (!frame) break;
|
|
58
|
+
if (frame.next < frame.deps.length) {
|
|
59
|
+
const dep = frame.deps[frame.next++];
|
|
60
|
+
if (dep === undefined) continue;
|
|
61
|
+
if (visited.has(dep)) continue;
|
|
62
|
+
if (onStack.has(dep)) {
|
|
63
|
+
const startIdx = path.indexOf(dep);
|
|
64
|
+
throw new PluginCycleError(path.slice(startIdx).concat(dep));
|
|
65
|
+
}
|
|
66
|
+
const depManifest = byPackage.get(dep);
|
|
67
|
+
if (!depManifest) continue; // unknown dep — leave for readiness gate
|
|
68
|
+
onStack.add(dep);
|
|
69
|
+
path.push(dep);
|
|
70
|
+
stack.push({ name: dep, deps: pluginDeps(depManifest.requirements), next: 0 });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// All deps emitted → post-order emit this frame, same as the recursive
|
|
74
|
+
// version pushed after its dependency loop.
|
|
75
|
+
const done = stack.pop()!;
|
|
76
|
+
onStack.delete(done.name);
|
|
77
|
+
path.pop();
|
|
78
|
+
visited.add(done.name);
|
|
79
|
+
const manifest = byPackage.get(done.name);
|
|
80
|
+
if (manifest) order.push(manifest);
|
|
81
|
+
}
|
|
54
82
|
};
|
|
55
83
|
|
|
56
|
-
for (const m of manifests)
|
|
84
|
+
for (const m of manifests) visitRoot(m.packageName);
|
|
57
85
|
return order;
|
|
58
86
|
}
|
|
59
87
|
|
package/src/preferences.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
@@ -10,13 +10,18 @@ import { loadPreferences, preferencesPath, savePreferences } from './preferences
|
|
|
10
10
|
let tmpHome: string;
|
|
11
11
|
let savedHome: string | undefined;
|
|
12
12
|
let savedUserProfile: string | undefined;
|
|
13
|
+
let savedMoxxyHome: string | undefined;
|
|
13
14
|
|
|
14
15
|
beforeEach(async () => {
|
|
15
16
|
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-'));
|
|
16
17
|
savedHome = process.env.HOME;
|
|
17
18
|
savedUserProfile = process.env.USERPROFILE;
|
|
19
|
+
savedMoxxyHome = process.env.MOXXY_HOME;
|
|
18
20
|
process.env.HOME = tmpHome;
|
|
19
21
|
process.env.USERPROFILE = tmpHome;
|
|
22
|
+
// The default-home tests below assert the `~/.moxxy` fallback, so MOXXY_HOME
|
|
23
|
+
// must be unset for them (it would otherwise win).
|
|
24
|
+
delete process.env.MOXXY_HOME;
|
|
20
25
|
await fs.mkdir(path.join(tmpHome, '.moxxy'), { recursive: true });
|
|
21
26
|
});
|
|
22
27
|
|
|
@@ -25,6 +30,8 @@ afterEach(async () => {
|
|
|
25
30
|
else process.env.HOME = savedHome;
|
|
26
31
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
27
32
|
else process.env.USERPROFILE = savedUserProfile;
|
|
33
|
+
if (savedMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
34
|
+
else process.env.MOXXY_HOME = savedMoxxyHome;
|
|
28
35
|
await fs.rm(tmpHome, { recursive: true, force: true });
|
|
29
36
|
});
|
|
30
37
|
|
|
@@ -36,6 +43,28 @@ describe('preferences store', () => {
|
|
|
36
43
|
expect(preferencesPath()).toBe(path.join(tmpHome, '.moxxy', 'preferences.json'));
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
it('honors $MOXXY_HOME so preferences follow the relocated data dir', async () => {
|
|
47
|
+
const altHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-alt-'));
|
|
48
|
+
const prevMoxxyHome = process.env.MOXXY_HOME;
|
|
49
|
+
process.env.MOXXY_HOME = altHome;
|
|
50
|
+
try {
|
|
51
|
+
// Path now lives directly under MOXXY_HOME, NOT under ~/.moxxy.
|
|
52
|
+
expect(preferencesPath()).toBe(path.join(altHome, 'preferences.json'));
|
|
53
|
+
await savePreferences({ model: 'relocated' });
|
|
54
|
+
// Written to the relocated dir, not the homedir fallback.
|
|
55
|
+
const raw = await fs.readFile(path.join(altHome, 'preferences.json'), 'utf8');
|
|
56
|
+
expect(JSON.parse(raw).model).toBe('relocated');
|
|
57
|
+
// The homedir fallback location must remain empty.
|
|
58
|
+
await expect(
|
|
59
|
+
fs.readFile(path.join(tmpHome, '.moxxy', 'preferences.json'), 'utf8'),
|
|
60
|
+
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
61
|
+
} finally {
|
|
62
|
+
if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
63
|
+
else process.env.MOXXY_HOME = prevMoxxyHome;
|
|
64
|
+
await fs.rm(altHome, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
39
68
|
it('returns an empty object when the file is missing', async () => {
|
|
40
69
|
expect(await loadPreferences()).toEqual({});
|
|
41
70
|
});
|
|
@@ -90,6 +119,28 @@ describe('preferences store', () => {
|
|
|
90
119
|
expect(loaded.mode).toBe('goal');
|
|
91
120
|
});
|
|
92
121
|
|
|
122
|
+
it('swallows a write failure: logs to stderr but never throws (best-effort contract)', async () => {
|
|
123
|
+
// savePreferences is documented best-effort — a persist failure must NOT
|
|
124
|
+
// bubble out and break the slash-command / shutdown that triggered it. Force
|
|
125
|
+
// an unwritable target by pointing MOXXY_HOME *under a regular file*, so the
|
|
126
|
+
// atomic writer's `mkdir(dirname)` fails with ENOTDIR.
|
|
127
|
+
const blocker = path.join(tmpHome, 'not-a-dir');
|
|
128
|
+
await fs.writeFile(blocker, 'x', 'utf8');
|
|
129
|
+
const prevMoxxyHome = process.env.MOXXY_HOME;
|
|
130
|
+
process.env.MOXXY_HOME = path.join(blocker, 'nested'); // dirname is a file → mkdir fails
|
|
131
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
132
|
+
try {
|
|
133
|
+
// Must resolve, not reject — the pick still took effect in-session.
|
|
134
|
+
await expect(savePreferences({ model: 'm' })).resolves.toBeUndefined();
|
|
135
|
+
// The failure is surfaced (not silently swallowed) on stderr.
|
|
136
|
+
expect(stderr).toHaveBeenCalled();
|
|
137
|
+
} finally {
|
|
138
|
+
stderr.mockRestore();
|
|
139
|
+
if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
140
|
+
else process.env.MOXXY_HOME = prevMoxxyHome;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
93
144
|
it('keeps ALL distinct keys present under many overlapping writers', async () => {
|
|
94
145
|
const patches: Array<Partial<Record<`k${number}`, string>>> = [];
|
|
95
146
|
for (let i = 0; i < 25; i++) patches.push({ [`k${i}`]: `v${i}` });
|
package/src/preferences.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
2
|
import { createMutex, migrateModeName } from '@moxxy/sdk';
|
|
5
|
-
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
3
|
+
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
6
|
* User-level runtime preferences persisted at ~/.moxxy/preferences.json.
|
|
@@ -28,7 +26,10 @@ export interface MoxxyPreferences {
|
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export function preferencesPath(): string {
|
|
31
|
-
|
|
29
|
+
// Route through `moxxyPath` so a `$MOXXY_HOME` override relocates preferences
|
|
30
|
+
// alongside the rest of the data dir (config.yaml, vault, providers.json, …).
|
|
31
|
+
// Identical to `~/.moxxy/preferences.json` when MOXXY_HOME is unset.
|
|
32
|
+
return moxxyPath('preferences.json');
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -55,7 +55,16 @@ export class ActiveBackendRegistry<TDef extends { name: string }, TInstance> {
|
|
|
55
55
|
replace(def: TDef, instance?: TInstance): void {
|
|
56
56
|
this.defs.set(def.name, def);
|
|
57
57
|
this.instances.delete(def.name);
|
|
58
|
-
if (instance)
|
|
58
|
+
if (instance) {
|
|
59
|
+
this.instances.set(def.name, instance);
|
|
60
|
+
} else if (this.active === def.name && !this.opts.buildOnRead) {
|
|
61
|
+
// For a non-buildOnRead registry, getActive() reads the cached instance
|
|
62
|
+
// directly and throws when it's missing. Dropping the active def's cache
|
|
63
|
+
// (e.g. a hot-reloaded plugin re-registering via replace) would otherwise
|
|
64
|
+
// strand getActive() until the next setActive. buildOnRead registries
|
|
65
|
+
// self-heal on read, so they don't need this.
|
|
66
|
+
this.instances.set(def.name, this.buildInstance(def.name));
|
|
67
|
+
}
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
unregister(name: string): void {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ChannelDef, ChannelFactoryDeps } from '@moxxy/sdk';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ChannelRegistryImpl } from './channels.js';
|
|
4
|
+
|
|
5
|
+
const deps = {} as ChannelFactoryDeps;
|
|
6
|
+
|
|
7
|
+
function fakeChannel(
|
|
8
|
+
name: string,
|
|
9
|
+
isAvailable?: ChannelDef['isAvailable'],
|
|
10
|
+
): ChannelDef {
|
|
11
|
+
return {
|
|
12
|
+
name,
|
|
13
|
+
description: `channel ${name}`,
|
|
14
|
+
create: () => ({}) as never,
|
|
15
|
+
...(isAvailable ? { isAvailable } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ChannelRegistryImpl.listWithAvailability', () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('treats a channel without isAvailable as ok and maps a thrown probe to not-ok', async () => {
|
|
25
|
+
const reg = new ChannelRegistryImpl();
|
|
26
|
+
reg.register(fakeChannel('always'));
|
|
27
|
+
reg.register(
|
|
28
|
+
fakeChannel('throws', async () => {
|
|
29
|
+
throw new Error('boom');
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
const out = await reg.listWithAvailability(deps);
|
|
33
|
+
const byName = Object.fromEntries(out.map((o) => [o.def.name, o.availability]));
|
|
34
|
+
expect(byName.always).toEqual({ ok: true });
|
|
35
|
+
expect(byName.throws).toEqual({ ok: false, reason: 'boom' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not let one hung probe block the others or the listing (bounded timeout)', async () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
const reg = new ChannelRegistryImpl();
|
|
41
|
+
// This probe never resolves — without a timeout it would wedge the listing.
|
|
42
|
+
reg.register(fakeChannel('hung', () => new Promise<never>(() => {})));
|
|
43
|
+
reg.register(fakeChannel('fast', async () => ({ ok: true })));
|
|
44
|
+
|
|
45
|
+
const promise = reg.listWithAvailability(deps);
|
|
46
|
+
// Advance past the probe timeout so the hung probe resolves to not-ok.
|
|
47
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
48
|
+
const out = await promise;
|
|
49
|
+
const byName = Object.fromEntries(out.map((o) => [o.def.name, o.availability]));
|
|
50
|
+
expect(byName.fast).toEqual({ ok: true });
|
|
51
|
+
expect(byName.hung.ok).toBe(false);
|
|
52
|
+
expect(byName.hung.reason).toMatch(/timed out/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('runs probes in parallel — a slow probe does not starve a later fast one', async () => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
const reg = new ChannelRegistryImpl();
|
|
58
|
+
let fastRan = false;
|
|
59
|
+
reg.register(
|
|
60
|
+
fakeChannel(
|
|
61
|
+
'slow',
|
|
62
|
+
() => new Promise<{ ok: true }>((resolve) => setTimeout(() => resolve({ ok: true }), 2_000)),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
reg.register(
|
|
66
|
+
fakeChannel('fast', async () => {
|
|
67
|
+
fastRan = true;
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const promise = reg.listWithAvailability(deps);
|
|
72
|
+
// The fast probe should already have started (parallel), before the slow
|
|
73
|
+
// one's timer is even advanced.
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
expect(fastRan).toBe(true);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
77
|
+
await promise;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -6,11 +6,17 @@ import type {
|
|
|
6
6
|
} from '@moxxy/sdk';
|
|
7
7
|
import { DefMapRegistry } from './def-map-registry.js';
|
|
8
8
|
|
|
9
|
+
/** Upper bound on a single channel availability probe. `isAvailable` hooks can
|
|
10
|
+
* do network/process probes (tunnel reachability, binary presence); a hung one
|
|
11
|
+
* (dead socket, no DNS) must not block the whole listing — which typically
|
|
12
|
+
* backs a UI picker — indefinitely. */
|
|
13
|
+
const AVAILABILITY_PROBE_TIMEOUT_MS = 4_000;
|
|
14
|
+
|
|
9
15
|
/**
|
|
10
16
|
* Flat name→def registry of channels ({@link DefMapRegistry}) plus
|
|
11
17
|
* `listWithAvailability`, which pairs each channel with its current
|
|
12
18
|
* availability (treating channels without an `isAvailable` hook as
|
|
13
|
-
* `{ ok: true }` and mapping a thrown probe to `{ ok: false }`).
|
|
19
|
+
* `{ ok: true }` and mapping a thrown OR timed-out probe to `{ ok: false }`).
|
|
14
20
|
*/
|
|
15
21
|
export class ChannelRegistryImpl extends DefMapRegistry<ChannelDef> implements ChannelRegistry {
|
|
16
22
|
constructor() {
|
|
@@ -20,23 +26,32 @@ export class ChannelRegistryImpl extends DefMapRegistry<ChannelDef> implements C
|
|
|
20
26
|
async listWithAvailability(
|
|
21
27
|
deps: ChannelFactoryDeps,
|
|
22
28
|
): Promise<ReadonlyArray<{ def: ChannelDef; availability: ChannelAvailability }>> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
// Probe in parallel — a serial loop meant one slow probe also starved every
|
|
30
|
+
// fast probe queued behind it — and bound each probe with a timeout so a
|
|
31
|
+
// single hung channel can't wedge the whole listing.
|
|
32
|
+
return Promise.all(
|
|
33
|
+
[...this.defs.values()].map(async (def) => ({
|
|
34
|
+
def,
|
|
35
|
+
availability: await this.probe(def, deps),
|
|
36
|
+
})),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async probe(def: ChannelDef, deps: ChannelFactoryDeps): Promise<ChannelAvailability> {
|
|
41
|
+
if (!def.isAvailable) return { ok: true };
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
try {
|
|
44
|
+
const timeout = new Promise<ChannelAvailability>((resolve) => {
|
|
45
|
+
timer = setTimeout(
|
|
46
|
+
() => resolve({ ok: false, reason: 'availability check timed out' }),
|
|
47
|
+
AVAILABILITY_PROBE_TIMEOUT_MS,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
return await Promise.race([Promise.resolve(def.isAvailable(deps)), timeout]);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
53
|
+
} finally {
|
|
54
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
39
55
|
}
|
|
40
|
-
return out;
|
|
41
56
|
}
|
|
42
57
|
}
|
|
@@ -58,6 +58,71 @@ describe('CommandRegistry', () => {
|
|
|
58
58
|
expect(reg.get('m')).toBeUndefined();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it('replace() refuses to hijack an alias owned by a DIFFERENT command', () => {
|
|
62
|
+
const reg = new CommandRegistry();
|
|
63
|
+
reg.register(fakeCommand('compact', { aliases: ['c'] }));
|
|
64
|
+
reg.register(fakeCommand('clear'));
|
|
65
|
+
// Replacing /clear with an alias 'c' already owned by /compact must throw,
|
|
66
|
+
// not silently steal the alias (register() guards this; replace() now too).
|
|
67
|
+
expect(() => reg.replace(fakeCommand('clear', { aliases: ['c'] }))).toThrow(
|
|
68
|
+
/alias already in use/,
|
|
69
|
+
);
|
|
70
|
+
// /compact still owns 'c'.
|
|
71
|
+
expect(reg.get('c')?.name).toBe('compact');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('replace() refuses an alias that collides with another command primary name', () => {
|
|
75
|
+
const reg = new CommandRegistry();
|
|
76
|
+
reg.register(fakeCommand('compact'));
|
|
77
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
78
|
+
// Re-defining /mode with an alias equal to another command's PRIMARY name
|
|
79
|
+
// would make get() ambiguous — reject it.
|
|
80
|
+
expect(() => reg.replace(fakeCommand('mode', { aliases: ['compact'] }))).toThrow(
|
|
81
|
+
/already in use/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('replace() still allows re-adding the command own primary name as an alias edge case is fine', () => {
|
|
86
|
+
const reg = new CommandRegistry();
|
|
87
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
88
|
+
// Replacing with the SAME alias set must not throw on the alias it already
|
|
89
|
+
// owns (cleared-then-readded).
|
|
90
|
+
expect(() => reg.replace(fakeCommand('mode', { aliases: ['m'], description: 'x' }))).not.toThrow();
|
|
91
|
+
expect(reg.get('m')?.name).toBe('mode');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('register() leaves NO partial state when a later alias collides', () => {
|
|
95
|
+
const reg = new CommandRegistry();
|
|
96
|
+
reg.register(fakeCommand('compact', { aliases: ['cc'] }));
|
|
97
|
+
// The new command's FIRST alias is free, its SECOND ('cc') collides. The
|
|
98
|
+
// registration must throw atomically: neither the command nor its first
|
|
99
|
+
// alias may be left behind.
|
|
100
|
+
expect(() =>
|
|
101
|
+
reg.register(fakeCommand('clear', { aliases: ['fresh', 'cc'] })),
|
|
102
|
+
).toThrow(/alias already in use/);
|
|
103
|
+
expect(reg.has('clear')).toBe(false);
|
|
104
|
+
expect(reg.get('fresh')).toBeUndefined();
|
|
105
|
+
// The first command is untouched.
|
|
106
|
+
expect(reg.get('cc')?.name).toBe('compact');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('replace() leaves the PRIOR definition intact when a new alias collides', () => {
|
|
110
|
+
const reg = new CommandRegistry();
|
|
111
|
+
reg.register(fakeCommand('compact', { aliases: ['c'] }));
|
|
112
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
113
|
+
// Replacing /mode where the SECOND new alias ('c') is owned by /compact must
|
|
114
|
+
// throw without first destroying /mode's existing alias 'm' (the prior bug:
|
|
115
|
+
// delete-prior-then-add left the registry corrupted on a mid-loop throw).
|
|
116
|
+
expect(() =>
|
|
117
|
+
reg.replace(fakeCommand('mode', { aliases: ['m2', 'c'] })),
|
|
118
|
+
).toThrow(/alias already in use/);
|
|
119
|
+
// /mode's original def + alias survive untouched.
|
|
120
|
+
expect(reg.get('m')?.name).toBe('mode');
|
|
121
|
+
expect(reg.get('m2')).toBeUndefined();
|
|
122
|
+
// /compact still owns 'c'.
|
|
123
|
+
expect(reg.get('c')?.name).toBe('compact');
|
|
124
|
+
});
|
|
125
|
+
|
|
61
126
|
it('listForChannel filters channel-scoped commands', () => {
|
|
62
127
|
const reg = new CommandRegistry();
|
|
63
128
|
reg.register(fakeCommand('everywhere'));
|