@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.
- package/README.md +13 -14
- package/README.zh.md +12 -13
- package/cordis.patch.yml +1 -1
- package/lib/client/card-controller.d.ts +1 -1
- package/lib/client/card-form.d.ts +2 -1
- package/lib/client/index.d.ts +2 -2
- package/lib/client/slot-contract.d.ts +7 -0
- package/lib/client/snapshot-store.d.ts +1 -1
- package/lib/compose.d.ts +2 -2
- package/lib/compose.js +1 -1
- package/lib/dsh/host-asks.d.ts +96 -0
- package/lib/dsh/host-asks.js +151 -0
- package/lib/dsh/host-events.d.ts +42 -0
- package/lib/dsh/host-events.js +279 -0
- package/lib/dsh/host-face.d.ts +418 -0
- package/lib/dsh/host-face.js +598 -0
- package/lib/features/adapter-status.d.ts +15 -2
- package/lib/features/adapter-status.js +100 -4
- package/lib/features/approval-store.d.ts +1 -1
- package/lib/features/interaction-router.d.ts +2 -2
- package/lib/features/model-control.d.ts +1 -1
- package/lib/features/project-list.d.ts +1 -1
- package/lib/features/project-list.js +1 -1
- package/lib/features/session-resume.d.ts +1 -1
- package/lib/i18n.d.ts +5 -0
- package/lib/i18n.js +14 -0
- package/lib/index.js +121 -67
- package/lib/settings.js +5 -2
- package/lib/startup.d.ts +1 -1
- package/lib/startup.js +14 -7
- package/lib/stream/live.d.ts +13 -36
- package/lib/stream/live.js +157 -86
- package/lib/stream/tool-view.d.ts +12 -2
- package/lib/stream/tool-view.js +32 -2
- package/package.json +10 -12
- package/lib/dsh/api-proxy-face.d.ts +0 -400
- package/lib/dsh/api-proxy-face.js +0 -515
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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/
|
|
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 (
|
|
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/
|
|
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
|
|
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
|
|
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/
|
|
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/i18n.d.ts
CHANGED
|
@@ -85,6 +85,11 @@ declare const zh: {
|
|
|
85
85
|
unboundNoticeAdministrator: string;
|
|
86
86
|
unboundNoticeMember: string;
|
|
87
87
|
interruptedMarker: string;
|
|
88
|
+
progressThinking: string;
|
|
89
|
+
progressThinkingStep: (step: number) => string;
|
|
90
|
+
progressWriting: string;
|
|
91
|
+
progressApprovalWait: string;
|
|
92
|
+
progressTurnSummary: (total: number, failed: number, breakdown: string) => string;
|
|
88
93
|
modelNeedsThread: string;
|
|
89
94
|
modelShowUnavailable: string;
|
|
90
95
|
modelShowHeader: (sel: string, groups: number) => string;
|
package/lib/i18n.js
CHANGED
|
@@ -93,6 +93,13 @@ const zh = {
|
|
|
93
93
|
unboundNoticeMember: '💡 此频道未绑定工作区;请工作区管理员运行 `/project bind`。',
|
|
94
94
|
// ── stream renderer ──────────────────────────────────────────────────
|
|
95
95
|
interruptedMarker: '*(已被中断)*',
|
|
96
|
+
progressThinking: '⏳ 思考中…',
|
|
97
|
+
progressThinkingStep: (step) => `⏳ 思考中…(步骤 ${String(step)})`,
|
|
98
|
+
progressWriting: '✍️ 撰写回复…',
|
|
99
|
+
progressApprovalWait: '⏳ 等待审批…',
|
|
100
|
+
progressTurnSummary: (total, failed, breakdown) => failed > 0
|
|
101
|
+
? `⚙️ 本轮 ${String(total)} 次工具调用 · ${String(failed)} 失败 ✗(${breakdown})`
|
|
102
|
+
: `⚙️ 本轮 ${String(total)} 次工具调用 ✓(${breakdown})`,
|
|
96
103
|
// ── /model show / select ─────────────────────────────────────────────
|
|
97
104
|
modelNeedsThread: '⚠️ /model 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
|
|
98
105
|
modelShowUnavailable: '⚠️ 模型目录暂时不可用,请稍后重试。',
|
|
@@ -215,6 +222,13 @@ const en = {
|
|
|
215
222
|
unboundNoticeAdministrator: '💡 This channel is not bound to a workspace. A workspace administrator can run `/project bind` to create and bind the project channel.',
|
|
216
223
|
unboundNoticeMember: '💡 This channel is not bound to a workspace; ask a workspace administrator to run `/project bind`.',
|
|
217
224
|
interruptedMarker: '*(interrupted)*',
|
|
225
|
+
progressThinking: '⏳ Thinking…',
|
|
226
|
+
progressThinkingStep: (step) => `⏳ Thinking… (step ${String(step)})`,
|
|
227
|
+
progressWriting: '✍️ Writing the answer…',
|
|
228
|
+
progressApprovalWait: '⏳ Waiting for approval…',
|
|
229
|
+
progressTurnSummary: (total, failed, breakdown) => failed > 0
|
|
230
|
+
? `⚙️ ${String(total)} tool calls this turn · ${String(failed)} failed ✗ (${breakdown})`
|
|
231
|
+
: `⚙️ ${String(total)} tool calls this turn ✓ (${breakdown})`,
|
|
218
232
|
// ── /model show / select ─────────────────────────────────────────────
|
|
219
233
|
modelNeedsThread: '⚠️ /model needs a thread bound to a Session (mention the bot in a project channel first).',
|
|
220
234
|
modelShowUnavailable: '⚠️ The model catalog is temporarily unavailable; try again later.',
|
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/
|
|
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 {
|
|
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 = ['
|
|
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(
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
122
|
-
//
|
|
123
|
-
// logging live in src/dsh/
|
|
124
|
-
const
|
|
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(
|
|
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(
|
|
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 =>
|
|
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 =>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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(
|
|
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(
|
|
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,49 @@ 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
|
-
|
|
674
|
-
|
|
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
|
+
// Ask-claim steering for the turn progress line (turn-progress spec):
|
|
715
|
+
// declared before the patches so the closure is stable; the live
|
|
716
|
+
// renderer attaches later in startup (asks cannot fire before then).
|
|
717
|
+
const liveProgressRef = { current: undefined };
|
|
718
|
+
hostAsksRef.current = installAskServicePatches(approvalService, questionsService, {
|
|
719
|
+
threadForSession,
|
|
720
|
+
askWiring,
|
|
721
|
+
approvalTimeoutMs: () => current.approvalTimeoutMs,
|
|
722
|
+
questionTimeoutMs: () => current.questionTimeoutMs,
|
|
723
|
+
nowMs: () => Date.now(),
|
|
724
|
+
log: rpcLog,
|
|
725
|
+
onWaitState: (threadId, waiting) => { liveProgressRef.current?.setApprovalWait(threadId, waiting); },
|
|
726
|
+
});
|
|
727
|
+
ctx.effect(() => () => { hostAsksRef.current?.dispose(); }, 'dsh-discord host ask answerers');
|
|
675
728
|
const approvalRespondPort = {
|
|
676
|
-
respond:
|
|
677
|
-
const
|
|
678
|
-
rpcLog('
|
|
679
|
-
|
|
729
|
+
respond: ({ approvalId, outcome }) => {
|
|
730
|
+
const settled = hostAsksRef.current?.settleApproval(approvalId, outcome) ?? false;
|
|
731
|
+
rpcLog('discord_approval_settled', { approvalId, outcome, settled });
|
|
732
|
+
// The settle promise IS the confirmation: an in-process resolution
|
|
733
|
+
// has no wire receipt to doubt.
|
|
734
|
+
return Promise.resolve(settled ? { outcome: 'confirmed' } : { outcome: 'rejected', reason: 'not-pending' });
|
|
680
735
|
},
|
|
681
736
|
};
|
|
682
737
|
const queueSnapshots = new Map();
|
|
@@ -694,10 +749,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
694
749
|
rpcLog('discord_guild_forget_deleted', { guildId, channels: plan.channelKeys.length, threads: plan.threadKeys.length });
|
|
695
750
|
};
|
|
696
751
|
const questionRespondPort = {
|
|
697
|
-
respond:
|
|
698
|
-
const
|
|
699
|
-
rpcLog('
|
|
700
|
-
return
|
|
752
|
+
respond: (input) => {
|
|
753
|
+
const settled = hostAsksRef.current?.settleQuestion(input.rpcId, input.answer) ?? false;
|
|
754
|
+
rpcLog('discord_question_settled', { rpcId: input.rpcId, settled });
|
|
755
|
+
return Promise.resolve(settled ? { outcome: 'confirmed' } : { outcome: 'rejected', reason: 'not-pending' });
|
|
701
756
|
},
|
|
702
757
|
};
|
|
703
758
|
const questionRoutingDeps = {
|
|
@@ -722,10 +777,15 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
722
777
|
handleQuestionComponent: input => handleSelectInput(questionRoutingDeps, input),
|
|
723
778
|
handleQuestionModal: input => handleModalSubmit(questionRoutingDeps, input),
|
|
724
779
|
dsh: {
|
|
725
|
-
cancel: sessionId => cancelSessionViaProxy(
|
|
726
|
-
steer: (sessionId, prompt) =>
|
|
727
|
-
|
|
728
|
-
|
|
780
|
+
cancel: sessionId => cancelSessionViaProxy(dsh, { sessionId }, { log: rpcLog }),
|
|
781
|
+
steer: async (sessionId, prompt) => {
|
|
782
|
+
const steered = await steerSession(dsh, { sessionId, prompt }, { log: rpcLog });
|
|
783
|
+
if (steered.outcome === 'accepted')
|
|
784
|
+
hostEvents.track(sessionId);
|
|
785
|
+
return steered;
|
|
786
|
+
},
|
|
787
|
+
removeQueueItem: (sessionId, itemId) => removeQueueItemViaProxy(dsh, { sessionId, itemId }, { log: rpcLog }),
|
|
788
|
+
readWorkspaceDetail: reference => readWorkspaceDetail(dsh, reference, { log: rpcLog }),
|
|
729
789
|
},
|
|
730
790
|
catalogPort,
|
|
731
791
|
resolver,
|
|
@@ -738,10 +798,10 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
738
798
|
await channelTable.delete(bindChannelKey(guildId, channelId));
|
|
739
799
|
rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
|
|
740
800
|
},
|
|
741
|
-
model: createModelPort(
|
|
801
|
+
model: createModelPort(dsh, { log: rpcLog }),
|
|
742
802
|
modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
|
|
743
803
|
resumeCandidates: createResumeCandidatesPort({
|
|
744
|
-
listSessions: () => listSessionSummaries(
|
|
804
|
+
listSessions: () => listSessionSummaries(dsh, { log: rpcLog }),
|
|
745
805
|
boundSessionIds: () => {
|
|
746
806
|
const bound = new Set();
|
|
747
807
|
for (const [, record] of threadTable.entries())
|
|
@@ -772,7 +832,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
772
832
|
return { outcome: 'refused-control-channel' };
|
|
773
833
|
}
|
|
774
834
|
}
|
|
775
|
-
const summaries = await listSessionSummaries(
|
|
835
|
+
const summaries = await listSessionSummaries(dsh, { log: rpcLog });
|
|
776
836
|
if (summaries.outcome !== 'completed')
|
|
777
837
|
return { outcome: 'failed' };
|
|
778
838
|
const summary = summaries.sessions.find(candidate => candidate.sessionId === sessionId);
|
|
@@ -823,6 +883,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
823
883
|
createdAtMs: Date.now(),
|
|
824
884
|
});
|
|
825
885
|
rpcLog('discord_session_resumed', { sessionId, threadId: thread.body.id, title });
|
|
886
|
+
hostEvents.track(sessionId);
|
|
826
887
|
return { outcome: 'started', threadId: thread.body.id };
|
|
827
888
|
},
|
|
828
889
|
log: rpcLog,
|
|
@@ -935,28 +996,15 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
935
996
|
void registerCommands().catch((cause) => {
|
|
936
997
|
emitLog(ctx, 'warn', { event: 'discord_command_register_failed', cause: String(cause) });
|
|
937
998
|
});
|
|
938
|
-
// ── Live streaming:
|
|
999
|
+
// ── Live streaming: host follow/control fan-in → per-thread rendering ──
|
|
939
1000
|
// The queue snapshot cache (declared with the interaction router) is the
|
|
940
|
-
// /queue surface's data source:
|
|
941
|
-
// authoritative whole-snapshot
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
};
|
|
1001
|
+
// /queue surface's data source: the 0.1.6 `session/control` stream owns
|
|
1002
|
+
// the authoritative whole-snapshot queue frames.
|
|
1003
|
+
// The 0.1.6 event bridge: every session the adapter tracks (create,
|
|
1004
|
+
// adopt, resume, prompt) joins one fan-in stream of live frames.
|
|
952
1005
|
const liveRef = { current: undefined };
|
|
953
1006
|
liveRef.current = startLiveRender({
|
|
954
|
-
frames:
|
|
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
|
-
},
|
|
1007
|
+
frames: signal => hostEvents.stream(signal),
|
|
960
1008
|
threadForSession,
|
|
961
1009
|
delivery: {
|
|
962
1010
|
send: async (request) => {
|
|
@@ -1035,11 +1083,16 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
1035
1083
|
},
|
|
1036
1084
|
updateIntervalMs: current.streamUpdateIntervalMs,
|
|
1037
1085
|
typingIntervalMs: current.typingIntervalMs,
|
|
1038
|
-
approvalTimeoutMs: current.approvalTimeoutMs,
|
|
1039
|
-
questionTimeoutMs: current.questionTimeoutMs,
|
|
1040
1086
|
verbosity: current.defaultVerbosity,
|
|
1041
1087
|
log: rpcLog,
|
|
1042
1088
|
interruptedMarker: () => copy.interruptedMarker,
|
|
1089
|
+
progressCopy: () => ({
|
|
1090
|
+
thinking: copy.progressThinking,
|
|
1091
|
+
thinkingStep: copy.progressThinkingStep,
|
|
1092
|
+
writing: copy.progressWriting,
|
|
1093
|
+
approvalWait: copy.progressApprovalWait,
|
|
1094
|
+
turnSummary: copy.progressTurnSummary,
|
|
1095
|
+
}),
|
|
1043
1096
|
onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
|
|
1044
1097
|
onTurnEnded: (sessionId) => {
|
|
1045
1098
|
const turn = turnTracker.active(sessionId);
|
|
@@ -1047,8 +1100,9 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
1047
1100
|
turnTracker.complete(turn.requestId);
|
|
1048
1101
|
rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
|
|
1049
1102
|
},
|
|
1050
|
-
requests: askWiring,
|
|
1051
1103
|
});
|
|
1104
|
+
// The ask patches steer the same renderer's progress line (3.2 wiring).
|
|
1105
|
+
liveProgressRef.current = liveRef.current;
|
|
1052
1106
|
ctx.effect(() => () => { liveRef.current?.dispose(); }, 'dsh-discord live render');
|
|
1053
1107
|
ctx.effect(() => () => { runtimeRef.current?.dispose(); }, 'dsh-discord composed runtime');
|
|
1054
1108
|
})().catch((cause) => {
|
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
|
-
|
|
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 ["
|
|
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
|
-
'
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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: ['
|
|
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
|
|
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];
|