@moxxy/sdk 0.10.0 → 0.12.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 (52) hide show
  1. package/dist/elision-state.d.ts.map +1 -1
  2. package/dist/elision-state.js +6 -0
  3. package/dist/elision-state.js.map +1 -1
  4. package/dist/events.d.ts +33 -1
  5. package/dist/events.d.ts.map +1 -1
  6. package/dist/index.d.ts +8 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mode-helpers.d.ts +12 -0
  11. package/dist/mode-helpers.d.ts.map +1 -1
  12. package/dist/mode-helpers.js +105 -3
  13. package/dist/mode-helpers.js.map +1 -1
  14. package/dist/mode.d.ts +9 -0
  15. package/dist/mode.d.ts.map +1 -1
  16. package/dist/mode.js.map +1 -1
  17. package/dist/plugin.d.ts +10 -1
  18. package/dist/plugin.d.ts.map +1 -1
  19. package/dist/provider-login-bridge.d.ts +53 -0
  20. package/dist/provider-login-bridge.d.ts.map +1 -0
  21. package/dist/provider-login-bridge.js +95 -0
  22. package/dist/provider-login-bridge.js.map +1 -0
  23. package/dist/provider.d.ts +53 -0
  24. package/dist/provider.d.ts.map +1 -1
  25. package/dist/session-like.d.ts +36 -0
  26. package/dist/session-like.d.ts.map +1 -1
  27. package/dist/subagent.d.ts +6 -0
  28. package/dist/subagent.d.ts.map +1 -1
  29. package/dist/surface.d.ts +159 -0
  30. package/dist/surface.d.ts.map +1 -0
  31. package/dist/surface.js +27 -0
  32. package/dist/surface.js.map +1 -0
  33. package/dist/tool-display.d.ts +73 -0
  34. package/dist/tool-display.d.ts.map +1 -0
  35. package/dist/tool-display.js +66 -0
  36. package/dist/tool-display.js.map +1 -0
  37. package/package.json +5 -1
  38. package/src/elision-state.ts +5 -0
  39. package/src/events.ts +36 -0
  40. package/src/index.ts +43 -0
  41. package/src/loop-helpers.test.ts +40 -0
  42. package/src/mode-helpers.ts +114 -3
  43. package/src/mode.ts +7 -0
  44. package/src/plugin.ts +10 -1
  45. package/src/provider-login-bridge.test.ts +78 -0
  46. package/src/provider-login-bridge.ts +105 -0
  47. package/src/provider.ts +45 -2
  48. package/src/session-like.ts +38 -0
  49. package/src/subagent.ts +6 -0
  50. package/src/surface.ts +173 -0
  51. package/src/tool-display.test.ts +71 -0
  52. package/src/tool-display.ts +118 -0
@@ -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
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cross-process bridge for relaying a provider login's interactive prompts —
3
+ * the out-of-band token / authorization-code paste that `claude-code` needs —
4
+ * from a spawned `moxxy login --stdin-prompts` subprocess to a GUI host that
5
+ * has no TTY (the desktop app).
6
+ *
7
+ * The subprocess emits each `ctx.prompt(question)` call as a NUL-bracketed
8
+ * JSON marker on stdout. NUL never appears in normal terminal output, so the
9
+ * host can scan the byte stream for these markers, render the prompt, and
10
+ * write the user's answer back as one stdin line. Loopback flows that never
11
+ * call `ctx.prompt` (e.g. openai-codex, which captures the code via a local
12
+ * callback server) emit no markers — the host just streams their output and
13
+ * waits for the process to exit.
14
+ *
15
+ * Living here (not in the CLI or the desktop contract) keeps the one wire
16
+ * format in a package both the CLI producer and the desktop consumer already
17
+ * depend on, so the two ends can never drift.
18
+ */
19
+
20
+ /** The byte that brackets a prompt marker. Absent from normal CLI output. */
21
+ const NUL = '\u0000';
22
+
23
+ /** Tag distinguishing a login-prompt marker from any other NUL-bracketed run. */
24
+ const PROMPT_TAG = 'moxxy.login.prompt';
25
+
26
+ export interface LoginPromptRequest {
27
+ /** The question to show the user, verbatim from `ctx.prompt`. */
28
+ readonly question: string;
29
+ /** True for secrets — the host should mask the input field. */
30
+ readonly mask: boolean;
31
+ }
32
+
33
+ /** One decoded item from a login subprocess's stdout stream. */
34
+ export type LoginStreamItem =
35
+ | { readonly type: 'output'; readonly text: string }
36
+ | { readonly type: 'prompt'; readonly prompt: LoginPromptRequest };
37
+
38
+ /**
39
+ * Serialize a prompt request as a NUL-bracketed marker for stdout. The
40
+ * leading NUL also delimits it from any partial preceding `ctx.write` output
41
+ * the host scanner has buffered.
42
+ */
43
+ export function encodeLoginPrompt(req: LoginPromptRequest): string {
44
+ return NUL + JSON.stringify({ tag: PROMPT_TAG, question: req.question, mask: req.mask }) + NUL;
45
+ }
46
+
47
+ /**
48
+ * Try to parse a single inter-NUL segment as a prompt marker. Returns null
49
+ * when it isn't one, so the host can pass the segment through as output.
50
+ */
51
+ export function decodeLoginPrompt(segment: string): LoginPromptRequest | null {
52
+ if (!segment.startsWith('{')) return null;
53
+ try {
54
+ const o = JSON.parse(segment) as Record<string, unknown>;
55
+ if (o.tag !== PROMPT_TAG || typeof o.question !== 'string') return null;
56
+ return { question: o.question, mask: o.mask === true };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Streaming scanner for a login subprocess's stdout. Feed it chunks; it
64
+ * returns the ordered output / prompt items decoded so far, holding back any
65
+ * incomplete trailing marker (an opening NUL with no closing NUL yet) until
66
+ * the rest of it arrives in a later chunk.
67
+ */
68
+ export function createLoginStreamScanner(): {
69
+ push(chunk: string): ReadonlyArray<LoginStreamItem>;
70
+ } {
71
+ let buf = '';
72
+ return {
73
+ push(chunk: string): ReadonlyArray<LoginStreamItem> {
74
+ buf += chunk;
75
+ const out: LoginStreamItem[] = [];
76
+ // Consume every COMPLETE marker (NUL <json> NUL), emitting the plain
77
+ // text before each as output. Stop at the first NUL with no closing
78
+ // partner — that's a marker still mid-flight, kept for the next chunk.
79
+ for (;;) {
80
+ const open = buf.indexOf(NUL);
81
+ if (open === -1) break;
82
+ const close = buf.indexOf(NUL, open + 1);
83
+ if (close === -1) break;
84
+ if (open > 0) out.push({ type: 'output', text: buf.slice(0, open) });
85
+ const segment = buf.slice(open + 1, close);
86
+ const prompt = decodeLoginPrompt(segment);
87
+ // A NUL-bracketed run that isn't a valid marker is anomalous; surface
88
+ // its inner text as output rather than swallowing it.
89
+ out.push(prompt ? { type: 'prompt', prompt } : { type: 'output', text: segment });
90
+ buf = buf.slice(close + 1);
91
+ }
92
+ // Flush any plain text that precedes a still-incomplete marker (or all
93
+ // of it when no NUL is pending), keeping only the partial marker.
94
+ const pending = buf.indexOf(NUL);
95
+ if (pending === -1) {
96
+ if (buf) out.push({ type: 'output', text: buf });
97
+ buf = '';
98
+ } else if (pending > 0) {
99
+ out.push({ type: 'output', text: buf.slice(0, pending) });
100
+ buf = buf.slice(pending);
101
+ }
102
+ return out;
103
+ },
104
+ };
105
+ }
package/src/provider.ts CHANGED
@@ -19,7 +19,24 @@ export type ContentBlock =
19
19
  * to a text placeholder. Text/Office files are inlined as `text` instead — only
20
20
  * formats a model ingests as bytes become `document` blocks.
21
21
  */
22
- | { readonly type: 'document'; readonly mediaType: string; readonly data: string; readonly name?: string };
22
+ | { readonly type: 'document'; readonly mediaType: string; readonly data: string; readonly name?: string }
23
+ /**
24
+ * A model reasoning/thinking block, preserved in conversation history so it
25
+ * can be replayed on the next request. Anthropic REQUIRES the signed
26
+ * `thinking` block be sent back (as the first block of an assistant turn that
27
+ * also carries tool_use) on an interleaved-thinking continuation — a missing
28
+ * or unsigned block is a hard 400, so the loop drops unsigned reasoning from
29
+ * what it replays. `redacted` blocks carry only `encrypted` (the opaque blob)
30
+ * and `text: ''`; they are replayed verbatim, never shown. Providers without
31
+ * reasoning ignore this block.
32
+ */
33
+ | {
34
+ readonly type: 'reasoning';
35
+ readonly text: string;
36
+ readonly signature?: string;
37
+ readonly redacted?: boolean;
38
+ readonly encrypted?: string;
39
+ };
23
40
 
24
41
  /**
25
42
  * Provider-neutral instruction for where a prompt-cache breakpoint should be
@@ -56,6 +73,15 @@ export interface ProviderRequest {
56
73
  readonly signal?: AbortSignal;
57
74
  /** Where to place prompt-cache breakpoints. Set by the active CacheStrategy. */
58
75
  readonly cacheHints?: ReadonlyArray<CacheHint>;
76
+ /**
77
+ * Request reasoning/thinking from the model. `false`/absent = off. Providers
78
+ * gate on this AND the model descriptor's `supportsReasoning`; unsupported
79
+ * providers/models ignore it. The loop sets it from the active provider's
80
+ * reasoning config (see the per-provider reasoning setting). `effort` maps to
81
+ * each provider's native knob (Anthropic thinking budget, OpenAI/Codex
82
+ * `reasoning.effort`).
83
+ */
84
+ readonly reasoning?: { readonly effort?: 'low' | 'medium' | 'high' } | boolean;
59
85
  }
60
86
 
61
87
  export type ProviderEvent =
@@ -65,7 +91,17 @@ export type ProviderEvent =
65
91
  | { readonly type: 'tool_use_delta'; readonly id: string; readonly partialInput: string }
66
92
  | { readonly type: 'tool_use_end'; readonly id: string; readonly input: unknown }
67
93
  | { readonly type: 'message_end'; readonly stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error'; readonly usage?: TokenUsage }
68
- | { readonly type: 'error'; readonly message: string; readonly retryable: boolean };
94
+ | { readonly type: 'error'; readonly message: string; readonly retryable: boolean }
95
+ /** A streamed reasoning/thinking text delta (visible summary). */
96
+ | { readonly type: 'reasoning_delta'; readonly delta: string }
97
+ /**
98
+ * End-of-reasoning-block metadata for history round-trip, emitted once per
99
+ * reasoning block. `signature` is Anthropic's thinking-block signature;
100
+ * `encrypted` carries an opaque blob (Anthropic redacted_thinking data /
101
+ * Codex reasoning encrypted_content) that must be replayed verbatim;
102
+ * `redacted` marks reasoning that must never be displayed.
103
+ */
104
+ | { readonly type: 'reasoning_signature'; readonly signature?: string; readonly redacted?: boolean; readonly encrypted?: string };
69
105
 
70
106
  export interface TokenUsage {
71
107
  readonly inputTokens: number;
@@ -101,6 +137,13 @@ export interface ModelDescriptor {
101
137
  * the transcript as text instead.
102
138
  */
103
139
  readonly supportsAudio?: boolean;
140
+ /**
141
+ * Whether this model can emit reasoning/thinking summaries (Anthropic
142
+ * extended thinking, OpenAI o-series / Codex reasoning). Gates the
143
+ * per-provider reasoning config UI and whether the loop requests reasoning
144
+ * for this model. When false, `ProviderRequest.reasoning` is ignored.
145
+ */
146
+ readonly supportsReasoning?: boolean;
104
147
  }
105
148
 
106
149
  export interface LLMProvider {
@@ -57,6 +57,12 @@ export interface ProviderInfo {
57
57
  * via /v1/models). Lets the desktop's model picker show a
58
58
  * "Fetch live" affordance only where it makes sense. */
59
59
  readonly supportsLiveModelDiscovery: boolean;
60
+ /**
61
+ * False when the user disabled this provider (it stays registered but can't
62
+ * be activated and is excluded from boot's candidate walk). Optional for
63
+ * wire back-compat — an older runner omits it; treat absent as enabled.
64
+ */
65
+ readonly enabled?: boolean;
60
66
  }
61
67
 
62
68
  /** Serializable tool metadata for status lines / slash menus / compact rendering. */
@@ -146,6 +152,36 @@ export interface McpAdminView {
146
152
  listServers(): Promise<ReadonlyArray<McpServerStatusView>>;
147
153
  }
148
154
 
155
+ /**
156
+ * Editable fields of a stored (runtime-registered) provider entry. Mirrors
157
+ * the persisted `providers.json` shape owned by `@moxxy/plugin-provider-admin`
158
+ * minus the immutable identity (`name`, `kind`).
159
+ */
160
+ export interface ProviderConfigurePatch {
161
+ readonly baseURL?: string;
162
+ readonly defaultModel?: string;
163
+ /** Override the API-key env-var/vault name (`<NAME>_API_KEY` by default). */
164
+ readonly envVar?: string;
165
+ readonly models?: ReadonlyArray<ModelDescriptor>;
166
+ }
167
+
168
+ /**
169
+ * The slice of the provider admin API a channel needs to edit a stored
170
+ * (runtime-registered) provider in place. Present on a local Session when
171
+ * `@moxxy/plugin-provider-admin` is wired (mirrors {@link McpAdminView});
172
+ * a `RemoteSession` proxies it over the runner protocol (v7+). Built-in
173
+ * providers are not configurable through this view — their config is code.
174
+ */
175
+ export interface ProviderAdminView {
176
+ /**
177
+ * Patch a stored provider's entry: re-registers the live provider def and
178
+ * persists the merged entry to providers.json. Throws a MoxxyError when no
179
+ * stored provider has that name or the patch is inconsistent (e.g. a
180
+ * defaultModel missing from the models list).
181
+ */
182
+ configure(name: string, patch: ProviderConfigurePatch): Promise<void>;
183
+ }
184
+
149
185
  /** One workflow's summary for the `/workflows` modal. */
150
186
  export interface WorkflowSummaryView {
151
187
  readonly name: string;
@@ -308,6 +344,8 @@ export interface SessionLike {
308
344
  credentialResolver?: CredentialResolver;
309
345
  /** MCP admin slice backing the MCP picker / status line. */
310
346
  mcpAdmin?: McpAdminView;
347
+ /** Provider admin slice — edit stored (runtime-registered) providers. */
348
+ providerAdmin?: ProviderAdminView;
311
349
  /** Workflows slice backing the `/workflows` modal. */
312
350
  workflows?: WorkflowsView;
313
351
  /** Plugin-management slice backing the `/plugins` picker. */
package/src/subagent.ts CHANGED
@@ -26,6 +26,12 @@ export interface SubagentSpec {
26
26
  readonly allowedTools?: ReadonlyArray<string>;
27
27
  /** Human-readable label surfaced in `subagent_*` event payloads. */
28
28
  readonly label?: string;
29
+ /**
30
+ * Named kind this child was spawned as (e.g. `'Explore'`, `'code-reviewer'`).
31
+ * Surfaced in `subagent_*` payloads so sibling agents of the same kind group
32
+ * under one collapsible header. Defaults to `'default'`.
33
+ */
34
+ readonly agentType?: string;
29
35
  /**
30
36
  * When true, the child session stays alive after the first turn for
31
37
  * {@link SubagentSpawner.continue}. `subagent_completed` is deferred until
package/src/surface.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * A Surface is a long-lived, interactive pane that BOTH a human viewer (a
3
+ * desktop panel) and the agent drive together: an embedded terminal (a shared
4
+ * PTY) or an in-window browser (a shared page). Unlike a {@link Channel} — which
5
+ * is a whole conversation transport — a Surface is a single resource attached to
6
+ * an existing Session, streaming opaque output frames out and accepting opaque
7
+ * input messages in.
8
+ *
9
+ * Surfaces are RUNNER-OWNED: the underlying resource (PTY, Playwright page) lives
10
+ * in the same process as the agent's tools, so the agent reads/writes the exact
11
+ * same instance the user watches. A thin client (the desktop) renders the
12
+ * streamed frames and relays the user's input back over the runner protocol —
13
+ * there is no reverse RPC. Plugins contribute surfaces via
14
+ * `definePlugin({ surfaces: [defineSurface(...)] })`; the plugin's own tools
15
+ * (e.g. `terminal`, `browser_session`) share the resource through module state.
16
+ *
17
+ * The payloads (`SurfaceDataMessage.payload`, `SurfaceInputMessage`) are
18
+ * deliberately opaque (`unknown` / open record): they cross the JSON-RPC wire
19
+ * verbatim and each surface kind defines its own shape (PTY bytes, a base64
20
+ * frame, a navigate request). Keeping them untyped here means a new surface kind
21
+ * never has to touch this file or the protocol.
22
+ */
23
+
24
+ /**
25
+ * Surface kind discriminator. Open string union so plugins add kinds without
26
+ * editing the SDK; the built-ins are `'terminal'` and `'browser'`.
27
+ */
28
+ export type SurfaceKind = 'terminal' | 'browser' | (string & {});
29
+
30
+ /** One outbound frame from a surface instance to its viewers. */
31
+ export interface SurfaceDataMessage {
32
+ /** The instance this frame belongs to (a viewer may watch several). */
33
+ readonly surfaceId: string;
34
+ readonly kind: SurfaceKind;
35
+ /** Surface-specific payload (PTY text, a base64 frame, a url/title update). */
36
+ readonly payload: unknown;
37
+ }
38
+
39
+ /**
40
+ * One inbound message from a viewer to a surface instance — a keystroke, a
41
+ * mouse event, a navigate request, a resize. `type` discriminates; the rest is
42
+ * surface-specific.
43
+ */
44
+ export interface SurfaceInputMessage {
45
+ readonly type: string;
46
+ readonly [key: string]: unknown;
47
+ }
48
+
49
+ /** Viewport hint for a resize (terminal cols/rows or pixel width/height). */
50
+ export interface SurfaceSize {
51
+ readonly cols?: number;
52
+ readonly rows?: number;
53
+ readonly width?: number;
54
+ readonly height?: number;
55
+ }
56
+
57
+ /**
58
+ * A live, attached surface instance — the running PTY or browser page. Opening
59
+ * the same kind twice returns the SAME instance (surfaces are shared), so a
60
+ * viewer that joins late gets the existing resource plus its {@link snapshot}.
61
+ */
62
+ export interface SurfaceInstance {
63
+ readonly id: string;
64
+ readonly kind: SurfaceKind;
65
+ /** Subscribe to outbound frames. Returns an unsubscribe fn. */
66
+ onData(cb: (payload: unknown) => void): () => void;
67
+ /**
68
+ * A catch-up payload for a late-joining viewer (terminal scrollback, the last
69
+ * browser frame, the current url). Optional — a surface with no replayable
70
+ * state omits it.
71
+ */
72
+ snapshot?(): unknown;
73
+ /** Apply an inbound message from a viewer (or the agent's tool). */
74
+ input(msg: SurfaceInputMessage): void | Promise<void>;
75
+ /** Re-size the viewport, when the surface is size-aware. */
76
+ resize?(size: SurfaceSize): void | Promise<void>;
77
+ /**
78
+ * Detach this instance. The underlying shared resource MAY persist (the
79
+ * agent's tool can still use the PTY/page); `close` just tears down streaming
80
+ * + viewer state. A surface that owns its resource exclusively disposes it.
81
+ */
82
+ close(): void | Promise<void>;
83
+ }
84
+
85
+ /** Whether a surface kind can run right now (e.g. node-pty present). */
86
+ export interface SurfaceAvailability {
87
+ readonly ok: boolean;
88
+ /** Human-readable explanation when `ok` is false (shown in the empty pane). */
89
+ readonly reason?: string;
90
+ }
91
+
92
+ /** Context handed to a surface when it opens. */
93
+ export interface SurfaceContext {
94
+ /** Working directory of the owning Session. */
95
+ readonly cwd: string;
96
+ readonly logger?: {
97
+ debug?(msg: string, meta?: Record<string, unknown>): void;
98
+ info?(msg: string, meta?: Record<string, unknown>): void;
99
+ warn?(msg: string, meta?: Record<string, unknown>): void;
100
+ error?(msg: string, meta?: Record<string, unknown>): void;
101
+ };
102
+ }
103
+
104
+ /**
105
+ * A registered, named factory for a Surface. `open` is idempotent per kind: a
106
+ * second `open` attaches to the resource the first one created (the host
107
+ * enforces this), so the agent's tool and the user's pane share one instance.
108
+ */
109
+ export interface SurfaceDef {
110
+ readonly kind: SurfaceKind;
111
+ readonly description?: string;
112
+ open(ctx: SurfaceContext): Promise<SurfaceInstance> | SurfaceInstance;
113
+ /** Optional runtime gate (default: always available). */
114
+ isAvailable?(ctx: SurfaceContext): Promise<SurfaceAvailability> | SurfaceAvailability;
115
+ }
116
+
117
+ /** Identity helper mirroring `defineTool` / `defineChannel`. */
118
+ export function defineSurface(def: SurfaceDef): SurfaceDef {
119
+ return def;
120
+ }
121
+
122
+ /**
123
+ * Read-only registry of surface defs contributed by plugins. Implementation
124
+ * lives in @moxxy/core.
125
+ */
126
+ export interface SurfaceRegistry {
127
+ list(): ReadonlyArray<SurfaceDef>;
128
+ get(kind: SurfaceKind): SurfaceDef | undefined;
129
+ has(kind: SurfaceKind): boolean;
130
+ }
131
+
132
+ /** Wire-friendly surface descriptor for `surface.list` (no functions). */
133
+ export interface SurfaceInfo {
134
+ readonly kind: SurfaceKind;
135
+ readonly description?: string;
136
+ readonly available: boolean;
137
+ /** Why it is unavailable, when `available` is false. */
138
+ readonly reason?: string;
139
+ }
140
+
141
+ /** Result of opening a surface — its id + a catch-up snapshot for the viewer. */
142
+ export interface OpenSurfaceResult {
143
+ readonly surfaceId: string;
144
+ readonly kind: SurfaceKind;
145
+ readonly snapshot?: unknown;
146
+ }
147
+
148
+ /**
149
+ * The runtime manager of open surface instances, present on an in-process
150
+ * `Session`. The runner server drives it on behalf of attached clients; the
151
+ * agent's tools reach the SAME underlying resources through plugin module state.
152
+ * Output from every open instance is multiplexed through {@link onData}.
153
+ */
154
+ export interface SurfaceHost {
155
+ /** Available surface kinds with their current availability. */
156
+ list(): Promise<ReadonlyArray<SurfaceInfo>>;
157
+ /** Open (or attach to the shared) instance for a kind. */
158
+ open(kind: SurfaceKind): Promise<OpenSurfaceResult>;
159
+ /** Route a viewer message to an open instance. No-op if it isn't open. */
160
+ input(surfaceId: string, msg: SurfaceInputMessage): Promise<void>;
161
+ /** Resize an open instance. No-op if it isn't open or isn't size-aware. */
162
+ resize(surfaceId: string, size: SurfaceSize): Promise<void>;
163
+ /** Close one open instance. */
164
+ close(surfaceId: string): Promise<void>;
165
+ /**
166
+ * Subscribe to outbound frames from EVERY open instance (multiplexed by
167
+ * `surfaceId`). Returns an unsubscribe fn. The runner subscribes once and
168
+ * broadcasts each frame as a `surface.data` notification.
169
+ */
170
+ onData(cb: (msg: SurfaceDataMessage) => void): () => void;
171
+ /** Close every open instance (session teardown). */
172
+ closeAll(): Promise<void>;
173
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ diffGutterNo,
4
+ fileDiffSummary,
5
+ fileDiffVerb,
6
+ isFileDiffDisplay,
7
+ isToolDisplayResult,
8
+ toDiffRows,
9
+ type FileDiffDisplay,
10
+ } from './tool-display.js';
11
+
12
+ const diff = (over: Partial<FileDiffDisplay> = {}): FileDiffDisplay => ({
13
+ kind: 'file-diff',
14
+ path: 'src/a.ts',
15
+ mode: 'update',
16
+ added: 10,
17
+ removed: 1,
18
+ hunks: [],
19
+ ...over,
20
+ });
21
+
22
+ describe('fileDiffSummary', () => {
23
+ it('pluralizes and joins', () => {
24
+ expect(fileDiffSummary(diff())).toBe('Added 10 lines, removed 1 line');
25
+ });
26
+ it('handles add-only / remove-only', () => {
27
+ expect(fileDiffSummary(diff({ removed: 0 }))).toBe('Added 10 lines');
28
+ expect(fileDiffSummary(diff({ added: 0, removed: 3 }))).toBe('Removed 3 lines');
29
+ });
30
+ it('notes truncation', () => {
31
+ expect(fileDiffSummary(diff({ truncated: true }))).toContain('truncated');
32
+ });
33
+ });
34
+
35
+ describe('fileDiffVerb', () => {
36
+ it('maps mode to verb', () => {
37
+ expect(fileDiffVerb(diff({ mode: 'create' }))).toBe('Create');
38
+ expect(fileDiffVerb(diff({ mode: 'update' }))).toBe('Update');
39
+ });
40
+ });
41
+
42
+ describe('diffGutterNo', () => {
43
+ it('uses old number for deletions, new otherwise', () => {
44
+ expect(diffGutterNo({ kind: 'del', text: '', oldNo: 5, newNo: undefined })).toBe(5);
45
+ expect(diffGutterNo({ kind: 'add', text: '', newNo: 7 })).toBe(7);
46
+ expect(diffGutterNo({ kind: 'context', text: '', oldNo: 3, newNo: 4 })).toBe(4);
47
+ });
48
+ });
49
+
50
+ describe('toDiffRows', () => {
51
+ it('inserts a gap marker between hunks', () => {
52
+ const d = diff({
53
+ hunks: [
54
+ { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: [{ kind: 'context', text: 'a' }] },
55
+ { oldStart: 9, oldLines: 1, newStart: 9, newLines: 1, lines: [{ kind: 'context', text: 'b' }] },
56
+ ],
57
+ });
58
+ const rows = toDiffRows(d);
59
+ expect(rows.map((r) => r.kind)).toEqual(['context', 'gap', 'context']);
60
+ });
61
+ });
62
+
63
+ describe('guards', () => {
64
+ it('isFileDiffDisplay / isToolDisplayResult', () => {
65
+ expect(isFileDiffDisplay(diff())).toBe(true);
66
+ expect(isFileDiffDisplay({ kind: 'other' })).toBe(false);
67
+ expect(isToolDisplayResult({ forModel: 'x', display: diff() })).toBe(true);
68
+ expect(isToolDisplayResult({ forModel: 'x' })).toBe(false);
69
+ expect(isToolDisplayResult('wrote 12 chars')).toBe(false);
70
+ });
71
+ });