dsh-bots 0.0.1 → 0.2.10

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/lib/sse.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * SSE plumbing for the host half — pure ring + parser (unit-tested) and a
3
+ * native `fetch` listener that keeps the ring fed from the sdk-bots gateway
4
+ * `/events` endpoint. The web client has no network, so this is the only live
5
+ * event path; it replays through `bots.eventsSince(seq)`.
6
+ *
7
+ * The wire uses `Accept-Encoding: identity` to sidestep gzip/deflate handling
8
+ * in the host fetch layer (the gateway also supports `?token=` for the old
9
+ * browser path, but we always send the `authorization` header).
10
+ * @module dsh-bots/sse
11
+ */
12
+ /** Incremental SSE wire parser: feed string chunks, receive frames. */
13
+ export class SseParser {
14
+ onEvent;
15
+ buffer = '';
16
+ event = '';
17
+ dataLines = [];
18
+ constructor(onEvent) {
19
+ this.onEvent = onEvent;
20
+ }
21
+ push(chunk) {
22
+ this.buffer += chunk;
23
+ let idx;
24
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
25
+ const line = this.buffer.slice(0, idx);
26
+ this.buffer = this.buffer.slice(idx + 1);
27
+ this.feedLine(line);
28
+ }
29
+ }
30
+ feedLine(line) {
31
+ if (line.endsWith('\r'))
32
+ line = line.slice(0, -1);
33
+ if (line === '') {
34
+ const data = this.dataLines.join('\n');
35
+ const event = this.event !== '' ? this.event : 'message';
36
+ if (data !== '' || (event !== 'message' && this.dataLines.length > 0)) {
37
+ this.onEvent(event, data);
38
+ }
39
+ this.event = '';
40
+ this.dataLines = [];
41
+ return;
42
+ }
43
+ // Comment / heartbeat line (`: ping`); ignore.
44
+ if (line.startsWith(':'))
45
+ return;
46
+ const colon = line.indexOf(':');
47
+ const field = colon < 0 ? line : line.slice(0, colon);
48
+ let value = colon < 0 ? '' : line.slice(colon + 1);
49
+ if (value.startsWith(' '))
50
+ value = value.slice(1);
51
+ if (field === 'event')
52
+ this.event = value;
53
+ else if (field === 'data')
54
+ this.dataLines.push(value);
55
+ // `id` / `retry` are intentionally ignored: seq is assigned locally.
56
+ }
57
+ }
58
+ /** Bounded, monotonic ring of normalized events with O(log n) `eventsSince`. */
59
+ export class SseRingBuffer {
60
+ onPush;
61
+ capacity;
62
+ entries = [];
63
+ nextSeq = 0;
64
+ constructor(capacity = 3000,
65
+ /** Optional side-channel observer (unread tracking); must never throw. */
66
+ onPush) {
67
+ this.onPush = onPush;
68
+ this.capacity = Math.max(1, Math.floor(capacity));
69
+ }
70
+ /** Append one event; returns its assigned seq. */
71
+ push(channel, data) {
72
+ const seq = this.nextSeq;
73
+ this.nextSeq += 1;
74
+ if (this.entries.length >= this.capacity)
75
+ this.entries.shift();
76
+ this.entries.push({ seq, channel, data });
77
+ if (this.onPush !== undefined) {
78
+ try {
79
+ this.onPush(channel, data);
80
+ }
81
+ catch { /* observer must not break the ring */ }
82
+ }
83
+ return seq;
84
+ }
85
+ /** Replay events strictly after `seq`, or everything when the seq fell off. */
86
+ eventsSince(seq) {
87
+ if (this.entries.length === 0)
88
+ return [];
89
+ const oldest = this.entries[0].seq;
90
+ if (seq < oldest - 1 || Number.isNaN(seq))
91
+ return this.entries.slice();
92
+ let lo = 0;
93
+ let hi = this.entries.length;
94
+ while (lo < hi) {
95
+ const mid = (lo + hi) >> 1;
96
+ if (this.entries[mid].seq <= seq)
97
+ lo = mid + 1;
98
+ else
99
+ hi = mid;
100
+ }
101
+ return this.entries.slice(lo);
102
+ }
103
+ get size() { return this.entries.length; }
104
+ get total() { return this.nextSeq; }
105
+ }
106
+ function toState(ring, live) {
107
+ return {
108
+ running: live.running,
109
+ ok: live.ok,
110
+ lastError: live.lastError,
111
+ connectedAt: live.connectedAt,
112
+ droppedAt: live.droppedAt,
113
+ buffered: ring.size,
114
+ total: ring.total,
115
+ };
116
+ }
117
+ /**
118
+ * Long-lived `/events` listener. Re-resolves discovery on every connect so a
119
+ * restarted sdk-bots host is picked up automatically; reconnects are silent
120
+ * and bounded. Deliberately never throws — errors surface via `state()`.
121
+ */
122
+ export class GatewaySseClient {
123
+ ring;
124
+ resolveBase;
125
+ reconnectBaseMs;
126
+ channels;
127
+ onConnected;
128
+ ac = null;
129
+ timer = null;
130
+ stopped = true;
131
+ ok = false;
132
+ lastError = null;
133
+ connectedAt = null;
134
+ droppedAt = null;
135
+ attempt = 0;
136
+ constructor(opts) {
137
+ this.ring = opts.ring ?? new SseRingBuffer();
138
+ this.resolveBase = opts.resolveBase;
139
+ this.reconnectBaseMs = opts.reconnectBaseMs ?? 2000;
140
+ this.channels = opts.channels;
141
+ this.onConnected = opts.onConnected;
142
+ }
143
+ get buffer() { return this.ring; }
144
+ start() {
145
+ if (!this.stopped)
146
+ return;
147
+ this.stopped = false;
148
+ this.attempt = 0;
149
+ void this.connect();
150
+ }
151
+ stop() {
152
+ this.stopped = true;
153
+ if (this.timer !== null) {
154
+ clearTimeout(this.timer);
155
+ this.timer = null;
156
+ }
157
+ this.ac?.abort();
158
+ this.ac = null;
159
+ this.ok = false;
160
+ this.droppedAt = this.connectedAt !== null ? new Date().toISOString() : null;
161
+ }
162
+ state() {
163
+ return toState(this.ring, {
164
+ running: !this.stopped,
165
+ ok: this.ok,
166
+ lastError: this.lastError,
167
+ connectedAt: this.connectedAt,
168
+ droppedAt: this.droppedAt,
169
+ });
170
+ }
171
+ eventsSince(seq) {
172
+ return { events: this.ring.eventsSince(seq), nextSeq: this.ring.total, state: this.state() };
173
+ }
174
+ async connect() {
175
+ if (this.stopped)
176
+ return;
177
+ const base = this.resolveBase();
178
+ if (base === null) {
179
+ this.lastError = 'no-gateway-json';
180
+ this.schedule();
181
+ return;
182
+ }
183
+ this.ac = new AbortController();
184
+ let endpoint = `${base.url}/events`;
185
+ if (this.channels !== undefined && this.channels.length > 0) {
186
+ endpoint += `?channels=${encodeURIComponent(this.channels.join(','))}`;
187
+ }
188
+ const headers = { accept: 'text/event-stream', 'accept-encoding': 'identity' };
189
+ if (base.token !== null)
190
+ headers['authorization'] = `Bearer ${base.token}`;
191
+ const parser = new SseParser((event, data) => {
192
+ let parsed = data;
193
+ try {
194
+ parsed = JSON.parse(data);
195
+ }
196
+ catch { /* keep raw string */ }
197
+ this.ring.push(event, parsed);
198
+ });
199
+ try {
200
+ const res = await fetch(endpoint, { headers, signal: this.ac.signal });
201
+ if (this.stopped)
202
+ return;
203
+ if (!res.ok || res.body === null) {
204
+ // Non-2xx or no body → not an SSE stream; treat as a hard connection error.
205
+ this.ok = false;
206
+ this.lastError = `health-http-${res.status}`;
207
+ this.droppedAt = new Date().toISOString();
208
+ this.schedule();
209
+ return;
210
+ }
211
+ this.ok = true;
212
+ this.lastError = null;
213
+ this.connectedAt = new Date().toISOString();
214
+ this.droppedAt = null;
215
+ this.attempt = 0;
216
+ if (this.onConnected !== undefined) {
217
+ try {
218
+ this.onConnected();
219
+ }
220
+ catch { /* observer must not break the loop */ }
221
+ }
222
+ const reader = res.body.getReader();
223
+ const decoder = new TextDecoder();
224
+ for (;;) {
225
+ const { done, value } = await reader.read();
226
+ if (done)
227
+ break;
228
+ parser.push(decoder.decode(value, { stream: true }));
229
+ }
230
+ this.ok = false;
231
+ this.droppedAt = new Date().toISOString();
232
+ }
233
+ catch (err) {
234
+ if (this.stopped)
235
+ return;
236
+ this.ok = false;
237
+ this.lastError = String(err?.message ?? err);
238
+ this.droppedAt = new Date().toISOString();
239
+ }
240
+ this.schedule();
241
+ }
242
+ schedule() {
243
+ if (this.stopped)
244
+ return;
245
+ this.attempt += 1;
246
+ const delay = Math.min(this.reconnectBaseMs * this.attempt, 15000);
247
+ this.timer = setTimeout(() => { this.timer = null; void this.connect(); }, delay);
248
+ }
249
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * dsh-bots — client half (web), formal-plugin runtime.
3
+ *
4
+ * Rendered by the dsh web shell through the standard slot system with full
5
+ * browser DOM access. Two rules keep the surface native rather than
6
+ * native-looking:
7
+ *
8
+ * 1. Components come from `@deepseek-ai/dsh-client-ui-primitives`, which the
9
+ * shell publishes in its static module map alongside `react`. Buttons,
10
+ * inputs, icons, state dots and the markdown renderer are therefore the
11
+ * shipped ones, not reimplementations. Every lookup degrades to a local
12
+ * fallback so a primitives reshuffle can never blank the plugin.
13
+ * 2. Our own CSS uses stable `dbs-` class names carrying the *values* read
14
+ * out of the shipped stylesheets (row heights, radii, the composer var
15
+ * family), expressed through dsh theme variables. No hashed class name is
16
+ * borrowed, so a dsh rebuild cannot break the visuals.
17
+ *
18
+ * Sidebar integration: the left-nav workspace region (`sidebar.workspaces` is
19
+ * a single slot) is shadowed at a low priority — an officially supported move,
20
+ * the shell's own error text reads "register at a different priority to shadow
21
+ * it (lowest renders)" — and re-rendered as a two-group collapsible nav:
22
+ * - 「工作区」 — delegates the ORIGINAL shipped workspace browser, with its
23
+ * child slots and its rail branch intact (see DelegatedBrowser).
24
+ * - 「Bots」 — our bot/group tree; clicking opens the chat.
25
+ *
26
+ * Chat lives in the `shell.overlay` layer but is inset to the frame's centre
27
+ * column, so the sidebar stays visible and usable while a bot conversation is
28
+ * open — matching how a native session behaves.
29
+ *
30
+ * Live data: the host keeps an SSE ring fed from the sdk-bots `/events`
31
+ * channel. ONE bus drains it here and fans channels out to subscribers; no
32
+ * component owns the cursor and no component polls the gateway directly.
33
+ * @module dsh-bots/client
34
+ */
35
+ export {};
@@ -0,0 +1,32 @@
1
+ /**
2
+ * sdk-bots gateway client for the host half.
3
+ *
4
+ * The formal plugin runs inside the real host process, so this uses native
5
+ * `fetch` and `node:fs` — the curl-over-shell bridge from the dynamic-plugin
6
+ * prototype is gone. Discovery follows `gateway.json` in the sdk-bots data
7
+ * directory; `SAND_GATEWAY_TOKEN` (or the file's optional `token` field) pins
8
+ * auth when present.
9
+ * @module dsh-plugin-bots/gateway
10
+ */
11
+ import type { AgentInfo, GatewayInfo, TranscriptEntry } from './shared.js';
12
+ export interface Discovery {
13
+ port: number;
14
+ pid: number | null;
15
+ token: string | null;
16
+ host: string;
17
+ }
18
+ export declare function expandHome(p: string): string;
19
+ /** Read loopback discovery from `<dataDir>/gateway.json`; null when absent/stale. */
20
+ export declare function readDiscovery(dataDir: string): Discovery | null;
21
+ /** Discovery + `/health` probe with pid match validation. */
22
+ export declare function discover(dataDir: string): Promise<GatewayInfo>;
23
+ /** POST one `/api/<method>` command; unwraps `{result}` and throws on errors. */
24
+ export declare function callGateway<T>(dataDir: string, method: string, args?: Record<string, unknown>): Promise<T>;
25
+ /** Project a raw gateway agent onto the wire type. */
26
+ export declare function trimAgent(a: any): AgentInfo | null;
27
+ /** Normalize one transcript entry for the UI's closed render switch. */
28
+ export declare function trimEntry(en: any): TranscriptEntry;
29
+ /** `listAgents` returns a bare array (also tolerates `{agents:[...]}`). */
30
+ export declare function normalizeAgents(raw: any): AgentInfo[];
31
+ /** Client nonce for idempotent create/send calls. */
32
+ export declare function nextNonce(): string;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * dsh-bots — host half.
3
+ *
4
+ * Bridges the sdk-bots orchestration gateway (single bots, group chats,
5
+ * transcripts, live SSE) into dsh as a formal plugin. Publishes the `bots`
6
+ * Remote namespace through the Typert gateway: every method takes a single
7
+ * `request` JSON value and returns plain JSON (source-mode descriptors,
8
+ * matching how the web client calls `bots/<method>` over the connection
9
+ * RPC carrier).
10
+ *
11
+ * Typert identity: dsh's host identifies a `TypertRemoteService` by the module
12
+ * instance it was loaded from — not by name. When this plugin is installed by
13
+ * pnpm it can resolve a *separate* copy of `@deepseek-ai/dsh-typert-protocol`
14
+ * from its own node_modules, producing a class that is unequal to the host's
15
+ * and silently dropping every method. We therefore anchor at load time to the
16
+ * running dsh CLI / global layout (the same technique used by dsh-freeroute)
17
+ * and only fall back to plain resolution when anchoring fails. The `Remote`
18
+ * markers are applied with a decorator-context shim for the same reason.
19
+ * @module dsh-bots
20
+ */
21
+ import type { Context } from '@deepseek-ai/cordis';
22
+ import type { AgentInfo, Config, EventsSinceResult, GatewayInfo, McpServerInfo, McpToolInfo, SessionInfo, SseState, TranscriptEntry, WorkspaceInfo } from './shared.js';
23
+ export declare const name = "dsh-bots";
24
+ /** No hard Cordis service requirements: host reads optional services via
25
+ * guarded `ctx.get` so a partially-provisioned kernel never blocks activation. */
26
+ export declare const inject: string[];
27
+ /** Cordis range this plugin is tested against; only surfaces a warning. */
28
+ export declare const TESTED_CORDIS_RANGE = "^4.0.1";
29
+ /**
30
+ * Peer-compatibility probe — non-fatal. An unresolvable version or a caret
31
+ * skew inside the tested range must not brick the whole plugin tree (the
32
+ * guard existed to surface silent mismatches loudly).
33
+ */
34
+ export declare function assertPeerCompatible(): void;
35
+ declare const TypertRemoteService: any;
36
+ /**
37
+ * The `bots` Remote namespace. One method per gateway capability, plus
38
+ * host-backed lists (workspaces/sessions) and live SSE replay so the web
39
+ * workbench renders natively without polling the transcript API.
40
+ */
41
+ /** Exported for diagnostics/tests: marker assertions need the prototype. */
42
+ export declare class BotsRemote extends TypertRemoteService {
43
+ private readonly cfg;
44
+ private readonly sse;
45
+ private readonly unread;
46
+ private rebasing;
47
+ constructor(ctx: Context, config: Config);
48
+ /**
49
+ * Ring observer: feed the unread model from live transcript traffic.
50
+ * `appended` is the single-arrival shape; the gateway also replays a
51
+ * `snapshot` payload on (re)connect — both are timestamp-guarded, so
52
+ * replays never double-count.
53
+ */
54
+ private observeTranscript;
55
+ /**
56
+ * Reconstruct counts from transcript tails (SSE has no replay, so events
57
+ * that fire while the stream is down would otherwise be lost forever).
58
+ * Runs once per (re)connect; never throws; skips while one is in flight.
59
+ */
60
+ private rebaseUnread;
61
+ gatewayInfo(request: {
62
+ dataDir?: string;
63
+ } | null): Promise<GatewayInfo>;
64
+ /**
65
+ * Descriptor contract: every remote method declares exactly one plain
66
+ * `request` formal parameter (no defaults/destructuring/rest) — the
67
+ * source-mode descriptor derives its wire field from the parameter name,
68
+ * and the client always sends `{ args: { request } }`. A zero-param method
69
+ * would make the gateway reject the envelope with "unexpected request".
70
+ */
71
+ list(request: unknown): Promise<AgentInfo[]>;
72
+ workspaces(request: unknown): Promise<{
73
+ workspaces: WorkspaceInfo[];
74
+ }>;
75
+ sessions(request: unknown): Promise<{
76
+ sessions: SessionInfo[];
77
+ }>;
78
+ create(request: {
79
+ name?: string;
80
+ description?: string;
81
+ } | null): Promise<AgentInfo | null>;
82
+ createGroup(request: {
83
+ name?: string;
84
+ memberIds?: string[];
85
+ } | null): Promise<AgentInfo | null>;
86
+ /**
87
+ * Replace a group's member list (add + remove in one call — the gateway
88
+ * command is a full-set put, not a delta). Wire field is `memberAgentIds`,
89
+ * same as `createGroup` (§7.2).
90
+ */
91
+ setGroupMembers(request: {
92
+ id?: string;
93
+ memberIds?: string[];
94
+ } | null): Promise<AgentInfo | null>;
95
+ update(request: {
96
+ id?: string;
97
+ profile?: Record<string, unknown>;
98
+ } | null): Promise<AgentInfo | null>;
99
+ remove(request: {
100
+ id?: string;
101
+ } | null): Promise<unknown>;
102
+ send(request: {
103
+ agentId?: string;
104
+ prompt?: string;
105
+ } | null): Promise<unknown>;
106
+ /**
107
+ * Interrupt an agent's active run (the composer's stop button). Returns the
108
+ * gateway's honest `{hadActiveRun}` so the UI can tell "stopped it" from
109
+ * "there was nothing to stop" — never a fake success.
110
+ */
111
+ interrupt(request: {
112
+ id?: string;
113
+ } | null): Promise<{
114
+ hadActiveRun: boolean;
115
+ }>;
116
+ transcriptTail(request: {
117
+ id?: string;
118
+ limit?: number;
119
+ } | null): Promise<{
120
+ entries: TranscriptEntry[];
121
+ }>;
122
+ /**
123
+ * Clear an agent's unread badge. The plugin owns the unread model (see
124
+ * `unread.ts`): the marker "上次读到哪" advances to now and the count
125
+ * zeroes. The gateway call is kept best-effort so desktop-app surfaces
126
+ * (spend guard's lastViewedAt) stay consistent with what the user saw.
127
+ */
128
+ markRead(request: {
129
+ id?: string;
130
+ atMs?: number;
131
+ } | null): Promise<unknown>;
132
+ /** Trim a gateway MCP server row to the settings-page projection. */
133
+ private trimMcpServer;
134
+ /** Trim a routed MCP tool row (schema passed through verbatim). */
135
+ private trimMcpTool;
136
+ /** Installed MCP servers (engine `management.listInstalled`). */
137
+ mcpServers(request: unknown): Promise<{
138
+ servers: McpServerInfo[];
139
+ }>;
140
+ /** All routed MCP tools across servers (engine `mcp.listTools`). */
141
+ mcpTools(request: unknown): Promise<{
142
+ tools: McpToolInfo[];
143
+ }>;
144
+ /**
145
+ * Register one MCP server. `configJson` must decode to a JSON object —
146
+ * either a stdio config (`{"command": "…", "args": […]}`) or a remote URL
147
+ * config; the gateway JSON.parses and re-validates it.
148
+ */
149
+ mcpAdd(request: {
150
+ name?: string;
151
+ configJson?: string;
152
+ } | null): Promise<{
153
+ servers: McpServerInfo[];
154
+ }>;
155
+ mcpRemove(request: {
156
+ serverId?: string;
157
+ } | null): Promise<unknown>;
158
+ /** Restart / reconnect all MCP servers (engine `management.restart`). */
159
+ mcpRefresh(request: unknown): Promise<unknown>;
160
+ /**
161
+ * Execute one routed MCP tool outside a bot turn (tool try-run panel).
162
+ * Passthrough of the gateway wire shape — note the engine swaps `name`/
163
+ * `toolName` on the way into the executor (DEVELOPMENT.md §13.5), so the
164
+ * caller maps `{name: row.toolName, toolName: row.name}` until实测 confirmed.
165
+ */
166
+ mcpExecute(request: {
167
+ agentId?: string;
168
+ name?: string;
169
+ toolName?: string;
170
+ providerIdentifier?: string;
171
+ args?: unknown;
172
+ toolCallId?: string;
173
+ } | null): Promise<unknown>;
174
+ workspaceList(request: unknown): Promise<{
175
+ workspaces: import("./workspace.js").AgentWorkspaceConfig[];
176
+ }>;
177
+ workspaceGet(request: {
178
+ agentId?: string;
179
+ } | null): Promise<import("./workspace.js").AgentWorkspaceConfig>;
180
+ workspaceSet(request: {
181
+ agentId?: string;
182
+ workspaceRoot?: string | null;
183
+ allowPaths?: string[];
184
+ } | null): Promise<import("./workspace.js").AgentWorkspaceConfig>;
185
+ /**
186
+ * Append one diagnostic record to `<dataDir>/dsh-bots-diag.jsonl`.
187
+ *
188
+ * The shadow takeover of `sidebar.workspaces` is the one part of this plugin
189
+ * whose failure mode is invisible — a missing shipped entry or a throwing
190
+ * prop synthesis just renders a small notice. Recording those transitions is
191
+ * the only way to tell, after the fact, whether delegation actually worked
192
+ * on a user's machine. Best-effort by construction: diagnostics must never
193
+ * be able to fail a render.
194
+ */
195
+ diag(request: {
196
+ stage?: string;
197
+ detail?: unknown;
198
+ } | null): Promise<{
199
+ written: boolean;
200
+ }>;
201
+ eventsSince(request: {
202
+ seq?: number;
203
+ } | null): EventsSinceResult;
204
+ sseState(request: unknown): SseState;
205
+ /** Stop the live event loop on plugin teardown. */
206
+ stopSse(): void;
207
+ }
208
+ /** Plugin entry: guard peers, publish the `bots` Remote namespace. */
209
+ export declare function apply(ctx: Context, config: Config): void;
210
+ export {};