@moxxy/sdk 0.18.0 → 0.20.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 (57) 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 +17 -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 +5 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/mode/collect-stream.d.ts.map +1 -1
  16. package/dist/mode/collect-stream.js +1 -0
  17. package/dist/mode/collect-stream.js.map +1 -1
  18. package/dist/mode/stuck-loop.d.ts +20 -6
  19. package/dist/mode/stuck-loop.d.ts.map +1 -1
  20. package/dist/mode/stuck-loop.js +14 -3
  21. package/dist/mode/stuck-loop.js.map +1 -1
  22. package/dist/mode-helpers.d.ts +1 -1
  23. package/dist/mode-helpers.d.ts.map +1 -1
  24. package/dist/mode-helpers.js.map +1 -1
  25. package/dist/mode.d.ts +9 -0
  26. package/dist/mode.d.ts.map +1 -1
  27. package/dist/mode.js.map +1 -1
  28. package/dist/plugin.d.ts +10 -1
  29. package/dist/plugin.d.ts.map +1 -1
  30. package/dist/schemas.d.ts +7 -7
  31. package/dist/services.d.ts +47 -0
  32. package/dist/services.d.ts.map +1 -0
  33. package/dist/services.js +2 -0
  34. package/dist/services.js.map +1 -0
  35. package/dist/session-like.d.ts +50 -3
  36. package/dist/session-like.d.ts.map +1 -1
  37. package/dist/tool-dispatch.d.ts.map +1 -1
  38. package/dist/tool-dispatch.js +1 -0
  39. package/dist/tool-dispatch.js.map +1 -1
  40. package/dist/workflow.d.ts +20 -0
  41. package/dist/workflow.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/errors.ts +1 -0
  44. package/src/event-store.ts +118 -0
  45. package/src/events.ts +18 -0
  46. package/src/hooks.ts +6 -0
  47. package/src/index.ts +15 -0
  48. package/src/mode/collect-stream.ts +1 -0
  49. package/src/mode/stuck-loop.ts +28 -11
  50. package/src/mode-helpers.ts +1 -0
  51. package/src/mode.ts +9 -0
  52. package/src/plugin.ts +10 -1
  53. package/src/services.ts +47 -0
  54. package/src/session-like.ts +52 -3
  55. package/src/stuck-loop.test.ts +16 -3
  56. package/src/tool-dispatch.ts +1 -0
  57. package/src/workflow.ts +20 -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.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
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
+ }
@@ -1,4 +1,4 @@
1
- import type { MoxxyEvent, UserPromptAttachment } from './events.js';
1
+ import type { MoxxyEvent, TriggerOrigin, UserPromptAttachment } from './events.js';
2
2
  import type { SessionId, TurnId } from './ids.js';
3
3
  import type { EventLogReader } from './log.js';
4
4
  import type { ApprovalResolver, ModeBadge } from './mode.js';
@@ -22,6 +22,13 @@ export interface RunTurnOptions {
22
22
  readonly signal?: AbortSignal;
23
23
  /** Inline attachments shipped alongside the prompt (images, audio, stdin). */
24
24
  readonly attachments?: ReadonlyArray<UserPromptAttachment>;
25
+ /**
26
+ * Provenance for a machine-initiated turn (a fired webhook/schedule/workflow).
27
+ * Stamped onto the resulting {@link UserPromptEvent} so renderers show a
28
+ * compact "trigger fired" marker instead of the raw synthesized prompt. Omit
29
+ * for an ordinary user-typed turn.
30
+ */
31
+ readonly origin?: TriggerOrigin;
25
32
  /**
26
33
  * Pre-minted turn id. When omitted, `runTurn` mints one. The runner passes
27
34
  * this so it can return the id to the client *before* the turn starts and
@@ -191,6 +198,12 @@ export interface WorkflowSummaryView {
191
198
  readonly steps: number;
192
199
  /** Human-readable trigger summary, e.g. `cron(0 8 * * *)` or `on-demand`. */
193
200
  readonly triggers: string;
201
+ /**
202
+ * Session this workflow's triggered runs are pinned to (where they run +
203
+ * display), or null when unpinned. Optional for wire back-compat — an older
204
+ * runner omits it; the desktop treats absent as null.
205
+ */
206
+ readonly targetSessionId?: string | null;
194
207
  }
195
208
 
196
209
  /** Result of running a workflow from the modal. */
@@ -278,6 +291,30 @@ export interface LoadedPluginView {
278
291
  readonly kinds: ReadonlyArray<string>;
279
292
  }
280
293
 
294
+ /** One swappable contribution within a {@link CategoryView}. */
295
+ export interface CategoryItemView {
296
+ /** Contribution name (e.g. provider `anthropic`, compactor `summarize`). */
297
+ readonly name: string;
298
+ /** Whether this item is the active default for its category. */
299
+ readonly isDefault: boolean;
300
+ }
301
+
302
+ /**
303
+ * One registry category in {@link PluginsAdminView.categories} — the active
304
+ * default plus the available items to swap between. Powers the per-category
305
+ * tabs in the `/plugins` picker and the `list_defaults` tool.
306
+ */
307
+ export interface CategoryView {
308
+ /** Registry kind, e.g. `provider` | `compactor` | `mode`. */
309
+ readonly category: string;
310
+ /** Active default contribution name, or null when the category is empty. */
311
+ readonly active: string | null;
312
+ /** The protected floor's name (never disableable), or null when none. */
313
+ readonly floor: string | null;
314
+ /** Swappable contributions registered for this category. */
315
+ readonly items: ReadonlyArray<CategoryItemView>;
316
+ }
317
+
281
318
  /**
282
319
  * The slice of plugin management a channel needs to drive the `/plugins`
283
320
  * picker: which plugins are disabled, what's installable, and a persist+
@@ -288,12 +325,24 @@ export interface LoadedPluginView {
288
325
  export interface PluginsAdminView {
289
326
  /** Currently-loaded plugins with their contribution kinds (for kind tabs). */
290
327
  loaded(): ReadonlyArray<LoadedPluginView>;
291
- /** Package names currently disabled (config `plugins[name].enabled=false`). */
328
+ /** Package names currently disabled (config `plugins.packages[name].enabled=false`). */
292
329
  disabled(): ReadonlyArray<string>;
330
+ /** Kernel package names that cannot be disabled (swap their default instead). */
331
+ protectedPackages(): ReadonlyArray<string>;
293
332
  /** Curated installable-plugin catalog for the picker's "Installable" tab. */
294
333
  catalog(): ReadonlyArray<InstallablePluginView>;
295
- /** Persist `plugins[name].enabled` AND apply it to the live session. */
334
+ /** Persist `plugins.packages[name].enabled` AND apply it to the live session. */
296
335
  setEnabled(packageName: string, enabled: boolean): Promise<void>;
336
+ /**
337
+ * Per-category active default + swappable items (the swap axis). Powers the
338
+ * `/plugins` category tabs and the `list_defaults` tool.
339
+ */
340
+ categories(): ReadonlyArray<CategoryView>;
341
+ /**
342
+ * Persist `plugins.<category>.default` AND apply it to the live session
343
+ * (`setActive`). Rejects an unknown category or an unregistered name.
344
+ */
345
+ setCategoryDefault(category: string, name: string): Promise<void>;
297
346
  }
298
347
 
299
348
  /**
@@ -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 },
package/src/workflow.ts CHANGED
@@ -151,6 +151,26 @@ export interface Workflow {
151
151
  readonly enabled: boolean;
152
152
  readonly inputs: Record<string, WorkflowInputSpec>;
153
153
  readonly on?: WorkflowTrigger;
154
+ /**
155
+ * Pin this workflow's *triggered* runs (schedule / fileChanged / afterWorkflow)
156
+ * to one chosen runner's session — identified by that runner's
157
+ * `MOXXY_SESSION_ID` — so the run executes and displays in the session the user
158
+ * picked. On the desktop, one `moxxy serve` runner == one session.
159
+ *
160
+ * - schedule: the scheduler mirror row is stamped with this as its owner, so the
161
+ * existing poller owner-gate fires the run on the target runner.
162
+ * - fileChanged: only the runner whose id matches watches/fires the trigger. If
163
+ * that runner is offline when the file changes, the event is missed — there is
164
+ * no fs-event replay (unlike webhooks/schedules, which catch up).
165
+ * - afterWorkflow: a dependent pinned to a *different* session than the runner
166
+ * that observed the parent's completion is skipped (the completion event is
167
+ * in-process); co-locate the chain or use a schedule trigger for fan-out.
168
+ *
169
+ * Unset (the default) preserves today's ambient behavior: triggered runs fire
170
+ * once across runners via the cross-process lock. Ignored in single-process
171
+ * CLI/TUI (no `MOXXY_SESSION_ID`).
172
+ */
173
+ readonly targetSessionId?: string;
154
174
  readonly delivery?: WorkflowDelivery;
155
175
  /** GUI-only metadata persisted with the YAML artifact. */
156
176
  readonly ui?: WorkflowUi;