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

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.
@@ -95,9 +95,105 @@ export function createAdapterManagementHandler(deps) {
95
95
  return Promise.resolve({ ok: false, error: { code: 'bad-request', message: `Unknown ${DISCORD_RPC_CHANNEL} endpoint.` } });
96
96
  };
97
97
  }
98
- /** Register the management channel; returns the connection service's disposer. */
99
- export function installAdapterStatusRpc(connection, tracker, deps = { tracker }) {
100
- return connection.rpc.handle(DISCORD_RPC_CHANNEL, createAdapterManagementHandler(deps), {
101
- authority: 'loopback',
98
+ function readBody(request) {
99
+ return new Promise((resolve, reject) => {
100
+ const chunks = [];
101
+ request.on('data', chunk => { chunks.push(chunk); });
102
+ request.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')); });
103
+ request.on('error', reject);
102
104
  });
103
105
  }
106
+ /** The endpoint segment path under the channel, validated like the host's. */
107
+ function endpointOf(channel, url) {
108
+ const pathname = url === undefined ? '' : url.split('?')[0] ?? '';
109
+ if (!pathname.startsWith(`${channel}/`))
110
+ return undefined;
111
+ const endpoint = pathname.slice(channel.length + 1);
112
+ const valid = endpoint.split('/').every(segment => segment !== '' && segment !== '.' && segment !== '..' && /^[A-Za-z0-9_$.-]+$/.test(segment));
113
+ return valid ? endpoint : undefined;
114
+ }
115
+ function respondEnvelope(response, rpcId, result) {
116
+ response.writeHead(200, { 'content-type': 'application/json' });
117
+ response.end(JSON.stringify({ type: 'server-response', rpcId, result }));
118
+ }
119
+ function badRequest(response, rpcId, message) {
120
+ respondEnvelope(response, rpcId, { ok: false, error: { code: 'gateway/bad-request', message, details: { issues: [] } } });
121
+ }
122
+ /**
123
+ * Serve one channel request: the connection service's fence first (the
124
+ * browser-auth + trusted-host verdict), then the host's channel wire
125
+ * protocol — POST + JSON `client-request` envelope whose method must match
126
+ * the endpoint, answered with a `server-response` envelope.
127
+ */
128
+ async function serveChannel(connection, handler, request, response) {
129
+ const rejection = connection.requestRejection(request);
130
+ if (rejection !== undefined) {
131
+ response.writeHead(rejection);
132
+ response.end(rejection === 401 ? 'unauthorized' : 'forbidden');
133
+ return;
134
+ }
135
+ const endpoint = endpointOf(DISCORD_RPC_CHANNEL, request.url);
136
+ if (request.method !== 'POST' || endpoint === undefined) {
137
+ response.writeHead(404);
138
+ response.end('not found');
139
+ return;
140
+ }
141
+ const contentType = request.headers['content-type'];
142
+ const contentTypeText = (Array.isArray(contentType) ? contentType[0] : contentType)?.split(';', 1)[0]?.trim().toLowerCase();
143
+ if (contentTypeText !== 'application/json') {
144
+ response.writeHead(415);
145
+ response.end('content type must be application/json');
146
+ return;
147
+ }
148
+ let body;
149
+ try {
150
+ body = JSON.parse(await readBody(request));
151
+ }
152
+ catch {
153
+ response.writeHead(400);
154
+ response.end('body is not JSON');
155
+ return;
156
+ }
157
+ const message = body;
158
+ const rpcId = typeof message.rpcId === 'string' ? message.rpcId : '';
159
+ if (message.type !== 'client-request' || rpcId === '' || typeof message.method !== 'string'
160
+ || typeof message.payload !== 'object' || message.payload === null || Array.isArray(message.payload)) {
161
+ badRequest(response, rpcId, 'invalid client-request message');
162
+ return;
163
+ }
164
+ if (message.method !== endpoint) {
165
+ badRequest(response, rpcId, `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`);
166
+ return;
167
+ }
168
+ try {
169
+ const result = await handler(endpoint, message.payload, undefined);
170
+ respondEnvelope(response, rpcId, result);
171
+ }
172
+ catch (cause) {
173
+ response.writeHead(500);
174
+ response.end(`handler failure: ${String(cause)}`);
175
+ }
176
+ }
177
+ /**
178
+ * Register the management channel on the webServer through the plugin's own
179
+ * context effect. The 0.1.6 host's `connection.rpc.handle` mounts through
180
+ * the connection service's OWN inject scope — an external plugin's call is
181
+ * rejected ("cannot get property webServer without inject") and a runtime
182
+ * ctx.inject wrapper never fires on an already-started plugin, so the
183
+ * sanctioned shape is: our prefix route, the connection service's fence
184
+ * method, and the channel envelope replicated verbatim.
185
+ */
186
+ export function installAdapterStatusRpc(ctx, tracker, deps = { tracker }) {
187
+ const connection = ctx.get('connection');
188
+ const webServer = ctx.get('webServer');
189
+ const handler = createAdapterManagementHandler(deps);
190
+ const disposer = ctx.effect(() => webServer.register({
191
+ kind: 'prefix',
192
+ path: DISCORD_RPC_CHANNEL,
193
+ handler: (request, response) => serveChannel(connection, handler, request, response),
194
+ }), 'dsh-discord management channel');
195
+ return () => {
196
+ if (typeof disposer === 'function')
197
+ disposer();
198
+ };
199
+ }
@@ -68,7 +68,7 @@ export interface ApprovalClick {
68
68
  }
69
69
  export type ApprovalDecision = {
70
70
  allowed: true;
71
- /** Respond data built solely from the record, for apiProxy.respond. */
71
+ /** Respond data built solely from the record, for the settle port. */
72
72
  respond: {
73
73
  rpcId: string;
74
74
  sessionId: string;
@@ -11,7 +11,7 @@ import type { SharedRestClient } from '../discord/rest.js';
11
11
  import { type PolicyTable } from '../policy/authorization.js';
12
12
  import { type ProjectListPort } from './project-list.js';
13
13
  import type { WorkspaceResolver } from './project-bind.js';
14
- import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailOutcome } from '../dsh/api-proxy-face.js';
14
+ import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailOutcome } from '../dsh/host-face.js';
15
15
  import type { TurnTracker } from './turn-ownership.js';
16
16
  import { type DshApprovalRespondPort } from './approval-routing.js';
17
17
  import { type DshModelPort } from './model-control.js';
@@ -20,7 +20,7 @@ import type { ApprovalStore } from './approval-store.js';
20
20
  import type { ChannelBinding } from '../state/records.js';
21
21
  import type { NormalizedInteraction } from '../gateway/inbound.js';
22
22
  import type { CopyTable } from '../i18n.js';
23
- /** The DSH faces the control commands submit through (apiProxy-backed). */
23
+ /** The DSH faces the control commands submit through (host-face-backed). */
24
24
  export interface InteractionDshFace {
25
25
  cancel(sessionId: string): Promise<CancelOutcome>;
26
26
  steer(sessionId: string, prompt: string): Promise<PromptOutcome>;
@@ -11,7 +11,7 @@
11
11
  * any mutation.
12
12
  */
13
13
  import type { AccessDecision } from '../policy/authorization.js';
14
- import type { ModelSelectionShape, SessionModelsShape } from '../dsh/api-proxy-face.js';
14
+ import type { ModelSelectionShape, SessionModelsShape } from '../dsh/host-face.js';
15
15
  export interface DshModelPort {
16
16
  models(sessionId: string): Promise<{
17
17
  outcome: 'completed';
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `/project list` discovery (design.md §3, §13). The feature talks to DSH
3
- * through one narrow port (satisfied by the apiProxy adapter in production,
3
+ * through one narrow port (satisfied by the host-face adapter in production,
4
4
  * by fakes in tests): every registered Workspace is selectable, labels come
5
5
  * from the least-disclosure policy (safe titles, opaque `ws:` references,
6
6
  * duplicate disambiguation); per amended design §3 the bind autocomplete
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `/project list` discovery (design.md §3, §13). The feature talks to DSH
3
- * through one narrow port (satisfied by the apiProxy adapter in production,
3
+ * through one narrow port (satisfied by the host-face adapter in production,
4
4
  * by fakes in tests): every registered Workspace is selectable, labels come
5
5
  * from the least-disclosure policy (safe titles, opaque `ws:` references,
6
6
  * duplicate disambiguation); per amended design §3 the bind autocomplete
@@ -6,7 +6,7 @@
6
6
  * title/id substring, and sort newest-first — Discord autocomplete caps at
7
7
  * 25 choices, the caller slices.
8
8
  */
9
- import type { SessionResumeRow } from '../dsh/api-proxy-face.js';
9
+ import type { SessionResumeRow } from '../dsh/host-face.js';
10
10
  export type { SessionResumeRow };
11
11
  import type { CopyTable } from '../i18n.js';
12
12
  import { type SelectorOption } from '../discord/selector.js';
package/lib/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { DEFAULT_DISCORD_SETTINGS, DiscordSettingsSchema, installDiscordSettings, normalizeDiscordSettings, } from './settings.js';
2
- import { settingsNamespace } from '@deepseek-ai/dsh-settings';
3
2
  import { installCancellationRoot } from './lifecycle.js';
4
3
  import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EPHEMERAL_FLAGS, safeTitle } from './policy/disclosure.js';
5
4
  import { validateHostCapabilities } from './startup.js';
@@ -10,7 +9,7 @@ import { createRestThreadPort } from './discord/thread-port.js';
10
9
  import { createComponentRegistry } from './discord/components.js';
11
10
  import { buildCommandRegistrations } from './discord/commands.js';
12
11
  import { startDiscordAdapter } from './compose.js';
13
- import { createModelPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy } from './dsh/api-proxy-face.js';
12
+ import { createModelPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy, resolveHostFace } from './dsh/host-face.js';
14
13
  import { createApprovalStore } from './features/approval-store.js';
15
14
  import { createAskWiring } from './features/ask-wiring.js';
16
15
  import { sweepExpiredApprovals } from './features/approval-expiry.js';
@@ -22,7 +21,9 @@ import { createInteractionRouter } from './features/interaction-router.js';
22
21
  import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
23
22
  import { planBindingReconciliation } from './features/reconcile-bindings.js';
24
23
  import { guildKeysToForget, sweepExpired } from './state/retention.js';
25
- import { listSessionIds, listSessionSummaries, createClientRespondPort } from './dsh/api-proxy-face.js';
24
+ import { createHostEventRouter } from './dsh/host-events.js';
25
+ import { listSessionIds, listSessionSummaries } from './dsh/host-face.js';
26
+ import { installAskServicePatches } from './dsh/host-asks.js';
26
27
  import { createBindingStore } from './state/bindings.js';
27
28
  import { createIntentStore } from './state/intents.js';
28
29
  import { createTurnTracker } from './features/turn-ownership.js';
@@ -37,7 +38,7 @@ import { planWorkspaceChannel, workspaceChannelName } from './features/workspace
37
38
  /** Stable Cordis plugin name for diagnostics. */
38
39
  export const name = 'dsh-discord';
39
40
  /** Host services required by the complete embedded adapter. */
40
- export const inject = ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection'];
41
+ export const inject = ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection'];
41
42
  export const Config = DiscordSettingsSchema;
42
43
  /**
43
44
  * Fixed Milestone 1 Gateway intents: guilds, messages, content. Member data
@@ -75,7 +76,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
75
76
  const resolveLanguage = () => {
76
77
  if (current.language !== 'auto')
77
78
  return current.language;
78
- const locale = ctx.get('settings').get(settingsNamespace('locale'));
79
+ const locale = ctx.get('settings').get('locale');
79
80
  return typeof locale?.preference === 'string' && locale.preference.startsWith('zh') ? 'zh' : 'en';
80
81
  };
81
82
  const copy = new Proxy({}, {
@@ -102,15 +103,20 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
102
103
  // Late-bound so the management channel can reach the runtime built inside
103
104
  // the composition IIFE below (the card's Connect button).
104
105
  const runtimeRef = { current: undefined };
105
- installAdapterStatusRpc(ctx.get('connection'), statusTracker, {
106
- tracker: statusTracker,
107
- setToken: async (value) => {
108
- await ctx.get('credentials').set(DISCORD_BOT_TOKEN_REF, value);
109
- statusTracker.setCredential({ configured: true });
110
- },
111
- connect: () => { runtimeRef.current?.connect(); },
112
- disconnect: () => { runtimeRef.current?.disconnect(); },
113
- });
106
+ // Channel registration mounts a prefix route through our OWN context
107
+ // effect (see installAdapterStatusRpc: the host's connection.rpc.handle
108
+ // is owner-scoped and unusable from an external plugin).
109
+ {
110
+ installAdapterStatusRpc(ctx, statusTracker, {
111
+ tracker: statusTracker,
112
+ setToken: async (value) => {
113
+ await ctx.get('credentials').set(DISCORD_BOT_TOKEN_REF, value);
114
+ statusTracker.setCredential({ configured: true });
115
+ },
116
+ connect: () => { runtimeRef.current?.connect(); },
117
+ disconnect: () => { runtimeRef.current?.disconnect(); },
118
+ });
119
+ }
114
120
  void describeDiscordCredential(ctx.get('credentials'))
115
121
  .then((view) => { statusTracker.setCredential(view); })
116
122
  .catch((cause) => {
@@ -118,10 +124,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
118
124
  statusTracker.setCredential({ configured: false });
119
125
  });
120
126
  const credentials = ctx.get('credentials');
121
- // The in-process apiProxy face: domain methods resolve RpcRequest →
122
- // RpcResponse and never throw business errors; boundedness and outcome
123
- // logging live in src/dsh/api-proxy-face.ts.
124
- const apiProxy = ctx.get('apiProxy');
127
+ // The in-process 0.1.6 controller face: plain requests in, plain values
128
+ // out, business rejections as thrown RemoteErrors; boundedness and outcome
129
+ // logging live in src/dsh/host-face.ts.
130
+ const dsh = resolveHostFace(ctx);
125
131
  // Default-quiet logging (16.33): flow records ride the Host's debug level
126
132
  // only — the adapter prints nothing into the DSH process at the default
127
133
  // level. Failure-shaped events (…failed/…threw/…blocked/…unknown) escalate
@@ -133,13 +139,16 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
133
139
  }
134
140
  emitLog(ctx, /(?:failed|threw|blocked|unknown)/.test(event) ? 'warn' : 'debug', { event, detail: serialized });
135
141
  };
142
+ // The 0.1.6 event bridge (declared early: every session-acquisition site
143
+ // below tracks into it; the live renderer consumes its fan-in stream).
144
+ const hostEvents = createHostEventRouter(dsh.session, { log: rpcLog });
136
145
  void (async () => {
137
146
  // The durable domain gates the whole composition: no adapter starts
138
147
  // without its bindings/intents tables, and close() drains queued writes
139
148
  // on teardown (fail-closed across restarts, design §10).
140
149
  const domain = await ctx.storageDomain.open(discordDomainSpec);
141
150
  ctx.effect(() => () => { void domain.close(); }, 'discord durable domain');
142
- const catalogPort = createWorkspaceCatalogPort(apiProxy, { log: rpcLog });
151
+ const catalogPort = createWorkspaceCatalogPort(dsh, { log: rpcLog });
143
152
  const policy = () => ({
144
153
  allowedGuildIds: [...current.allowedGuildIds],
145
154
  memberUserIds: [...current.memberUserIds],
@@ -180,7 +189,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
180
189
  const sharedRegistry = createComponentRegistry();
181
190
  const questionCancelPort = {
182
191
  cancel: async ({ sessionId }) => {
183
- const cancelled = await cancelSessionViaProxy(apiProxy, { sessionId }, { log: rpcLog });
192
+ const cancelled = await cancelSessionViaProxy(dsh, { sessionId }, { log: rpcLog });
184
193
  const turn = turnTracker.active(sessionId);
185
194
  if (turn !== undefined)
186
195
  turnTracker.complete(turn.requestId);
@@ -284,14 +293,24 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
284
293
  };
285
294
  })();
286
295
  const dshSessionPort = {
287
- createSession: request => createSessionViaProxy(apiProxy, request, { log: rpcLog }),
296
+ createSession: async (request) => {
297
+ const created = await createSessionViaProxy(dsh, request, { log: rpcLog });
298
+ if (created.outcome === 'completed')
299
+ hostEvents.track(created.sessionId);
300
+ return created;
301
+ },
288
302
  };
289
303
  const dshPromptPort = {
290
- submit: request => promptSession(apiProxy, {
291
- sessionId: request.sessionId,
292
- prompt: request.prompt,
293
- ...(request.images === undefined ? {} : { images: request.images }),
294
- }, { log: rpcLog, rpcId: request.requestId }),
304
+ submit: async (request) => {
305
+ const submitted = await promptSession(dsh, {
306
+ sessionId: request.sessionId,
307
+ prompt: request.prompt,
308
+ ...(request.images === undefined ? {} : { images: request.images }),
309
+ }, { log: rpcLog, rpcId: request.requestId });
310
+ if (submitted.outcome === 'accepted')
311
+ hostEvents.track(request.sessionId);
312
+ return submitted;
313
+ },
295
314
  };
296
315
  /**
297
316
  * The bounded image collection boundary (16.50): downloads ride the
@@ -383,7 +402,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
383
402
  // Bind provision resolves the selection against the live catalog; the
384
403
  // Discord confirm button carries only opaque registry ids between the
385
404
  // command and the write.
386
- const resolver = createWorkspaceResolver(apiProxy, { log: rpcLog });
405
+ const resolver = createWorkspaceResolver(dsh, { log: rpcLog });
387
406
  // Adapter-owned guild surfaces: the "DeepSeek Harness" category, the
388
407
  // general control channel (both provisioned on READY), and — on bind —
389
408
  // one channel per Workspace, named after its display title. Nothing
@@ -537,7 +556,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
537
556
  rpcLog('discord_reconcile_blocked', 'workspace-catalog-unavailable');
538
557
  return;
539
558
  }
540
- const sessionIds = await listSessionIds(apiProxy, { log: rpcLog });
559
+ const sessionIds = await listSessionIds(dsh, { log: rpcLog });
541
560
  if (sessionIds.outcome !== 'completed') {
542
561
  rpcLog('discord_reconcile_blocked', 'session-list-unavailable');
543
562
  return;
@@ -670,13 +689,44 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
670
689
  }).catch((cause) => { rpcLog('discord_question_expiry_threw', String(cause)); });
671
690
  }, 30_000);
672
691
  ctx.effect(() => () => { clearInterval(expiryTimer); }, 'discord interaction expiry sweep');
673
- // DSH approval respond face: the client-response echoes the ask's rpcId.
674
- const clientRespond = createClientRespondPort(apiProxy, { log: rpcLog });
692
+ const threadForSession = (sessionId) => {
693
+ for (const [key, record] of threadTable.entries()) {
694
+ if (record.sessionId !== sessionId)
695
+ continue;
696
+ const scope = parseThreadBindingKey(key);
697
+ if (scope !== undefined)
698
+ return scope.threadId;
699
+ }
700
+ return undefined;
701
+ };
702
+ // DSH ask answerers (0.1.6 composed-approval model): the waterfalls
703
+ // dispatch on the BASE TREE's event bus (the profile composes one event
704
+ // tree per bundle and a waterfall only enumerates its own registry), so
705
+ // the listeners register there — resolved off a base service's context.
706
+ // Ask carrier: wrap the base-tree ask services at the method boundary
707
+ // (the composed-answerer waterfalls are not reachable from an external
708
+ // plugin's registrations — see host-asks.ts).
709
+ const approvalService = ctx.get('approval');
710
+ const questionsService = ctx.get('userQuestions');
711
+ if (approvalService === undefined)
712
+ throw new TypeError('dsh-discord cannot reach the approval service');
713
+ const hostAsksRef = { current: undefined };
714
+ hostAsksRef.current = installAskServicePatches(approvalService, questionsService, {
715
+ threadForSession,
716
+ askWiring,
717
+ approvalTimeoutMs: () => current.approvalTimeoutMs,
718
+ questionTimeoutMs: () => current.questionTimeoutMs,
719
+ nowMs: () => Date.now(),
720
+ log: rpcLog,
721
+ });
722
+ ctx.effect(() => () => { hostAsksRef.current?.dispose(); }, 'dsh-discord host ask answerers');
675
723
  const approvalRespondPort = {
676
- respond: async ({ rpcId, sessionId, approvalId, outcome }) => {
677
- const receipt = await clientRespond.respond(rpcId, { sessionId, approvalId, outcome });
678
- rpcLog('discord_approval_respond_receipt', { rpcId, approvalId, outcome, receipt });
679
- return receipt;
724
+ respond: ({ approvalId, outcome }) => {
725
+ const settled = hostAsksRef.current?.settleApproval(approvalId, outcome) ?? false;
726
+ rpcLog('discord_approval_settled', { approvalId, outcome, settled });
727
+ // The settle promise IS the confirmation: an in-process resolution
728
+ // has no wire receipt to doubt.
729
+ return Promise.resolve(settled ? { outcome: 'confirmed' } : { outcome: 'rejected', reason: 'not-pending' });
680
730
  },
681
731
  };
682
732
  const queueSnapshots = new Map();
@@ -694,10 +744,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
694
744
  rpcLog('discord_guild_forget_deleted', { guildId, channels: plan.channelKeys.length, threads: plan.threadKeys.length });
695
745
  };
696
746
  const questionRespondPort = {
697
- respond: async (input) => {
698
- const receipt = await clientRespond.respond(input.rpcId, { sessionId: input.sessionId, answer: input.answer });
699
- rpcLog('discord_question_respond_receipt', { rpcId: input.rpcId, receipt });
700
- return receipt;
747
+ respond: (input) => {
748
+ const settled = hostAsksRef.current?.settleQuestion(input.rpcId, input.answer) ?? false;
749
+ rpcLog('discord_question_settled', { rpcId: input.rpcId, settled });
750
+ return Promise.resolve(settled ? { outcome: 'confirmed' } : { outcome: 'rejected', reason: 'not-pending' });
701
751
  },
702
752
  };
703
753
  const questionRoutingDeps = {
@@ -722,10 +772,15 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
722
772
  handleQuestionComponent: input => handleSelectInput(questionRoutingDeps, input),
723
773
  handleQuestionModal: input => handleModalSubmit(questionRoutingDeps, input),
724
774
  dsh: {
725
- cancel: sessionId => cancelSessionViaProxy(apiProxy, { sessionId }, { log: rpcLog }),
726
- steer: (sessionId, prompt) => steerSession(apiProxy, { sessionId, prompt }, { log: rpcLog }),
727
- removeQueueItem: (sessionId, itemId) => removeQueueItemViaProxy(apiProxy, { sessionId, itemId }, { log: rpcLog }),
728
- readWorkspaceDetail: reference => readWorkspaceDetail(apiProxy, reference, { log: rpcLog }),
775
+ cancel: sessionId => cancelSessionViaProxy(dsh, { sessionId }, { log: rpcLog }),
776
+ steer: async (sessionId, prompt) => {
777
+ const steered = await steerSession(dsh, { sessionId, prompt }, { log: rpcLog });
778
+ if (steered.outcome === 'accepted')
779
+ hostEvents.track(sessionId);
780
+ return steered;
781
+ },
782
+ removeQueueItem: (sessionId, itemId) => removeQueueItemViaProxy(dsh, { sessionId, itemId }, { log: rpcLog }),
783
+ readWorkspaceDetail: reference => readWorkspaceDetail(dsh, reference, { log: rpcLog }),
729
784
  },
730
785
  catalogPort,
731
786
  resolver,
@@ -738,10 +793,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
738
793
  await channelTable.delete(bindChannelKey(guildId, channelId));
739
794
  rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
740
795
  },
741
- model: createModelPort(apiProxy, { log: rpcLog }),
796
+ model: createModelPort(dsh, { log: rpcLog }),
742
797
  modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
743
798
  resumeCandidates: createResumeCandidatesPort({
744
- listSessions: () => listSessionSummaries(apiProxy, { log: rpcLog }),
799
+ listSessions: () => listSessionSummaries(dsh, { log: rpcLog }),
745
800
  boundSessionIds: () => {
746
801
  const bound = new Set();
747
802
  for (const [, record] of threadTable.entries())
@@ -772,7 +827,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
772
827
  return { outcome: 'refused-control-channel' };
773
828
  }
774
829
  }
775
- const summaries = await listSessionSummaries(apiProxy, { log: rpcLog });
830
+ const summaries = await listSessionSummaries(dsh, { log: rpcLog });
776
831
  if (summaries.outcome !== 'completed')
777
832
  return { outcome: 'failed' };
778
833
  const summary = summaries.sessions.find(candidate => candidate.sessionId === sessionId);
@@ -823,6 +878,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
823
878
  createdAtMs: Date.now(),
824
879
  });
825
880
  rpcLog('discord_session_resumed', { sessionId, threadId: thread.body.id, title });
881
+ hostEvents.track(sessionId);
826
882
  return { outcome: 'started', threadId: thread.body.id };
827
883
  },
828
884
  log: rpcLog,
@@ -935,28 +991,15 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
935
991
  void registerCommands().catch((cause) => {
936
992
  emitLog(ctx, 'warn', { event: 'discord_command_register_failed', cause: String(cause) });
937
993
  });
938
- // ── Live streaming: DSH events.mux → per-thread Discord rendering ─────
994
+ // ── Live streaming: host follow/control fan-in → per-thread rendering ──
939
995
  // The queue snapshot cache (declared with the interaction router) is the
940
- // /queue surface's data source: DSH 0.1.1 has no queue-list RPC, only the
941
- // authoritative whole-snapshot mux frame.
942
- const threadForSession = (sessionId) => {
943
- for (const [key, record] of threadTable.entries()) {
944
- if (record.sessionId !== sessionId)
945
- continue;
946
- const scope = parseThreadBindingKey(key);
947
- if (scope !== undefined)
948
- return scope.threadId;
949
- }
950
- return undefined;
951
- };
996
+ // /queue surface's data source: the 0.1.6 `session/control` stream owns
997
+ // the authoritative whole-snapshot queue frames.
998
+ // The 0.1.6 event bridge: every session the adapter tracks (create,
999
+ // adopt, resume, prompt) joins one fan-in stream of live frames.
952
1000
  const liveRef = { current: undefined };
953
1001
  liveRef.current = startLiveRender({
954
- frames: (signal) => {
955
- const events = apiProxy.events;
956
- if (events === undefined)
957
- throw new TypeError('apiProxy.events is unavailable on this Host');
958
- return events.mux({ rpcId: crypto.randomUUID(), payload: {} }, signal);
959
- },
1002
+ frames: signal => hostEvents.stream(signal),
960
1003
  threadForSession,
961
1004
  delivery: {
962
1005
  send: async (request) => {
@@ -1035,8 +1078,6 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
1035
1078
  },
1036
1079
  updateIntervalMs: current.streamUpdateIntervalMs,
1037
1080
  typingIntervalMs: current.typingIntervalMs,
1038
- approvalTimeoutMs: current.approvalTimeoutMs,
1039
- questionTimeoutMs: current.questionTimeoutMs,
1040
1081
  verbosity: current.defaultVerbosity,
1041
1082
  log: rpcLog,
1042
1083
  interruptedMarker: () => copy.interruptedMarker,
@@ -1047,7 +1088,6 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
1047
1088
  turnTracker.complete(turn.requestId);
1048
1089
  rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
1049
1090
  },
1050
- requests: askWiring,
1051
1091
  });
1052
1092
  ctx.effect(() => () => { liveRef.current?.dispose(); }, 'dsh-discord live render');
1053
1093
  ctx.effect(() => () => { runtimeRef.current?.dispose(); }, 'dsh-discord composed runtime');
package/lib/settings.js CHANGED
@@ -1,4 +1,3 @@
1
- import { installSettingsSection } from '@deepseek-ai/dsh-settings';
2
1
  import z from '@deepseek-ai/schemastery';
3
2
  import { DISCORD_SETTINGS_NAMESPACE } from './settings-namespace.js';
4
3
  export { DISCORD_SETTINGS_NAMESPACE } from './settings-namespace.js';
@@ -92,7 +91,11 @@ export function validateDiscordSettings(input) {
92
91
  }
93
92
  export function installDiscordSettings(ctx, entry, onChange) {
94
93
  let source = () => entry;
95
- installSettingsSection(ctx, DISCORD_SETTINGS_NAMESPACE, DiscordSettingsSchema, entry, {
94
+ const provider = ctx.get('settings');
95
+ if (provider === undefined || typeof provider.installSection !== 'function') {
96
+ throw new TypeError('settings provider with installSection is unavailable on this Host');
97
+ }
98
+ provider.installSection(ctx, DISCORD_SETTINGS_NAMESPACE, DiscordSettingsSchema, entry, {
96
99
  validate: value => { validateDiscordSettings(normalizeDiscordSettings(value)); },
97
100
  setSource: current => {
98
101
  source = () => normalizeDiscordSettings(current());
package/lib/startup.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * with a single actionable diagnostic naming every gap.
7
7
  */
8
8
  /** The exact Host service roster the adapter requires (design decision 1). */
9
- export declare const REQUIRED_HOST_SERVICES: readonly ["apiProxy", "credentials", "settings", "storageDomain", "connection"];
9
+ export declare const REQUIRED_HOST_SERVICES: readonly ["sessionController", "workspaceController", "sessionQuery", "webServer", "credentials", "settings", "storageDomain", "connection"];
10
10
  export type HostServiceName = (typeof REQUIRED_HOST_SERVICES)[number];
11
11
  /**
12
12
  * Validate every required Host capability through `resolveService`, which
package/lib/startup.js CHANGED
@@ -7,26 +7,33 @@
7
7
  */
8
8
  /** The exact Host service roster the adapter requires (design decision 1). */
9
9
  export const REQUIRED_HOST_SERVICES = [
10
- 'apiProxy',
10
+ 'sessionController',
11
+ 'workspaceController',
12
+ 'sessionQuery',
13
+ 'webServer',
11
14
  'credentials',
12
15
  'settings',
13
16
  'storageDomain',
14
17
  'connection',
15
18
  ];
16
19
  /**
17
- * Minimum contract members per service, confirmed against the installed DSH
18
- * `0.1.1-rc.2` types. A service lacking any member means a version drift the
19
- * exact peer pin should have prevented; refuse rather than probe at runtime.
20
+ * Minimum contract members per service, confirmed against the 0.1.6
21
+ * controller faces (dsh-api-session-controller / dsh-api-workspace-controller
22
+ * / dsh-session-query). A service lacking any member means a version drift
23
+ * the startup probe should have refused; refuse rather than probe at runtime.
20
24
  */
21
25
  const REQUIRED_CONTRACT_MEMBERS = {
22
- apiProxy: ['sessions', 'workspace', 'events', 'host'],
26
+ sessionController: ['prompt', 'create', 'list', 'cancel', 'updateQueue', 'selectModel', 'modelCatalog', 'follow', 'control'],
27
+ workspaceController: ['follow'],
28
+ sessionQuery: ['observeSession'],
29
+ webServer: [],
23
30
  credentials: ['resolve', 'describe', 'set', 'unset'],
24
- settings: ['register'],
31
+ settings: ['installSection'],
25
32
  storageDomain: ['open'],
26
33
  connection: ['rpc'],
27
34
  };
28
35
  /** Actionable remediation hint appended to every activation failure. */
29
- const REMEDIATION = 'install @addozhang/dsh-discord into a dsh web profile providing the pinned DSH 0.1.1-rc.2 contracts';
36
+ const REMEDIATION = 'install @addozhang/dsh-discord into a dsh web profile providing the 0.1.6 host controller services';
30
37
  function missingMembers(service, required) {
31
38
  if (service === undefined || service === null)
32
39
  return [...required];
@@ -84,10 +84,6 @@ export interface LiveRenderDeps {
84
84
  threadName?: (channelId: string) => Promise<string | undefined>;
85
85
  updateIntervalMs: number;
86
86
  typingIntervalMs: number;
87
- /** Approval ask deadline (approvalTimeoutMs setting). */
88
- approvalTimeoutMs: number;
89
- /** Question ask deadline (questionTimeoutMs setting). */
90
- questionTimeoutMs: number;
91
87
  /** Coalescing budget for tool-activity edits (default 1s). */
92
88
  activityCoalesceMs?: number;
93
89
  verbosity?: DiscordVerbosity;
@@ -101,39 +97,6 @@ export interface LiveRenderDeps {
101
97
  interruptedMarker?: () => string;
102
98
  /** Turn ownership release on turn/end. */
103
99
  onTurnEnded?: (sessionId: string) => void;
104
- /**
105
- * Answerable server-request frames (interaction-routing spec): approvals
106
- * and questions arrive as mux frames with an envelope rpcId the response
107
- * must echo. The composition owns rendering, ownership, and expiry.
108
- */
109
- requests?: {
110
- onApprovalRequested(input: {
111
- sessionId: string;
112
- threadId: string;
113
- rpcId: string;
114
- approvalId: string;
115
- toolName: string;
116
- reason?: string | undefined;
117
- expiresAtMs: number;
118
- }): void;
119
- onApprovalResolved(input: {
120
- sessionId: string;
121
- approvalId: string;
122
- outcome?: string | undefined;
123
- }): void;
124
- onQuestionRequested(input: {
125
- sessionId: string;
126
- threadId: string;
127
- rpcId: string;
128
- questions: Array<Record<string, unknown>>;
129
- expiresAtMs: number;
130
- }): void;
131
- onQuestionResolved(input: {
132
- sessionId: string;
133
- questionRpcId: string;
134
- outcome: 'answered' | 'cancelled';
135
- }): void;
136
- };
137
100
  }
138
101
  export declare function startLiveRender(deps: LiveRenderDeps): {
139
102
  dispose(): void;