@moxxy/sdk 0.1.0 → 0.1.3

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 (79) hide show
  1. package/README.md +239 -0
  2. package/dist/compactor-helpers.d.ts +4 -1
  3. package/dist/compactor-helpers.d.ts.map +1 -1
  4. package/dist/compactor-helpers.js +50 -21
  5. package/dist/compactor-helpers.js.map +1 -1
  6. package/dist/define.d.ts +4 -0
  7. package/dist/define.d.ts.map +1 -1
  8. package/dist/define.js +6 -0
  9. package/dist/define.js.map +1 -1
  10. package/dist/embedding.d.ts +17 -0
  11. package/dist/embedding.d.ts.map +1 -1
  12. package/dist/errors.d.ts +1 -1
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js.map +1 -1
  15. package/dist/fs-utils.d.ts +27 -0
  16. package/dist/fs-utils.d.ts.map +1 -0
  17. package/dist/fs-utils.js +44 -0
  18. package/dist/fs-utils.js.map +1 -0
  19. package/dist/http-utils.d.ts +16 -0
  20. package/dist/http-utils.d.ts.map +1 -0
  21. package/dist/http-utils.js +40 -0
  22. package/dist/http-utils.js.map +1 -0
  23. package/dist/index.d.ts +12 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/mode-helpers.d.ts +58 -0
  28. package/dist/mode-helpers.d.ts.map +1 -1
  29. package/dist/mode-helpers.js +119 -0
  30. package/dist/mode-helpers.js.map +1 -1
  31. package/dist/mode.d.ts +8 -0
  32. package/dist/mode.d.ts.map +1 -1
  33. package/dist/mutex.d.ts +15 -0
  34. package/dist/mutex.d.ts.map +1 -0
  35. package/dist/mutex.js +15 -0
  36. package/dist/mutex.js.map +1 -0
  37. package/dist/plugin.d.ts +27 -1
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/provider-utils.d.ts +6 -0
  40. package/dist/provider-utils.d.ts.map +1 -1
  41. package/dist/provider-utils.js +8 -0
  42. package/dist/provider-utils.js.map +1 -1
  43. package/dist/resolvers.d.ts +52 -0
  44. package/dist/resolvers.d.ts.map +1 -0
  45. package/dist/resolvers.js +123 -0
  46. package/dist/resolvers.js.map +1 -0
  47. package/dist/session-like.d.ts +85 -1
  48. package/dist/session-like.d.ts.map +1 -1
  49. package/dist/voice.d.ts +36 -0
  50. package/dist/voice.d.ts.map +1 -0
  51. package/dist/voice.js +82 -0
  52. package/dist/voice.js.map +1 -0
  53. package/dist/workflow.d.ts +144 -0
  54. package/dist/workflow.d.ts.map +1 -0
  55. package/dist/workflow.js +14 -0
  56. package/dist/workflow.js.map +1 -0
  57. package/package.json +38 -2
  58. package/src/compactor-helpers.test.ts +41 -0
  59. package/src/compactor-helpers.ts +53 -19
  60. package/src/define.ts +10 -0
  61. package/src/embedding.ts +18 -0
  62. package/src/errors.ts +3 -0
  63. package/src/fs-utils.test.ts +61 -0
  64. package/src/fs-utils.ts +55 -0
  65. package/src/http-utils.test.ts +36 -0
  66. package/src/http-utils.ts +40 -0
  67. package/src/index.ts +58 -3
  68. package/src/mode-helpers.ts +169 -0
  69. package/src/mode.ts +8 -0
  70. package/src/mutex.test.ts +36 -0
  71. package/src/mutex.ts +31 -0
  72. package/src/plugin.ts +27 -1
  73. package/src/provider-utils.ts +9 -0
  74. package/src/resolvers.test.ts +45 -0
  75. package/src/resolvers.ts +164 -0
  76. package/src/session-like.ts +87 -1
  77. package/src/stuck-loop.test.ts +42 -0
  78. package/src/voice.ts +103 -0
  79. package/src/workflow.ts +165 -0
@@ -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
 
@@ -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,6 +3,8 @@ 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';
@@ -11,8 +13,9 @@ import type { ToolDef } from './tool.js';
11
13
  import type { TranscriberDef } from './transcriber.js';
12
14
  import type { ViewRendererDef } from './view-renderer.js';
13
15
  import type { TunnelProviderDef } from './tunnel.js';
16
+ import type { WorkflowExecutorDef } from './workflow.js';
14
17
 
15
- export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber';
18
+ export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'embedder' | 'isolator' | 'workflow-executor';
16
19
 
17
20
  export interface PluginSpec {
18
21
  readonly name: string;
@@ -47,6 +50,22 @@ export interface PluginSpec {
47
50
  * the active provider does not advertise `supportsAudio`.
48
51
  */
49
52
  readonly transcribers?: ReadonlyArray<TranscriberDef>;
53
+ /**
54
+ * Text-embedding backends contributed by the plugin. Selected by name via
55
+ * `session.embedders.setActive(name, config)`; @moxxy/plugin-memory uses the
56
+ * active embedder for semantic recall. `createClient` is lazy so a discovered
57
+ * embedder plugin never pulls its (often heavy) runtime in until selected.
58
+ */
59
+ readonly embedders?: ReadonlyArray<EmbedderDef>;
60
+ /**
61
+ * Capability isolators contributed by the plugin (worker_threads, subprocess,
62
+ * wasm, …). Registered into the active security layer's `IsolatorRegistry`
63
+ * and selected by name via `security.isolator` config. Registration alone is
64
+ * inert — a contributed isolator is NEVER auto-activated as the sandbox
65
+ * boundary; the user must opt in by name (so a rogue plugin can't silently
66
+ * weaken isolation).
67
+ */
68
+ readonly isolators?: ReadonlyArray<Isolator>;
50
69
  /**
51
70
  * Typed subagent kinds the plugin contributes. Each becomes
52
71
  * dispatchable as `dispatch_agent({ agentType: <name>, ... })`.
@@ -64,6 +83,13 @@ export interface PluginSpec {
64
83
  * pickers, raw-mode toggles) inside the channel itself.
65
84
  */
66
85
  readonly commands?: ReadonlyArray<CommandDef>;
86
+ /**
87
+ * Workflow-execution strategies contributed by the plugin. One is active per
88
+ * session (selected via `session.workflowExecutors.setActive(name)`); the
89
+ * active executor runs a workflow DAG. `@moxxy/plugin-workflows` ships the
90
+ * default `dag` executor.
91
+ */
92
+ readonly workflowExecutors?: ReadonlyArray<WorkflowExecutorDef>;
67
93
  readonly hooks?: LifecycleHooks;
68
94
  readonly skillsDir?: string;
69
95
  }
@@ -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
@@ -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
+ }
@@ -40,10 +40,23 @@ export interface SessionLogReader extends EventLogReader {
40
40
  subscribe(fn: (event: MoxxyEvent) => void | Promise<void>): () => void;
41
41
  }
42
42
 
43
- /** Serializable provider metadata (models + context windows) for display. */
43
+ /** How a provider authenticates. UIs use this to decide whether to
44
+ * show an API-key field or kick off an OAuth flow. */
45
+ export type ProviderAuthKind = 'api-key' | 'oauth';
46
+
47
+ /** Serializable provider metadata (models + context windows + auth)
48
+ * for display. */
44
49
  export interface ProviderInfo {
45
50
  readonly name: string;
46
51
  readonly models: ReadonlyArray<ModelDescriptor>;
52
+ /** 'oauth' when the provider declares an oauth login on its plugin
53
+ * definition, 'api-key' otherwise. Defaults to 'api-key' for
54
+ * providers that don't declare. */
55
+ readonly authKind: ProviderAuthKind;
56
+ /** True when the provider's plugin can list its models live (e.g.
57
+ * via /v1/models). Lets the desktop's model picker show a
58
+ * "Fetch live" affordance only where it makes sense. */
59
+ readonly supportsLiveModelDiscovery: boolean;
47
60
  }
48
61
 
49
62
  /** Serializable tool metadata for status lines / slash menus / compact rendering. */
@@ -94,6 +107,64 @@ export interface SessionInfo {
94
107
  readonly activeTranscriber: string | null;
95
108
  }
96
109
 
110
+ /**
111
+ * Resolves a provider's stored credentials (vault tokens / API keys) into the
112
+ * config object `providers.setActive` needs. The host installs one on a local
113
+ * Session at boot; it is undefined across a `RemoteSession` transport (a closure
114
+ * can't cross the wire — the runner side resolves credentials there instead).
115
+ */
116
+ export type CredentialResolver = (providerName: string) => Promise<Record<string, unknown>>;
117
+
118
+ /** One server's status in {@link McpAdminView.listServers}. */
119
+ export interface McpServerStatusView {
120
+ readonly name: string;
121
+ readonly enabled: boolean;
122
+ readonly connected: boolean;
123
+ }
124
+
125
+ /**
126
+ * The slice of the MCP admin API a channel needs to drive the MCP picker and
127
+ * status line. Present on a local Session when the MCP admin plugin is wired;
128
+ * a `RemoteSession` leaves {@link SessionLike.mcpAdmin} undefined and the UI
129
+ * degrades gracefully.
130
+ */
131
+ export interface McpAdminView {
132
+ enableAndAttach(name: string): Promise<{ toolNames: ReadonlyArray<string> } | null>;
133
+ detach(name: string): Promise<boolean>;
134
+ listServers(): Promise<ReadonlyArray<McpServerStatusView>>;
135
+ }
136
+
137
+ /** One workflow's summary for the `/workflows` modal. */
138
+ export interface WorkflowSummaryView {
139
+ readonly name: string;
140
+ readonly description: string;
141
+ readonly enabled: boolean;
142
+ readonly scope: string;
143
+ readonly steps: number;
144
+ /** Human-readable trigger summary, e.g. `cron(0 8 * * *)` or `on-demand`. */
145
+ readonly triggers: string;
146
+ }
147
+
148
+ /** Result of running a workflow from the modal. */
149
+ export interface WorkflowRunView {
150
+ readonly ok: boolean;
151
+ readonly output: string;
152
+ readonly error?: string;
153
+ readonly steps: ReadonlyArray<{ readonly id: string; readonly status: string; readonly error?: string }>;
154
+ }
155
+
156
+ /**
157
+ * The slice of the workflows API a channel needs to drive the `/workflows`
158
+ * modal (list, enable/disable toggle, run). Present on a local Session when
159
+ * `@moxxy/plugin-workflows` is wired; a `RemoteSession` leaves
160
+ * {@link SessionLike.workflows} undefined and the UI degrades gracefully.
161
+ */
162
+ export interface WorkflowsView {
163
+ list(): Promise<ReadonlyArray<WorkflowSummaryView>>;
164
+ setEnabled(name: string, enabled: boolean): Promise<void>;
165
+ run(name: string): Promise<WorkflowRunView>;
166
+ }
167
+
97
168
  /**
98
169
  * The session surface a `Channel` depends on, decoupled from whether the
99
170
  * session runs in-process (`@moxxy/core`'s `Session`) or is a thin-client
@@ -115,4 +186,19 @@ export interface SessionLike {
115
186
  /** Wire-friendly registry snapshot for rendering. */
116
187
  getInfo(): SessionInfo;
117
188
  close(reason?: string): Promise<void>;
189
+
190
+ /**
191
+ * Live runtime capabilities present only on an in-process Session; a
192
+ * `RemoteSession` thin client leaves them undefined, so callers MUST guard.
193
+ * For plain display prefer the serializable {@link getInfo} snapshot — these
194
+ * are for the mutate/guard paths a channel drives (provider switch, MCP picker).
195
+ */
196
+ /** Providers whose credentials resolved this session (live, mutable). */
197
+ readyProviders?: Set<string>;
198
+ /** Re-resolves a provider's credentials before `providers.setActive`. */
199
+ credentialResolver?: CredentialResolver;
200
+ /** MCP admin slice backing the MCP picker / status line. */
201
+ mcpAdmin?: McpAdminView;
202
+ /** Workflows slice backing the `/workflows` modal. */
203
+ workflows?: WorkflowsView;
118
204
  }