@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
@@ -22,25 +22,42 @@ export class CommandRegistry {
22
22
  if (this.aliases.has(cmd.name)) {
23
23
  throw new Error(`Command name already in use as an alias: /${cmd.name}`);
24
24
  }
25
- this.commands.set(cmd.name, cmd);
25
+ // Validate EVERY alias up front, before mutating any state, so a collision
26
+ // on the second alias can't leave the command half-registered (def set +
27
+ // some aliases) — the registry stays atomic on a throw.
26
28
  for (const alias of cmd.aliases ?? []) {
27
29
  if (this.aliases.has(alias) || this.commands.has(alias)) {
28
30
  throw new Error(`Command alias already in use: /${alias}`);
29
31
  }
30
- this.aliases.set(alias, cmd.name);
31
32
  }
33
+ this.commands.set(cmd.name, cmd);
34
+ for (const alias of cmd.aliases ?? []) this.aliases.set(alias, cmd.name);
32
35
  }
33
36
 
34
37
  replace(cmd: CommandDef): void {
35
- // Clean up any aliases of the prior definition before re-adding.
36
38
  const prior = this.commands.get(cmd.name);
37
- if (prior) {
38
- for (const alias of prior.aliases ?? []) this.aliases.delete(alias);
39
- }
40
- this.commands.set(cmd.name, cmd);
39
+ const priorAliases = new Set(prior?.aliases ?? []);
40
+ // Validate the new aliases BEFORE mutating anything, mirroring register()'s
41
+ // guards so replace() can't silently hijack an alias owned by a different
42
+ // command or shadow another command's primary name. Validating first (rather
43
+ // than deleting the prior aliases then re-adding) keeps replace() atomic: a
44
+ // collision on a later alias must not have already destroyed the prior
45
+ // definition's aliases. An alias the prior def itself owned is exempt (it's
46
+ // about to be re-added under the same name).
41
47
  for (const alias of cmd.aliases ?? []) {
42
- this.aliases.set(alias, cmd.name);
48
+ const owner = this.aliases.get(alias);
49
+ if (owner && owner !== cmd.name && !priorAliases.has(alias)) {
50
+ throw new Error(`Command alias already in use: /${alias}`);
51
+ }
52
+ if (this.commands.has(alias) && alias !== cmd.name) {
53
+ throw new Error(`Command alias already in use as a command name: /${alias}`);
54
+ }
43
55
  }
56
+ // All validated — now mutate. Clean up the prior definition's aliases, then
57
+ // install the new def + its aliases.
58
+ for (const alias of priorAliases) this.aliases.delete(alias);
59
+ this.commands.set(cmd.name, cmd);
60
+ for (const alias of cmd.aliases ?? []) this.aliases.set(alias, cmd.name);
44
61
  }
45
62
 
46
63
  unregister(name: string): void {
@@ -32,6 +32,43 @@ describe('IsolatorRegistry (core contribution collection)', () => {
32
32
  expect(reg.get('worker')).toBe(second);
33
33
  });
34
34
 
35
+ it('refuses to let a discovered (untrusted) isolator shadow a trusted builtin', () => {
36
+ const reg = new IsolatorRegistry();
37
+ const trusted = fakeIsolator('worker');
38
+ const rogue = fakeIsolator('worker');
39
+ // Static builtins register as trusted (default).
40
+ expect(reg.register(trusted, { trusted: true })).toBe(true);
41
+ // A discovered plugin contributing the SAME name must not swap the impl the
42
+ // security layer resolves by config — the trusted one stays. register()
43
+ // returns false so the host knows the registration never took effect (and
44
+ // must not track it for rollback / unload).
45
+ expect(reg.register(rogue, { trusted: false })).toBe(false);
46
+ expect(reg.get('worker')).toBe(trusted);
47
+ expect(reg.list()).toHaveLength(1);
48
+ });
49
+
50
+ it('returns true when a registration actually takes effect', () => {
51
+ const reg = new IsolatorRegistry();
52
+ expect(reg.register(fakeIsolator('docker'), { trusted: false })).toBe(true);
53
+ expect(reg.register(fakeIsolator('docker'), { trusted: false })).toBe(true); // overwrite still applied
54
+ });
55
+
56
+ it('lets a trusted registration still overwrite (builtin re-register / bundled+discovered same role)', () => {
57
+ const reg = new IsolatorRegistry();
58
+ const a = fakeIsolator('subprocess');
59
+ const b = fakeIsolator('subprocess');
60
+ reg.register(a, { trusted: true });
61
+ reg.register(b, { trusted: true });
62
+ expect(reg.get('subprocess')).toBe(b);
63
+ });
64
+
65
+ it('allows a discovered isolator with a brand-new name', () => {
66
+ const reg = new IsolatorRegistry();
67
+ const novel = fakeIsolator('docker');
68
+ reg.register(novel, { trusted: false });
69
+ expect(reg.get('docker')).toBe(novel);
70
+ });
71
+
35
72
  it('has no concept of an active isolator (selection stays with the security layer)', () => {
36
73
  const reg = new IsolatorRegistry();
37
74
  reg.register(fakeIsolator('wasm'));
@@ -1,4 +1,15 @@
1
1
  import type { Isolator } from '@moxxy/sdk';
2
+ import type { Logger } from '../logger.js';
3
+
4
+ export interface IsolatorRegisterOptions {
5
+ /**
6
+ * True when the isolator comes from a statically-registered (trusted)
7
+ * builtin, false for a discovered (untrusted) plugin. A discovered isolator
8
+ * is never allowed to shadow a name already claimed by a trusted one.
9
+ */
10
+ readonly trusted?: boolean;
11
+ readonly logger?: Logger;
12
+ }
2
13
 
3
14
  /**
4
15
  * Collection of capability isolators contributed by plugins via
@@ -10,18 +21,54 @@ import type { Isolator } from '@moxxy/sdk';
10
21
  * A contributed isolator is therefore NEVER auto-activated: registration only
11
22
  * makes it available; the user must opt in by name, so a rogue plugin can't
12
23
  * silently weaken isolation just by being installed.
24
+ *
25
+ * This is the one registry where "last wins" crosses a trust boundary: a
26
+ * discovered plugin contributing an isolator whose name matches a trusted
27
+ * builtin would silently swap the implementation the security layer resolves by
28
+ * `security.isolator`. So a discovered registration may NEVER overwrite a name
29
+ * a trusted registration already claimed; it's refused with a warning.
13
30
  */
14
31
  export class IsolatorRegistry {
15
32
  private readonly impls = new Map<string, Isolator>();
33
+ /** Names registered by a trusted (static) source — these are never shadowed. */
34
+ private readonly trustedNames = new Set<string>();
16
35
 
17
- register(iso: Isolator): void {
36
+ /**
37
+ * Register an isolator by name. Returns `true` when the impl was stored,
38
+ * `false` when the registration was REFUSED (a discovered plugin trying to
39
+ * shadow a trusted name). The boolean lets the PluginHost avoid tracking a
40
+ * refused registration for rollback — otherwise a later mid-`applyPlugin`
41
+ * failure would `unregister(name)` and delete the very trusted isolator the
42
+ * refusal protected.
43
+ */
44
+ register(iso: Isolator, opts: IsolatorRegisterOptions = {}): boolean {
45
+ const trusted = opts.trusted ?? true;
46
+ if (!trusted && this.trustedNames.has(iso.name)) {
47
+ // A discovered plugin must not shadow a trusted isolator: that would let
48
+ // it silently weaken an isolation boundary the user already opted into.
49
+ opts.logger?.warn(
50
+ 'IsolatorRegistry: refusing to let a discovered plugin shadow a trusted isolator',
51
+ { name: iso.name },
52
+ );
53
+ return false;
54
+ }
55
+ if (this.impls.has(iso.name) && !trusted) {
56
+ // Replacing an existing (untrusted) entry from another discovered copy:
57
+ // allowed (same name → same role) but surfaced so it isn't silent.
58
+ opts.logger?.warn('IsolatorRegistry: a discovered isolator overwrote an existing name', {
59
+ name: iso.name,
60
+ });
61
+ }
18
62
  // Overwrite by name: an isolator may arrive via more than one path (a
19
63
  // bundled built-in AND a discovered copy). Same name → same role; last wins.
20
64
  this.impls.set(iso.name, iso);
65
+ if (trusted) this.trustedNames.add(iso.name);
66
+ return true;
21
67
  }
22
68
 
23
69
  unregister(name: string): void {
24
70
  this.impls.delete(name);
71
+ this.trustedNames.delete(name);
25
72
  }
26
73
 
27
74
  get(name: string): Isolator | undefined {
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { defineMode } from '@moxxy/sdk';
3
3
  import { ModeRegistry } from './modes.js';
4
4
 
@@ -63,4 +63,24 @@ describe('ModeRegistry legacy-name migration', () => {
63
63
  reg.replace(mode('goal')); // not the active mode
64
64
  expect(fired).toBe(0);
65
65
  });
66
+
67
+ it('isolates a throwing change listener so register/setActive never aborts', () => {
68
+ const reg = new ModeRegistry();
69
+ const after = vi.fn();
70
+ reg.onActiveChange(() => {
71
+ throw new Error('observer down (e.g. dead socket on InfoChanged)');
72
+ });
73
+ reg.onActiveChange(after);
74
+ // A throwing observer must NOT abort the remaining listeners nor unwind into
75
+ // the registration that triggered the notification.
76
+ expect(() => reg.register(mode('default'))).not.toThrow();
77
+ expect(after).toHaveBeenCalledTimes(1);
78
+ expect(reg.getActive().name).toBe('default');
79
+
80
+ // Same isolation on an explicit setActive (mode switch).
81
+ reg.register(mode('goal'));
82
+ after.mockClear();
83
+ expect(() => reg.setActive('goal')).not.toThrow();
84
+ expect(after).toHaveBeenCalledTimes(1);
85
+ });
66
86
  });
@@ -37,7 +37,7 @@ export class ModeRegistry {
37
37
  // active behaviour changed under them, or remote clients keep driving the
38
38
  // stale def. `activate()` would early-return on the name match, so notify
39
39
  // directly.
40
- for (const fn of this.changeListeners) fn();
40
+ this.notifyChange();
41
41
  }
42
42
  }
43
43
 
@@ -75,6 +75,19 @@ export class ModeRegistry {
75
75
  private activate(mode: ModeDef): void {
76
76
  if (this.active === mode.name) return;
77
77
  this.active = mode.name;
78
- for (const fn of this.changeListeners) fn();
78
+ this.notifyChange();
79
+ }
80
+
81
+ /** Fan out to change listeners, isolating each fault so a single throwing
82
+ * observer (e.g. the runner's InfoChanged broadcast hitting a dead socket)
83
+ * can't abort the rest or unwind into register()/replace()/setActive(). */
84
+ private notifyChange(): void {
85
+ for (const fn of this.changeListeners) {
86
+ try {
87
+ fn();
88
+ } catch {
89
+ // Swallow: an observer fault must not break mode activation.
90
+ }
91
+ }
79
92
  }
80
93
  }
@@ -69,6 +69,14 @@ export class ProviderRegistry {
69
69
  return !this.disabled.has(name);
70
70
  }
71
71
 
72
+ /**
73
+ * Activate a provider, building its client on first activation. NOTE: once an
74
+ * instance is cached, a later `setActive(name, newConfig)` REUSES the cached
75
+ * instance and DROPS `newConfig` — re-config requires `replace()` first
76
+ * (which clears the cache), the flow the sole production caller follows.
77
+ * Reconfiguring by rebuilding here is intentionally avoided so callers don't
78
+ * silently lose instance identity / in-flight state on a redundant activate.
79
+ */
72
80
  setActive(name: string, config?: Record<string, unknown>): LLMProvider {
73
81
  const def = this.defs.get(name);
74
82
  if (!def) throw new Error(`Provider not registered: ${name}`);
@@ -1,5 +1,6 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import type { CompactorDef, ModeDef, ProviderDef } from '@moxxy/sdk';
3
+ import { silentLogger } from '../logger.js';
3
4
  import { ProviderRegistry } from './providers.js';
4
5
  import { ModeRegistry } from './modes.js';
5
6
  import { CompactorRegistry } from './compactors.js';
@@ -114,4 +115,35 @@ describe('Registry consistency (PR3-1)', () => {
114
115
  expect(r.byName('bar')?.body).toBe('two');
115
116
  expect(r.list()).toHaveLength(1);
116
117
  });
118
+
119
+ it('SkillRegistryImpl: warns (not silent) when two distinct skills share a frontmatter name', () => {
120
+ const warn = vi.fn();
121
+ const r = new SkillRegistryImpl({ logger: { ...silentLogger, warn } });
122
+ const a = {
123
+ id: 'project/a' as never,
124
+ path: '/a.md',
125
+ scope: 'project' as const,
126
+ frontmatter: { name: 'dupe', description: 'd' },
127
+ body: 'a',
128
+ };
129
+ const b = { ...a, id: 'project/b' as never, path: '/b.md', body: 'b' };
130
+ r.register(a);
131
+ r.register(b); // same frontmatter.name, different id → must warn
132
+ expect(warn).toHaveBeenCalledTimes(1);
133
+ // Last-wins by name is preserved, but no longer silent.
134
+ expect(r.byName('dupe')?.id).toBe('project/b');
135
+ // Both are still reachable by id.
136
+ expect(r.list()).toHaveLength(2);
137
+ });
138
+
139
+ it('SkillRegistryImpl: replaceAll warns on a frontmatter-name collision within the batch', () => {
140
+ const warn = vi.fn();
141
+ const r = new SkillRegistryImpl({ logger: { ...silentLogger, warn } });
142
+ r.replaceAll([
143
+ { id: 'p/a' as never, path: '/a.md', scope: 'project' as const, frontmatter: { name: 'n', description: 'd' }, body: 'a' },
144
+ { id: 'p/b' as never, path: '/b.md', scope: 'project' as const, frontmatter: { name: 'n', description: 'd' }, body: 'b' },
145
+ ]);
146
+ expect(warn).toHaveBeenCalledTimes(1);
147
+ expect(r.byName('n')?.id).toBe('p/b');
148
+ });
117
149
  });
@@ -1,8 +1,28 @@
1
1
  import type { Skill, SkillRegistry } from '@moxxy/sdk';
2
+ import type { Logger } from '../logger.js';
2
3
 
3
4
  export class SkillRegistryImpl implements SkillRegistry {
4
5
  private readonly byId = new Map<string, Skill>();
5
6
  private readonly byNameIdx = new Map<string, Skill>();
7
+ private readonly logger?: Logger;
8
+
9
+ constructor(opts: { logger?: Logger } = {}) {
10
+ this.logger = opts.logger;
11
+ }
12
+
13
+ /** Warn (don't silently last-wins) when two distinct skills fight over one
14
+ * frontmatter.name — otherwise byName() is non-deterministic by load order
15
+ * and the first skill becomes unreachable by name. */
16
+ private warnNameCollision(name: string, incomingId: string): void {
17
+ const existing = this.byNameIdx.get(name);
18
+ if (existing && existing.id !== incomingId) {
19
+ this.logger?.warn('SkillRegistry: two skills share a frontmatter name; last one wins by name', {
20
+ name,
21
+ existingId: existing.id,
22
+ incomingId,
23
+ });
24
+ }
25
+ }
6
26
 
7
27
  list(): ReadonlyArray<Skill> {
8
28
  return [...this.byId.values()];
@@ -34,6 +54,7 @@ export class SkillRegistryImpl implements SkillRegistry {
34
54
  if (this.byId.has(skill.id)) {
35
55
  throw new Error(`Skill already registered: ${skill.id}`);
36
56
  }
57
+ this.warnNameCollision(skill.frontmatter.name, skill.id);
37
58
  this.byId.set(skill.id, skill);
38
59
  this.byNameIdx.set(skill.frontmatter.name, skill);
39
60
  }
@@ -74,6 +95,13 @@ export class SkillRegistryImpl implements SkillRegistry {
74
95
  const nextByName = new Map<string, Skill>();
75
96
  for (const s of skills) {
76
97
  nextById.set(s.id, s);
98
+ const priorForName = nextByName.get(s.frontmatter.name);
99
+ if (priorForName && priorForName.id !== s.id) {
100
+ this.logger?.warn(
101
+ 'SkillRegistry: two skills share a frontmatter name; last one wins by name',
102
+ { name: s.frontmatter.name, existingId: priorForName.id, incomingId: s.id },
103
+ );
104
+ }
77
105
  nextByName.set(s.frontmatter.name, s);
78
106
  }
79
107
  // Synchronous swap: callers between these lines still see the OLD
@@ -67,4 +67,17 @@ describe('TranscriberRegistry', () => {
67
67
  expect(r.text).toBe('new');
68
68
  });
69
69
  });
70
+
71
+ it('replacing the ACTIVE def eagerly rebuilds so getActive() never strands (non-buildOnRead)', async () => {
72
+ const reg = new TranscriberRegistry();
73
+ reg.register(fake('whisper', 'old'));
74
+ reg.setActive('whisper');
75
+ // A hot-reloaded plugin re-registers via replace() WITHOUT calling setActive
76
+ // again. For a non-buildOnRead registry, getActive() reads the cached
77
+ // instance directly; dropping it must not leave getActive() throwing.
78
+ reg.replace(fake('whisper', 'new'));
79
+ const active = reg.getActive(); // must NOT throw "has no instance"
80
+ const out = await active.transcribe(new Uint8Array());
81
+ expect(out.text).toBe('new'); // and it's the NEW def, not the stale one
82
+ });
70
83
  });
@@ -87,6 +87,41 @@ describe('RequirementRegistry', () => {
87
87
  expect(check.issues[0]).toMatchObject({ code: 'not_ready' });
88
88
  });
89
89
 
90
+ it('does not report version_mismatch for a non-plugin kind carrying a version', () => {
91
+ // `version` is only resolvable for the plugin kind. A version on any other
92
+ // kind used to compare against an always-undefined target.version and emit a
93
+ // permanent spurious version_mismatch ("(unknown)") even though the target
94
+ // was present and active.
95
+ const { requirements, providers } = makeRequirements();
96
+ providers.register(
97
+ defineProvider({
98
+ name: 'openai-codex',
99
+ models: [],
100
+ createClient: () => ({ name: 'openai-codex', models: [], stream: async function* () {}, countTokens: async () => 0 }),
101
+ }),
102
+ );
103
+ providers.setActive('openai-codex');
104
+
105
+ const check = requirements.check([
106
+ { kind: 'provider', name: 'openai-codex', state: 'active', version: '1.0.0' },
107
+ ]);
108
+
109
+ expect(check.ready).toBe(true);
110
+ expect(check.issues).toEqual([]);
111
+ });
112
+
113
+ it('still honors a version mismatch for the plugin kind', () => {
114
+ const { requirements } = makeRequirements();
115
+ requirements.registerPlugin('@moxxy/plugin-example', '2.0.0');
116
+
117
+ const check = requirements.check([
118
+ { kind: 'plugin', name: '@moxxy/plugin-example', version: '1.0.0' },
119
+ ]);
120
+
121
+ expect(check.ready).toBe(false);
122
+ expect(check.issues[0]).toMatchObject({ code: 'version_mismatch' });
123
+ });
124
+
90
125
  it('isReady checks a single named target without composing per-def requirements', () => {
91
126
  const { requirements, transcribers } = makeRequirements();
92
127
  transcribers.register(
@@ -141,7 +141,13 @@ export class RequirementRegistry {
141
141
  return issue(requirement, 'missing', `Required ${label(requirement.kind)} is not registered: ${requirement.name}`);
142
142
  }
143
143
 
144
- if (requirement.version && target.version !== requirement.version) {
144
+ // `version` is only resolvable for the `plugin` kind (the sole kind whose
145
+ // `targetInfo` populates a version — see {@link targetInfo}). The SDK type
146
+ // permits `version` on every kind, so without this gate a `version` on any
147
+ // non-plugin kind would compare against an always-undefined `target.version`
148
+ // and report a permanent, spurious `version_mismatch` (target shown as
149
+ // `(unknown)`) even though the target is present and active.
150
+ if (requirement.kind === 'plugin' && requirement.version && target.version !== requirement.version) {
145
151
  return issue(
146
152
  requirement,
147
153
  'version_mismatch',
@@ -176,3 +176,222 @@ describe('runTurn threads real cwd/env into onToolCall hooks', () => {
176
176
  expect(captured.envFoo).toBe(process.env.PATH);
177
177
  });
178
178
  });
179
+
180
+ describe('runTurn worst-case hardening', () => {
181
+ it('aborting iteration early aborts the strategy promptly (no full background run)', async () => {
182
+ let observedAbort = false;
183
+ // A mode that loops indefinitely until its signal aborts. If the consumer's
184
+ // early `break` did NOT abort the turn, this would run forever and the
185
+ // generator's `finally` would hang awaiting it.
186
+ const foreverMode = defineMode({
187
+ name: 'forever',
188
+ run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
189
+ for (let i = 0; ; i++) {
190
+ if (ctx.signal.aborted) {
191
+ observedAbort = true;
192
+ return;
193
+ }
194
+ await ctx.emit({
195
+ type: 'assistant_message',
196
+ sessionId: ctx.sessionId,
197
+ turnId: ctx.turnId,
198
+ source: 'assistant',
199
+ text: `tick-${i}`,
200
+ });
201
+ await new Promise((r) => setTimeout(r, 5));
202
+ }
203
+ },
204
+ });
205
+
206
+ const session = new Session({ cwd: '/tmp', silent: true });
207
+ session.pluginHost.registerStatic(
208
+ definePlugin({
209
+ name: 'forever-plugin',
210
+ version: '0.0.0',
211
+ providers: [makeNoopProvider()],
212
+ modes: [foreverMode],
213
+ }),
214
+ );
215
+ session.providers.setActive('noop');
216
+ session.modes.setActive('forever');
217
+
218
+ // Break out after the first event — the generator must abort the strategy
219
+ // in its finally and return promptly (this await would never resolve if the
220
+ // turn ran to its non-existent completion in the background).
221
+ for await (const event of runTurn(session, 'go')) {
222
+ if (event.type === 'assistant_message') break;
223
+ }
224
+
225
+ expect(observedAbort).toBe(true);
226
+ expect(session.signal.aborted).toBe(false); // session-level signal untouched
227
+ });
228
+
229
+ it('an abandoned turn does NOT apply a mode switch it requested before being abandoned', async () => {
230
+ // A mode that immediately requests a hand-off, then loops until aborted and
231
+ // returns CLEANLY (no throw) on `signal.aborted` — so strategyError stays
232
+ // null. If the post-turn switch were gated only on `!strategyError`, an
233
+ // abandoned (consumer-broke-early) turn would silently flip the session into
234
+ // 'other' behind the user's back. It must stay on the original mode.
235
+ const requestThenLoop = defineMode({
236
+ name: 'requester',
237
+ run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
238
+ ctx.requestModeSwitch('other');
239
+ for (let i = 0; ; i++) {
240
+ if (ctx.signal.aborted) return; // clean return on abandonment
241
+ await ctx.emit({
242
+ type: 'assistant_message',
243
+ sessionId: ctx.sessionId,
244
+ turnId: ctx.turnId,
245
+ source: 'assistant',
246
+ text: `tick-${i}`,
247
+ });
248
+ await new Promise((r) => setTimeout(r, 5));
249
+ }
250
+ },
251
+ });
252
+
253
+ const session = new Session({ cwd: '/tmp', silent: true });
254
+ session.pluginHost.registerStatic(
255
+ definePlugin({
256
+ name: 'switch-on-abandon',
257
+ version: '0.0.0',
258
+ providers: [makeNoopProvider()],
259
+ modes: [requestThenLoop, defineMode({ name: 'other', run: async function* () {} })],
260
+ }),
261
+ );
262
+ session.providers.setActive('noop');
263
+ session.modes.setActive('requester');
264
+
265
+ // Abandon after the first event — the strategy aborts and returns cleanly.
266
+ for await (const event of runTurn(session, 'go')) {
267
+ if (event.type === 'assistant_message') break;
268
+ }
269
+
270
+ // The requested switch must be DROPPED because the turn was abandoned.
271
+ expect(session.modes.getActive().name).toBe('requester');
272
+ });
273
+
274
+ it('a completed turn still applies a requested mode switch', async () => {
275
+ // The positive case: a mode that requests a switch and completes normally
276
+ // hands off as before — the abandonment gate must not regress this.
277
+ const requestThenDone = defineMode({
278
+ name: 'handoff',
279
+ run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
280
+ ctx.requestModeSwitch('target');
281
+ },
282
+ });
283
+ const session = new Session({ cwd: '/tmp', silent: true });
284
+ session.pluginHost.registerStatic(
285
+ definePlugin({
286
+ name: 'switch-on-complete',
287
+ version: '0.0.0',
288
+ providers: [makeNoopProvider()],
289
+ modes: [requestThenDone, defineMode({ name: 'target', run: async function* () {} })],
290
+ }),
291
+ );
292
+ session.providers.setActive('noop');
293
+ session.modes.setActive('handoff');
294
+
295
+ await collectTurn(session, 'go');
296
+ expect(session.modes.getActive().name).toBe('target');
297
+ });
298
+
299
+ it('fires turnEnd even when the strategy throws (paired with turnStart)', async () => {
300
+ const calls: string[] = [];
301
+ const throwingMode = defineMode({
302
+ name: 'throws',
303
+ run: async function* (): AsyncIterable<MoxxyEvent> {
304
+ throw new Error('strategy boom');
305
+ },
306
+ });
307
+
308
+ const session = new Session({ cwd: '/tmp', silent: true });
309
+ session.pluginHost.registerStatic(
310
+ definePlugin({
311
+ name: 'lifecycle-probe',
312
+ version: '0.0.0',
313
+ providers: [makeNoopProvider()],
314
+ modes: [throwingMode],
315
+ hooks: {
316
+ onTurnStart: () => {
317
+ calls.push('start');
318
+ },
319
+ onTurnEnd: () => {
320
+ calls.push('end');
321
+ },
322
+ },
323
+ }),
324
+ );
325
+ session.providers.setActive('noop');
326
+ session.modes.setActive('throws');
327
+
328
+ let threw = false;
329
+ try {
330
+ await collectTurn(session, 'go');
331
+ } catch {
332
+ threw = true;
333
+ }
334
+ expect(threw).toBe(true);
335
+ // turnEnd must pair with turnStart even on the error path — no leaked
336
+ // turn-scoped plugin state.
337
+ expect(calls).toEqual(['start', 'end']);
338
+ });
339
+
340
+ it('a missing active provider records the prompt + a structured error, then rejects', async () => {
341
+ const session = new Session({ cwd: '/tmp', silent: true });
342
+ // No provider registered/active — getActive() throws.
343
+ session.pluginHost.registerStatic(
344
+ definePlugin({
345
+ name: 'mode-only',
346
+ version: '0.0.0',
347
+ modes: [makeMarkerLoop('marker', 1)],
348
+ }),
349
+ );
350
+ session.modes.setActive('marker');
351
+
352
+ let threw = false;
353
+ try {
354
+ await collectTurn(session, 'remember me');
355
+ } catch {
356
+ threw = true;
357
+ }
358
+ expect(threw).toBe(true);
359
+
360
+ // The user prompt is preserved (not silently discarded) and a structured
361
+ // error event was logged so channels see a normal failed turn.
362
+ const events = session.log.slice();
363
+ const prompt = events.find((e) => e.type === 'user_prompt');
364
+ expect((prompt as { text?: string } | undefined)?.text).toBe('remember me');
365
+ const error = events.find((e) => e.type === 'error');
366
+ expect(error).toBeDefined();
367
+ expect((error as { kind?: string }).kind).toBe('fatal');
368
+ });
369
+
370
+ it('a provider advertising no models fails fast (no opaque "default" model id)', async () => {
371
+ const emptyModelsProvider = defineProvider({
372
+ name: 'empty',
373
+ models: [],
374
+ createClient: () => ({
375
+ name: 'empty',
376
+ models: [],
377
+ stream: async function* () {},
378
+ countTokens: async () => 0,
379
+ }),
380
+ });
381
+ const session = new Session({ cwd: '/tmp', silent: true });
382
+ session.pluginHost.registerStatic(
383
+ definePlugin({
384
+ name: 'empty-models',
385
+ version: '0.0.0',
386
+ providers: [emptyModelsProvider],
387
+ modes: [makeMarkerLoop('marker', 1)],
388
+ }),
389
+ );
390
+ session.providers.setActive('empty');
391
+ session.modes.setActive('marker');
392
+
393
+ await expect(collectTurn(session, 'go')).rejects.toThrow(/no models configured/i);
394
+ // lastResolvedModel never gets the bogus sentinel.
395
+ expect(session.lastResolvedModel).toBeNull();
396
+ });
397
+ });