@moxxy/sdk 0.1.2 → 0.2.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 (114) hide show
  1. package/README.md +73 -73
  2. package/dist/client-session.d.ts +10 -0
  3. package/dist/client-session.d.ts.map +1 -1
  4. package/dist/compactor-helpers.d.ts +4 -1
  5. package/dist/compactor-helpers.d.ts.map +1 -1
  6. package/dist/compactor-helpers.js +50 -21
  7. package/dist/compactor-helpers.js.map +1 -1
  8. package/dist/define.d.ts +6 -0
  9. package/dist/define.d.ts.map +1 -1
  10. package/dist/define.js +9 -0
  11. package/dist/define.js.map +1 -1
  12. package/dist/embedding.d.ts +17 -0
  13. package/dist/embedding.d.ts.map +1 -1
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js.map +1 -1
  17. package/dist/fs-utils.d.ts +27 -0
  18. package/dist/fs-utils.d.ts.map +1 -0
  19. package/dist/fs-utils.js +44 -0
  20. package/dist/fs-utils.js.map +1 -0
  21. package/dist/http-utils.d.ts +16 -0
  22. package/dist/http-utils.d.ts.map +1 -0
  23. package/dist/http-utils.js +40 -0
  24. package/dist/http-utils.js.map +1 -0
  25. package/dist/index.d.ts +15 -8
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -4
  28. package/dist/index.js.map +1 -1
  29. package/dist/mode-helpers.d.ts +58 -0
  30. package/dist/mode-helpers.d.ts.map +1 -1
  31. package/dist/mode-helpers.js +119 -0
  32. package/dist/mode-helpers.js.map +1 -1
  33. package/dist/mode.d.ts +34 -0
  34. package/dist/mode.d.ts.map +1 -1
  35. package/dist/mutex.d.ts +15 -0
  36. package/dist/mutex.d.ts.map +1 -0
  37. package/dist/mutex.js +15 -0
  38. package/dist/mutex.js.map +1 -0
  39. package/dist/plugin.d.ts +36 -1
  40. package/dist/plugin.d.ts.map +1 -1
  41. package/dist/provider-utils.d.ts +6 -0
  42. package/dist/provider-utils.d.ts.map +1 -1
  43. package/dist/provider-utils.js +8 -0
  44. package/dist/provider-utils.js.map +1 -1
  45. package/dist/requirements.d.ts +1 -1
  46. package/dist/requirements.d.ts.map +1 -1
  47. package/dist/resolvers.d.ts +52 -0
  48. package/dist/resolvers.d.ts.map +1 -0
  49. package/dist/resolvers.js +123 -0
  50. package/dist/resolvers.js.map +1 -0
  51. package/dist/schemas.d.ts +28 -28
  52. package/dist/schemas.d.ts.map +1 -1
  53. package/dist/schemas.js +2 -0
  54. package/dist/schemas.js.map +1 -1
  55. package/dist/session-like.d.ts +98 -2
  56. package/dist/session-like.d.ts.map +1 -1
  57. package/dist/synthesizer.d.ts +63 -0
  58. package/dist/synthesizer.d.ts.map +1 -0
  59. package/dist/synthesizer.js +21 -0
  60. package/dist/synthesizer.js.map +1 -0
  61. package/dist/tool.d.ts +13 -0
  62. package/dist/tool.d.ts.map +1 -1
  63. package/dist/voice.d.ts +36 -0
  64. package/dist/voice.d.ts.map +1 -0
  65. package/dist/voice.js +82 -0
  66. package/dist/voice.js.map +1 -0
  67. package/dist/workflow.d.ts +144 -0
  68. package/dist/workflow.d.ts.map +1 -0
  69. package/dist/workflow.js +14 -0
  70. package/dist/workflow.js.map +1 -0
  71. package/package.json +14 -14
  72. package/src/client-session.ts +11 -0
  73. package/src/compactor-helpers.test.ts +41 -0
  74. package/src/compactor-helpers.ts +53 -19
  75. package/src/define.ts +15 -0
  76. package/src/embedding.ts +18 -0
  77. package/src/errors.ts +3 -0
  78. package/src/fs-utils.test.ts +61 -0
  79. package/src/fs-utils.ts +55 -0
  80. package/src/http-utils.test.ts +36 -0
  81. package/src/http-utils.ts +40 -0
  82. package/src/index.ts +69 -3
  83. package/src/mode-helpers.ts +169 -0
  84. package/src/mode.ts +35 -0
  85. package/src/mutex.test.ts +36 -0
  86. package/src/mutex.ts +31 -0
  87. package/src/plugin.ts +36 -1
  88. package/src/provider-utils.ts +9 -0
  89. package/src/requirements.ts +1 -0
  90. package/src/resolvers.test.ts +45 -0
  91. package/src/resolvers.ts +164 -0
  92. package/src/schemas.ts +2 -0
  93. package/src/session-like.ts +100 -2
  94. package/src/stuck-loop.test.ts +42 -0
  95. package/src/synthesizer.ts +67 -0
  96. package/src/tool.ts +13 -0
  97. package/src/voice.ts +103 -0
  98. package/src/workflow.ts +165 -0
  99. package/dist/command-sync.d.ts +0 -23
  100. package/dist/command-sync.d.ts.map +0 -1
  101. package/dist/command-sync.js +0 -36
  102. package/dist/command-sync.js.map +0 -1
  103. package/dist/loop-helpers.d.ts +0 -68
  104. package/dist/loop-helpers.d.ts.map +0 -1
  105. package/dist/loop-helpers.js +0 -283
  106. package/dist/loop-helpers.js.map +0 -1
  107. package/dist/loop.d.ts +0 -106
  108. package/dist/loop.d.ts.map +0 -1
  109. package/dist/loop.js +0 -2
  110. package/dist/loop.js.map +0 -1
  111. package/dist/plugin-kind.d.ts +0 -10
  112. package/dist/plugin-kind.d.ts.map +0 -1
  113. package/dist/plugin-kind.js +0 -31
  114. package/dist/plugin-kind.js.map +0 -1
@@ -12,6 +12,9 @@ import {
12
12
  toolResultStubbed,
13
13
  } from './elision-state.js';
14
14
  import { applyLazyTools } from './tool-gating.js';
15
+ import { runCompactionIfNeeded } from './compactor-helpers.js';
16
+ import { runElisionIfNeeded } from './elision-helpers.js';
17
+ import { usageEventFields } from './token-accounting.js';
15
18
 
16
19
  /**
17
20
  * Shared bits used by every loop strategy: a typed tool-use struct and a
@@ -481,3 +484,169 @@ export async function collectProviderStream(
481
484
  }
482
485
  return { text, toolUses: finalToolUses, stopReason, error, ...(usage ? { usage } : {}) };
483
486
  }
487
+
488
+ /**
489
+ * Run a single-shot (no-tools) provider turn — the shape every planner /
490
+ * synthesis phase shares. Runs context management (compaction + elision),
491
+ * emits the `provider_request` bookend, streams the response with tools
492
+ * disabled, then emits either an `error` event (returning `null`) or the
493
+ * `provider_response` bookend (returning the collected text).
494
+ *
495
+ * Replaces the ~40-line block each mode phase used to inline; centralizing it
496
+ * keeps event emission uniform and means a fix here (e.g. always running
497
+ * elision) lands for every loop strategy at once.
498
+ */
499
+ export async function runSingleShotTurn(
500
+ ctx: ModeContext,
501
+ messages: ReadonlyArray<ProviderMessage>,
502
+ opts: { maxTokens?: number } = {},
503
+ ): Promise<string | null> {
504
+ await runCompactionIfNeeded(ctx);
505
+ await runElisionIfNeeded(ctx);
506
+
507
+ await ctx.emit({
508
+ type: 'provider_request',
509
+ sessionId: ctx.sessionId,
510
+ turnId: ctx.turnId,
511
+ source: 'system',
512
+ provider: ctx.provider.name,
513
+ model: ctx.model,
514
+ });
515
+
516
+ const { text, usage, error } = await collectProviderStream(ctx, messages, {
517
+ includeTools: false,
518
+ ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
519
+ });
520
+ if (error) {
521
+ await ctx.emit({
522
+ type: 'error',
523
+ sessionId: ctx.sessionId,
524
+ turnId: ctx.turnId,
525
+ source: 'system',
526
+ kind: error.retryable ? 'retryable' : 'fatal',
527
+ message: error.message,
528
+ });
529
+ return null;
530
+ }
531
+
532
+ await ctx.emit({
533
+ type: 'provider_response',
534
+ sessionId: ctx.sessionId,
535
+ turnId: ctx.turnId,
536
+ source: 'system',
537
+ provider: ctx.provider.name,
538
+ model: ctx.model,
539
+ ...usageEventFields(usage),
540
+ });
541
+
542
+ return text;
543
+ }
544
+
545
+ /**
546
+ * Sliding-window detector for "model keeps making the same tool call".
547
+ *
548
+ * When the same `(toolName, input)` pair appears `repeatThreshold` times in
549
+ * the last `windowSize` calls, the model is almost certainly stuck — polling a
550
+ * tool that returns the same thing, mis-handling an error, etc. Bail early
551
+ * instead of burning through the iteration cap.
552
+ *
553
+ * Shared across every loop strategy so detection is uniform — previously each
554
+ * mode re-rolled this, and one copy used a non-canonical `JSON.stringify`
555
+ * signature that silently missed key-reordered repeats.
556
+ */
557
+ export interface StuckSignal {
558
+ /** True when the loop guard should trip. */
559
+ readonly stuck: boolean;
560
+ /** Repeat count behind the trip — for the error message. */
561
+ readonly count: number;
562
+ /**
563
+ * `exact` = the same (tool, full-input) repeated `repeatThreshold` times.
564
+ * `near` = the same (tool, identity arg — url / file_path / command / …)
565
+ * repeated `nearThreshold` times while only volatile args (maxBytes,
566
+ * timeoutMs) varied. Catches the "refetch the same URL with a bigger
567
+ * maxBytes over and over" loop the exact check sails past.
568
+ */
569
+ readonly kind: 'exact' | 'near';
570
+ }
571
+
572
+ export interface StuckLoopDetector {
573
+ readonly windowSize: number;
574
+ readonly repeatThreshold: number;
575
+ /** Record the call and report whether the loop guard should trip. */
576
+ record(toolName: string, input: unknown): StuckSignal;
577
+ }
578
+
579
+ /** Identity arguments that pin "the same target" across volatile-arg variation,
580
+ * best-first. The first present string field wins. */
581
+ const IDENTITY_ARG_KEYS = ['url', 'file_path', 'path', 'command', 'cmd', 'query', 'pattern'];
582
+
583
+ function identityArg(input: unknown): string | null {
584
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
585
+ const o = input as Record<string, unknown>;
586
+ for (const k of IDENTITY_ARG_KEYS) {
587
+ const v = o[k];
588
+ if (typeof v === 'string' && v.trim().length > 0) return `${k}=${v}`;
589
+ }
590
+ return null;
591
+ }
592
+
593
+ export function createStuckLoopDetector(
594
+ opts: {
595
+ windowSize?: number;
596
+ repeatThreshold?: number;
597
+ nearWindowSize?: number;
598
+ nearThreshold?: number;
599
+ } = {},
600
+ ): StuckLoopDetector {
601
+ const windowSize = opts.windowSize ?? 8;
602
+ const repeatThreshold = opts.repeatThreshold ?? 3;
603
+ // Near-dups need a higher count + a wider window (they're spread out across a
604
+ // burst of other calls), and tolerate a couple of legit "bigger refetch" tries.
605
+ const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 16);
606
+ const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
607
+ const recent: string[] = [];
608
+ const recentNear: string[] = [];
609
+ return {
610
+ windowSize,
611
+ repeatThreshold,
612
+ record(toolName, input): StuckSignal {
613
+ const key = `${toolName}|${stableHash(input)}`;
614
+ recent.push(key);
615
+ if (recent.length > windowSize) recent.shift();
616
+ const exactCount = recent.filter((k) => k === key).length;
617
+ if (exactCount >= repeatThreshold) return { stuck: true, count: exactCount, kind: 'exact' };
618
+
619
+ let nearCount = 0;
620
+ const id = identityArg(input);
621
+ if (id !== null) {
622
+ const nearKey = `${toolName}|${id}`;
623
+ recentNear.push(nearKey);
624
+ if (recentNear.length > nearWindowSize) recentNear.shift();
625
+ nearCount = recentNear.filter((k) => k === nearKey).length;
626
+ if (nearCount >= nearThreshold) return { stuck: true, count: nearCount, kind: 'near' };
627
+ }
628
+ return { stuck: false, count: Math.max(exactCount, nearCount), kind: 'exact' };
629
+ },
630
+ };
631
+ }
632
+
633
+ /**
634
+ * Stable, key-order-canonical hash of a tool call's input, so `{a:1,b:2}` and
635
+ * `{b:2,a:1}` produce the same key. Use for any "have I seen this call before"
636
+ * comparison — a raw `JSON.stringify` is NOT order-stable.
637
+ */
638
+ export function stableHash(input: unknown): string {
639
+ return canonicalize(input);
640
+ }
641
+
642
+ function canonicalize(value: unknown): string {
643
+ if (value === null || value === undefined) return 'null';
644
+ if (typeof value !== 'object') return JSON.stringify(value);
645
+ if (Array.isArray(value)) {
646
+ return '[' + value.map(canonicalize).join(',') + ']';
647
+ }
648
+ const entries = Object.entries(value as Record<string, unknown>).sort(
649
+ ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
650
+ );
651
+ return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalize(v)).join(',') + '}';
652
+ }
package/src/mode.ts CHANGED
@@ -88,6 +88,14 @@ export interface ModeContext {
88
88
  * Absent in synthetic test contexts that don't model a full Session.
89
89
  */
90
90
  readonly subagents?: SubagentSpawner;
91
+ /**
92
+ * Request the session switch its active mode AFTER this turn fully drains.
93
+ * Used by terminal workflow modes (BMAD finishing its last phase) to hand
94
+ * control back to a normal mode so the next message isn't trapped in the
95
+ * workflow. The switch is applied post-turn by the runner; an unknown /
96
+ * unregistered mode name is ignored. Absent in synthetic test contexts.
97
+ */
98
+ readonly requestModeSwitch?: (modeName: string) => void;
91
99
  emit(event: EmittedEvent): Promise<MoxxyEvent>;
92
100
  }
93
101
 
@@ -127,6 +135,28 @@ export interface ApprovalResolver {
127
135
  confirm(req: ApprovalRequest): Promise<ApprovalDecision>;
128
136
  }
129
137
 
138
+ /**
139
+ * Optional presentation hint a mode can advertise so channels render a
140
+ * persistent, accent-coloured badge while it is active — even mid-run,
141
+ * when the usual mode footer is hidden behind a "Thinking" marker. This
142
+ * lets a high-autonomy mode (e.g. goal mode, which auto-approves tools and
143
+ * keeps working unattended) always be obvious to the user. Modes that omit
144
+ * it get the plain `mode: <name>` footer treatment.
145
+ *
146
+ * Carried on {@link SessionInfo.activeModeBadge} so thin clients (desktop
147
+ * over RPC) see it too — keep it plain data (it crosses the wire).
148
+ */
149
+ export interface ModeBadge {
150
+ /** Short, uppercase label for the badge (e.g. "GOAL"). Keep it tiny. */
151
+ readonly label: string;
152
+ /**
153
+ * Accent tone each channel maps to its palette. `attention` for
154
+ * elevated / autonomous modes the user must always notice; `info` for a
155
+ * quieter highlight. Defaults to `info` when omitted.
156
+ */
157
+ readonly tone?: 'attention' | 'info';
158
+ }
159
+
130
160
  export interface ModeDef {
131
161
  readonly name: string;
132
162
  /**
@@ -135,5 +165,10 @@ export interface ModeDef {
135
165
  * memorising plugin internals. Keep short — the picker truncates.
136
166
  */
137
167
  readonly description?: string;
168
+ /**
169
+ * Optional presentation hint. When set, channels surface a persistent
170
+ * accent badge while this mode is active. See {@link ModeBadge}.
171
+ */
172
+ readonly badge?: ModeBadge;
138
173
  run(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
139
174
  }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createMutex } from './mutex.js';
3
+
4
+ const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
5
+
6
+ describe('createMutex', () => {
7
+ it('serializes overlapping runs (no interleave)', async () => {
8
+ const mutex = createMutex();
9
+ const order: string[] = [];
10
+ const slow = mutex.run(async () => {
11
+ order.push('a:start');
12
+ await tick(20);
13
+ order.push('a:end');
14
+ });
15
+ const fast = mutex.run(async () => {
16
+ order.push('b:start');
17
+ await tick(1);
18
+ order.push('b:end');
19
+ });
20
+ await Promise.all([slow, fast]);
21
+ expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']);
22
+ });
23
+
24
+ it('returns the callback result', async () => {
25
+ const mutex = createMutex();
26
+ await expect(mutex.run(() => 42)).resolves.toBe(42);
27
+ await expect(mutex.run(async () => 'x')).resolves.toBe('x');
28
+ });
29
+
30
+ it('keeps the lock alive after a rejection', async () => {
31
+ const mutex = createMutex();
32
+ await expect(mutex.run(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom');
33
+ // Next run must still execute — the chain isn't poisoned.
34
+ await expect(mutex.run(() => 'recovered')).resolves.toBe('recovered');
35
+ });
36
+ });
package/src/mutex.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Per-instance write mutex: a promise chain that serializes async mutators so
3
+ * two overlapping read-modify-write cycles can't both read the same snapshot
4
+ * and clobber each other.
5
+ *
6
+ * The single home for the framework's "serialize file-state mutators"
7
+ * invariant. Stores (vault, memory, permissions, scheduler, webhooks, …) hold
8
+ * one `Mutex` instance and run every mutation through {@link Mutex.run}.
9
+ */
10
+ export interface Mutex {
11
+ /** Run `fn` after all previously-queued runs settle. */
12
+ run<T>(fn: () => Promise<T> | T): Promise<T>;
13
+ }
14
+
15
+ export function createMutex(): Mutex {
16
+ let chain: Promise<unknown> = Promise.resolve();
17
+ return {
18
+ run<T>(fn: () => Promise<T> | T): Promise<T> {
19
+ // `.then(fn, fn)` runs `fn` whether the previous run resolved or
20
+ // rejected, so one failed mutation doesn't deadlock the queue.
21
+ const next = chain.then(fn as () => T, fn as () => T);
22
+ // Keep the chain alive across rejections — never let a rejected link
23
+ // permanently poison the lock.
24
+ chain = next.then(
25
+ () => undefined,
26
+ () => undefined,
27
+ );
28
+ return next;
29
+ },
30
+ };
31
+ }
package/src/plugin.ts CHANGED
@@ -3,16 +3,20 @@ import type { CacheStrategyDef } from './cache-strategy.js';
3
3
  import type { ChannelDef } from './channel.js';
4
4
  import type { CommandDef } from './command.js';
5
5
  import type { CompactorDef } from './compactor.js';
6
+ import type { EmbedderDef } from './embedding.js';
7
+ import type { Isolator } from './isolation.js';
6
8
  import type { LifecycleHooks } from './hooks.js';
7
9
  import type { ModeDef } from './mode.js';
8
10
  import type { ProviderDef } from './provider.js';
9
11
  import type { MoxxyRequirement } from './requirements.js';
10
12
  import type { ToolDef } from './tool.js';
11
13
  import type { TranscriberDef } from './transcriber.js';
14
+ import type { SynthesizerDef } from './synthesizer.js';
12
15
  import type { ViewRendererDef } from './view-renderer.js';
13
16
  import type { TunnelProviderDef } from './tunnel.js';
17
+ import type { WorkflowExecutorDef } from './workflow.js';
14
18
 
15
- export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber';
19
+ export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor';
16
20
 
17
21
  export interface PluginSpec {
18
22
  readonly name: string;
@@ -47,6 +51,30 @@ export interface PluginSpec {
47
51
  * the active provider does not advertise `supportsAudio`.
48
52
  */
49
53
  readonly transcribers?: ReadonlyArray<TranscriberDef>;
54
+ /**
55
+ * Text-to-speech backends contributed by the plugin. Selected by name via
56
+ * `session.synthesizers.setActive(name)`; read-aloud surfaces (the desktop's
57
+ * "Read aloud" button) call `session.synthesizers.getActive()` to turn text
58
+ * into audio. One active at a time; absent → the surface falls back to the
59
+ * OS voice.
60
+ */
61
+ readonly synthesizers?: ReadonlyArray<SynthesizerDef>;
62
+ /**
63
+ * Text-embedding backends contributed by the plugin. Selected by name via
64
+ * `session.embedders.setActive(name, config)`; @moxxy/plugin-memory uses the
65
+ * active embedder for semantic recall. `createClient` is lazy so a discovered
66
+ * embedder plugin never pulls its (often heavy) runtime in until selected.
67
+ */
68
+ readonly embedders?: ReadonlyArray<EmbedderDef>;
69
+ /**
70
+ * Capability isolators contributed by the plugin (worker_threads, subprocess,
71
+ * wasm, …). Registered into the active security layer's `IsolatorRegistry`
72
+ * and selected by name via `security.isolator` config. Registration alone is
73
+ * inert — a contributed isolator is NEVER auto-activated as the sandbox
74
+ * boundary; the user must opt in by name (so a rogue plugin can't silently
75
+ * weaken isolation).
76
+ */
77
+ readonly isolators?: ReadonlyArray<Isolator>;
50
78
  /**
51
79
  * Typed subagent kinds the plugin contributes. Each becomes
52
80
  * dispatchable as `dispatch_agent({ agentType: <name>, ... })`.
@@ -64,6 +92,13 @@ export interface PluginSpec {
64
92
  * pickers, raw-mode toggles) inside the channel itself.
65
93
  */
66
94
  readonly commands?: ReadonlyArray<CommandDef>;
95
+ /**
96
+ * Workflow-execution strategies contributed by the plugin. One is active per
97
+ * session (selected via `session.workflowExecutors.setActive(name)`); the
98
+ * active executor runs a workflow DAG. `@moxxy/plugin-workflows` ships the
99
+ * default `dag` executor.
100
+ */
101
+ readonly workflowExecutors?: ReadonlyArray<WorkflowExecutorDef>;
67
102
  readonly hooks?: LifecycleHooks;
68
103
  readonly skillsDir?: string;
69
104
  }
@@ -8,6 +8,15 @@ import { classifyNetworkError } from './errors.js';
8
8
  /** Canonical stop reasons emitted on `message_end` events. */
9
9
  export type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error';
10
10
 
11
+ /**
12
+ * Rough token estimate for a blob of text: ~4 chars per token. The shared
13
+ * fallback for a provider's `countTokens` when no native tokenizer is wired —
14
+ * previously each provider hardcoded `Math.ceil(text.length / 4)` independently.
15
+ */
16
+ export function estimateTextTokens(text: string): number {
17
+ return Math.ceil(text.length / 4);
18
+ }
19
+
11
20
  /**
12
21
  * Heuristic: should a provider request retry on this error? Used by stream
13
22
  * modes to attach a `retryable: boolean` flag to the `error` event so callers
@@ -3,6 +3,7 @@ export type RequirementKind =
3
3
  | 'provider'
4
4
  | 'tool'
5
5
  | 'transcriber'
6
+ | 'synthesizer'
6
7
  | 'mode'
7
8
  | 'compactor'
8
9
  | 'channel'
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { evaluateToolRule } from './resolvers.js';
3
+ import type { PendingToolCall, PermissionRule } from './permission.js';
4
+
5
+ function call(name: string, input: unknown = {}): PendingToolCall {
6
+ return { callId: 'c1', name, input } as unknown as PendingToolCall;
7
+ }
8
+
9
+ describe('evaluateToolRule', () => {
10
+ it('returns null when the tool declares no rule (defer to resolver)', () => {
11
+ expect(evaluateToolRule(undefined, call('reload_skills'))).toBeNull();
12
+ });
13
+
14
+ it('allows a tool that declares action allow', () => {
15
+ const rule: PermissionRule = { action: 'allow' };
16
+ expect(evaluateToolRule(rule, call('reload_skills'))).toEqual({
17
+ mode: 'allow',
18
+ reason: 'tool-declared allow',
19
+ });
20
+ });
21
+
22
+ it('denies a tool that declares action deny', () => {
23
+ const rule: PermissionRule = { action: 'deny', reason: 'nope' };
24
+ expect(evaluateToolRule(rule, call('danger'))).toEqual({ mode: 'deny', reason: 'nope' });
25
+ });
26
+
27
+ it('defers (null) for action prompt so the interactive resolver decides', () => {
28
+ expect(evaluateToolRule({ action: 'prompt' }, call('self_update_verify'))).toBeNull();
29
+ });
30
+
31
+ it('only applies when the name pattern matches', () => {
32
+ const rule: PermissionRule = { action: 'allow', pattern: { name: 'reload_skills' } };
33
+ expect(evaluateToolRule(rule, call('reload_skills'))).not.toBeNull();
34
+ expect(evaluateToolRule(rule, call('other_tool'))).toBeNull();
35
+ });
36
+
37
+ it('matches on inputMatches (string + RegExp)', () => {
38
+ const rule: PermissionRule = {
39
+ action: 'allow',
40
+ pattern: { inputMatches: { path: /^\/tmp\// } },
41
+ };
42
+ expect(evaluateToolRule(rule, call('write', { path: '/tmp/ok.txt' }))).not.toBeNull();
43
+ expect(evaluateToolRule(rule, call('write', { path: '/etc/passwd' }))).toBeNull();
44
+ });
45
+ });
@@ -0,0 +1,164 @@
1
+ import type {
2
+ PendingToolCall,
3
+ PermissionContext,
4
+ PermissionDecision,
5
+ PermissionResolver,
6
+ PermissionRule,
7
+ } from './permission.js';
8
+
9
+ export const autoAllowResolver: PermissionResolver = {
10
+ name: 'auto-allow',
11
+ async check(): Promise<PermissionDecision> {
12
+ return { mode: 'allow', reason: 'auto-allow resolver (test mode)' };
13
+ },
14
+ };
15
+
16
+ export const denyByDefaultResolver: PermissionResolver = {
17
+ name: 'deny-by-default',
18
+ async check(): Promise<PermissionDecision> {
19
+ return { mode: 'deny', reason: 'No interactive resolver available in headless mode. Use --allow-tools or permissions.json.' };
20
+ },
21
+ };
22
+
23
+ export interface CallbackResolverOptions {
24
+ readonly name?: string;
25
+ readonly callback: (call: PendingToolCall, ctx: PermissionContext) => Promise<PermissionDecision>;
26
+ }
27
+
28
+ export function createCallbackResolver(opts: CallbackResolverOptions): PermissionResolver {
29
+ return {
30
+ name: opts.name ?? 'callback',
31
+ check: opts.callback,
32
+ };
33
+ }
34
+
35
+ export function createAllowListResolver(toolNames: ReadonlyArray<string>): PermissionResolver {
36
+ const allowed = new Set(toolNames);
37
+ return {
38
+ name: 'allow-list',
39
+ async check(call) {
40
+ if (allowed.has(call.name)) return { mode: 'allow_session', reason: 'allow-list' };
41
+ return { mode: 'deny', reason: `Tool '${call.name}' not in allow-list` };
42
+ },
43
+ };
44
+ }
45
+
46
+ export type PermissionPromptHandler = (
47
+ call: PendingToolCall,
48
+ ctx: PermissionContext,
49
+ ) => Promise<PermissionDecision>;
50
+
51
+ export interface DeferredPermissionResolverOptions {
52
+ readonly prompt: PermissionPromptHandler;
53
+ readonly name?: string;
54
+ readonly sessionAllows?: Set<string>;
55
+ }
56
+
57
+ export interface DeferredPermissionResolver extends PermissionResolver {
58
+ /**
59
+ * Resolve all in-flight prompts with `deny`. Call from a channel's `stop`
60
+ * so a pending permission prompt doesn't hang forever when the host UI
61
+ * unmounts (the TUI bug the audit flagged).
62
+ */
63
+ abortAll(reason?: string): void;
64
+ }
65
+
66
+ /**
67
+ * Resolver for channels that defer permission decisions to an external UI
68
+ * (Ink dialog, Telegram inline keyboard, web form). Wraps a `prompt`
69
+ * callback with:
70
+ * - sessionAllows shortcut — `allow_session` decisions skip subsequent
71
+ * prompts for the same tool name.
72
+ * - in-flight tracking — `abortAll()` resolves pending prompts with
73
+ * `deny`, so the channel can shut down cleanly without hangs.
74
+ */
75
+ export function createDeferredPermissionResolver(
76
+ opts: DeferredPermissionResolverOptions,
77
+ ): DeferredPermissionResolver {
78
+ const sessionAllows = opts.sessionAllows ?? new Set<string>();
79
+ const pending = new Set<(d: PermissionDecision) => void>();
80
+ return {
81
+ name: opts.name ?? 'deferred',
82
+ async check(call, ctx) {
83
+ if (sessionAllows.has(call.name)) {
84
+ return { mode: 'allow_session', reason: 'allow_session previously granted' };
85
+ }
86
+ const decision = await new Promise<PermissionDecision>((resolve) => {
87
+ pending.add(resolve);
88
+ opts.prompt(call, ctx).then(
89
+ (d) => {
90
+ pending.delete(resolve);
91
+ resolve(d);
92
+ },
93
+ (err) => {
94
+ pending.delete(resolve);
95
+ resolve({ mode: 'deny', reason: err instanceof Error ? err.message : String(err) });
96
+ },
97
+ );
98
+ });
99
+ // Both allow_session and allow_always should skip future prompts for
100
+ // the same tool within this resolver instance. allow_always
101
+ // additionally signals to the caller (via the decision flag) that
102
+ // the rule should be persisted to ~/.moxxy/permissions.json — but
103
+ // that persistence isn't our job; the channel does it when wiring
104
+ // up the dialog.
105
+ if (decision.mode === 'allow_session' || decision.mode === 'allow_always') {
106
+ sessionAllows.add(call.name);
107
+ }
108
+ return decision;
109
+ },
110
+ abortAll(reason = 'channel closed') {
111
+ for (const r of pending) r({ mode: 'deny', reason });
112
+ pending.clear();
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Evaluate a tool's OWN declared {@link PermissionRule} against a call.
119
+ *
120
+ * Tools can ship a `permission` rule to express their author's policy — e.g.
121
+ * `reload_skills` / `load_tool` declare `{ action: 'allow' }` because they're
122
+ * safe, internal, idempotent operations that should never prompt. That rule
123
+ * was previously stored on the ToolDef but never consulted, so in headless
124
+ * runs (where the channel resolver denies by default) even these safe tools
125
+ * were blocked. The session resolver now consults this BETWEEN the user's
126
+ * `permissions.json` policy and the channel resolver, so:
127
+ *
128
+ * user policy (deny/allow) > tool-declared rule > channel resolver
129
+ *
130
+ * Returns a decision for a matching `allow`/`deny` rule, or `null` to defer
131
+ * (no rule, pattern didn't match, or `action: 'prompt'` — the interactive
132
+ * resolver should handle those).
133
+ */
134
+ export function evaluateToolRule(
135
+ rule: PermissionRule | undefined,
136
+ call: PendingToolCall,
137
+ ): PermissionDecision | null {
138
+ if (!rule || !toolRuleMatches(rule, call)) return null;
139
+ switch (rule.action) {
140
+ case 'allow':
141
+ return { mode: 'allow', reason: rule.reason ?? 'tool-declared allow' };
142
+ case 'deny':
143
+ return { mode: 'deny', reason: rule.reason ?? 'tool-declared deny' };
144
+ case 'prompt':
145
+ return null; // defer to the interactive resolver
146
+ }
147
+ }
148
+
149
+ function toolRuleMatches(rule: PermissionRule, call: PendingToolCall): boolean {
150
+ const p = rule.pattern;
151
+ if (!p) return true; // no pattern → applies to every call of this tool
152
+ if (p.name !== undefined && !patternMatch(p.name, call.name)) return false;
153
+ if (p.inputMatches) {
154
+ const input = (call.input ?? {}) as Record<string, unknown>;
155
+ for (const [key, matcher] of Object.entries(p.inputMatches)) {
156
+ if (!patternMatch(matcher, String(input[key] ?? ''))) return false;
157
+ }
158
+ }
159
+ return true;
160
+ }
161
+
162
+ function patternMatch(pattern: string | RegExp, candidate: string): boolean {
163
+ return pattern instanceof RegExp ? pattern.test(candidate) : pattern === candidate;
164
+ }
package/src/schemas.ts CHANGED
@@ -15,6 +15,7 @@ const pluginKindSchema = z.enum([
15
15
  'agent',
16
16
  'command',
17
17
  'transcriber',
18
+ 'synthesizer',
18
19
  ]);
19
20
 
20
21
  export const requirementSchema = z.object({
@@ -23,6 +24,7 @@ export const requirementSchema = z.object({
23
24
  'provider',
24
25
  'tool',
25
26
  'transcriber',
27
+ 'synthesizer',
26
28
  'mode',
27
29
  'compactor',
28
30
  'channel',