@addozhang/dsh-discord 0.3.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.
Files changed (49) hide show
  1. package/README.md +13 -13
  2. package/README.zh.md +13 -13
  3. package/cordis.patch.yml +1 -1
  4. package/lib/client/card-controller.d.ts +1 -1
  5. package/lib/client/card-form.d.ts +2 -1
  6. package/lib/client/index.d.ts +2 -2
  7. package/lib/client/slot-contract.d.ts +7 -0
  8. package/lib/client/snapshot-store.d.ts +1 -1
  9. package/lib/compose.d.ts +22 -2
  10. package/lib/compose.js +9 -4
  11. package/lib/dsh/host-asks.d.ts +90 -0
  12. package/lib/dsh/host-asks.js +143 -0
  13. package/lib/dsh/host-events.d.ts +42 -0
  14. package/lib/dsh/host-events.js +234 -0
  15. package/lib/dsh/host-face.d.ts +418 -0
  16. package/lib/dsh/host-face.js +598 -0
  17. package/lib/features/adapter-status.d.ts +15 -2
  18. package/lib/features/adapter-status.js +100 -4
  19. package/lib/features/approval-store.d.ts +1 -1
  20. package/lib/features/image-collection.d.ts +11 -0
  21. package/lib/features/image-collection.js +22 -8
  22. package/lib/features/image-download.d.ts +6 -0
  23. package/lib/features/image-download.js +21 -0
  24. package/lib/features/interaction-router.d.ts +2 -2
  25. package/lib/features/model-control.d.ts +1 -1
  26. package/lib/features/project-list.d.ts +1 -1
  27. package/lib/features/project-list.js +1 -1
  28. package/lib/features/prompt-submission.d.ts +9 -0
  29. package/lib/features/prompt-submission.js +7 -1
  30. package/lib/features/session-mainline.d.ts +42 -1
  31. package/lib/features/session-mainline.js +29 -3
  32. package/lib/features/session-resume.d.ts +1 -1
  33. package/lib/gateway/inbound.d.ts +14 -0
  34. package/lib/gateway/inbound.js +41 -0
  35. package/lib/i18n.d.ts +1 -0
  36. package/lib/i18n.js +2 -0
  37. package/lib/index.js +142 -65
  38. package/lib/settings.js +5 -2
  39. package/lib/startup.d.ts +1 -1
  40. package/lib/startup.js +14 -7
  41. package/lib/stream/live.d.ts +0 -37
  42. package/lib/stream/live.js +10 -58
  43. package/lib/stream/tool-view.d.ts +12 -2
  44. package/lib/stream/tool-view.js +32 -2
  45. package/package.json +10 -12
  46. package/lib/dsh/api-proxy-face.d.ts +0 -390
  47. package/lib/dsh/api-proxy-face.js +0 -510
  48. package/lib/features/image-submission.d.ts +0 -67
  49. package/lib/features/image-submission.js +0 -57
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';
@@ -31,12 +32,13 @@ import { createSessionCreationFlow } from './features/session-creation.js';
31
32
  import { createResumeCandidatesPort } from './features/session-resume.js';
32
33
  import { createPromptSubmissionFlow } from './features/prompt-submission.js';
33
34
  import { createSessionMainline, requestIdFor } from './features/session-mainline.js';
35
+ import { collectImages, createSafeImageDownloadPort } from './features/image-collection.js';
34
36
  import { startLiveRender } from './stream/live.js';
35
37
  import { planWorkspaceChannel, workspaceChannelName } from './features/workspace-channel.js';
36
38
  /** Stable Cordis plugin name for diagnostics. */
37
39
  export const name = 'dsh-discord';
38
40
  /** Host services required by the complete embedded adapter. */
39
- export const inject = ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection'];
41
+ export const inject = ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection'];
40
42
  export const Config = DiscordSettingsSchema;
41
43
  /**
42
44
  * Fixed Milestone 1 Gateway intents: guilds, messages, content. Member data
@@ -74,7 +76,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
74
76
  const resolveLanguage = () => {
75
77
  if (current.language !== 'auto')
76
78
  return current.language;
77
- const locale = ctx.get('settings').get(settingsNamespace('locale'));
79
+ const locale = ctx.get('settings').get('locale');
78
80
  return typeof locale?.preference === 'string' && locale.preference.startsWith('zh') ? 'zh' : 'en';
79
81
  };
80
82
  const copy = new Proxy({}, {
@@ -101,15 +103,20 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
101
103
  // Late-bound so the management channel can reach the runtime built inside
102
104
  // the composition IIFE below (the card's Connect button).
103
105
  const runtimeRef = { current: undefined };
104
- installAdapterStatusRpc(ctx.get('connection'), statusTracker, {
105
- tracker: statusTracker,
106
- setToken: async (value) => {
107
- await ctx.get('credentials').set(DISCORD_BOT_TOKEN_REF, value);
108
- statusTracker.setCredential({ configured: true });
109
- },
110
- connect: () => { runtimeRef.current?.connect(); },
111
- disconnect: () => { runtimeRef.current?.disconnect(); },
112
- });
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
+ }
113
120
  void describeDiscordCredential(ctx.get('credentials'))
114
121
  .then((view) => { statusTracker.setCredential(view); })
115
122
  .catch((cause) => {
@@ -117,10 +124,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
117
124
  statusTracker.setCredential({ configured: false });
118
125
  });
119
126
  const credentials = ctx.get('credentials');
120
- // The in-process apiProxy face: domain methods resolve RpcRequest →
121
- // RpcResponse and never throw business errors; boundedness and outcome
122
- // logging live in src/dsh/api-proxy-face.ts.
123
- 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);
124
131
  // Default-quiet logging (16.33): flow records ride the Host's debug level
125
132
  // only — the adapter prints nothing into the DSH process at the default
126
133
  // level. Failure-shaped events (…failed/…threw/…blocked/…unknown) escalate
@@ -132,13 +139,16 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
132
139
  }
133
140
  emitLog(ctx, /(?:failed|threw|blocked|unknown)/.test(event) ? 'warn' : 'debug', { event, detail: serialized });
134
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 });
135
145
  void (async () => {
136
146
  // The durable domain gates the whole composition: no adapter starts
137
147
  // without its bindings/intents tables, and close() drains queued writes
138
148
  // on teardown (fail-closed across restarts, design §10).
139
149
  const domain = await ctx.storageDomain.open(discordDomainSpec);
140
150
  ctx.effect(() => () => { void domain.close(); }, 'discord durable domain');
141
- const catalogPort = createWorkspaceCatalogPort(apiProxy, { log: rpcLog });
151
+ const catalogPort = createWorkspaceCatalogPort(dsh, { log: rpcLog });
142
152
  const policy = () => ({
143
153
  allowedGuildIds: [...current.allowedGuildIds],
144
154
  memberUserIds: [...current.memberUserIds],
@@ -179,7 +189,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
179
189
  const sharedRegistry = createComponentRegistry();
180
190
  const questionCancelPort = {
181
191
  cancel: async ({ sessionId }) => {
182
- const cancelled = await cancelSessionViaProxy(apiProxy, { sessionId }, { log: rpcLog });
192
+ const cancelled = await cancelSessionViaProxy(dsh, { sessionId }, { log: rpcLog });
183
193
  const turn = turnTracker.active(sessionId);
184
194
  if (turn !== undefined)
185
195
  turnTracker.complete(turn.requestId);
@@ -283,10 +293,50 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
283
293
  };
284
294
  })();
285
295
  const dshSessionPort = {
286
- 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
+ },
287
302
  };
288
303
  const dshPromptPort = {
289
- submit: request => promptSession(apiProxy, { sessionId: request.sessionId, prompt: request.prompt }, { 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
+ },
314
+ };
315
+ /**
316
+ * The bounded image collection boundary (16.50): downloads ride the
317
+ * safe-download port (allowlisted CDN hosts, capped sizes, timeout) and
318
+ * come back base64-encoded for the prompt parts.
319
+ */
320
+ const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
321
+ const imageCollector = {
322
+ collect: async ({ attachments }) => {
323
+ const result = await collectImages(createSafeImageDownloadPort(), {
324
+ nowMs: Date.now(),
325
+ timeoutMs: IMAGE_DOWNLOAD_TIMEOUT_MS,
326
+ attachments,
327
+ });
328
+ if (result.outcome !== 'collected') {
329
+ const reason = result.outcome === 'too-large' ? `too-large:${result.reason}` : result.outcome;
330
+ return { outcome: 'failed', reason };
331
+ }
332
+ return {
333
+ outcome: 'collected',
334
+ images: result.downloaded.map(image => ({
335
+ mediaType: image.mediaType,
336
+ base64: Buffer.from(image.body).toString('base64'),
337
+ })),
338
+ };
339
+ },
290
340
  };
291
341
  const composedMainline = createSessionMainline({
292
342
  threads: createThreadCreationFlow({
@@ -305,6 +355,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
305
355
  nowMs: () => Date.now(),
306
356
  }),
307
357
  turns: turnTracker,
358
+ images: imageCollector,
308
359
  });
309
360
  /** Mainline outcome → copy key; the string resolves live per language. */
310
361
  const MAINLINE_FAILURE_KEYS = {
@@ -314,6 +365,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
314
365
  'session-unknown': 'mainlineSessionUnknown',
315
366
  'prompt-rejected': 'mainlinePromptRejected',
316
367
  'prompt-unknown': 'mainlinePromptUnknown',
368
+ 'image-failed': 'mainlineImageFailed',
317
369
  };
318
370
  const mainline = {
319
371
  admitMention: async (request) => {
@@ -334,9 +386,13 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
334
386
  },
335
387
  continueInThread: async (request) => {
336
388
  const result = await composedMainline.continueInThread(request);
337
- if (result.outcome === 'rejected' || result.outcome === 'unknown') {
389
+ if (result.outcome === 'rejected' || result.outcome === 'unknown' || result.outcome === 'image-failed') {
338
390
  rpcLog('discord_continuation_not_queued', { messageId: request.messageId, outcome: result.outcome });
339
- const notice = result.outcome === 'rejected' ? copy.continuationRejected : copy.continuationUnknown;
391
+ const notice = result.outcome === 'rejected'
392
+ ? copy.continuationRejected
393
+ : result.outcome === 'image-failed'
394
+ ? copy.mainlineImageFailed
395
+ : copy.continuationUnknown;
340
396
  void withRest(rest => rest.request('POST', `/channels/${request.threadId}/messages`, { content: notice, flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, allowed_mentions: ALLOWED_MENTIONS_NONE }))
341
397
  .catch((cause) => { rpcLog('discord_mainline_notice_failed', String(cause)); });
342
398
  }
@@ -346,7 +402,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
346
402
  // Bind provision resolves the selection against the live catalog; the
347
403
  // Discord confirm button carries only opaque registry ids between the
348
404
  // command and the write.
349
- const resolver = createWorkspaceResolver(apiProxy, { log: rpcLog });
405
+ const resolver = createWorkspaceResolver(dsh, { log: rpcLog });
350
406
  // Adapter-owned guild surfaces: the "DeepSeek Harness" category, the
351
407
  // general control channel (both provisioned on READY), and — on bind —
352
408
  // one channel per Workspace, named after its display title. Nothing
@@ -500,7 +556,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
500
556
  rpcLog('discord_reconcile_blocked', 'workspace-catalog-unavailable');
501
557
  return;
502
558
  }
503
- const sessionIds = await listSessionIds(apiProxy, { log: rpcLog });
559
+ const sessionIds = await listSessionIds(dsh, { log: rpcLog });
504
560
  if (sessionIds.outcome !== 'completed') {
505
561
  rpcLog('discord_reconcile_blocked', 'session-list-unavailable');
506
562
  return;
@@ -633,13 +689,44 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
633
689
  }).catch((cause) => { rpcLog('discord_question_expiry_threw', String(cause)); });
634
690
  }, 30_000);
635
691
  ctx.effect(() => () => { clearInterval(expiryTimer); }, 'discord interaction expiry sweep');
636
- // DSH approval respond face: the client-response echoes the ask's rpcId.
637
- 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');
638
723
  const approvalRespondPort = {
639
- respond: async ({ rpcId, sessionId, approvalId, outcome }) => {
640
- const receipt = await clientRespond.respond(rpcId, { sessionId, approvalId, outcome });
641
- rpcLog('discord_approval_respond_receipt', { rpcId, approvalId, outcome, receipt });
642
- 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' });
643
730
  },
644
731
  };
645
732
  const queueSnapshots = new Map();
@@ -657,10 +744,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
657
744
  rpcLog('discord_guild_forget_deleted', { guildId, channels: plan.channelKeys.length, threads: plan.threadKeys.length });
658
745
  };
659
746
  const questionRespondPort = {
660
- respond: async (input) => {
661
- const receipt = await clientRespond.respond(input.rpcId, { sessionId: input.sessionId, answer: input.answer });
662
- rpcLog('discord_question_respond_receipt', { rpcId: input.rpcId, receipt });
663
- 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' });
664
751
  },
665
752
  };
666
753
  const questionRoutingDeps = {
@@ -685,10 +772,15 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
685
772
  handleQuestionComponent: input => handleSelectInput(questionRoutingDeps, input),
686
773
  handleQuestionModal: input => handleModalSubmit(questionRoutingDeps, input),
687
774
  dsh: {
688
- cancel: sessionId => cancelSessionViaProxy(apiProxy, { sessionId }, { log: rpcLog }),
689
- steer: (sessionId, prompt) => steerSession(apiProxy, { sessionId, prompt }, { log: rpcLog }),
690
- removeQueueItem: (sessionId, itemId) => removeQueueItemViaProxy(apiProxy, { sessionId, itemId }, { log: rpcLog }),
691
- 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 }),
692
784
  },
693
785
  catalogPort,
694
786
  resolver,
@@ -701,10 +793,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
701
793
  await channelTable.delete(bindChannelKey(guildId, channelId));
702
794
  rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
703
795
  },
704
- model: createModelPort(apiProxy, { log: rpcLog }),
796
+ model: createModelPort(dsh, { log: rpcLog }),
705
797
  modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
706
798
  resumeCandidates: createResumeCandidatesPort({
707
- listSessions: () => listSessionSummaries(apiProxy, { log: rpcLog }),
799
+ listSessions: () => listSessionSummaries(dsh, { log: rpcLog }),
708
800
  boundSessionIds: () => {
709
801
  const bound = new Set();
710
802
  for (const [, record] of threadTable.entries())
@@ -735,7 +827,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
735
827
  return { outcome: 'refused-control-channel' };
736
828
  }
737
829
  }
738
- const summaries = await listSessionSummaries(apiProxy, { log: rpcLog });
830
+ const summaries = await listSessionSummaries(dsh, { log: rpcLog });
739
831
  if (summaries.outcome !== 'completed')
740
832
  return { outcome: 'failed' };
741
833
  const summary = summaries.sessions.find(candidate => candidate.sessionId === sessionId);
@@ -786,6 +878,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
786
878
  createdAtMs: Date.now(),
787
879
  });
788
880
  rpcLog('discord_session_resumed', { sessionId, threadId: thread.body.id, title });
881
+ hostEvents.track(sessionId);
789
882
  return { outcome: 'started', threadId: thread.body.id };
790
883
  },
791
884
  log: rpcLog,
@@ -898,28 +991,15 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
898
991
  void registerCommands().catch((cause) => {
899
992
  emitLog(ctx, 'warn', { event: 'discord_command_register_failed', cause: String(cause) });
900
993
  });
901
- // ── Live streaming: DSH events.mux → per-thread Discord rendering ─────
994
+ // ── Live streaming: host follow/control fan-in → per-thread rendering ──
902
995
  // The queue snapshot cache (declared with the interaction router) is the
903
- // /queue surface's data source: DSH 0.1.1 has no queue-list RPC, only the
904
- // authoritative whole-snapshot mux frame.
905
- const threadForSession = (sessionId) => {
906
- for (const [key, record] of threadTable.entries()) {
907
- if (record.sessionId !== sessionId)
908
- continue;
909
- const scope = parseThreadBindingKey(key);
910
- if (scope !== undefined)
911
- return scope.threadId;
912
- }
913
- return undefined;
914
- };
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.
915
1000
  const liveRef = { current: undefined };
916
1001
  liveRef.current = startLiveRender({
917
- frames: (signal) => {
918
- const events = apiProxy.events;
919
- if (events === undefined)
920
- throw new TypeError('apiProxy.events is unavailable on this Host');
921
- return events.mux({ rpcId: crypto.randomUUID(), payload: {} }, signal);
922
- },
1002
+ frames: signal => hostEvents.stream(signal),
923
1003
  threadForSession,
924
1004
  delivery: {
925
1005
  send: async (request) => {
@@ -998,8 +1078,6 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
998
1078
  },
999
1079
  updateIntervalMs: current.streamUpdateIntervalMs,
1000
1080
  typingIntervalMs: current.typingIntervalMs,
1001
- approvalTimeoutMs: current.approvalTimeoutMs,
1002
- questionTimeoutMs: current.questionTimeoutMs,
1003
1081
  verbosity: current.defaultVerbosity,
1004
1082
  log: rpcLog,
1005
1083
  interruptedMarker: () => copy.interruptedMarker,
@@ -1010,7 +1088,6 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
1010
1088
  turnTracker.complete(turn.requestId);
1011
1089
  rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
1012
1090
  },
1013
- requests: askWiring,
1014
1091
  });
1015
1092
  ctx.effect(() => () => { liveRef.current?.dispose(); }, 'dsh-discord live render');
1016
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;
@@ -15,6 +15,7 @@ import { createToolActivitySurface } from './tool-view.js';
15
15
  import { createAnswerFinalizer } from './finalizer.js';
16
16
  import { buildOutboundMessage } from './outbound.js';
17
17
  import { toolCategoryIcon } from './icons.js';
18
+ import { shellCommandTitle } from './tool-view.js';
18
19
  import { discordChannelNameKey, safeTitle } from '../policy/disclosure.js';
19
20
  /** Coalescing budget for activity-message edits under parallel tools. */
20
21
  const DEFAULT_ACTIVITY_COALESCE_MS = 1_000;
@@ -313,7 +314,8 @@ export function startLiveRender(deps) {
313
314
  case 'tool/call': {
314
315
  if (typeof data['callId'] !== 'string' || typeof data['name'] !== 'string')
315
316
  return;
316
- const title = presentationTitle(frameView);
317
+ const rawArguments = typeof data['arguments'] === 'string' ? data['arguments'] : undefined;
318
+ const title = presentationTitle(frameView) ?? shellCommandTitle(data['name'], rawArguments);
317
319
  runtime.toolNames.set(data['callId'], data['name']);
318
320
  if (title !== undefined)
319
321
  runtime.toolTitles.set(data['callId'], title);
@@ -322,7 +324,7 @@ export function startLiveRender(deps) {
322
324
  toolName: data['name'],
323
325
  state: 'running',
324
326
  title,
325
- rawArguments: typeof data['arguments'] === 'string' ? data['arguments'] : undefined,
327
+ rawArguments,
326
328
  });
327
329
  runtime.activityScheduler?.schedule(renderActivityContent(runtime));
328
330
  return;
@@ -375,7 +377,7 @@ export function startLiveRender(deps) {
375
377
  .join(' '), 120);
376
378
  return { id, summary: text === '' ? '(非文本消息)' : text };
377
379
  }
378
- function handleFrame(raw, envelopeRpcId) {
380
+ function handleFrame(raw) {
379
381
  const frame = (typeof raw === 'object' && raw !== null ? raw : {});
380
382
  const type = frame['type'];
381
383
  const sessionId = typeof frame['sessionId'] === 'string' ? frame['sessionId'] : undefined;
@@ -436,52 +438,8 @@ export function startLiveRender(deps) {
436
438
  });
437
439
  return;
438
440
  }
439
- if (type === 'approval/requested') {
440
- const threadId = deps.threadForSession(sessionId);
441
- if (threadId === undefined || deps.requests === undefined)
442
- return;
443
- deps.requests.onApprovalRequested({
444
- sessionId,
445
- threadId,
446
- rpcId: envelopeRpcId ?? '',
447
- approvalId: typeof frame['approvalId'] === 'string' ? frame['approvalId'] : '',
448
- toolName: typeof frame['toolName'] === 'string' ? frame['toolName'] : 'tool',
449
- reason: typeof frame['reason'] === 'string' ? frame['reason'] : undefined,
450
- expiresAtMs: Date.now() + deps.approvalTimeoutMs,
451
- });
452
- return;
453
- }
454
- if (type === 'approval/resolved') {
455
- deps.requests?.onApprovalResolved({
456
- sessionId,
457
- approvalId: typeof frame['approvalId'] === 'string' ? frame['approvalId'] : '',
458
- outcome: typeof frame['outcome'] === 'string' ? frame['outcome'] : undefined,
459
- });
460
- return;
461
- }
462
- if (type === 'question/requested') {
463
- const threadId = deps.threadForSession(sessionId);
464
- if (threadId === undefined || deps.requests === undefined)
465
- return;
466
- const questions = Array.isArray(frame['questions']) ? frame['questions'] : [];
467
- deps.requests.onQuestionRequested({
468
- sessionId,
469
- threadId,
470
- rpcId: envelopeRpcId ?? '',
471
- questions,
472
- expiresAtMs: Date.now() + deps.questionTimeoutMs,
473
- });
474
- return;
475
- }
476
- if (type === 'question/resolved') {
477
- const outcome = frame['outcome'] === 'cancelled' ? 'cancelled' : 'answered';
478
- deps.requests?.onQuestionResolved({
479
- sessionId,
480
- questionRpcId: typeof frame['questionRpcId'] === 'string' ? frame['questionRpcId'] : '',
481
- outcome,
482
- });
483
- return;
484
- }
441
+ // Answerable asks no longer ride the frame stream: the 0.1.6 host routes
442
+ // them through the composed-answerer waterfalls (src/dsh/host-asks.ts).
485
443
  if (type !== 'session/event')
486
444
  return;
487
445
  const threadId = deps.threadForSession(sessionId);
@@ -508,18 +466,12 @@ export function startLiveRender(deps) {
508
466
  if (isDisposed())
509
467
  return;
510
468
  try {
511
- const envelopeRpcId = (typeof frame === 'object' && frame !== null && 'rpcId' in frame)
512
- ? frame.rpcId
513
- : undefined;
514
- const payload = (typeof frame === 'object' && frame !== null && 'payload' in frame)
515
- ? frame.payload
516
- : frame;
517
469
  if (TRACE) {
518
- const t = (typeof payload === 'object' && payload !== null ? payload.type : undefined);
519
- const sid = (typeof payload === 'object' && payload !== null ? payload.sessionId : undefined);
470
+ const t = (typeof frame === 'object' && frame !== null ? frame.type : undefined);
471
+ const sid = (typeof frame === 'object' && frame !== null ? frame.sessionId : undefined);
520
472
  trace('frame', String(t), String(sid));
521
473
  }
522
- handleFrame(payload, typeof envelopeRpcId === 'string' ? envelopeRpcId : undefined);
474
+ handleFrame(frame);
523
475
  }
524
476
  catch (cause) {
525
477
  deps.log?.('discord_live_frame_threw', { cause: String(cause) });