@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.
- package/dist/elision-state.d.ts.map +1 -1
- package/dist/elision-state.js +6 -0
- package/dist/elision-state.js.map +1 -1
- package/dist/events.d.ts +33 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mode-helpers.d.ts +12 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +105 -3
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +9 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/provider-login-bridge.d.ts +53 -0
- package/dist/provider-login-bridge.d.ts.map +1 -0
- package/dist/provider-login-bridge.js +95 -0
- package/dist/provider-login-bridge.js.map +1 -0
- package/dist/provider.d.ts +53 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/session-like.d.ts +36 -0
- package/dist/session-like.d.ts.map +1 -1
- package/dist/subagent.d.ts +6 -0
- package/dist/subagent.d.ts.map +1 -1
- package/dist/surface.d.ts +159 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +27 -0
- package/dist/surface.js.map +1 -0
- package/dist/tool-display.d.ts +73 -0
- package/dist/tool-display.d.ts.map +1 -0
- package/dist/tool-display.js +66 -0
- package/dist/tool-display.js.map +1 -0
- package/package.json +5 -1
- package/src/elision-state.ts +5 -0
- package/src/events.ts +36 -0
- package/src/index.ts +43 -0
- package/src/loop-helpers.test.ts +40 -0
- package/src/mode-helpers.ts +114 -3
- package/src/mode.ts +7 -0
- package/src/plugin.ts +10 -1
- package/src/provider-login-bridge.test.ts +78 -0
- package/src/provider-login-bridge.ts +105 -0
- package/src/provider.ts +45 -2
- package/src/session-like.ts +38 -0
- package/src/subagent.ts +6 -0
- package/src/surface.ts +173 -0
- package/src/tool-display.test.ts +71 -0
- 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 {
|
package/src/session-like.ts
CHANGED
|
@@ -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
|
+
});
|