@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.
Files changed (151) hide show
  1. package/dist/events/log.d.ts.map +1 -1
  2. package/dist/events/log.js +38 -1
  3. package/dist/events/log.js.map +1 -1
  4. package/dist/permissions/engine.d.ts.map +1 -1
  5. package/dist/permissions/engine.js +80 -17
  6. package/dist/permissions/engine.js.map +1 -1
  7. package/dist/plugins/discovery.d.ts.map +1 -1
  8. package/dist/plugins/discovery.js +48 -7
  9. package/dist/plugins/discovery.js.map +1 -1
  10. package/dist/plugins/host.d.ts.map +1 -1
  11. package/dist/plugins/host.js +54 -11
  12. package/dist/plugins/host.js.map +1 -1
  13. package/dist/plugins/lifecycle.d.ts.map +1 -1
  14. package/dist/plugins/lifecycle.js +7 -0
  15. package/dist/plugins/lifecycle.js.map +1 -1
  16. package/dist/plugins/loader.d.ts +9 -0
  17. package/dist/plugins/loader.d.ts.map +1 -1
  18. package/dist/plugins/loader.js +34 -8
  19. package/dist/plugins/loader.js.map +1 -1
  20. package/dist/plugins/registry-kinds.d.ts +21 -2
  21. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  22. package/dist/plugins/registry-kinds.js +3 -1
  23. package/dist/plugins/registry-kinds.js.map +1 -1
  24. package/dist/plugins/toposort.d.ts.map +1 -1
  25. package/dist/plugins/toposort.js +43 -18
  26. package/dist/plugins/toposort.js.map +1 -1
  27. package/dist/preferences.d.ts.map +1 -1
  28. package/dist/preferences.js +5 -4
  29. package/dist/preferences.js.map +1 -1
  30. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  31. package/dist/registries/active-backend-registry.js +10 -1
  32. package/dist/registries/active-backend-registry.js.map +1 -1
  33. package/dist/registries/channels.d.ts +2 -1
  34. package/dist/registries/channels.d.ts.map +1 -1
  35. package/dist/registries/channels.js +30 -20
  36. package/dist/registries/channels.js.map +1 -1
  37. package/dist/registries/commands.d.ts.map +1 -1
  38. package/dist/registries/commands.js +27 -8
  39. package/dist/registries/commands.js.map +1 -1
  40. package/dist/registries/isolators.d.ts +27 -1
  41. package/dist/registries/isolators.d.ts.map +1 -1
  42. package/dist/registries/isolators.js +35 -1
  43. package/dist/registries/isolators.js.map +1 -1
  44. package/dist/registries/modes.d.ts +4 -0
  45. package/dist/registries/modes.d.ts.map +1 -1
  46. package/dist/registries/modes.js +15 -4
  47. package/dist/registries/modes.js.map +1 -1
  48. package/dist/registries/providers.d.ts +8 -0
  49. package/dist/registries/providers.d.ts.map +1 -1
  50. package/dist/registries/providers.js +8 -0
  51. package/dist/registries/providers.js.map +1 -1
  52. package/dist/registries/skills.d.ts +9 -0
  53. package/dist/registries/skills.d.ts.map +1 -1
  54. package/dist/registries/skills.js +22 -0
  55. package/dist/registries/skills.js.map +1 -1
  56. package/dist/requirements.d.ts.map +1 -1
  57. package/dist/requirements.js +7 -1
  58. package/dist/requirements.js.map +1 -1
  59. package/dist/run-turn.d.ts.map +1 -1
  60. package/dist/run-turn.js +65 -17
  61. package/dist/run-turn.js.map +1 -1
  62. package/dist/session.d.ts +9 -0
  63. package/dist/session.d.ts.map +1 -1
  64. package/dist/session.js +25 -3
  65. package/dist/session.js.map +1 -1
  66. package/dist/sessions/persistence.d.ts +15 -0
  67. package/dist/sessions/persistence.d.ts.map +1 -1
  68. package/dist/sessions/persistence.js +116 -13
  69. package/dist/sessions/persistence.js.map +1 -1
  70. package/dist/skills/loader.d.ts.map +1 -1
  71. package/dist/skills/loader.js +11 -2
  72. package/dist/skills/loader.js.map +1 -1
  73. package/dist/skills/synthesize.d.ts.map +1 -1
  74. package/dist/skills/synthesize.js +87 -36
  75. package/dist/skills/synthesize.js.map +1 -1
  76. package/dist/subagents/events.js +33 -2
  77. package/dist/subagents/events.js.map +1 -1
  78. package/dist/subagents/registry.d.ts +22 -1
  79. package/dist/subagents/registry.d.ts.map +1 -1
  80. package/dist/subagents/registry.js +72 -2
  81. package/dist/subagents/registry.js.map +1 -1
  82. package/dist/subagents/run-child.d.ts.map +1 -1
  83. package/dist/subagents/run-child.js +138 -46
  84. package/dist/subagents/run-child.js.map +1 -1
  85. package/dist/surfaces/host.d.ts +10 -0
  86. package/dist/surfaces/host.d.ts.map +1 -1
  87. package/dist/surfaces/host.js +41 -3
  88. package/dist/surfaces/host.js.map +1 -1
  89. package/dist/usage-stats.d.ts +7 -1
  90. package/dist/usage-stats.d.ts.map +1 -1
  91. package/dist/usage-stats.js +41 -15
  92. package/dist/usage-stats.js.map +1 -1
  93. package/dist/view/parse.d.ts.map +1 -1
  94. package/dist/view/parse.js +119 -19
  95. package/dist/view/parse.js.map +1 -1
  96. package/package.json +4 -4
  97. package/src/events/log.test.ts +45 -0
  98. package/src/events/log.ts +39 -1
  99. package/src/permissions/engine.test.ts +60 -0
  100. package/src/permissions/engine.ts +81 -16
  101. package/src/plugins/discovery.test.ts +59 -1
  102. package/src/plugins/discovery.ts +57 -9
  103. package/src/plugins/host.test.ts +40 -0
  104. package/src/plugins/host.ts +48 -10
  105. package/src/plugins/lifecycle.test.ts +29 -0
  106. package/src/plugins/lifecycle.ts +7 -0
  107. package/src/plugins/loader.ts +45 -7
  108. package/src/plugins/registry-kinds.test.ts +53 -0
  109. package/src/plugins/registry-kinds.ts +25 -3
  110. package/src/plugins/toposort.ts +45 -17
  111. package/src/preferences.test.ts +52 -1
  112. package/src/preferences.ts +5 -4
  113. package/src/registries/active-backend-registry.ts +10 -1
  114. package/src/registries/channels.test.ts +79 -0
  115. package/src/registries/channels.ts +33 -18
  116. package/src/registries/commands.test.ts +65 -0
  117. package/src/registries/commands.ts +25 -8
  118. package/src/registries/isolators.test.ts +37 -0
  119. package/src/registries/isolators.ts +48 -1
  120. package/src/registries/modes.test.ts +21 -1
  121. package/src/registries/modes.ts +15 -2
  122. package/src/registries/providers.ts +8 -0
  123. package/src/registries/semantics.test.ts +33 -1
  124. package/src/registries/skills.ts +28 -0
  125. package/src/registries/transcribers.test.ts +13 -0
  126. package/src/requirements.test.ts +35 -0
  127. package/src/requirements.ts +7 -1
  128. package/src/run-turn.test.ts +219 -0
  129. package/src/run-turn.ts +68 -18
  130. package/src/session.test.ts +24 -0
  131. package/src/session.ts +24 -3
  132. package/src/sessions/page.test.ts +17 -0
  133. package/src/sessions/persistence.test.ts +93 -1
  134. package/src/sessions/persistence.ts +138 -30
  135. package/src/skills/loader.test.ts +18 -0
  136. package/src/skills/loader.ts +12 -1
  137. package/src/skills/synthesize.test.ts +59 -0
  138. package/src/skills/synthesize.ts +89 -36
  139. package/src/subagents/events.test.ts +56 -0
  140. package/src/subagents/events.ts +32 -2
  141. package/src/subagents/registry.test.ts +106 -0
  142. package/src/subagents/registry.ts +77 -3
  143. package/src/subagents/run-child.test.ts +125 -2
  144. package/src/subagents/run-child.ts +150 -47
  145. package/src/surfaces/host.test.ts +71 -1
  146. package/src/surfaces/host.ts +39 -2
  147. package/src/usage-stats.test.ts +99 -1
  148. package/src/usage-stats.ts +42 -17
  149. package/src/view/parse-extended.test.ts +80 -0
  150. package/src/view/parse.test.ts +14 -0
  151. package/src/view/parse.ts +113 -18
@@ -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`)),
@@ -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
- let jitiInstance: ((id: string) => unknown) | null = null;
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
- if (jitiInstance) return jitiInstance;
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) return null;
19
- jitiInstance = factory(cwd, { interopDefault: true });
20
- return jitiInstance;
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
- const bust = opts.cacheBust?.() ?? `${Date.now()}-${Math.random()}`;
43
- const url = `${pathToFileURL(entry).href}?v=${bust}`;
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
- /** Register a single def into its registry (uses `register` or `replace`). */
68
- readonly register: (opts: PluginHostOptions, def: TDef) => void;
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) => o.isolators.register(i),
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
  {
@@ -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
- const stack: string[] = [];
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
- const visit = (name: string): void => {
39
- if (visited.has(name)) return;
40
- if (onStack.has(name)) {
41
- const startIdx = stack.indexOf(name);
42
- throw new PluginCycleError(stack.slice(startIdx).concat(name));
43
- }
44
- const manifest = byPackage.get(name);
45
- if (!manifest) return; // unknown dep — leave for readiness gate
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
- onStack.add(name);
48
- stack.push(name);
49
- for (const dep of pluginDeps(manifest.requirements)) visit(dep);
50
- onStack.delete(name);
51
- stack.pop();
52
- visited.add(name);
53
- order.push(manifest);
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) visit(m.packageName);
84
+ for (const m of manifests) visitRoot(m.packageName);
57
85
  return order;
58
86
  }
59
87
 
@@ -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}` });
@@ -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
- return path.join(os.homedir(), '.moxxy', 'preferences.json');
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) this.instances.set(def.name, 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
- const out: Array<{ def: ChannelDef; availability: ChannelAvailability }> = [];
24
- for (const def of this.defs.values()) {
25
- let availability: ChannelAvailability;
26
- if (def.isAvailable) {
27
- try {
28
- availability = await def.isAvailable(deps);
29
- } catch (err) {
30
- availability = {
31
- ok: false,
32
- reason: err instanceof Error ? err.message : String(err),
33
- };
34
- }
35
- } else {
36
- availability = { ok: true };
37
- }
38
- out.push({ def, availability });
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'));