@addozhang/dsh-discord 0.4.0 → 0.5.0-alpha.2

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.
@@ -0,0 +1,598 @@
1
+ /**
2
+ * The typed防腐层 over the Host's 0.1.6 controller services — the cordis
3
+ * services `sessionController`, `workspaceController`, and `sessionQuery`
4
+ * that replaced the 0.1.1-rc.2 `apiProxy` gateway. Domain methods speak the
5
+ * typert faces directly: plain request objects in, plain values out, and
6
+ * business rejections carried as thrown `RemoteError`s (`{code, message}`).
7
+ * Two guarantees are preserved from the rc.2 seam:
8
+ *
9
+ * 1. Boundedness — a Host that never answers must not wedge an interaction
10
+ * handler (or a Discord ephemeral) forever; every call races a timeout and
11
+ * resolves to an unobservable outcome instead.
12
+ * 2. Observability — every terminal outcome is reported through the injected
13
+ * log sink, so a silent-void call can never again be misread as a hang.
14
+ *
15
+ * The exported port-level API (function names, outcome unions) is unchanged
16
+ * from the rc.2 face: the Discord features and their tests keep their
17
+ * vocabulary while this module alone absorbs the host-side rebase.
18
+ */
19
+ import { parseWorkspaceReference } from '../policy/disclosure.js';
20
+ /**
21
+ * Resolve the 0.1.6 controller services off the Cordis context. Throws one
22
+ * actionable TypeError naming every absent service — the composition root
23
+ * treats that as a fail-loud startup boundary, never a silent half-mount.
24
+ */
25
+ export function resolveHostFace(ctx) {
26
+ const missing = [];
27
+ for (const name of ['sessionController', 'workspaceController', 'sessionQuery']) {
28
+ if (ctx.get(name) === undefined || ctx.get(name) === null)
29
+ missing.push(name);
30
+ }
31
+ if (missing.length > 0) {
32
+ throw new TypeError(`dsh-discord requires the 0.1.6 host controller services; missing ${missing.map(name => `'${name}'`).join(', ')}`);
33
+ }
34
+ return {
35
+ session: ctx.get('sessionController'),
36
+ workspace: ctx.get('workspaceController'),
37
+ sessionQuery: ctx.get('sessionQuery'),
38
+ };
39
+ }
40
+ /** Raised when the Host did not answer within the bounded window. */
41
+ export class RpcTimeoutError extends Error {
42
+ constructor(timeoutMs) {
43
+ super(`dsh host call did not answer within ${String(timeoutMs)}ms`);
44
+ this.name = 'RpcTimeoutError';
45
+ }
46
+ }
47
+ /** Race one host promise against a bounded window. */
48
+ export function withRpcTimeout(promise, timeoutMs) {
49
+ let timer;
50
+ const timeout = new Promise((_, reject) => {
51
+ timer = setTimeout(() => { reject(new RpcTimeoutError(timeoutMs)); }, timeoutMs);
52
+ });
53
+ return Promise.race([promise, timeout]).finally(() => {
54
+ if (timer !== undefined)
55
+ clearTimeout(timer);
56
+ });
57
+ }
58
+ /** Default bounded window for unary catalog reads (local, no model work). */
59
+ const CATALOG_TIMEOUT_MS = 5_000;
60
+ /** Default bounded window for prompt admission (the Host may enqueue). */
61
+ const PROMPT_TIMEOUT_MS = 30_000;
62
+ /** Default bounded window for the model directory read. */
63
+ const MODELS_TIMEOUT_MS = 10_000;
64
+ /**
65
+ * A definitive Host business rejection: the 0.1.6 controllers throw
66
+ * `RemoteError` ({code, message}) for every admission refusal. Anything else
67
+ * in a catch is a host fault, not a business verdict.
68
+ */
69
+ function remoteRejectionOf(error) {
70
+ const code = error?.code;
71
+ return typeof code === 'string' && code !== '' ? { code } : undefined;
72
+ }
73
+ /** Defensive record probe: the wire is untrusted regardless of declared types. */
74
+ function isRecord(value) {
75
+ return typeof value === 'object' && value !== null;
76
+ }
77
+ /**
78
+ * Map a Host RemoteError code onto the adapter's stable internal vocabulary:
79
+ * the `session/` namespace prefix is stripped (`session/agent-busy` →
80
+ * `agent-busy`), matching the rc.2 reasons the Discord copy interpolates.
81
+ * Unknown namespaces pass through verbatim.
82
+ */
83
+ function rejectionReason(error) {
84
+ const rejection = remoteRejectionOf(error);
85
+ if (rejection === undefined)
86
+ return 'host-error';
87
+ return rejection.code.startsWith('session/')
88
+ ? rejection.code.slice('session/'.length)
89
+ : rejection.code;
90
+ }
91
+ /** Pull exactly the first frame of one stream (the opening baseline). */
92
+ function firstFrameOf(stream) {
93
+ const iterator = stream[Symbol.asyncIterator]();
94
+ return iterator.next().then(result => {
95
+ if (result.done === true)
96
+ throw new Error('workspace stream closed before the baseline frame');
97
+ return result.value;
98
+ });
99
+ }
100
+ /**
101
+ * Read the registry baseline as the follow stream's opening frame: one
102
+ * short-lived subscription, aborted the moment the baseline lands. Returns
103
+ * the narrowed baseline record, or undefined when the frame is malformed.
104
+ */
105
+ async function readWorkspaceBaseline(dsh, timeoutMs) {
106
+ const per = new AbortController();
107
+ let frame;
108
+ try {
109
+ frame = await withRpcTimeout(firstFrameOf(dsh.workspace.follow(per.signal)), timeoutMs);
110
+ }
111
+ finally {
112
+ per.abort();
113
+ }
114
+ const value = isRecord(frame) && frame['type'] === 'baseline' ? frame['value'] : undefined;
115
+ if (!isRecord(value) || !Array.isArray(value['items']))
116
+ throw new Error('workspace baseline frame is malformed');
117
+ return {
118
+ items: value['items'],
119
+ archivedSessionIds: Array.isArray(value['archivedSessionIds']) ? value['archivedSessionIds'] : [],
120
+ };
121
+ }
122
+ /**
123
+ * The bind flow's catalog verifier: resolves an opaque `ws:` reference
124
+ * against the live workspace baseline. A well-formed reference the registry
125
+ * no longer knows — and any malformed one — resolve `stale` (fail-closed, no
126
+ * write can follow); a Host error is `failed`; a timeout is `unknown`.
127
+ */
128
+ export function createWorkspaceResolver(dsh, options = {}) {
129
+ const port = createWorkspaceCatalogPort(dsh, options);
130
+ return {
131
+ async resolve(reference) {
132
+ const catalog = await port.listWorkspaces();
133
+ if (catalog.outcome !== 'completed') {
134
+ return catalog.outcome === 'unknown' ? { outcome: 'unknown' } : { outcome: 'failed' };
135
+ }
136
+ const id = parseWorkspaceReference(reference);
137
+ const found = id === undefined
138
+ ? undefined
139
+ : catalog.workspaces.find(workspace => workspace.id === id);
140
+ return found === undefined
141
+ ? { outcome: 'stale' }
142
+ : { outcome: 'found', workspace: { id: found.id, title: found.title } };
143
+ },
144
+ };
145
+ }
146
+ /**
147
+ * The `/project list` catalog port over the workspace registry baseline.
148
+ * Outcomes follow the port contract: a definitive Host error is `failed`
149
+ * (sanitized before Discord), while a timeout or unreadable body is
150
+ * `unknown` — delivery was not observed, so no retry is implied.
151
+ */
152
+ export function createWorkspaceCatalogPort(dsh, options = {}) {
153
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
154
+ const log = options.log;
155
+ return {
156
+ async listWorkspaces() {
157
+ let baseline;
158
+ try {
159
+ baseline = await readWorkspaceBaseline(dsh, timeoutMs);
160
+ }
161
+ catch (cause) {
162
+ if (cause instanceof RpcTimeoutError) {
163
+ log?.('discord_workspace_list_timeout', { timeoutMs });
164
+ return { outcome: 'unknown' };
165
+ }
166
+ log?.('discord_workspace_list_threw', { cause: String(cause) });
167
+ return { outcome: 'failed' };
168
+ }
169
+ if (!isRecord(baseline) || !Array.isArray(baseline['items'])) {
170
+ log?.('discord_workspace_list_malformed');
171
+ return { outcome: 'failed' };
172
+ }
173
+ const rows = baseline['items'];
174
+ return {
175
+ outcome: 'completed',
176
+ workspaces: rows
177
+ .filter(workspace => isRecord(workspace) && typeof workspace['workspaceId'] === 'string' && workspace['workspaceId'] !== '')
178
+ .map(workspace => {
179
+ const row = workspace;
180
+ return {
181
+ id: row.workspaceId,
182
+ title: typeof row.title === 'string' ? row.title : row.workspaceId,
183
+ // The registered path rides every baseline row (Host
184
+ // WorkspaceView); /session resume scopes candidates by it and
185
+ // /project autocomplete abbreviates it — dropping it here once
186
+ // silently emptied the resume list everywhere (16.46).
187
+ ...(typeof row.path === 'string' ? { path: row.path } : {}),
188
+ };
189
+ }),
190
+ // The registry's archived set rides the baseline (session.list rows
191
+ // carry NO archived marker): /session resume subtracts it — resuming
192
+ // an archived session dead-ends in a thread whose turns never run
193
+ // (16.49).
194
+ archivedSessionIds: Array.isArray(baseline['archivedSessionIds'])
195
+ ? baseline['archivedSessionIds'].filter((id) => typeof id === 'string')
196
+ : [],
197
+ };
198
+ },
199
+ };
200
+ }
201
+ /**
202
+ * Read one Workspace's full view (title plus canonical path). The path is
203
+ * for the administrator-only ephemeral info response — the disclosure
204
+ * policy owns whether it ever renders; this face only carries it in memory.
205
+ */
206
+ export async function readWorkspaceDetail(dsh, reference, options = {}) {
207
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
208
+ const log = options.log;
209
+ let baseline;
210
+ try {
211
+ baseline = await readWorkspaceBaseline(dsh, timeoutMs);
212
+ }
213
+ catch (cause) {
214
+ if (cause instanceof RpcTimeoutError) {
215
+ log?.('discord_workspace_detail_timeout', { timeoutMs });
216
+ return { outcome: 'unknown' };
217
+ }
218
+ log?.('discord_workspace_detail_threw', { cause: String(cause) });
219
+ return { outcome: 'failed' };
220
+ }
221
+ const items = Array.isArray(baseline.items) ? baseline.items : [];
222
+ const id = parseWorkspaceReference(reference) ?? (reference === '' ? undefined : reference);
223
+ const found = items.find(workspace => workspace.workspaceId === id);
224
+ if (found === undefined)
225
+ return { outcome: 'stale' };
226
+ return {
227
+ outcome: 'found',
228
+ workspace: {
229
+ id: found.workspaceId,
230
+ title: found.title,
231
+ path: typeof found.path === 'string' ? found.path : undefined,
232
+ },
233
+ };
234
+ }
235
+ /**
236
+ * Submit one prompt turn through the session controller. A definitive Host
237
+ * rejection (thrown RemoteError) is a rejection carrying the sanitized
238
+ * reason; a timeout or host fault is `unknown` — the turn may or may not
239
+ * have been admitted, so callers must not resubmit. `options.rpcId` pins
240
+ * the adapter-owned stable request id, which the Host records on the
241
+ * durable user message (`source.rpcId`) and de-duplicates on — the 0.1.6
242
+ * admission layer replays `{accepted: true}` for a repeated id, keeping the
243
+ * at-most-once discipline observable. Images (16.50) encode as ordered
244
+ * `image` parts after the text part.
245
+ */
246
+ export async function promptSession(dsh, request, options = {}) {
247
+ return submitPromptTurn(dsh, { ...request, mode: 'queue' }, options);
248
+ }
249
+ /**
250
+ * Steer the session's active turn: `session.prompt` with `mode: 'steer'`,
251
+ * carrying the same stable request-id discipline as the queue path.
252
+ */
253
+ export async function steerSession(dsh, request, options = {}) {
254
+ return submitPromptTurn(dsh, { ...request, mode: 'steer' }, options);
255
+ }
256
+ async function submitPromptTurn(dsh, request, options) {
257
+ const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
258
+ const log = options.log;
259
+ const per = new AbortController();
260
+ try {
261
+ await withRpcTimeout(dsh.session.prompt({
262
+ requestId: options.rpcId ?? crypto.randomUUID(),
263
+ sessionId: request.sessionId,
264
+ mode: request.mode,
265
+ content: [
266
+ { type: 'text', text: request.prompt },
267
+ ...(request.images ?? []).map(image => ({ type: 'image', mediaType: image.mediaType, data: image.base64 })),
268
+ ],
269
+ }, per.signal), timeoutMs);
270
+ // The controller schema pins accepted to literal true; admission
271
+ // returning at all is the accepted verdict.
272
+ return { outcome: 'accepted' };
273
+ }
274
+ catch (cause) {
275
+ if (cause instanceof RpcTimeoutError) {
276
+ log?.('discord_prompt_submit_timeout', { timeoutMs, sessionId: request.sessionId });
277
+ return { outcome: 'unknown' };
278
+ }
279
+ const rejection = remoteRejectionOf(cause);
280
+ if (rejection !== undefined) {
281
+ log?.('discord_prompt_submit_rejected', {
282
+ code: rejection.code,
283
+ sessionId: request.sessionId,
284
+ });
285
+ return { outcome: 'rejected', reason: rejectionReason(cause) };
286
+ }
287
+ log?.('discord_prompt_submit_threw', { cause: String(cause), sessionId: request.sessionId });
288
+ return { outcome: 'unknown' };
289
+ }
290
+ finally {
291
+ // The host checks the signal only before admission; releasing it after
292
+ // the bounded window keeps the caller's cancellation observable.
293
+ per.abort();
294
+ }
295
+ }
296
+ /**
297
+ * Create one DSH Session against a preallocated id (design.md §10): the 0.1.6
298
+ * controller adopts the same session id idempotently, so an uncertain
299
+ * response never forks a second Session. Same outcome discipline as the
300
+ * prompt path.
301
+ */
302
+ export async function createSessionViaProxy(dsh, request, options = {}) {
303
+ const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
304
+ const log = options.log;
305
+ try {
306
+ const value = await withRpcTimeout(dsh.session.create({
307
+ workspaceId: request.workspaceId,
308
+ sessionId: request.sessionId,
309
+ }), timeoutMs);
310
+ if (!isRecord(value) || typeof value['sessionId'] !== 'string') {
311
+ log?.('discord_session_create_malformed', { sessionId: request.sessionId });
312
+ return { outcome: 'unknown' };
313
+ }
314
+ return { outcome: 'completed', sessionId: value['sessionId'] };
315
+ }
316
+ catch (cause) {
317
+ if (cause instanceof RpcTimeoutError) {
318
+ log?.('discord_session_create_timeout', { timeoutMs, sessionId: request.sessionId });
319
+ return { outcome: 'unknown' };
320
+ }
321
+ const rejection = remoteRejectionOf(cause);
322
+ if (rejection !== undefined) {
323
+ log?.('discord_session_create_rejected', { code: rejection.code, sessionId: request.sessionId });
324
+ return { outcome: 'rejected', reason: rejectionReason(cause) };
325
+ }
326
+ log?.('discord_session_create_threw', { cause: String(cause), sessionId: request.sessionId });
327
+ return { outcome: 'unknown' };
328
+ }
329
+ }
330
+ /** List durable Session ids (`session.list` returns everything in one page). */
331
+ export async function listSessionIds(dsh, options = {}) {
332
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
333
+ const log = options.log;
334
+ let items;
335
+ try {
336
+ const value = await withRpcTimeout(dsh.session.list(undefined), timeoutMs);
337
+ // In-process the controller returns the BARE array; the {items} envelope
338
+ // is the typert wire shape. Accept both.
339
+ items = Array.isArray(value) ? value : (isRecord(value) && Array.isArray(value['items']) ? value['items'] : []);
340
+ }
341
+ catch (cause) {
342
+ if (cause instanceof RpcTimeoutError) {
343
+ log?.('discord_session_list_timeout', { timeoutMs });
344
+ return { outcome: 'unknown' };
345
+ }
346
+ log?.('discord_session_list_threw', { cause: String(cause) });
347
+ return { outcome: 'unknown' };
348
+ }
349
+ const ids = [];
350
+ for (const item of items) {
351
+ const row = item;
352
+ if (typeof row.sessionId === 'string' && row.sessionId !== '')
353
+ ids.push(row.sessionId);
354
+ }
355
+ return { outcome: 'completed', ids };
356
+ }
357
+ /**
358
+ * The rich `session.list` for the /session resume surface: titles ride each
359
+ * row's projection values (absence = the session has no title yet), blank
360
+ * sessions are flagged, and rows arrive updatedAt-descending. Defensive
361
+ * narrowing: the wire is untrusted, extra/missing fields never throw.
362
+ */
363
+ export async function listSessionSummaries(dsh, options = {}) {
364
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
365
+ const log = options.log;
366
+ let items;
367
+ try {
368
+ const value = await withRpcTimeout(dsh.session.list(undefined), timeoutMs);
369
+ // In-process the controller returns the BARE array; the {items} envelope
370
+ // is the typert wire shape. Accept both.
371
+ items = Array.isArray(value) ? value : (isRecord(value) && Array.isArray(value['items']) ? value['items'] : []);
372
+ }
373
+ catch (cause) {
374
+ if (cause instanceof RpcTimeoutError) {
375
+ log?.('discord_session_summaries_timeout', { timeoutMs });
376
+ return { outcome: 'unknown' };
377
+ }
378
+ log?.('discord_session_summaries_threw', { cause: String(cause) });
379
+ return { outcome: 'unknown' };
380
+ }
381
+ const sessions = [];
382
+ // The wire is untrusted: narrow every row defensively before use.
383
+ for (const item of items) {
384
+ if (typeof item !== 'object' || item === null)
385
+ continue;
386
+ const row = item;
387
+ if (typeof row.sessionId !== 'string' || row.sessionId === '')
388
+ continue;
389
+ const values = row.projections?.values;
390
+ const title = typeof values?.title === 'string' && values.title !== '' ? values.title : undefined;
391
+ sessions.push({
392
+ sessionId: row.sessionId,
393
+ title,
394
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
395
+ running: row.running ?? false,
396
+ blank: row.blank === true,
397
+ cwd: typeof row.cwd === 'string' ? row.cwd : undefined,
398
+ origin: row.origin === 'subagent' ? 'subagent' : undefined,
399
+ });
400
+ }
401
+ return { outcome: 'completed', sessions };
402
+ }
403
+ /** Cancel the session's active turn (`session.cancel`); DSH preserves the pending inbox. */
404
+ export async function cancelSessionViaProxy(dsh, request, options = {}) {
405
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
406
+ const log = options.log;
407
+ try {
408
+ await withRpcTimeout(dsh.session.cancel({ sessionId: request.sessionId }), timeoutMs);
409
+ return { outcome: 'accepted' };
410
+ }
411
+ catch (cause) {
412
+ if (cause instanceof RpcTimeoutError) {
413
+ log?.('discord_session_cancel_timeout', { timeoutMs, sessionId: request.sessionId });
414
+ return { outcome: 'unknown' };
415
+ }
416
+ const rejection = remoteRejectionOf(cause);
417
+ if (rejection !== undefined) {
418
+ log?.('discord_session_cancel_rejected', { code: rejection.code, sessionId: request.sessionId });
419
+ return { outcome: 'rejected', reason: rejectionReason(cause) };
420
+ }
421
+ log?.('discord_session_cancel_threw', { cause: String(cause), sessionId: request.sessionId });
422
+ return { outcome: 'unknown' };
423
+ }
424
+ }
425
+ /** Remove one pending inbox item (`session.updateQueue`, action remove). */
426
+ export async function removeQueueItemViaProxy(dsh, request, options = {}) {
427
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
428
+ const log = options.log;
429
+ try {
430
+ await withRpcTimeout(dsh.session.updateQueue({
431
+ sessionId: request.sessionId,
432
+ itemId: request.itemId,
433
+ action: { kind: 'remove' },
434
+ }), timeoutMs);
435
+ return { outcome: 'accepted' };
436
+ }
437
+ catch (cause) {
438
+ if (cause instanceof RpcTimeoutError) {
439
+ log?.('discord_queue_remove_timeout', { timeoutMs, sessionId: request.sessionId });
440
+ return { outcome: 'unknown' };
441
+ }
442
+ const rejection = remoteRejectionOf(cause);
443
+ if (rejection !== undefined) {
444
+ log?.('discord_queue_remove_rejected', { code: rejection.code, sessionId: request.sessionId });
445
+ return { outcome: 'rejected', reason: rejectionReason(cause) };
446
+ }
447
+ log?.('discord_queue_remove_threw', { cause: String(cause), sessionId: request.sessionId });
448
+ return { outcome: 'unknown' };
449
+ }
450
+ }
451
+ /**
452
+ * Read the session's live selection off the `modelSelection` projection
453
+ * (wired view `{lastUsed, next}`); defensively accept either wired or raw
454
+ * (`pending`) state shapes. Any read failure resolves undefined — the
455
+ * catalog default takes over, never a hard failure.
456
+ */
457
+ async function readSessionSelection(dsh, sessionId, options) {
458
+ const log = options.log;
459
+ let observation;
460
+ try {
461
+ observation = await withRpcTimeout(dsh.sessionQuery.observeSession(sessionId), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
462
+ }
463
+ catch {
464
+ return undefined;
465
+ }
466
+ try {
467
+ const projections = isRecord(observation) && isRecord(observation['projections'])
468
+ ? observation['projections']
469
+ : undefined;
470
+ const values = projections !== undefined && isRecord(projections['values'])
471
+ ? projections['values']
472
+ : undefined;
473
+ const selection = (values !== undefined ? values['modelSelection'] : undefined);
474
+ const candidate = selection?.next ?? selection?.pending ?? selection?.lastUsed;
475
+ if (candidate === null || candidate === undefined)
476
+ return undefined;
477
+ const typed = candidate;
478
+ if (typeof typed.provider !== 'string' || typeof typed.model !== 'string')
479
+ return undefined;
480
+ return {
481
+ provider: typed.provider,
482
+ model: typed.model,
483
+ ...(typeof typed.reasoningEffort === 'string' ? { reasoningEffort: typed.reasoningEffort } : {}),
484
+ };
485
+ }
486
+ finally {
487
+ const dispose = observation?.[Symbol.asyncDispose]
488
+ ?? observation?.dispose;
489
+ if (typeof dispose === 'function') {
490
+ try {
491
+ void dispose.call(observation);
492
+ }
493
+ catch (cause) {
494
+ log?.('discord_session_selection_dispose_threw', { cause: String(cause) });
495
+ }
496
+ }
497
+ }
498
+ }
499
+ /**
500
+ * The session's detached model directory: the live selection, whether its
501
+ * route still serves, and the per-provider catalog groups the /model
502
+ * cascade browses. Composed from the 0.1.6 global `modelCatalog` plus the
503
+ * session's `modelSelection` projection — the rc.2 per-session
504
+ * `sessions.models` RPC no longer exists.
505
+ */
506
+ export async function sessionModels(dsh, request, options = {}) {
507
+ const timeoutMs = options.timeoutMs ?? MODELS_TIMEOUT_MS;
508
+ const log = options.log;
509
+ let catalog;
510
+ try {
511
+ catalog = await withRpcTimeout(dsh.session.modelCatalog(), timeoutMs);
512
+ }
513
+ catch (cause) {
514
+ if (cause instanceof RpcTimeoutError) {
515
+ log?.('discord_models_timeout', { sessionId: request.sessionId });
516
+ return { outcome: 'unknown' };
517
+ }
518
+ const rejection = remoteRejectionOf(cause);
519
+ log?.('discord_models_failed', { sessionId: request.sessionId, code: rejection?.code ?? 'malformed' });
520
+ return { outcome: 'failed' };
521
+ }
522
+ if (!isRecord(catalog) || !Array.isArray(catalog['groups'])) {
523
+ log?.('discord_models_malformed', { sessionId: request.sessionId });
524
+ return { outcome: 'failed' };
525
+ }
526
+ const selection = await readSessionSelection(dsh, request.sessionId, options);
527
+ const fallback = isRecord(catalog['default']) ? catalog['default'] : {};
528
+ const current = selection ?? {
529
+ provider: typeof fallback['provider'] === 'string' ? fallback['provider'] : '',
530
+ model: typeof fallback['model'] === 'string' ? fallback['model'] : '',
531
+ ...(typeof fallback['reasoningEffort'] === 'string' ? { reasoningEffort: fallback['reasoningEffort'] } : {}),
532
+ };
533
+ const routableProviders = Array.isArray(catalog['routableProviders']) ? catalog['routableProviders'] : [];
534
+ const routable = routableProviders.includes(current.provider);
535
+ return {
536
+ outcome: 'completed',
537
+ models: {
538
+ current,
539
+ routable,
540
+ groups: catalog['groups'],
541
+ failures: Array.isArray(catalog['failures']) ? catalog['failures'] : [],
542
+ },
543
+ };
544
+ }
545
+ /**
546
+ * Select the complete model selection for one session (session.selectModel):
547
+ * the session switches immediately and the Host records the choice as the
548
+ * default for sessions that have not logged their own — the response only
549
+ * proves the session switch, so callers must not claim the persistence
550
+ * outcome (design.md §7).
551
+ */
552
+ export async function selectSessionModel(dsh, request, options = {}) {
553
+ const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
554
+ const log = options.log;
555
+ try {
556
+ const value = await withRpcTimeout(dsh.session.selectModel({
557
+ sessionId: request.sessionId,
558
+ provider: request.provider,
559
+ model: request.model,
560
+ ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
561
+ }), timeoutMs);
562
+ const selected = isRecord(value) ? value['selected'] : undefined;
563
+ if (!isRecord(selected) || typeof selected['provider'] !== 'string' || typeof selected['model'] !== 'string') {
564
+ log?.('discord_model_select_malformed', { sessionId: request.sessionId });
565
+ return { outcome: 'unknown' };
566
+ }
567
+ const chosen = {
568
+ provider: selected['provider'],
569
+ model: selected['model'],
570
+ ...(typeof selected['reasoningEffort'] === 'string' ? { reasoningEffort: selected['reasoningEffort'] } : {}),
571
+ };
572
+ return { outcome: 'completed', selected: chosen };
573
+ }
574
+ catch (cause) {
575
+ if (cause instanceof RpcTimeoutError) {
576
+ log?.('discord_model_select_timeout', { sessionId: request.sessionId });
577
+ return { outcome: 'unknown' };
578
+ }
579
+ const rejection = remoteRejectionOf(cause);
580
+ if (rejection !== undefined) {
581
+ log?.('discord_model_select_rejected', { code: rejection.code, sessionId: request.sessionId });
582
+ return { outcome: 'rejected', reason: rejectionReason(cause) };
583
+ }
584
+ log?.('discord_model_select_threw', { cause: String(cause), sessionId: request.sessionId });
585
+ return { outcome: 'unknown' };
586
+ }
587
+ }
588
+ /**
589
+ * The /model surface over the session controller: the composed per-session
590
+ * directory and the guarded selection mutation — the shapes model-control
591
+ * reasons about.
592
+ */
593
+ export function createModelPort(dsh, options = {}) {
594
+ return {
595
+ models: sessionId => sessionModels(dsh, { sessionId }, options),
596
+ selectModel: request => selectSessionModel(dsh, request, options),
597
+ };
598
+ }
@@ -106,6 +106,19 @@ export interface ManagementChannelDeps {
106
106
  export declare function createAdapterManagementHandler(deps: ManagementChannelDeps): (endpoint: string, payload: unknown, signal: {
107
107
  aborted: boolean;
108
108
  } | undefined) => Promise<RpcAnswer>;
109
- /** Register the management channel; returns the connection service's disposer. */
110
- export declare function installAdapterStatusRpc(connection: ConnectionRpc, tracker: AdapterStatusTracker, deps?: ManagementChannelDeps): () => void;
109
+ /** Narrow composition context: service accessor plus the plugin's effect scope. */
110
+ export interface HostChannelContext {
111
+ get(name: string): unknown;
112
+ effect(execute: () => unknown, label?: string): unknown;
113
+ }
114
+ /**
115
+ * Register the management channel on the webServer through the plugin's own
116
+ * context effect. The 0.1.6 host's `connection.rpc.handle` mounts through
117
+ * the connection service's OWN inject scope — an external plugin's call is
118
+ * rejected ("cannot get property webServer without inject") and a runtime
119
+ * ctx.inject wrapper never fires on an already-started plugin, so the
120
+ * sanctioned shape is: our prefix route, the connection service's fence
121
+ * method, and the channel envelope replicated verbatim.
122
+ */
123
+ export declare function installAdapterStatusRpc(ctx: HostChannelContext, tracker: AdapterStatusTracker, deps?: ManagementChannelDeps): () => void;
111
124
  export {};