@moxxy/sdk 0.19.0 → 0.21.0

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 (56) hide show
  1. package/dist/errors.d.ts +1 -1
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js.map +1 -1
  4. package/dist/event-store.d.ts +113 -0
  5. package/dist/event-store.d.ts.map +1 -0
  6. package/dist/event-store.js +2 -0
  7. package/dist/event-store.js.map +1 -0
  8. package/dist/events.d.ts +1 -1
  9. package/dist/events.d.ts.map +1 -1
  10. package/dist/hooks.d.ts +6 -0
  11. package/dist/hooks.d.ts.map +1 -1
  12. package/dist/index.d.ts +6 -4
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/mode/collect-stream.d.ts.map +1 -1
  17. package/dist/mode/collect-stream.js +1 -0
  18. package/dist/mode/collect-stream.js.map +1 -1
  19. package/dist/mode/stuck-loop.d.ts +20 -6
  20. package/dist/mode/stuck-loop.d.ts.map +1 -1
  21. package/dist/mode/stuck-loop.js +14 -3
  22. package/dist/mode/stuck-loop.js.map +1 -1
  23. package/dist/mode-helpers.d.ts +1 -1
  24. package/dist/mode-helpers.d.ts.map +1 -1
  25. package/dist/mode-helpers.js.map +1 -1
  26. package/dist/mode.d.ts +41 -0
  27. package/dist/mode.d.ts.map +1 -1
  28. package/dist/mode.js +11 -0
  29. package/dist/mode.js.map +1 -1
  30. package/dist/plugin.d.ts +10 -1
  31. package/dist/plugin.d.ts.map +1 -1
  32. package/dist/services.d.ts +47 -0
  33. package/dist/services.d.ts.map +1 -0
  34. package/dist/services.js +2 -0
  35. package/dist/services.js.map +1 -0
  36. package/dist/session-like.d.ts +43 -2
  37. package/dist/session-like.d.ts.map +1 -1
  38. package/dist/tool-dispatch.d.ts.map +1 -1
  39. package/dist/tool-dispatch.js +1 -0
  40. package/dist/tool-dispatch.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/errors.ts +1 -0
  43. package/src/event-store.ts +118 -0
  44. package/src/events.ts +1 -0
  45. package/src/hooks.ts +6 -0
  46. package/src/index.ts +16 -1
  47. package/src/mode/collect-stream.ts +1 -0
  48. package/src/mode/stuck-loop.ts +28 -11
  49. package/src/mode-helpers.ts +1 -0
  50. package/src/mode.test.ts +20 -1
  51. package/src/mode.ts +43 -0
  52. package/src/plugin.ts +10 -1
  53. package/src/services.ts +47 -0
  54. package/src/session-like.ts +45 -2
  55. package/src/stuck-loop.test.ts +16 -3
  56. package/src/tool-dispatch.ts +1 -0
@@ -34,6 +34,27 @@ export interface StuckLoopDetector {
34
34
  record(toolName: string, input: unknown): StuckSignal;
35
35
  }
36
36
 
37
+ /**
38
+ * User-tunable loop-guard settings (config `context.loopGuard`). All optional —
39
+ * omitted fields use the defaults below. Set `enabled: false` to turn the guard
40
+ * off entirely and rely solely on the mode's `maxIterations` cap.
41
+ */
42
+ export interface LoopGuardSettings {
43
+ readonly enabled?: boolean;
44
+ readonly windowSize?: number;
45
+ readonly repeatThreshold?: number;
46
+ readonly nearWindowSize?: number;
47
+ readonly nearThreshold?: number;
48
+ }
49
+
50
+ /** Default exact-repeat window + trip count. Deliberately generous: the
51
+ * `maxIterations` cap (500 in default mode) is the real runaway backstop, so
52
+ * this only needs to catch a *tight* same-call loop, not legitimately repeated
53
+ * work (re-reading a file, re-running `git status` across steps, a couple of
54
+ * retries). Tunable via `context.loopGuard`. */
55
+ export const DEFAULT_LOOP_WINDOW_SIZE = 12;
56
+ export const DEFAULT_LOOP_REPEAT_THRESHOLD = 8;
57
+
37
58
  /** Identity arguments that pin "the same target" across volatile-arg variation,
38
59
  * best-first. The first present string field wins. */
39
60
  const IDENTITY_ARG_KEYS = ['url', 'file_path', 'path', 'command', 'cmd', 'query', 'pattern'];
@@ -48,19 +69,13 @@ function identityArg(input: unknown): string | null {
48
69
  return null;
49
70
  }
50
71
 
51
- export function createStuckLoopDetector(
52
- opts: {
53
- windowSize?: number;
54
- repeatThreshold?: number;
55
- nearWindowSize?: number;
56
- nearThreshold?: number;
57
- } = {},
58
- ): StuckLoopDetector {
59
- const windowSize = opts.windowSize ?? 8;
60
- const repeatThreshold = opts.repeatThreshold ?? 3;
72
+ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoopDetector {
73
+ const enabled = opts.enabled ?? true;
74
+ const windowSize = opts.windowSize ?? DEFAULT_LOOP_WINDOW_SIZE;
75
+ const repeatThreshold = opts.repeatThreshold ?? DEFAULT_LOOP_REPEAT_THRESHOLD;
61
76
  // Near-dups need a higher count + a wider window (they're spread out across a
62
77
  // burst of other calls), and tolerate a couple of legit "bigger refetch" tries.
63
- const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 16);
78
+ const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 24);
64
79
  const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
65
80
  const recent: string[] = [];
66
81
  const recentNear: string[] = [];
@@ -68,6 +83,8 @@ export function createStuckLoopDetector(
68
83
  windowSize,
69
84
  repeatThreshold,
70
85
  record(toolName, input): StuckSignal {
86
+ // Disabled → never trip (rely on the maxIterations cap alone).
87
+ if (!enabled) return { stuck: false, count: 0, kind: 'exact' };
71
88
  const key = `${toolName}|${stableHash(input)}`;
72
89
  recent.push(key);
73
90
  if (recent.length > windowSize) recent.shift();
@@ -30,5 +30,6 @@ export {
30
30
  createStuckLoopDetector,
31
31
  type StuckLoopDetector,
32
32
  type StuckSignal,
33
+ type LoopGuardSettings,
33
34
  } from './mode/stuck-loop.js';
34
35
  export { stableHash } from './mode/stable-hash.js';
package/src/mode.test.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { migrateModeName } from './mode.js';
2
+ import { migrateModeName, isSelectableMode } from './mode.js';
3
+
4
+ describe('isSelectableMode', () => {
5
+ it('treats a plain mode (no `special`) as selectable', () => {
6
+ expect(isSelectableMode({ special: undefined })).toBe(true);
7
+ expect(isSelectableMode({})).toBe(true);
8
+ });
9
+ it('treats a special mode as NOT selectable (any descriptor, even empty)', () => {
10
+ expect(isSelectableMode({ special: {} })).toBe(false);
11
+ expect(isSelectableMode({ special: { invokedBy: 'collab' } })).toBe(false);
12
+ });
13
+ it('filters special modes out of a list, keeping plain ones', () => {
14
+ const modes = [
15
+ { name: 'default' },
16
+ { name: 'goal' },
17
+ { name: 'collaborative', special: { invokedBy: 'collab' } },
18
+ ];
19
+ expect(modes.filter(isSelectableMode).map((m) => m.name)).toEqual(['default', 'goal']);
20
+ });
21
+ });
3
22
 
4
23
  describe('migrateModeName', () => {
5
24
  it('maps every legacy mode name to its current target', () => {
package/src/mode.ts CHANGED
@@ -2,10 +2,12 @@ import type { CacheStrategyDef } from './cache-strategy.js';
2
2
  import type { CompactorDef } from './compactor.js';
3
3
  import type { EmittedEvent, MoxxyEvent } from './events.js';
4
4
  import type { HookDispatcher } from './hooks.js';
5
+ import type { ServiceRegistry } from './services.js';
5
6
  import type { SessionId, TurnId } from './ids.js';
6
7
  import type { EventLogReader } from './log.js';
7
8
  import type { PermissionResolver } from './permission.js';
8
9
  import type { LLMProvider } from './provider.js';
10
+ import type { LoopGuardSettings } from './mode/stuck-loop.js';
9
11
  import type { Skill } from './skill.js';
10
12
  import type { SubagentSpawner } from './subagent.js';
11
13
  import type { ToolDef } from './tool.js';
@@ -106,8 +108,15 @@ export interface ModeContext {
106
108
  readonly approval?: ApprovalResolver;
107
109
  readonly hooks: HookDispatcher;
108
110
  readonly pluginHost: PluginHostHandle;
111
+ /** Inter-plugin service registry (mirrors {@link AppContext.services}). */
112
+ readonly services: ServiceRegistry;
109
113
  readonly signal: AbortSignal;
110
114
  readonly maxIterations?: number;
115
+ /**
116
+ * Loop-guard tuning (config `context.loopGuard`), forwarded to each mode's
117
+ * stuck-loop detector. When omitted the detector uses its defaults.
118
+ */
119
+ readonly loopGuard?: LoopGuardSettings;
111
120
  /**
112
121
  * Spawn one or more child agents that share the parent's registries
113
122
  * but run in isolation. Children stream their events back to the
@@ -197,9 +206,43 @@ export interface ModeDef {
197
206
  * accent badge while this mode is active. See {@link ModeBadge}.
198
207
  */
199
208
  readonly badge?: ModeBadge;
209
+ /**
210
+ * Marks a "special" mode — one the user does NOT pick from `/mode`. Set this
211
+ * and the mode is, uniformly across every surface (TUI, mobile, desktop):
212
+ * - **hidden** from every mode list / picker (presence), and
213
+ * - **refused** by the normal mode-switch by name (execution) — it can only
214
+ * be entered through its own invocation (a slash command, a spawned
215
+ * agent's `MOXXY_MODE`, …) via the raw registry `setActive`.
216
+ * The mode stays registered + runnable; it's just never *offered* or
217
+ * name-switched. Extensible: any number of special modes (collaborative today,
218
+ * more later) opt in the same way. See {@link isSelectableMode}.
219
+ */
220
+ readonly special?: ModeSpecial;
200
221
  run(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
201
222
  }
202
223
 
224
+ /** Descriptor for a {@link ModeDef.special} mode — see that field. */
225
+ export interface ModeSpecial {
226
+ /**
227
+ * Human-facing entry point that enters this mode, e.g. `'collab'` (the
228
+ * `/collab` command). Surfaced when the user tries to pick/switch it ("entered
229
+ * via /collab") so the UI can point at the right action.
230
+ */
231
+ readonly invokedBy?: string;
232
+ }
233
+
234
+ /**
235
+ * Whether a mode is user-selectable — i.e. NOT a {@link ModeDef.special} mode.
236
+ * The single predicate every surface (TUI/mobile/desktop mode lists + the
237
+ * by-name mode-switch) uses to filter/refuse special modes uniformly. The
238
+ * parameter is intentionally loose (`{ special? }`) so it also applies to the
239
+ * minimal `{ name }` rows the swap-UI registries expose — non-mode defs simply
240
+ * carry no `special` and are always selectable.
241
+ */
242
+ export function isSelectableMode(mode: { readonly special?: unknown }): boolean {
243
+ return mode.special === undefined;
244
+ }
245
+
203
246
  /**
204
247
  * Legacy mode-name → current-name map for backward compatibility. Modes were
205
248
  * renamed/slimmed: `tool-use`→`default`, `deep-research`→`research`, and the
package/src/plugin.ts CHANGED
@@ -16,8 +16,9 @@ import type { SynthesizerDef } from './synthesizer.js';
16
16
  import type { ViewRendererDef } from './view-renderer.js';
17
17
  import type { TunnelProviderDef } from './tunnel.js';
18
18
  import type { WorkflowExecutorDef } from './workflow.js';
19
+ import type { EventStoreDef } from './event-store.js';
19
20
 
20
- export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor';
21
+ export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor' | 'event-store';
21
22
 
22
23
  export interface PluginSpec {
23
24
  readonly name: string;
@@ -44,6 +45,14 @@ export interface PluginSpec {
44
45
  * the proxy relay). One active per session; core seeds a `localhost` no-op.
45
46
  */
46
47
  readonly tunnelProviders?: ReadonlyArray<TunnelProviderDef>;
48
+ /**
49
+ * Event-store backends — the storage behind a session's event log. Core seeds
50
+ * a protected JSONL default; a plugin can contribute an alternative (SQLite,
51
+ * remote, encrypted), activated only by explicit `plugins.eventStore.default`
52
+ * (a discovered store is registered but never auto-active — the trust
53
+ * boundary, since the store sees every event). See {@link EventStoreDef}.
54
+ */
55
+ readonly eventStores?: ReadonlyArray<EventStoreDef>;
47
56
  readonly channels?: ReadonlyArray<ChannelDef>;
48
57
  /**
49
58
  * Interactive surfaces contributed by the plugin — long-lived panes a human
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Minimal read-only view of a name-keyed registry. The host publishes its core
3
+ * registries (agents, tools, …) on the {@link ServiceRegistry} under these
4
+ * names, so a discovery-loaded plugin can resolve one in `onInit` (e.g.
5
+ * `ctx.services.require<NamedRegistry<AgentDef>>('agents')`) without importing
6
+ * `@moxxy/core`'s concrete registry types. Core's `DefMapRegistry`-based
7
+ * registries satisfy it structurally.
8
+ *
9
+ * Well-known service names the host publishes: `'agents'`, `'tools'`,
10
+ * `'providers'`, `'viewRenderers'`, `'synthesizers'`.
11
+ */
12
+ export interface NamedRegistry<T> {
13
+ get(name: string): T | undefined;
14
+ list(): ReadonlyArray<T>;
15
+ has(name: string): boolean;
16
+ }
17
+
18
+ /**
19
+ * Inter-plugin service registry. A plugin can **publish** a named service in its
20
+ * `onInit` hook (e.g. the vault plugin publishes its secret store) and another
21
+ * plugin can **consume** it in its own `onInit` — decoupling cross-plugin
22
+ * dependencies from the host's constructor wiring so a plugin can be
23
+ * discovery-loaded (default-exported, no `build*({deps})` closure) instead of
24
+ * hand-built by the orchestrator.
25
+ *
26
+ * Ordering is by plugin requirements: declare `moxxy.requirements` on the
27
+ * consumer (e.g. `@moxxy/plugin-oauth` requires `@moxxy/plugin-vault`) so the
28
+ * provider's `onInit` runs first and the service is registered before the
29
+ * consumer resolves it. Exposed on {@link AppContext.services}.
30
+ *
31
+ * Plugin `onInit` already runs with full in-process privileges (the security
32
+ * isolation wraps *tool* execution, not plugin code), so reaching a sibling
33
+ * plugin's service here doesn't widen the effective trust surface.
34
+ */
35
+ export interface ServiceRegistry {
36
+ /** Publish a named service for other plugins to consume in their `onInit`. */
37
+ register<T>(name: string, impl: T): void;
38
+ /** Resolve a published service, or `undefined` when it isn't registered. */
39
+ get<T>(name: string): T | undefined;
40
+ /**
41
+ * Resolve a published service or throw — use when a declared requirement
42
+ * guarantees the provider ran first.
43
+ */
44
+ require<T>(name: string): T;
45
+ /** Whether a service has been published. */
46
+ has(name: string): boolean;
47
+ }
@@ -289,6 +289,37 @@ export interface LoadedPluginView {
289
289
  readonly version: string;
290
290
  /** Contribution categories (e.g. `['provider']`, `['tool','command']`). */
291
291
  readonly kinds: ReadonlyArray<string>;
292
+ /**
293
+ * True when discovered from `~/.moxxy/plugins` (installed on demand), false/
294
+ * absent when statically bundled into the binary. Lets surfaces distinguish
295
+ * "installed" from "built-in". Optional so a thin-client `RemoteSession` may
296
+ * omit it (treated as built-in).
297
+ */
298
+ readonly installed?: boolean;
299
+ }
300
+
301
+ /** One swappable contribution within a {@link CategoryView}. */
302
+ export interface CategoryItemView {
303
+ /** Contribution name (e.g. provider `anthropic`, compactor `summarize`). */
304
+ readonly name: string;
305
+ /** Whether this item is the active default for its category. */
306
+ readonly isDefault: boolean;
307
+ }
308
+
309
+ /**
310
+ * One registry category in {@link PluginsAdminView.categories} — the active
311
+ * default plus the available items to swap between. Powers the per-category
312
+ * tabs in the `/plugins` picker and the `list_defaults` tool.
313
+ */
314
+ export interface CategoryView {
315
+ /** Registry kind, e.g. `provider` | `compactor` | `mode`. */
316
+ readonly category: string;
317
+ /** Active default contribution name, or null when the category is empty. */
318
+ readonly active: string | null;
319
+ /** The protected floor's name (never disableable), or null when none. */
320
+ readonly floor: string | null;
321
+ /** Swappable contributions registered for this category. */
322
+ readonly items: ReadonlyArray<CategoryItemView>;
292
323
  }
293
324
 
294
325
  /**
@@ -301,12 +332,24 @@ export interface LoadedPluginView {
301
332
  export interface PluginsAdminView {
302
333
  /** Currently-loaded plugins with their contribution kinds (for kind tabs). */
303
334
  loaded(): ReadonlyArray<LoadedPluginView>;
304
- /** Package names currently disabled (config `plugins[name].enabled=false`). */
335
+ /** Package names currently disabled (config `plugins.packages[name].enabled=false`). */
305
336
  disabled(): ReadonlyArray<string>;
337
+ /** Kernel package names that cannot be disabled (swap their default instead). */
338
+ protectedPackages(): ReadonlyArray<string>;
306
339
  /** Curated installable-plugin catalog for the picker's "Installable" tab. */
307
340
  catalog(): ReadonlyArray<InstallablePluginView>;
308
- /** Persist `plugins[name].enabled` AND apply it to the live session. */
341
+ /** Persist `plugins.packages[name].enabled` AND apply it to the live session. */
309
342
  setEnabled(packageName: string, enabled: boolean): Promise<void>;
343
+ /**
344
+ * Per-category active default + swappable items (the swap axis). Powers the
345
+ * `/plugins` category tabs and the `list_defaults` tool.
346
+ */
347
+ categories(): ReadonlyArray<CategoryView>;
348
+ /**
349
+ * Persist `plugins.<category>.default` AND apply it to the live session
350
+ * (`setActive`). Rejects an unknown category or an unregistered name.
351
+ */
352
+ setCategoryDefault(category: string, name: string): Promise<void>;
310
353
  }
311
354
 
312
355
  /**
@@ -3,7 +3,7 @@ import { createStuckLoopDetector, stableHash } from './mode-helpers.js';
3
3
 
4
4
  describe('createStuckLoopDetector', () => {
5
5
  it('trips on exact-input repeats at repeatThreshold', () => {
6
- const d = createStuckLoopDetector(); // repeatThreshold 3
6
+ const d = createStuckLoopDetector({ repeatThreshold: 3 });
7
7
  const input = { x: 1 };
8
8
  expect(d.record('Read', input).stuck).toBe(false);
9
9
  expect(d.record('Read', input).stuck).toBe(false);
@@ -11,8 +11,21 @@ describe('createStuckLoopDetector', () => {
11
11
  expect(sig).toMatchObject({ stuck: true, count: 3, kind: 'exact' });
12
12
  });
13
13
 
14
+ it('uses a generous default exact threshold (does not trip on a few repeats)', () => {
15
+ const d = createStuckLoopDetector(); // default repeatThreshold = 8
16
+ const input = { x: 1 };
17
+ for (let i = 0; i < 7; i++) expect(d.record('Read', input).stuck).toBe(false);
18
+ expect(d.record('Read', input).stuck).toBe(true); // 8th identical call trips
19
+ });
20
+
21
+ it('never trips when disabled (relies on maxIterations alone)', () => {
22
+ const d = createStuckLoopDetector({ enabled: false, repeatThreshold: 2 });
23
+ const input = { x: 1 };
24
+ for (let i = 0; i < 20; i++) expect(d.record('Read', input).stuck).toBe(false);
25
+ });
26
+
14
27
  it('trips on same-target near-dups even when volatile args vary', () => {
15
- const d = createStuckLoopDetector(); // nearThreshold 5
28
+ const d = createStuckLoopDetector({ repeatThreshold: 3 }); // nearThreshold 5
16
29
  const url = 'https://example.com/big';
17
30
  // Same url, different maxBytes each time — exact check never fires.
18
31
  for (let i = 0; i < 4; i++) {
@@ -44,7 +57,7 @@ describe('createStuckLoopDetector', () => {
44
57
  // dispatch path; a throw there crashes the whole turn. These assert it stays
45
58
  // total on hostile/partial input — the worst case the provider can hand us.
46
59
  it('does not throw recording a tool input with a circular reference', () => {
47
- const d = createStuckLoopDetector();
60
+ const d = createStuckLoopDetector({ repeatThreshold: 3 });
48
61
  const input: Record<string, unknown> = { a: 1 };
49
62
  input.self = input;
50
63
  expect(() => d.record('weird', input)).not.toThrow();
@@ -37,6 +37,7 @@ export async function* dispatchToolCall(
37
37
  cwd: ctx.cwd,
38
38
  log: ctx.log,
39
39
  env: ctx.env,
40
+ services: ctx.services,
40
41
  turnId: ctx.turnId,
41
42
  iteration,
42
43
  call: { callId: asToolCallId(t.id), name: t.name, input: t.input },