@moxxy/sdk 0.1.2 → 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 (96) hide show
  1. package/README.md +73 -73
  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/schemas.d.ts +24 -24
  48. package/dist/session-like.d.ts +85 -1
  49. package/dist/session-like.d.ts.map +1 -1
  50. package/dist/voice.d.ts +36 -0
  51. package/dist/voice.d.ts.map +1 -0
  52. package/dist/voice.js +82 -0
  53. package/dist/voice.js.map +1 -0
  54. package/dist/workflow.d.ts +144 -0
  55. package/dist/workflow.d.ts.map +1 -0
  56. package/dist/workflow.js +14 -0
  57. package/dist/workflow.js.map +1 -0
  58. package/package.json +14 -14
  59. package/src/compactor-helpers.test.ts +41 -0
  60. package/src/compactor-helpers.ts +53 -19
  61. package/src/define.ts +10 -0
  62. package/src/embedding.ts +18 -0
  63. package/src/errors.ts +3 -0
  64. package/src/fs-utils.test.ts +61 -0
  65. package/src/fs-utils.ts +55 -0
  66. package/src/http-utils.test.ts +36 -0
  67. package/src/http-utils.ts +40 -0
  68. package/src/index.ts +58 -3
  69. package/src/mode-helpers.ts +169 -0
  70. package/src/mode.ts +8 -0
  71. package/src/mutex.test.ts +36 -0
  72. package/src/mutex.ts +31 -0
  73. package/src/plugin.ts +27 -1
  74. package/src/provider-utils.ts +9 -0
  75. package/src/resolvers.test.ts +45 -0
  76. package/src/resolvers.ts +164 -0
  77. package/src/session-like.ts +87 -1
  78. package/src/stuck-loop.test.ts +42 -0
  79. package/src/voice.ts +103 -0
  80. package/src/workflow.ts +165 -0
  81. package/dist/command-sync.d.ts +0 -23
  82. package/dist/command-sync.d.ts.map +0 -1
  83. package/dist/command-sync.js +0 -36
  84. package/dist/command-sync.js.map +0 -1
  85. package/dist/loop-helpers.d.ts +0 -68
  86. package/dist/loop-helpers.d.ts.map +0 -1
  87. package/dist/loop-helpers.js +0 -283
  88. package/dist/loop-helpers.js.map +0 -1
  89. package/dist/loop.d.ts +0 -106
  90. package/dist/loop.d.ts.map +0 -1
  91. package/dist/loop.js +0 -2
  92. package/dist/loop.js.map +0 -1
  93. package/dist/plugin-kind.d.ts +0 -10
  94. package/dist/plugin-kind.d.ts.map +0 -1
  95. package/dist/plugin-kind.js +0 -31
  96. package/dist/plugin-kind.js.map +0 -1
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ export interface WriteFileAtomicOptions {
7
+ /** Mode for the final file, e.g. `0o600` for secrets. Enforced past umask. */
8
+ readonly mode?: number;
9
+ /** Encoding when `data` is a string. Defaults to `'utf8'`. Ignored for bytes. */
10
+ readonly encoding?: BufferEncoding;
11
+ }
12
+
13
+ /**
14
+ * Crash-atomic file write: write a unique sibling temp file, then `rename` it
15
+ * over the target. POSIX `rename` is atomic on the same filesystem, so a crash
16
+ * (or full disk) mid-write leaves the previous file intact rather than a
17
+ * truncated one. The temp name carries pid + a random UUID so concurrent
18
+ * writers to the same target never collide on the temp path.
19
+ *
20
+ * This is the single home for the framework's "persist atomically" invariant —
21
+ * every file-state writer (vault, memory, permissions, sessions, the Write/Edit
22
+ * tools) should call this instead of hand-rolling tmp+rename or writing in place.
23
+ */
24
+ export async function writeFileAtomic(
25
+ target: string,
26
+ data: string | Uint8Array,
27
+ opts: WriteFileAtomicOptions = {},
28
+ ): Promise<void> {
29
+ await mkdir(dirname(target), { recursive: true });
30
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
31
+ try {
32
+ await writeFile(tmp, data, { encoding: opts.encoding ?? 'utf8' });
33
+ // chmod explicitly: writeFile's mode option is masked by umask, but a
34
+ // 0o600 secret file must be exactly 0o600 regardless of the host umask.
35
+ if (opts.mode != null) await chmod(tmp, opts.mode);
36
+ await rename(tmp, target);
37
+ } catch (err) {
38
+ await rm(tmp, { force: true }).catch(() => {});
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The moxxy home directory: `$MOXXY_HOME` when set, else `~/.moxxy`. Single
45
+ * source of truth so the env override is honored uniformly — previously half
46
+ * the plugins inlined `~/.moxxy` and ignored `MOXXY_HOME`.
47
+ */
48
+ export function moxxyHome(): string {
49
+ return process.env.MOXXY_HOME ?? join(homedir(), '.moxxy');
50
+ }
51
+
52
+ /** Join path segments under {@link moxxyHome}. */
53
+ export function moxxyPath(...segments: string[]): string {
54
+ return join(moxxyHome(), ...segments);
55
+ }
@@ -0,0 +1,36 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { Readable } from 'node:stream';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { bearerTokenMatches, readRequestBody } from './http-utils.js';
5
+
6
+ function fakeReq(body: string | Buffer): IncomingMessage {
7
+ return Readable.from([Buffer.from(body)]) as unknown as IncomingMessage;
8
+ }
9
+
10
+ describe('bearerTokenMatches', () => {
11
+ it('matches an identical token', () => {
12
+ expect(bearerTokenMatches('s3cret', 's3cret')).toBe(true);
13
+ });
14
+ it('rejects a different token of equal length', () => {
15
+ expect(bearerTokenMatches('aaaaaa', 'bbbbbb')).toBe(false);
16
+ });
17
+ it('rejects a length mismatch without throwing', () => {
18
+ expect(bearerTokenMatches('short', 'longer-token')).toBe(false);
19
+ });
20
+ it('rejects empty/missing presented tokens', () => {
21
+ expect(bearerTokenMatches(undefined, 'x')).toBe(false);
22
+ expect(bearerTokenMatches(null, 'x')).toBe(false);
23
+ expect(bearerTokenMatches('', 'x')).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe('readRequestBody', () => {
28
+ it('reads the full body', async () => {
29
+ const buf = await readRequestBody(fakeReq('hello world'), 1024);
30
+ expect(buf.toString('utf8')).toBe('hello world');
31
+ });
32
+
33
+ it('rejects when the body exceeds maxBytes', async () => {
34
+ await expect(readRequestBody(fakeReq('x'.repeat(100)), 10)).rejects.toThrow(/exceeds 10 bytes/);
35
+ });
36
+ });
@@ -0,0 +1,40 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import type { IncomingMessage } from 'node:http';
3
+
4
+ /**
5
+ * Read a request body into a Buffer, rejecting (and destroying the socket) once
6
+ * `maxBytes` is exceeded. Bounds in-memory buffering so a malicious or runaway
7
+ * client can't exhaust the host's memory. Shared by every HTTP channel/listener.
8
+ */
9
+ export async function readRequestBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
10
+ return new Promise<Buffer>((resolve, reject) => {
11
+ const chunks: Buffer[] = [];
12
+ let size = 0;
13
+ req.on('data', (chunk: Buffer) => {
14
+ size += chunk.length;
15
+ if (size > maxBytes) {
16
+ reject(new Error(`request body exceeds ${maxBytes} bytes`));
17
+ req.destroy();
18
+ return;
19
+ }
20
+ chunks.push(chunk);
21
+ });
22
+ req.on('end', () => resolve(Buffer.concat(chunks)));
23
+ req.on('error', reject);
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Constant-time comparison of a presented bearer token against the expected
29
+ * value. A length mismatch returns false WITHOUT calling `timingSafeEqual`
30
+ * (which throws on unequal lengths and would leak the length); equal-length
31
+ * inputs are compared in constant time so an attacker can't recover the token
32
+ * byte-by-byte via response timing.
33
+ */
34
+ export function bearerTokenMatches(presented: string | undefined | null, expected: string): boolean {
35
+ if (!presented) return false;
36
+ const a = Buffer.from(presented);
37
+ const b = Buffer.from(expected);
38
+ if (a.length !== b.length) return false;
39
+ return timingSafeEqual(a, b);
40
+ }
package/src/index.ts CHANGED
@@ -47,6 +47,12 @@ export type {
47
47
  ToolInfo,
48
48
  SkillInfo,
49
49
  CommandInfo,
50
+ CredentialResolver,
51
+ McpAdminView,
52
+ McpServerStatusView,
53
+ WorkflowsView,
54
+ WorkflowSummaryView,
55
+ WorkflowRunView,
50
56
  } from './session-like.js';
51
57
 
52
58
  export type {
@@ -135,7 +141,22 @@ export type {
135
141
  } from './view-renderer.js';
136
142
  export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS } from './view-renderer.js';
137
143
  export type { TunnelProviderDef, TunnelHandle, TunnelOpenOptions } from './tunnel.js';
138
- export { isRetryableError, toFriendlyError, zodToJsonSchema, type StopReason } from './provider-utils.js';
144
+ export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
145
+ export { writeFileAtomic, moxxyHome, moxxyPath, type WriteFileAtomicOptions } from './fs-utils.js';
146
+ export { createMutex, type Mutex } from './mutex.js';
147
+ export { readRequestBody, bearerTokenMatches } from './http-utils.js';
148
+ export {
149
+ autoAllowResolver,
150
+ denyByDefaultResolver,
151
+ createCallbackResolver,
152
+ createAllowListResolver,
153
+ createDeferredPermissionResolver,
154
+ evaluateToolRule,
155
+ type CallbackResolverOptions,
156
+ type PermissionPromptHandler,
157
+ type DeferredPermissionResolver,
158
+ type DeferredPermissionResolverOptions,
159
+ } from './resolvers.js';
139
160
  export {
140
161
  MoxxyError,
141
162
  classifyHttpStatus,
@@ -145,18 +166,27 @@ export {
145
166
  } from './errors.js';
146
167
  export {
147
168
  collectProviderStream,
169
+ runSingleShotTurn,
148
170
  projectMessagesFromLog,
149
171
  projectMessages,
150
172
  buildSystemPromptWithSkills,
173
+ createStuckLoopDetector,
174
+ stableHash,
151
175
  type CollectedToolUse,
152
176
  type StreamResult,
153
177
  type ProjectMessagesOptions,
154
178
  type ProjectedMessages,
179
+ type StuckLoopDetector,
180
+ type StuckSignal,
155
181
  } from './mode-helpers.js';
156
182
  export { dispatchToolCall } from './tool-dispatch.js';
157
183
 
158
184
  export type { TokenBudget, CompactContext, CompactorDef } from './compactor.js';
159
- export { estimateContextTokens, runCompactionIfNeeded } from './compactor-helpers.js';
185
+ export {
186
+ estimateContextTokens,
187
+ runCompactionIfNeeded,
188
+ isContextOverflowError,
189
+ } from './compactor-helpers.js';
160
190
  export {
161
191
  runElisionIfNeeded,
162
192
  resolveElisionSettings,
@@ -192,6 +222,23 @@ export {
192
222
 
193
223
  export type { Skill, SkillDef, SkillFrontmatter, SkillScope, SkillSchedule } from './skill.js';
194
224
 
225
+ export type {
226
+ Workflow,
227
+ WorkflowStep,
228
+ WorkflowTrigger,
229
+ WorkflowStepErrorMode,
230
+ WorkflowInputSpec,
231
+ WorkflowDelivery,
232
+ WorkflowToolRunner,
233
+ WorkflowLookup,
234
+ WorkflowEventSubtype,
235
+ WorkflowRunDeps,
236
+ WorkflowStepStatus,
237
+ WorkflowStepResult,
238
+ WorkflowRunResult,
239
+ WorkflowExecutorDef,
240
+ } from './workflow.js';
241
+
195
242
  export type { AgentDef } from './agent.js';
196
243
 
197
244
  export type {
@@ -245,7 +292,7 @@ export type {
245
292
  ChannelSubcommandContext,
246
293
  ChannelCommandArgs,
247
294
  } from './channel.js';
248
- export type { EmbeddingProvider } from './embedding.js';
295
+ export type { EmbeddingProvider, EmbedderDef } from './embedding.js';
249
296
  export { CachedEmbeddingProvider } from './embedding-cache.js';
250
297
 
251
298
  export type {
@@ -281,8 +328,10 @@ export {
281
328
  definePermission,
282
329
  defineSkill,
283
330
  defineTranscriber,
331
+ defineEmbedder,
284
332
  defineCommand,
285
333
  defineAgent,
334
+ defineWorkflowExecutor,
286
335
  } from './define.js';
287
336
 
288
337
  export {
@@ -301,4 +350,10 @@ export {
301
350
  type InstallTarget,
302
351
  } from './install-hints.js';
303
352
 
353
+ export {
354
+ checkTranscriberReady,
355
+ pickFirstAvailableTranscriber,
356
+ resolveTranscriber,
357
+ } from './voice.js';
358
+
304
359
  export { z } from 'zod';
@@ -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
+ });