@moxxy/sdk 0.11.0 → 0.13.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 (73) hide show
  1. package/dist/assert.d.ts +19 -0
  2. package/dist/assert.d.ts.map +1 -0
  3. package/dist/assert.js +29 -0
  4. package/dist/assert.js.map +1 -0
  5. package/dist/elision-state.d.ts.map +1 -1
  6. package/dist/elision-state.js +6 -0
  7. package/dist/elision-state.js.map +1 -1
  8. package/dist/events.d.ts +33 -1
  9. package/dist/events.d.ts.map +1 -1
  10. package/dist/fs-utils.d.ts +8 -0
  11. package/dist/fs-utils.d.ts.map +1 -1
  12. package/dist/fs-utils.js +29 -0
  13. package/dist/fs-utils.js.map +1 -1
  14. package/dist/index.d.ts +11 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/mode-helpers.d.ts +12 -0
  19. package/dist/mode-helpers.d.ts.map +1 -1
  20. package/dist/mode-helpers.js +105 -3
  21. package/dist/mode-helpers.js.map +1 -1
  22. package/dist/mode.d.ts +9 -0
  23. package/dist/mode.d.ts.map +1 -1
  24. package/dist/mode.js.map +1 -1
  25. package/dist/plugin.d.ts +10 -1
  26. package/dist/plugin.d.ts.map +1 -1
  27. package/dist/provider-login-bridge.d.ts +53 -0
  28. package/dist/provider-login-bridge.d.ts.map +1 -0
  29. package/dist/provider-login-bridge.js +95 -0
  30. package/dist/provider-login-bridge.js.map +1 -0
  31. package/dist/provider.d.ts +53 -0
  32. package/dist/provider.d.ts.map +1 -1
  33. package/dist/semver.d.ts +16 -0
  34. package/dist/semver.d.ts.map +1 -0
  35. package/dist/semver.js +28 -0
  36. package/dist/semver.js.map +1 -0
  37. package/dist/subagent.d.ts +6 -0
  38. package/dist/subagent.d.ts.map +1 -1
  39. package/dist/surface.d.ts +159 -0
  40. package/dist/surface.d.ts.map +1 -0
  41. package/dist/surface.js +27 -0
  42. package/dist/surface.js.map +1 -0
  43. package/dist/tool-display.d.ts +73 -0
  44. package/dist/tool-display.d.ts.map +1 -0
  45. package/dist/tool-display.js +66 -0
  46. package/dist/tool-display.js.map +1 -0
  47. package/dist/view-renderer.d.ts +6 -0
  48. package/dist/view-renderer.d.ts.map +1 -1
  49. package/dist/view-renderer.js +18 -1
  50. package/dist/view-renderer.js.map +1 -1
  51. package/package.json +7 -3
  52. package/src/assert.test.ts +35 -0
  53. package/src/assert.ts +28 -0
  54. package/src/elision-state.ts +5 -0
  55. package/src/events.ts +36 -0
  56. package/src/fs-utils.test.ts +47 -1
  57. package/src/fs-utils.ts +31 -0
  58. package/src/index.ts +51 -2
  59. package/src/loop-helpers.test.ts +40 -0
  60. package/src/mode-helpers.ts +114 -3
  61. package/src/mode.ts +7 -0
  62. package/src/plugin.ts +10 -1
  63. package/src/provider-login-bridge.test.ts +78 -0
  64. package/src/provider-login-bridge.ts +105 -0
  65. package/src/provider.ts +45 -2
  66. package/src/semver.test.ts +43 -0
  67. package/src/semver.ts +27 -0
  68. package/src/subagent.ts +6 -0
  69. package/src/surface.ts +173 -0
  70. package/src/tool-display.test.ts +71 -0
  71. package/src/tool-display.ts +118 -0
  72. package/src/view-renderer.test.ts +57 -0
  73. package/src/view-renderer.ts +18 -1
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
- import { moxxyHome, moxxyPath, writeFileAtomic } from './fs-utils.js';
5
+ import { moxxyHome, moxxyPath, writeFileAtomic, writeFileAtomicSync } from './fs-utils.js';
6
6
 
7
7
  describe('writeFileAtomic', () => {
8
8
  let dir: string;
@@ -41,6 +41,52 @@ describe('writeFileAtomic', () => {
41
41
  });
42
42
  });
43
43
 
44
+ describe('writeFileAtomicSync', () => {
45
+ let dir: string;
46
+ beforeEach(async () => {
47
+ dir = await mkdtemp(join(tmpdir(), 'moxxy-fss-'));
48
+ });
49
+
50
+ it('writes content and creates missing parent dirs', async () => {
51
+ const target = join(dir, 'nested', 'deep', 'file.json');
52
+ writeFileAtomicSync(target, '{"a":1}');
53
+ expect(await readFile(target, 'utf8')).toBe('{"a":1}');
54
+ });
55
+
56
+ it('overwrites an existing file and leaves no temp file behind', async () => {
57
+ const target = join(dir, 'file.txt');
58
+ writeFileAtomicSync(target, 'first');
59
+ writeFileAtomicSync(target, 'second');
60
+ expect(await readFile(target, 'utf8')).toBe('second');
61
+ const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
62
+ expect(leftovers).toEqual([]);
63
+ });
64
+
65
+ it('enforces the requested mode past umask', async () => {
66
+ const target = join(dir, 'secret.json');
67
+ writeFileAtomicSync(target, 'shh', { mode: 0o600 });
68
+ const mode = (await stat(target)).mode & 0o777;
69
+ expect(mode).toBe(0o600);
70
+ });
71
+
72
+ it('writes binary data unchanged', async () => {
73
+ const target = join(dir, 'bytes.bin');
74
+ writeFileAtomicSync(target, new Uint8Array([0, 1, 2, 255]));
75
+ expect(Array.from(await readFile(target))).toEqual([0, 1, 2, 255]);
76
+ });
77
+
78
+ it('cleans up the temp file and throws when rename fails', async () => {
79
+ // A directory in place of the final file makes renameSync fail; the temp
80
+ // file must be removed and the error surfaced rather than swallowed.
81
+ const { mkdir } = await import('node:fs/promises');
82
+ const target = join(dir, 'as-dir');
83
+ await mkdir(target);
84
+ expect(() => writeFileAtomicSync(target, 'nope')).toThrow();
85
+ const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
86
+ expect(leftovers).toEqual([]);
87
+ });
88
+ });
89
+
44
90
  describe('moxxyHome / moxxyPath', () => {
45
91
  const original = process.env.MOXXY_HOME;
46
92
  afterEach(() => {
package/src/fs-utils.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
3
  import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
5
  import { dirname, join } from 'node:path';
@@ -40,6 +41,36 @@ export async function writeFileAtomic(
40
41
  }
41
42
  }
42
43
 
44
+ /**
45
+ * Synchronous twin of {@link writeFileAtomic}, for the few call sites that
46
+ * cannot be async — boot/bootstrap and self-update gates that run before the
47
+ * event loop is the right tool. Same crash-atomic guarantee: write a unique
48
+ * sibling temp file, then `rename` it over the target. Hand-rolled
49
+ * `mkdirSync + writeFileSync(tmp) + renameSync` copies should call this instead.
50
+ */
51
+ export function writeFileAtomicSync(
52
+ target: string,
53
+ data: string | Uint8Array,
54
+ opts: WriteFileAtomicOptions = {},
55
+ ): void {
56
+ mkdirSync(dirname(target), { recursive: true });
57
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
58
+ try {
59
+ writeFileSync(tmp, data, { encoding: opts.encoding ?? 'utf8' });
60
+ // chmod explicitly: writeFileSync's mode option is masked by umask, but a
61
+ // 0o600 secret file must be exactly 0o600 regardless of the host umask.
62
+ if (opts.mode != null) chmodSync(tmp, opts.mode);
63
+ renameSync(tmp, target);
64
+ } catch (err) {
65
+ try {
66
+ rmSync(tmp, { force: true });
67
+ } catch {
68
+ /* best-effort cleanup */
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
43
74
  /**
44
75
  * The moxxy home directory: `$MOXXY_HOME` when set, else `~/.moxxy`. Single
45
76
  * source of truth so the env override is honored uniformly — previously half
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export type {
19
19
  UserPromptAttachment,
20
20
  AssistantChunkEvent,
21
21
  AssistantMessageEvent,
22
+ ReasoningChunkEvent,
23
+ ReasoningMessageEvent,
22
24
  ToolCallRequestedEvent,
23
25
  ToolCallApprovedEvent,
24
26
  ToolCallDeniedEvent,
@@ -136,7 +138,30 @@ export type {
136
138
  ProviderOAuthStatus,
137
139
  ProviderAuthDescriptor,
138
140
  } from './provider.js';
141
+ export {
142
+ encodeLoginPrompt,
143
+ decodeLoginPrompt,
144
+ createLoginStreamScanner,
145
+ } from './provider-login-bridge.js';
146
+ export type { LoginPromptRequest, LoginStreamItem } from './provider-login-bridge.js';
139
147
  export type { CacheStrategyDef, CacheStrategyContext } from './cache-strategy.js';
148
+ export type {
149
+ DiffLine,
150
+ DiffHunk,
151
+ DiffRow,
152
+ FileDiffDisplay,
153
+ ToolDisplay,
154
+ ToolDisplayResult,
155
+ } from './tool-display.js';
156
+ export {
157
+ isToolDisplayResult,
158
+ isToolDisplay,
159
+ isFileDiffDisplay,
160
+ fileDiffSummary,
161
+ fileDiffVerb,
162
+ diffGutterNo,
163
+ toDiffRows,
164
+ } from './tool-display.js';
140
165
  export type {
141
166
  ViewNode,
142
167
  ViewAction,
@@ -148,7 +173,7 @@ export type {
148
173
  ViewTagSpec,
149
174
  ViewRendererDef,
150
175
  } from './view-renderer.js';
151
- export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl } from './view-renderer.js';
176
+ export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS, isSafeViewUrl, countNodes } from './view-renderer.js';
152
177
  export type {
153
178
  TunnelProviderDef,
154
179
  TunnelHandle,
@@ -158,8 +183,16 @@ export type {
158
183
  } from './tunnel.js';
159
184
  export { spawnCliTunnel, isCliTunnelAvailable } from './tunnel.js';
160
185
  export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
161
- export { writeFileAtomic, moxxyHome, moxxyPath, type WriteFileAtomicOptions } from './fs-utils.js';
186
+ export {
187
+ writeFileAtomic,
188
+ writeFileAtomicSync,
189
+ moxxyHome,
190
+ moxxyPath,
191
+ type WriteFileAtomicOptions,
192
+ } from './fs-utils.js';
162
193
  export { createMutex, type Mutex } from './mutex.js';
194
+ export { assertNever } from './assert.js';
195
+ export { compareSemver, parseSemverCore } from './semver.js';
163
196
  export { readRequestBody, bearerTokenMatches } from './http-utils.js';
164
197
  export {
165
198
  resolveChannelToken,
@@ -334,6 +367,22 @@ export type {
334
367
  export type { EmbeddingProvider, EmbedderDef } from './embedding.js';
335
368
  export { CachedEmbeddingProvider } from './embedding-cache.js';
336
369
 
370
+ export { defineSurface } from './surface.js';
371
+ export type {
372
+ SurfaceKind,
373
+ SurfaceDataMessage,
374
+ SurfaceInputMessage,
375
+ SurfaceSize,
376
+ SurfaceInstance,
377
+ SurfaceAvailability,
378
+ SurfaceContext,
379
+ SurfaceDef,
380
+ SurfaceRegistry,
381
+ SurfaceInfo,
382
+ OpenSurfaceResult,
383
+ SurfaceHost,
384
+ } from './surface.js';
385
+
337
386
  export type {
338
387
  RequirementKind,
339
388
  RequirementState,
@@ -151,6 +151,46 @@ describe('projectMessagesFromLog', () => {
151
151
  content: [{ type: 'text', text: 'running it now' }],
152
152
  });
153
153
  });
154
+
155
+ it('projects only `forModel` for a rich tool result (file diff), never the display payload', () => {
156
+ // A Write/Edit returns { forModel, display:{kind:'file-diff', hunks:[…]} }.
157
+ // The model must see ONLY the short summary — the bulky hunks stay
158
+ // channel-side, so the context window never carries the full diff.
159
+ const display = {
160
+ forModel: 'edited /repo/a.ts — added 1 line, removed 1 line',
161
+ display: {
162
+ kind: 'file-diff' as const,
163
+ path: 'a.ts',
164
+ mode: 'update' as const,
165
+ added: 1,
166
+ removed: 1,
167
+ hunks: [
168
+ {
169
+ oldStart: 1,
170
+ oldLines: 1,
171
+ newStart: 1,
172
+ newLines: 1,
173
+ lines: [{ kind: 'del' as const, text: 'before', oldNo: 1 }, { kind: 'add' as const, text: 'after', newNo: 1 }],
174
+ },
175
+ ],
176
+ },
177
+ };
178
+ const log = reader([
179
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'edit it' }),
180
+ event(1, { type: 'tool_call_requested', turnId: t1, source: 'model', callId: 'c1', name: 'Edit', input: {} }),
181
+ event(2, { type: 'tool_result', turnId: t1, source: 'tool', callId: 'c1', ok: true, output: display }),
182
+ ]);
183
+
184
+ const messages = projectMessagesFromLog({ log });
185
+ const toolResult = messages.find((m) => m.role === 'tool_result');
186
+ const block = toolResult!.content[0]!;
187
+ expect(block).toMatchObject({ type: 'tool_result', content: display.forModel });
188
+ // The hunks / file content must NOT leak into the projected text.
189
+ if (block.type === 'tool_result') {
190
+ expect(block.content).not.toContain('hunks');
191
+ expect(block.content).not.toContain('before');
192
+ }
193
+ });
154
194
  });
155
195
 
156
196
  function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
@@ -11,6 +11,7 @@ import {
11
11
  toolResultStub,
12
12
  toolResultStubbed,
13
13
  } from './elision-state.js';
14
+ import { isToolDisplayResult } from './tool-display.js';
14
15
  import { applyLazyTools } from './tool-gating.js';
15
16
  import { runCompactionIfNeeded } from './compactor-helpers.js';
16
17
  import { runElisionIfNeeded } from './elision-helpers.js';
@@ -196,6 +197,14 @@ export function projectMessages(
196
197
 
197
198
  let pendingAssistant: ProviderMessage | null = null;
198
199
  let pendingAssistantMaxSeq = -1;
200
+ // Reasoning block awaiting attachment to the current assistant turn. Only set
201
+ // for REPLAYABLE reasoning (Anthropic signature / redacted-or-Codex encrypted
202
+ // blob) — render-only reasoning is never sent back. Attached as content[0] of
203
+ // the turn's assistant message (Anthropic requires the signed thinking block
204
+ // first on an interleaved-thinking tool-use continuation; a missing/unsigned
205
+ // one is a hard 400). Dropped at any turn/compaction boundary it doesn't reach.
206
+ let pendingReasoning: Extract<ContentBlock, { type: 'reasoning' }> | null = null;
207
+ let pendingReasoningSeq = -1;
199
208
  const flush = (): void => {
200
209
  if (!pendingAssistant) return;
201
210
  const flushed = pendingAssistant;
@@ -237,6 +246,7 @@ export function projectMessages(
237
246
  if (!emittedCompactions.has(compaction)) {
238
247
  emittedCompactions.add(compaction);
239
248
  flush();
249
+ pendingReasoning = null;
240
250
  messages.push({
241
251
  role: 'user',
242
252
  content: [{ type: 'text', text: `[summary of earlier turns]\n${compaction.summary}` }],
@@ -249,6 +259,7 @@ export function projectMessages(
249
259
  switch (e.type) {
250
260
  case 'user_prompt': {
251
261
  flush();
262
+ pendingReasoning = null;
252
263
  // Elided + conversational: collapse to a stub (anchor/tiny kept full).
253
264
  if (conversationalStubbed(e, el)) {
254
265
  messages.push({
@@ -286,9 +297,26 @@ export function projectMessages(
286
297
  recordStable(e.seq);
287
298
  break;
288
299
  }
300
+ case 'reasoning_message': {
301
+ // Render-only reasoning (no signature/encrypted) is never replayed —
302
+ // it exists only for the live/scrollback "Thinking" view. Replayable
303
+ // reasoning is stashed for content[0] of this turn's assistant message.
304
+ if (e.signature || e.encrypted) {
305
+ pendingReasoning = {
306
+ type: 'reasoning',
307
+ text: e.content,
308
+ ...(e.signature ? { signature: e.signature } : {}),
309
+ ...(e.redacted ? { redacted: true } : {}),
310
+ ...(e.encrypted ? { encrypted: e.encrypted } : {}),
311
+ };
312
+ pendingReasoningSeq = e.seq;
313
+ }
314
+ break;
315
+ }
289
316
  case 'assistant_message':
290
317
  flush();
291
318
  if (conversationalStubbed(e, el)) {
319
+ pendingReasoning = null;
292
320
  messages.push({
293
321
  role: 'assistant',
294
322
  content: [{ type: 'text', text: conversationalStub('assistant', e.seq) }],
@@ -303,14 +331,31 @@ export function projectMessages(
303
331
  // tool_use blocks are projected from tool_call_requested events —
304
332
  // which also un-wedges historical logs that already contain one.
305
333
  if (e.content.trim().length === 0) {
334
+ pendingReasoning = null;
306
335
  recordStable(e.seq);
307
336
  break;
308
337
  }
309
- messages.push({ role: 'assistant', content: [{ type: 'text', text: e.content }] });
338
+ {
339
+ const content: Array<ProviderMessage['content'][number]> = [];
340
+ if (pendingReasoning) {
341
+ content.push(pendingReasoning);
342
+ pendingReasoning = null;
343
+ }
344
+ content.push({ type: 'text', text: e.content });
345
+ messages.push({ role: 'assistant', content });
346
+ }
310
347
  recordStable(e.seq);
311
348
  break;
312
349
  case 'tool_call_requested': {
313
- pendingAssistant ??= { role: 'assistant', content: [] };
350
+ if (!pendingAssistant) {
351
+ // Seed the assistant turn so the signed reasoning block is content[0],
352
+ // ahead of every tool_use (Anthropic's interleaved-thinking ordering).
353
+ pendingAssistant = { role: 'assistant', content: pendingReasoning ? [pendingReasoning] : [] };
354
+ if (pendingReasoning) {
355
+ pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, pendingReasoningSeq);
356
+ pendingReasoning = null;
357
+ }
358
+ }
314
359
  pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, e.seq);
315
360
  (pendingAssistant.content as Array<ProviderMessage['content'][number]>).push({
316
361
  type: 'tool_use',
@@ -330,6 +375,10 @@ export function projectMessages(
330
375
  text = toolResultStub(e.callId, toolResultBytes(e.output), recalled);
331
376
  } else if (e.error) {
332
377
  text = `[error:${e.error.kind}] ${e.error.message}`;
378
+ } else if (isToolDisplayResult(e.output)) {
379
+ // Rich result (e.g. a file diff): the model only needs the short
380
+ // `forModel` summary — the structured `display` is for channels.
381
+ text = e.output.forModel;
333
382
  } else {
334
383
  text = typeof e.output === 'string' ? e.output : JSON.stringify(e.output ?? '');
335
384
  }
@@ -361,6 +410,18 @@ export interface StreamResult {
361
410
  readonly error: { readonly message: string; readonly retryable: boolean } | null;
362
411
  /** Token usage reported by the provider on `message_end`, including cache hits/writes. */
363
412
  readonly usage?: TokenUsage;
413
+ /**
414
+ * Reasoning/thinking summary for this provider call, when the model emitted
415
+ * any. The mode emits it as a `reasoning_message` event (so it persists and
416
+ * round-trips). `signature`/`encrypted` carry Anthropic's signed thinking
417
+ * block / redacted blob; `redacted` marks display-suppressed reasoning.
418
+ */
419
+ readonly reasoning?: {
420
+ readonly text: string;
421
+ readonly signature?: string;
422
+ readonly redacted?: boolean;
423
+ readonly encrypted?: string;
424
+ };
364
425
  }
365
426
 
366
427
  /**
@@ -431,12 +492,17 @@ export async function collectProviderStream(
431
492
  // message-derived system text. Prefilling it would duplicate the prompt.
432
493
  // It stays as the side channel `onBeforeProviderCall` hooks use to inject
433
494
  // per-request system text (e.g. the memory consolidation nudge).
495
+ // Forward the per-provider reasoning preference, but only when THIS model
496
+ // advertises `supportsReasoning` — providers ignore the knob otherwise, but
497
+ // gating here keeps requests clean and avoids unsupported-param errors.
498
+ const reqReasoning = descriptor?.supportsReasoning ? ctx.reasoning : undefined;
434
499
  const req = {
435
500
  model: ctx.model,
436
501
  messages: effectiveMessages,
437
502
  ...(toolList ? { tools: toolList } : {}),
438
503
  ...(cacheHints && cacheHints.length > 0 ? { cacheHints } : {}),
439
504
  ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
505
+ ...(reqReasoning ? { reasoning: reqReasoning } : {}),
440
506
  signal: ctx.signal,
441
507
  };
442
508
  const transformed = await ctx.hooks.dispatchBeforeProviderCall(req, {
@@ -453,6 +519,14 @@ export async function collectProviderStream(
453
519
  let stopReason: StopReason = 'end_turn';
454
520
  let error: StreamResult['error'] = null;
455
521
  let usage: TokenUsage | undefined;
522
+ // Reasoning/thinking accumulation for this single provider call. Emitted as a
523
+ // finalized `reasoning_message` by the mode (turn-iterator / goal-loop) so it
524
+ // persists and round-trips; `signature`/`encrypted` carry Anthropic's signed
525
+ // thinking block / redacted blob for replay.
526
+ let reasoningText = '';
527
+ let reasoningSignature: string | undefined;
528
+ let reasoningRedacted = false;
529
+ let reasoningEncrypted: string | undefined;
456
530
 
457
531
  let stream: AsyncIterable<ProviderEvent>;
458
532
  try {
@@ -498,6 +572,25 @@ export async function collectProviderStream(
498
572
  error = { message: event.message, retryable: event.retryable };
499
573
  break;
500
574
  }
575
+ case 'reasoning_delta': {
576
+ reasoningText += event.delta;
577
+ // Live preview only — parallels `assistant_chunk`; renderers
578
+ // accumulate ephemerally and clear on the finalized reasoning_message.
579
+ await ctx.emit({
580
+ type: 'reasoning_chunk',
581
+ sessionId: ctx.sessionId,
582
+ turnId: ctx.turnId,
583
+ source: 'model',
584
+ delta: event.delta,
585
+ });
586
+ break;
587
+ }
588
+ case 'reasoning_signature': {
589
+ if (event.signature) reasoningSignature = event.signature;
590
+ if (event.encrypted) reasoningEncrypted = event.encrypted;
591
+ if (event.redacted) reasoningRedacted = true;
592
+ break;
593
+ }
501
594
  case 'message_start':
502
595
  case 'tool_use_delta':
503
596
  default:
@@ -516,7 +609,25 @@ export async function collectProviderStream(
516
609
  if (!partial.name) continue;
517
610
  finalToolUses.push({ id, name: partial.name, input: partial.input ?? {} });
518
611
  }
519
- return { text, toolUses: finalToolUses, stopReason, error, ...(usage ? { usage } : {}) };
612
+ // Surface reasoning when there's visible text OR an opaque blob to replay
613
+ // (a redacted_thinking block has no text but must still round-trip).
614
+ const reasoning =
615
+ reasoningText.trim().length > 0 || reasoningEncrypted
616
+ ? {
617
+ text: reasoningText,
618
+ ...(reasoningSignature ? { signature: reasoningSignature } : {}),
619
+ ...(reasoningRedacted ? { redacted: true } : {}),
620
+ ...(reasoningEncrypted ? { encrypted: reasoningEncrypted } : {}),
621
+ }
622
+ : undefined;
623
+ return {
624
+ text,
625
+ toolUses: finalToolUses,
626
+ stopReason,
627
+ error,
628
+ ...(usage ? { usage } : {}),
629
+ ...(reasoning ? { reasoning } : {}),
630
+ };
520
631
  }
521
632
 
522
633
  /**
package/src/mode.ts CHANGED
@@ -77,6 +77,13 @@ export interface ModeContext {
77
77
  readonly elision?: ElisionSettings;
78
78
  /** When true, send only always-on + loaded tool schemas; index the rest. */
79
79
  readonly lazyTools?: boolean;
80
+ /**
81
+ * Per-provider reasoning/thinking preference, resolved from the active
82
+ * provider's config at session build. Forwarded to the provider as
83
+ * `ProviderRequest.reasoning` by {@link collectProviderStream}, gated on the
84
+ * active model's `supportsReasoning`. Absent/false → reasoning off.
85
+ */
86
+ readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean;
80
87
  readonly permissions: PermissionResolver;
81
88
  /**
82
89
  * Optional generic "ask the user a question" gate. Any loop strategy can
package/src/plugin.ts CHANGED
@@ -9,6 +9,7 @@ import type { LifecycleHooks } from './hooks.js';
9
9
  import type { ModeDef } from './mode.js';
10
10
  import type { ProviderDef } from './provider.js';
11
11
  import type { MoxxyRequirement } from './requirements.js';
12
+ import type { SurfaceDef } from './surface.js';
12
13
  import type { ToolDef } from './tool.js';
13
14
  import type { TranscriberDef } from './transcriber.js';
14
15
  import type { SynthesizerDef } from './synthesizer.js';
@@ -16,7 +17,7 @@ import type { ViewRendererDef } from './view-renderer.js';
16
17
  import type { TunnelProviderDef } from './tunnel.js';
17
18
  import type { WorkflowExecutorDef } from './workflow.js';
18
19
 
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';
20
+ export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor';
20
21
 
21
22
  export interface PluginSpec {
22
23
  readonly name: string;
@@ -44,6 +45,14 @@ export interface PluginSpec {
44
45
  */
45
46
  readonly tunnelProviders?: ReadonlyArray<TunnelProviderDef>;
46
47
  readonly channels?: ReadonlyArray<ChannelDef>;
48
+ /**
49
+ * Interactive surfaces contributed by the plugin — long-lived panes a human
50
+ * and the agent drive together (an embedded terminal, an in-window browser).
51
+ * Registered into the session's `SurfaceRegistry`; the runner exposes them to
52
+ * thin clients over the `surface.*` protocol family. The plugin's own tools
53
+ * reach the same underlying resource through module state. See {@link SurfaceDef}.
54
+ */
55
+ readonly surfaces?: ReadonlyArray<SurfaceDef>;
47
56
  /**
48
57
  * Speech-to-text backends contributed by the plugin. Selected by name via
49
58
  * `session.transcribers.setActive(name)`; channels with audio input use
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ createLoginStreamScanner,
4
+ decodeLoginPrompt,
5
+ encodeLoginPrompt,
6
+ type LoginStreamItem,
7
+ } from './provider-login-bridge.js';
8
+
9
+ describe('encode/decode login prompt', () => {
10
+ it('round-trips a request', () => {
11
+ const marker = encodeLoginPrompt({ question: 'Paste token:', mask: true });
12
+ // NUL-bracketed.
13
+ expect(marker.startsWith('\u0000')).toBe(true);
14
+ expect(marker.endsWith('\u0000')).toBe(true);
15
+ const inner = marker.slice(1, -1);
16
+ expect(decodeLoginPrompt(inner)).toEqual({ question: 'Paste token:', mask: true });
17
+ });
18
+
19
+ it('rejects non-marker segments', () => {
20
+ expect(decodeLoginPrompt('just text')).toBeNull();
21
+ expect(decodeLoginPrompt('{"tag":"other","question":"x"}')).toBeNull();
22
+ expect(decodeLoginPrompt('{not json')).toBeNull();
23
+ });
24
+ });
25
+
26
+ /** Drain a scanner across the given chunks into a flat item list. */
27
+ function scanAll(chunks: string[]): LoginStreamItem[] {
28
+ const scanner = createLoginStreamScanner();
29
+ return chunks.flatMap((c) => [...scanner.push(c)]);
30
+ }
31
+
32
+ describe('createLoginStreamScanner', () => {
33
+ it('passes plain output straight through', () => {
34
+ expect(scanAll(['hello world'])).toEqual([{ type: 'output', text: 'hello world' }]);
35
+ });
36
+
37
+ it('extracts a marker with surrounding output', () => {
38
+ const stream = 'opening browser\n' + encodeLoginPrompt({ question: 'Paste code:', mask: false }) + 'done\n';
39
+ expect(scanAll([stream])).toEqual([
40
+ { type: 'output', text: 'opening browser\n' },
41
+ { type: 'prompt', prompt: { question: 'Paste code:', mask: false } },
42
+ { type: 'output', text: 'done\n' },
43
+ ]);
44
+ });
45
+
46
+ it('reassembles a marker split across chunks', () => {
47
+ const marker = encodeLoginPrompt({ question: 'Paste token:', mask: true });
48
+ const mid = Math.floor(marker.length / 2);
49
+ const items = scanAll(['prefix', marker.slice(0, mid), marker.slice(mid), 'suffix']);
50
+ expect(items).toEqual([
51
+ { type: 'output', text: 'prefix' },
52
+ { type: 'prompt', prompt: { question: 'Paste token:', mask: true } },
53
+ { type: 'output', text: 'suffix' },
54
+ ]);
55
+ });
56
+
57
+ it('handles two prompts in sequence', () => {
58
+ const a = encodeLoginPrompt({ question: 'first', mask: false });
59
+ const b = encodeLoginPrompt({ question: 'second', mask: true });
60
+ expect(scanAll([a + b])).toEqual([
61
+ { type: 'prompt', prompt: { question: 'first', mask: false } },
62
+ { type: 'prompt', prompt: { question: 'second', mask: true } },
63
+ ]);
64
+ });
65
+
66
+ it('holds back a lone opening NUL until its partner arrives', () => {
67
+ const scanner = createLoginStreamScanner();
68
+ const marker = encodeLoginPrompt({ question: 'q', mask: false });
69
+ // Feed everything except the closing NUL: nothing should surface yet.
70
+ expect([...scanner.push('text' + marker.slice(0, -1))]).toEqual([
71
+ { type: 'output', text: 'text' },
72
+ ]);
73
+ // Closing NUL completes the marker.
74
+ expect([...scanner.push('\u0000')]).toEqual([
75
+ { type: 'prompt', prompt: { question: 'q', mask: false } },
76
+ ]);
77
+ });
78
+ });