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/index.js ADDED
@@ -0,0 +1,508 @@
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 { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
23
+ import { appendFileSync, realpathSync } from 'node:fs';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { callGateway, discover, expandHome, nextNonce, normalizeAgents, readDiscovery, trimAgent, trimEntry } from './gateway.js';
26
+ import { GatewaySseClient, SseRingBuffer } from './sse.js';
27
+ import { UnreadStore } from './unread.js';
28
+ import { listAgentWorkspaces, readAgentWorkspace, setAgentWorkspace } from './workspace.js';
29
+ import { satisfiesCaret } from './version.js';
30
+ export const name = 'dsh-bots';
31
+ /** No hard Cordis service requirements: host reads optional services via
32
+ * guarded `ctx.get` so a partially-provisioned kernel never blocks activation. */
33
+ export const inject = [];
34
+ /** Cordis range this plugin is tested against; only surfaces a warning. */
35
+ export const TESTED_CORDIS_RANGE = '^4.0.1';
36
+ const DEFAULT_DATA_DIR = '~/.sdk-bots';
37
+ /** Append-only diagnostics file, read when a shadow takeover misbehaves. */
38
+ const DIAG_FILE = 'dsh-bots-diag.jsonl';
39
+ /** Persisted read markers ("读到哪了") backing the sidebar unread badge. */
40
+ const UNREAD_FILE = 'dsh-bots-unread.json';
41
+ /** Stamped into every diagnostic line so records survive version skew. */
42
+ const PLUGIN_VERSION = resolvedModuleVersion('dsh-bots');
43
+ const require = createRequire(import.meta.url);
44
+ /** Resolve a package version without tripping `exports` maps (no /package.json). */
45
+ function resolvedModuleVersion(id) {
46
+ try {
47
+ const entry = require.resolve(id);
48
+ const manifest = join(dirname(dirname(entry)), 'package.json');
49
+ const pkg = require(manifest);
50
+ return pkg.version ?? 'unknown';
51
+ }
52
+ catch {
53
+ return 'unresolved';
54
+ }
55
+ }
56
+ /**
57
+ * Peer-compatibility probe — non-fatal. An unresolvable version or a caret
58
+ * skew inside the tested range must not brick the whole plugin tree (the
59
+ * guard existed to surface silent mismatches loudly).
60
+ */
61
+ export function assertPeerCompatible() {
62
+ const cordis = resolvedModuleVersion('@deepseek-ai/cordis');
63
+ if (cordis !== 'unknown' && cordis !== 'unresolved' && !satisfiesCaret(cordis, TESTED_CORDIS_RANGE)) {
64
+ // eslint-disable-next-line no-console
65
+ console.warn(`[dsh-bots] resolved @deepseek-ai/cordis ${cordis}, tested with ${TESTED_CORDIS_RANGE}`);
66
+ }
67
+ }
68
+ /**
69
+ * Anchor to the running dsh CLI / global layout where the host's typert copy
70
+ * lives, mirroring dsh-freeroute. Tries argv anchors, the global npm layout,
71
+ * then plain resolution; returns the first module exposing the Typert API.
72
+ */
73
+ async function resolveTypert() {
74
+ const candidates = [];
75
+ try {
76
+ const anchors = [];
77
+ for (const a of [process.argv && process.argv[1], process.argv && process.argv[0]]) {
78
+ if (typeof a !== 'string' || a.length === 0)
79
+ continue;
80
+ anchors.push(a);
81
+ try {
82
+ anchors.push(realpathSync(a));
83
+ }
84
+ catch { /* keep original */ }
85
+ }
86
+ for (const a of anchors) {
87
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
88
+ candidates.push(async () => import(pathToFileURL(createRequire(a).resolve('@deepseek-ai/dsh-typert-protocol')).href));
89
+ }
90
+ if (process.execPath) {
91
+ const cand = join(dirname(dirname(process.execPath)), 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-typert-protocol', 'lib', 'index.js');
92
+ candidates.push(async () => import(pathToFileURL(cand).href));
93
+ }
94
+ }
95
+ catch { /* anchoring failed → fall back to plain resolution */ }
96
+ candidates.push(async () => import('@deepseek-ai/dsh-typert-protocol'));
97
+ for (const load of candidates) {
98
+ try {
99
+ const m = await load();
100
+ if (m && m.TypertRemoteService && m.Remote) {
101
+ return { Remote: m.Remote, TypertRemoteService: m.TypertRemoteService };
102
+ }
103
+ }
104
+ catch { /* try next anchor */ }
105
+ }
106
+ throw new Error('[dsh-bots] 无法解析 @deepseek-ai/dsh-typert-protocol(宿主锚定与回退均失败)');
107
+ }
108
+ const { Remote, TypertRemoteService } = await resolveTypert();
109
+ /** Apply one `@Remote(method)` marker via a decorator-context shim. */
110
+ function markRemoteMethod(prototype, method) {
111
+ const decorator = Remote(method);
112
+ decorator(undefined, {
113
+ name: method,
114
+ private: false,
115
+ static: false,
116
+ addInitializer(fn) { fn.call(Object.create(prototype)); },
117
+ });
118
+ }
119
+ /**
120
+ * The `bots` Remote namespace. One method per gateway capability, plus
121
+ * host-backed lists (workspaces/sessions) and live SSE replay so the web
122
+ * workbench renders natively without polling the transcript API.
123
+ */
124
+ /** Exported for diagnostics/tests: marker assertions need the prototype. */
125
+ export class BotsRemote extends TypertRemoteService {
126
+ cfg;
127
+ sse;
128
+ unread;
129
+ rebasing = false;
130
+ constructor(ctx, config) {
131
+ super(ctx, 'bots');
132
+ this.cfg = { dataDir: config?.dataDir ?? DEFAULT_DATA_DIR };
133
+ this.unread = new UnreadStore(join(expandHome(this.cfg.dataDir), UNREAD_FILE));
134
+ const ring = new SseRingBuffer(3000, (channel, data) => { this.observeTranscript(channel, data); });
135
+ this.sse = new GatewaySseClient({
136
+ ring,
137
+ resolveBase: () => {
138
+ const d = readDiscovery(this.cfg.dataDir);
139
+ if (d === null)
140
+ return null;
141
+ return { url: `http://${d.host}:${d.port}`, token: d.token };
142
+ },
143
+ channels: ['transcript', 'client-side-tool-v2', 'agents', 'agent-upserted', 'host-settings', 'outline'],
144
+ onConnected: () => { void this.rebaseUnread(); },
145
+ });
146
+ }
147
+ /**
148
+ * Ring observer: feed the unread model from live transcript traffic.
149
+ * `appended` is the single-arrival shape; the gateway also replays a
150
+ * `snapshot` payload on (re)connect — both are timestamp-guarded, so
151
+ * replays never double-count.
152
+ */
153
+ observeTranscript(channel, data) {
154
+ if (channel !== 'transcript' || data === null || typeof data !== 'object')
155
+ return;
156
+ const payload = data;
157
+ if (payload?.type === 'appended') {
158
+ this.unread.bump(String(payload?.agentId ?? ''), payload?.entry);
159
+ }
160
+ else if (payload?.type === 'snapshot' && Array.isArray(payload?.entries)) {
161
+ const agentId = String(payload?.activeAgentId ?? '');
162
+ for (const entry of payload.entries)
163
+ this.unread.bump(agentId, entry);
164
+ }
165
+ }
166
+ /**
167
+ * Reconstruct counts from transcript tails (SSE has no replay, so events
168
+ * that fire while the stream is down would otherwise be lost forever).
169
+ * Runs once per (re)connect; never throws; skips while one is in flight.
170
+ */
171
+ async rebaseUnread() {
172
+ if (this.rebasing)
173
+ return;
174
+ this.rebasing = true;
175
+ try {
176
+ const agents = normalizeAgents(await callGateway(this.cfg.dataDir, 'listAgents', {}));
177
+ for (const agent of agents) {
178
+ try {
179
+ const res = await callGateway(this.cfg.dataDir, 'getAgentTranscriptTail', { id: agent.id, limit: 50 });
180
+ this.unread.rebase(agent.id, Array.isArray(res?.entries) ? res.entries : []);
181
+ }
182
+ catch { /* one failed tail must not stop the others */ }
183
+ }
184
+ this.unread.prune(new Set(agents.map((a) => a.id)));
185
+ }
186
+ catch { /* gateway offline — the next reconnect retries */ }
187
+ finally {
188
+ this.rebasing = false;
189
+ }
190
+ }
191
+ async gatewayInfo(request) {
192
+ const dataDir = request?.dataDir ?? this.cfg.dataDir;
193
+ // Report the CONFIGURED directory, so the settings page stops showing a
194
+ // hardcoded default after the operator overrides `dataDir` in cordis.yml.
195
+ // workspaceRoot mirrors the engine's exec-daemon default (`<dataDir>/
196
+ // box-workspace`); once the engine exposes the authoritative value on
197
+ // /health we prefer it — the plugin-side fallback stays for older engines.
198
+ const discovered = await discover(dataDir);
199
+ const healthRoot = discovered.health?.workspaceRoot;
200
+ const root = typeof healthRoot === 'string' && healthRoot.trim() !== ''
201
+ ? healthRoot
202
+ : join(expandHome(dataDir), 'box-workspace');
203
+ return { ...discovered, dataDir, workspaceRoot: root };
204
+ }
205
+ /**
206
+ * Descriptor contract: every remote method declares exactly one plain
207
+ * `request` formal parameter (no defaults/destructuring/rest) — the
208
+ * source-mode descriptor derives its wire field from the parameter name,
209
+ * and the client always sends `{ args: { request } }`. A zero-param method
210
+ * would make the gateway reject the envelope with "unexpected request".
211
+ */
212
+ async list(request) {
213
+ return normalizeAgents(await callGateway(this.cfg.dataDir, 'listAgents', {}));
214
+ }
215
+ async workspaces(request) {
216
+ const registry = this.ctx.get('workspaceRegistry');
217
+ if (registry === undefined || typeof registry.list !== 'function')
218
+ return { workspaces: [] };
219
+ try {
220
+ const list = await registry.list();
221
+ return {
222
+ workspaces: (Array.isArray(list) ? list : []).map((w) => ({
223
+ id: String(w.id),
224
+ title: typeof w.title === 'string' ? w.title : '',
225
+ path: typeof w.path === 'string' || w.path === null ? w.path : null,
226
+ })),
227
+ };
228
+ }
229
+ catch {
230
+ return { workspaces: [] };
231
+ }
232
+ }
233
+ async sessions(request) {
234
+ const q = this.ctx.get('sessionQuery');
235
+ if (q === undefined || typeof q.listSessions !== 'function')
236
+ return { sessions: [] };
237
+ const records = await q.listSessions();
238
+ const recent = (Array.isArray(records) ? records : [])
239
+ .filter((r) => r !== null && typeof r === 'object' && r.header !== undefined)
240
+ .sort((a, b) => (b.header.createdAt ?? 0) - (a.header.createdAt ?? 0))
241
+ .slice(0, 15);
242
+ const titleMap = new Map();
243
+ const titleSvc = this.ctx.get('sessionTitle');
244
+ if (titleSvc !== undefined && typeof titleSvc.readTitleSnapshots === 'function') {
245
+ const obs = await titleSvc.readTitleSnapshots(recent.map((r) => r.header.id));
246
+ for (const o of Array.isArray(obs) ? obs : []) {
247
+ if (o?.status === 'fulfilled' && o.value?.title?.title)
248
+ titleMap.set(o.sessionId, o.value.title.title);
249
+ }
250
+ }
251
+ return {
252
+ sessions: recent.map((r) => ({
253
+ id: r.header.id,
254
+ title: titleMap.get(r.header.id)
255
+ ?? (r.header.cwd ? String(r.header.cwd).split('/').pop() : '未命名会话'),
256
+ live: Boolean(r.live),
257
+ })),
258
+ };
259
+ }
260
+ async create(request) {
261
+ const created = await callGateway(this.cfg.dataDir, 'createAgent', {
262
+ name: String(request?.name ?? '').trim(),
263
+ description: request?.description ? String(request.description) : '',
264
+ clientNonce: nextNonce(),
265
+ });
266
+ return trimAgent(created?.agent ?? created);
267
+ }
268
+ async createGroup(request) {
269
+ const created = await callGateway(this.cfg.dataDir, 'createGroup', {
270
+ name: String(request?.name ?? '').trim(),
271
+ memberAgentIds: Array.isArray(request?.memberIds) ? request.memberIds : [],
272
+ });
273
+ return trimAgent(created?.agent ?? created);
274
+ }
275
+ /**
276
+ * Replace a group's member list (add + remove in one call — the gateway
277
+ * command is a full-set put, not a delta). Wire field is `memberAgentIds`,
278
+ * same as `createGroup` (§7.2).
279
+ */
280
+ async setGroupMembers(request) {
281
+ const updated = await callGateway(this.cfg.dataDir, 'setGroupMembers', {
282
+ id: request?.id,
283
+ memberAgentIds: Array.isArray(request?.memberIds) ? request.memberIds : [],
284
+ });
285
+ return trimAgent(updated?.agent ?? updated);
286
+ }
287
+ async update(request) {
288
+ const updated = await callGateway(this.cfg.dataDir, 'updateAgent', {
289
+ id: request?.id,
290
+ profile: request?.profile ?? {},
291
+ });
292
+ return trimAgent(updated?.agent ?? updated);
293
+ }
294
+ async remove(request) {
295
+ return callGateway(this.cfg.dataDir, 'deleteAgent', { id: request?.id });
296
+ }
297
+ async send(request) {
298
+ return callGateway(this.cfg.dataDir, 'sendPrompt', {
299
+ agentId: request?.agentId,
300
+ prompt: String(request?.prompt ?? ''),
301
+ clientNonce: nextNonce(),
302
+ });
303
+ }
304
+ /**
305
+ * Interrupt an agent's active run (the composer's stop button). Returns the
306
+ * gateway's honest `{hadActiveRun}` so the UI can tell "stopped it" from
307
+ * "there was nothing to stop" — never a fake success.
308
+ */
309
+ async interrupt(request) {
310
+ const id = String(request?.id ?? '').trim();
311
+ if (id === '')
312
+ throw new Error('interrupt requires an agent id');
313
+ const res = await callGateway(this.cfg.dataDir, 'interruptAgent', {
314
+ id, reason: '用户在 dsh Bots 工作台停止了生成',
315
+ });
316
+ const body = res?.result ?? res ?? {};
317
+ return { hadActiveRun: body.hadActiveRun === true };
318
+ }
319
+ async transcriptTail(request) {
320
+ const res = await callGateway(this.cfg.dataDir, 'getAgentTranscriptTail', {
321
+ id: request?.id,
322
+ limit: Number(request?.limit) || 40,
323
+ });
324
+ const entries = Array.isArray(res?.entries) ? res.entries : [];
325
+ return { entries: entries.map(trimEntry) };
326
+ }
327
+ /**
328
+ * Clear an agent's unread badge. The plugin owns the unread model (see
329
+ * `unread.ts`): the marker "上次读到哪" advances to now and the count
330
+ * zeroes. The gateway call is kept best-effort so desktop-app surfaces
331
+ * (spend guard's lastViewedAt) stay consistent with what the user saw.
332
+ */
333
+ async markRead(request) {
334
+ if (typeof request?.id !== 'string' || request.id === '')
335
+ return null;
336
+ const atMs = typeof request.atMs === 'number' && Number.isFinite(request.atMs) ? request.atMs : undefined;
337
+ this.unread.markRead(request.id, atMs);
338
+ return callGateway(this.cfg.dataDir, 'setAgentUnread', {
339
+ id: request.id, isUnread: false, atMs: atMs ?? Date.now(),
340
+ });
341
+ }
342
+ // ==========================================================
343
+ // MCP bridge (DEVELOPMENT.md §13): the engine already ships a
344
+ // full MCP stack — management, routed tools, OAuth — so the
345
+ // plugin only forwards. Every method is a thin `callGateway`
346
+ // passthrough honoring the single-`request` descriptor rule.
347
+ // ==========================================================
348
+ /** Trim a gateway MCP server row to the settings-page projection. */
349
+ trimMcpServer(row) {
350
+ return {
351
+ id: String(row?.id ?? ''),
352
+ serverIdentifier: String(row?.serverIdentifier ?? row?.id ?? ''),
353
+ name: String(row?.name ?? ''),
354
+ status: String(row?.status ?? 'unknown'),
355
+ accountKey: String(row?.accountKey ?? ''),
356
+ transport: String(row?.transport ?? ''),
357
+ toolCount: Number(row?.toolCount ?? 0) || 0,
358
+ ...(row?.disabledToolCount == null ? {} : { disabledToolCount: Number(row.disabledToolCount) || 0 }),
359
+ ...(row?.statusDetail == null ? {} : { statusDetail: String(row.statusDetail) }),
360
+ ...(row?.customInstructions == null ? {} : { customInstructions: String(row.customInstructions) }),
361
+ };
362
+ }
363
+ /** Trim a routed MCP tool row (schema passed through verbatim). */
364
+ trimMcpTool(row) {
365
+ return {
366
+ name: String(row?.name ?? ''),
367
+ providerIdentifier: String(row?.providerIdentifier ?? ''),
368
+ toolName: String(row?.toolName ?? ''),
369
+ ...(row?.description == null ? {} : { description: String(row.description) }),
370
+ ...(row?.inputSchema == null ? {} : { inputSchema: row.inputSchema }),
371
+ };
372
+ }
373
+ /** Installed MCP servers (engine `management.listInstalled`). */
374
+ async mcpServers(request) {
375
+ const res = await callGateway(this.cfg.dataDir, 'listMcpServers', {});
376
+ const rows = Array.isArray(res) ? res : Array.isArray(res?.servers) ? res.servers : [];
377
+ return { servers: rows.map((r) => this.trimMcpServer(r)) };
378
+ }
379
+ /** All routed MCP tools across servers (engine `mcp.listTools`). */
380
+ async mcpTools(request) {
381
+ const res = await callGateway(this.cfg.dataDir, 'listRoutedMcpTools', {});
382
+ const rows = Array.isArray(res) ? res : Array.isArray(res?.tools) ? res.tools : [];
383
+ return { tools: rows.map((r) => this.trimMcpTool(r)) };
384
+ }
385
+ /**
386
+ * Register one MCP server. `configJson` must decode to a JSON object —
387
+ * either a stdio config (`{"command": "…", "args": […]}`) or a remote URL
388
+ * config; the gateway JSON.parses and re-validates it.
389
+ */
390
+ async mcpAdd(request) {
391
+ const name = String(request?.name ?? '').trim();
392
+ const configJson = String(request?.configJson ?? '').trim();
393
+ if (name === '')
394
+ throw new Error('mcpAdd 需要 name');
395
+ if (configJson === '')
396
+ throw new Error('mcpAdd 需要 configJson(stdio 或 URL 配置的 JSON 对象串)');
397
+ try {
398
+ JSON.parse(configJson);
399
+ }
400
+ catch (e) {
401
+ throw new Error(`configJson 不是合法 JSON:${String(e?.message ?? e)}`);
402
+ }
403
+ const res = await callGateway(this.cfg.dataDir, 'addMcpServer', { name, configJson });
404
+ const rows = Array.isArray(res?.servers) ? res.servers : [];
405
+ return { servers: rows.map((r) => this.trimMcpServer(r)) };
406
+ }
407
+ async mcpRemove(request) {
408
+ const serverId = String(request?.serverId ?? '').trim();
409
+ if (serverId === '')
410
+ throw new Error('mcpRemove 需要 serverId');
411
+ return callGateway(this.cfg.dataDir, 'removeMcpServer', { serverId });
412
+ }
413
+ /** Restart / reconnect all MCP servers (engine `management.restart`). */
414
+ async mcpRefresh(request) {
415
+ return callGateway(this.cfg.dataDir, 'refreshMcp', {});
416
+ }
417
+ /**
418
+ * Execute one routed MCP tool outside a bot turn (tool try-run panel).
419
+ * Passthrough of the gateway wire shape — note the engine swaps `name`/
420
+ * `toolName` on the way into the executor (DEVELOPMENT.md §13.5), so the
421
+ * caller maps `{name: row.toolName, toolName: row.name}` until实测 confirmed.
422
+ */
423
+ async mcpExecute(request) {
424
+ return callGateway(this.cfg.dataDir, 'executeRoutedMcpTool', {
425
+ agentId: request?.agentId,
426
+ name: request?.name,
427
+ toolName: request?.toolName,
428
+ providerIdentifier: request?.providerIdentifier,
429
+ args: request?.args ?? {},
430
+ toolCallId: request?.toolCallId ?? `dsh-try-${Date.now()}`,
431
+ });
432
+ }
433
+ // ==========================================================
434
+ // Per-agent workspace jail bridge. The ENGINE owns the isolation
435
+ // (macOS Seatbelt write confinement, engine `agent-workspace-jail.ts`);
436
+ // these methods only read/write the per-agent settings.json contract.
437
+ // ==========================================================
438
+ async workspaceList(request) {
439
+ return { workspaces: listAgentWorkspaces(expandHome(this.cfg.dataDir)) };
440
+ }
441
+ async workspaceGet(request) {
442
+ const config = readAgentWorkspace(expandHome(this.cfg.dataDir), String(request?.agentId ?? ''));
443
+ if (config === null)
444
+ throw new Error(`workspaceGet: agent 不存在或 id 不合法`);
445
+ return config;
446
+ }
447
+ async workspaceSet(request) {
448
+ return setAgentWorkspace(expandHome(this.cfg.dataDir), String(request?.agentId ?? ''), {
449
+ workspaceRoot: request?.workspaceRoot,
450
+ allowPaths: request?.allowPaths,
451
+ });
452
+ }
453
+ /**
454
+ * Append one diagnostic record to `<dataDir>/dsh-bots-diag.jsonl`.
455
+ *
456
+ * The shadow takeover of `sidebar.workspaces` is the one part of this plugin
457
+ * whose failure mode is invisible — a missing shipped entry or a throwing
458
+ * prop synthesis just renders a small notice. Recording those transitions is
459
+ * the only way to tell, after the fact, whether delegation actually worked
460
+ * on a user's machine. Best-effort by construction: diagnostics must never
461
+ * be able to fail a render.
462
+ */
463
+ async diag(request) {
464
+ if (typeof request?.stage !== 'string' || request.stage === '')
465
+ return { written: false };
466
+ try {
467
+ const line = JSON.stringify({
468
+ at: new Date().toISOString(),
469
+ version: PLUGIN_VERSION,
470
+ stage: request.stage,
471
+ detail: request.detail ?? null,
472
+ });
473
+ appendFileSync(join(expandHome(this.cfg.dataDir), DIAG_FILE), line + '\n');
474
+ return { written: true };
475
+ }
476
+ catch {
477
+ return { written: false };
478
+ }
479
+ }
480
+ // Live event channel: ring-replay instead of transcript polling.
481
+ eventsSince(request) {
482
+ if (!this.sse.state().running)
483
+ this.sse.start();
484
+ return { ...this.sse.eventsSince(Number(request?.seq) || 0), unread: this.unread.counts() };
485
+ }
486
+ sseState(request) {
487
+ return this.sse.state();
488
+ }
489
+ /** Stop the live event loop on plugin teardown. */
490
+ stopSse() {
491
+ this.sse.stop();
492
+ }
493
+ }
494
+ for (const m of [
495
+ 'gatewayInfo', 'list', 'workspaces', 'sessions',
496
+ 'create', 'createGroup', 'setGroupMembers', 'update', 'remove', 'send', 'interrupt', 'transcriptTail', 'markRead', 'diag',
497
+ 'mcpServers', 'mcpTools', 'mcpAdd', 'mcpRemove', 'mcpRefresh', 'mcpExecute',
498
+ 'workspaceList', 'workspaceGet', 'workspaceSet',
499
+ 'eventsSince', 'sseState',
500
+ ]) {
501
+ markRemoteMethod(BotsRemote.prototype, m);
502
+ }
503
+ /** Plugin entry: guard peers, publish the `bots` Remote namespace. */
504
+ export function apply(ctx, config) {
505
+ assertPeerCompatible();
506
+ const remote = new BotsRemote(ctx, config);
507
+ ctx.effect(() => () => { remote.stopSse(); });
508
+ }
package/lib/shared.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Wire types shared by the host half (gateway bridge) and the client half
3
+ * (web UI). Keep everything JSON-serializable: these shapes cross the
4
+ * connection RPC boundary verbatim.
5
+ * @module dsh-bots/shared
6
+ */
7
+ /** Largest avatar data URL inlined into a list response (bytes). */
8
+ export const AVATAR_DATA_URL_MAX = 64 * 1024;
9
+ /**
10
+ * Every prop the dsh shell's renderer puts on a slot component before the
11
+ * entry's own `inject` result, and how our `sidebar.workspaces` shadow
12
+ * reproduces it when it re-renders the shipped entry underneath.
13
+ *
14
+ * Taking over a `single` slot means taking over this assembly. The list is the
15
+ * contract: `tests/delegation.spec.ts` reads the installed renderer and fails
16
+ * if it grows a key that is not accounted for here, so a dsh upgrade surfaces
17
+ * as a red test rather than as another silently blank region.
18
+ */
19
+ export const DELEGATED_KIT_KEYS = {
20
+ /** Root-scope standard props — bound from the host face. */
21
+ useSessions: 'synthesized',
22
+ useWorkspaces: 'synthesized',
23
+ /** Locale seat, when the registration declares a namespace. */
24
+ t: 'synthesized',
25
+ /** Store pair, when the registration declares a store. */
26
+ useStore: 'synthesized',
27
+ actions: 'synthesized',
28
+ /** Child-slot renderer — the one that used to be stubbed to null. */
29
+ renderSlot: 'synthesized',
30
+ /** Needs renderer-internal chain composition; we warn instead of faking it. */
31
+ renderSlotChain: 'guarded',
32
+ /** Needs the renderer's session seat; we warn instead of faking it. */
33
+ SessionProvider: 'guarded',
34
+ /** Only assembled for session-scoped slots; `sidebar.workspaces` is root. */
35
+ sessionId: 'session-only',
36
+ useProjection: 'session-only',
37
+ };