@addozhang/dsh-discord 0.2.0 → 0.2.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 CHANGED
@@ -17,6 +17,7 @@ This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'setti
17
17
  - **Stream rendering** — typing indicators, a single edited head message, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
18
18
  - **Approvals & questions** — DSH ask frames become Discord buttons, select menus, and a free-text modal; ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and remote resolution retires the controls.
19
19
  - **Session control** — `/steer`, `/stop`, `/queue list|remove` with turn-ownership checks; `/project bind|list|info` for guild↔workspace binding; `/guild forget` for operator cleanup.
20
+ - **Model selection** — `/model show` reads the session's live model directory (current selection, routability, catalog groups); `/model select` walks an interactive provider → model → reasoning cascade (any authorized member by default; restrictable to Host operators), or applies a typed `provider/model` directly.
20
21
  - **Settings card** — token onboarding (paste + Connect; stored in the Host credential service, never in settings or logs), connect/disconnect, guild allowlist, thread auto-archive, and bot language.
21
22
  - **Bilingual copy** — every Discord-visible string ships in Chinese and English; the bot language defaults to following the DSH language preference and can be pinned from the card.
22
23
  - **Hardened by design** — deny-first authorization inside an explicit guild allowlist, mention suppression via `allowed_mentions` plus byte-level neutralization on every wire body, at-most-once DSH submission with unknown-preserving reconciliation, durable bindings that survive restarts, and a READY sweep that rebuilds the deleted category/control channel while treating a deleted workspace channel as user intent (the mapping retires; the workspace stays bindable).
@@ -61,9 +62,10 @@ All keys live in the `dsh-discord` settings namespace and can be set either from
61
62
  | `enabled` | `false` | Adapter master switch; the card's Connect starts it once a token is stored. |
62
63
  | `allowedGuildIds` | `[]` | Guild allowlist. Anything outside is ignored with zero adapter or DSH calls. |
63
64
  | `memberUserIds` / `memberRoleIds` | `[]` | Member-level authorization inside an allowed guild. |
64
- | `administratorUserIds` / `administratorRoleIds` | `[]` | Workspace-administrator level (`/project bind`, preset changes). |
65
+ | `administratorUserIds` / `administratorRoleIds` | `[]` | Workspace-administrator level (`/project bind`). |
65
66
  | `deniedUserIds` / `deniedRoleIds` | `[]` | Deny entries; they win over every grant above. |
66
- | `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`). |
67
+ | `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`; `/model select` when `modelSelectOperatorOnly` is enabled). |
68
+ | `modelSelectOperatorOnly` | `false` | Restrict `/model select` to Host operators (`settings.yaml` only; the card does not expose it). Default `false`: any authorized member may switch, and the switch still updates the Host default. |
67
69
  | `defaultVerbosity` | `essential-tools` | Tool-activity row granularity: `text-only`, `essential-tools`, or `full-tools`. |
68
70
  | `language` | `auto` | Bot-visible copy language: `auto` follows the DSH language preference (non-Chinese renders English), or pin `zh`/`en`. |
69
71
  | `streamUpdateIntervalMs` | `800` | Coalescing budget for stream edits (250–10000). |
@@ -86,7 +88,8 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
86
88
  2. Boot the profile and open the web UI.
87
89
  3. In **Settings → Discord**, paste the bot token (Developer Portal → your application → Bot → Reset Token) and press **Connect**. The token is stored by the Host credential service — never in settings, logs, or the client.
88
90
  4. Fill in **Allowed servers** (server IDs via Discord's Developer Mode → right-click a server → Copy Server ID). Everything outside this allowlist is ignored.
89
- 5. Pick the bot language and mention the bot in a bound channel to start a session.
91
+ 5. `/model select` works for any authorized member by default (single-user deployments). To restrict it to Host operators, add their IDs under `hostOperatorUserIds` and set `modelSelectOperatorOnly: true` in `settings.yaml`. `/guild forget` always requires a Host operator.
92
+ 6. Pick the bot language and mention the bot in a bound channel to start a session.
90
93
 
91
94
  ## Commands
92
95
 
@@ -96,10 +99,9 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
96
99
  | `/project list` / `info` | any channel | list workspaces / inspect this channel's binding |
97
100
  | `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
98
101
  | `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
102
+ | `/model show` / `select` | session thread | show the live model directory; `select` without arguments walks the interactive provider → model → reasoning cascade (any authorized member by default) |
99
103
  | `/guild forget` | any channel | operator-only removal of adapter records |
100
104
 
101
- `/model` remains registered ahead of its interactive provider → model → thinking/reasoning cascade (next milestone); `/preset`, `/skill`, and `/host` are deregistered until the router wires them — see Known limitations.
102
-
103
105
  ## Design notes
104
106
 
105
107
  - The settings card is the first-run onboarding surface: the token entry writes the credential service's `DSH_DISCORD_BOT_TOKEN` ref over the plugin management channel, then triggers the start chain. Disconnect keeps the credential; an empty reconnect uses it.
@@ -112,7 +114,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
112
114
  ## Known Limitations and Deferred Work
113
115
 
114
116
  - **`/session new|resume` is not registered** — the selector and cold-adoption modules are implemented and unit-tested, but the Host RPC face cannot back them yet (`sessions.list` v1 returns bare ids; no `session.inspect`). They return with the next milestone.
115
- - **`/model` is registered but not routed yet** — its control module (model catalog with guarded selection) is implemented and unit-tested; the interactive provider → model → thinking/reasoning cascade lands with the router-wiring milestone. `/preset`, `/skill`, and `/host` stay deregistered until the same pass wires them (the `/preset` thread-context guard rides along).
117
+ - **`/preset`, `/skill`, and `/host` stay deregistered** — their control modules are implemented and unit-tested and return when the router wires them (the `/preset` thread-context guard rides along).
116
118
  - **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
117
119
  - **Deferred after a Kimaki parity pass**: reconcile-interactions wiring, typing pause during ask waits, fail-closed binding/session-owner store wiring, and credential-rotation watching.
118
120
  - **Known tension**: the 250ms minimum stream-edit interval against Discord's edit budget under heavy load (429s self-heal), and typing has no duration-capped watchdog.
@@ -121,7 +123,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
121
123
 
122
124
  ```sh
123
125
  pnpm install --ignore-scripts
124
- pnpm test # 642 tests incl. gateway/REST twin E2E
126
+ pnpm test # 650 tests incl. gateway/REST twin E2E
125
127
  pnpm typecheck
126
128
  pnpm lint
127
129
  pnpm build # lib + client bundle
package/README.zh.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
18
18
  - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,远端决议自动退役控件。
19
19
  - **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 管理 Guild↔工作区绑定;`/guild forget` 供操作员清理。
20
+ - **模型切换** — `/model show` 读取会话的实时模型目录(当前选择、可服务状态、目录分组);`/model select` 走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放,可通过设置收紧为仅 Host 操作员),也可直接填写 `provider/model` 应用。
20
21
  - **设置卡片** — Token 引导(粘贴 + 连接;存入 Host 凭据服务,绝不写入设置或日志)、连接/断开、服务器白名单、线程自动归档、Bot 语言。
21
22
  - **双语文案** — 所有 Discord 可见文案提供中英双语;Bot 语言默认跟随 DSH 语言偏好,也可从卡片固定。
22
23
  - **安全设计** — 显式服务器白名单内的 deny-first 授权、每条 wire 请求携带 `allowed_mentions` 并做字节级提及中和、至多一次的 DSH 提交与保留 unknown 的对账、重启后持久的绑定,以及 READY 扫描:被删的 category/控制频道会重建,被删的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
@@ -61,9 +62,10 @@ dsh plugin --profile web rm @addozhang/dsh-discord # 卸载;先执行 /guild
61
62
  | `enabled` | `false` | 适配器总开关;卡片 Connect 在 Token 存入后启动。 |
62
63
  | `allowedGuildIds` | `[]` | 服务器白名单。白名单之外零响应、零 DSH 调用。 |
63
64
  | `memberUserIds` / `memberRoleIds` | `[]` | 白名单 Guild 内的成员级授权。 |
64
- | `administratorUserIds` / `administratorRoleIds` | `[]` | 工作区管理员级(`/project bind`、预设修改)。 |
65
+ | `administratorUserIds` / `administratorRoleIds` | `[]` | 工作区管理员级(`/project bind`)。 |
65
66
  | `deniedUserIds` / `deniedRoleIds` | `[]` | 拒绝名单;优先级高于上述一切授权。 |
66
- | `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`)。 |
67
+ | `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`;启用 `modelSelectOperatorOnly` 后也包括 `/model select`)。 |
68
+ | `modelSelectOperatorOnly` | `false` | 将 `/model select` 限制为 Host 操作员(仅 `settings.yaml`,卡片不展示)。默认 `false`:任何授权成员均可切换,且切换仍会更新 Host 默认。 |
67
69
  | `defaultVerbosity` | `essential-tools` | 工具活动行粒度:`text-only`、`essential-tools`、`full-tools`。 |
68
70
  | `language` | `auto` | Bot 可见文案语言:`auto` 跟随 DSH 语言偏好(非中文渲染英文),或固定 `zh`/`en`。 |
69
71
  | `streamUpdateIntervalMs` | `800` | 流式编辑合并间隔(250–10000)。 |
@@ -86,7 +88,8 @@ dsh-discord:
86
88
  2. 启动 profile 并打开 Web 界面。
87
89
  3. 在 **Settings → Discord** 中粘贴 Bot Token(开发者门户 → 你的应用 → Bot → Reset Token)并点击 **Connect**。Token 由 Host 凭据服务保存——绝不写入设置、日志或客户端。
88
90
  4. 填写 **允许的服务器**(服务器 ID 获取方式:Discord 开发者模式 → 右键服务器 → 复制服务器 ID)。白名单之外的一切都会被忽略。
89
- 5. 选择 Bot 语言,然后在已绑定的频道 @机器人 开始会话。
91
+ 5. `/model select` 默认对任何授权成员开放(单人自用部署)。如需限制为 Host 操作员:在 `hostOperatorUserIds` 中加入其用户 ID,并把 `modelSelectOperatorOnly` 设为 `true`(`settings.yaml`)。`/guild forget` 始终需要 Host 操作员。
92
+ 6. 选择 Bot 语言,然后在已绑定的频道 @机器人 开始会话。
90
93
 
91
94
  ## 命令
92
95
 
@@ -96,9 +99,9 @@ dsh-discord:
96
99
  | `/project list` / `info` | 任意频道 | 列出工作区 / 查看当前频道绑定 |
97
100
  | `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
98
101
  | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
102
+ | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
99
103
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
100
104
 
101
- `/model` 保持注册,等待其交互式 provider → 模型 → thinking/reasoning 级联(下个里程碑);`/preset`、`/skill`、`/host` 已取消注册,待路由接线后回归——见已知限制。
102
105
 
103
106
  ## 设计说明
104
107
 
@@ -112,7 +115,7 @@ dsh-discord:
112
115
  ## 已知限制与推迟项
113
116
 
114
117
  - **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
115
- - **`/model` 已注册但尚未接线** — 其控制模块(模型目录与受控选择)已实现并通过单元测试;交互式 provider → 模型 → thinking/reasoning 级联将随路由接线里程碑落地。`/preset`、`/skill`、`/host` 保持注销状态,同一次接线时回归(`/preset` 的会话线程守卫一并处理)。
118
+ - **`/preset`、`/skill`、`/host` 保持注销状态** — 控制模块已实现并通过单元测试,待路由接线时回归(`/preset` 的会话线程守卫一并处理)。
116
119
  - **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
117
120
  - **经 Kimaki 对齐后有意推迟**:reconcile-interactions 接线、ask 等待期暂停 typing、fail-closed 绑定/会话属主 store 接线、凭据轮换监听。
118
121
  - **已知张力**:流式编辑 250ms 下限与高负载下 Discord 编辑预算的冲突(429 自愈),以及 typing 缺少时长上限看门狗。
@@ -121,7 +124,7 @@ dsh-discord:
121
124
 
122
125
  ```sh
123
126
  pnpm install --ignore-scripts
124
- pnpm test # 642 tests incl. gateway/REST twin E2E
127
+ pnpm test # 650 tests incl. gateway/REST twin E2E
125
128
  pnpm typecheck
126
129
  pnpm lint
127
130
  pnpm build # lib + client bundle
@@ -16,6 +16,8 @@ interface DiscordPluginsLocale {
16
16
  discordDescription: string;
17
17
  discordAllowedGuildIds: string;
18
18
  discordGuildIdHelp: string;
19
+ discordHostOperators: string;
20
+ discordHostOperatorsHelp: string;
19
21
  discordLanguageAuto: string;
20
22
  discordTokenLabel: string;
21
23
  discordTokenPlaceholder: string;
@@ -30,6 +32,8 @@ interface DiscordPluginsLocale {
30
32
  discordLanguage: string;
31
33
  discordLanguageHint: string;
32
34
  discordInvalidLanguage: string;
35
+ discordModelOperatorOnly: string;
36
+ discordModelOperatorOnlyHint: string;
33
37
  discordInvalidIds: string;
34
38
  discordInvalidArchive: string;
35
39
  discordStatusConnected: string;
package/lib/client.js CHANGED
@@ -951,7 +951,7 @@ var DiscordCardForm = class {
951
951
  const value = section?.[field];
952
952
  if (Array.isArray(value)) return value.join("\n");
953
953
  if (typeof value === "string") return value;
954
- if (typeof value === "number") return String(value);
954
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
955
955
  return "";
956
956
  }
957
957
  snapshot() {
@@ -31,7 +31,9 @@ export const MILESTONE_ONE_COMMANDS = [
31
31
  leaf('stop', 'Cancel the running turn of this thread'),
32
32
  grouped('model', 'Show or select the session model', [
33
33
  { name: 'show' },
34
- { name: 'select', options: [{ name: 'model', required: true }, { name: 'reasoning', required: false }] },
34
+ // select without a typed model opens the interactive provider model
35
+ // → reasoning cascade (16.35); typing `provider/model` applies directly.
36
+ { name: 'select', options: [{ name: 'model', required: false }, { name: 'reasoning', required: false }] },
35
37
  ]),
36
38
  grouped('guild', 'Guild-scoped adapter operations', [
37
39
  { name: 'forget' },
@@ -66,8 +66,61 @@ export interface DshApiProxyFace {
66
66
  sessionId: string;
67
67
  }>;
68
68
  }>>;
69
+ models(request: RpcRequestShape<{
70
+ sessionId: string;
71
+ }>): Promise<RpcResponseShape<SessionModelsShape>>;
72
+ selectModel(request: RpcRequestShape<{
73
+ sessionId: string;
74
+ provider: string;
75
+ model: string;
76
+ reasoningEffort?: string;
77
+ }>): Promise<RpcResponseShape<{
78
+ selected: ModelSelectionShape;
79
+ }>>;
69
80
  };
70
81
  }
82
+ /** One reasoning effort a model's adapter advertises (sessions.d.ts). */
83
+ import type { DshModelPort } from '../features/model-control.js';
84
+ export interface ModelReasoningEffortShape {
85
+ id: string;
86
+ name: string;
87
+ description?: string;
88
+ }
89
+ /** Exact-route reasoning metadata for one catalog model. */
90
+ export interface ModelReasoningShape {
91
+ efforts: ModelReasoningEffortShape[];
92
+ defaultEffort?: string;
93
+ }
94
+ /** One model inside a provider group (sessions.d.ts ModelCatalogModel). */
95
+ export interface ModelCatalogModelShape {
96
+ id: string;
97
+ name: string;
98
+ description?: string;
99
+ reasoning?: ModelReasoningShape;
100
+ }
101
+ /** One provider group and its models (sessions.d.ts ModelProviderGroup). */
102
+ export interface ModelProviderGroupShape {
103
+ id: string;
104
+ name: string;
105
+ models: ModelCatalogModelShape[];
106
+ }
107
+ /** The detached model directory `session.models` returns for one session. */
108
+ export interface SessionModelsShape {
109
+ current: ModelSelectionShape;
110
+ routable: boolean;
111
+ groups: ModelProviderGroupShape[];
112
+ failures: Array<{
113
+ id: string;
114
+ name: string;
115
+ message: string;
116
+ }>;
117
+ }
118
+ /** The complete provider/model/reasoning selection (dsh-agent ModelSelection). */
119
+ export interface ModelSelectionShape {
120
+ provider: string;
121
+ model: string;
122
+ reasoningEffort?: string;
123
+ }
71
124
  /** Signature-layer narrow request form (RpcId brand erased at this seam). */
72
125
  export interface RpcRequestShape<P> {
73
126
  rpcId: string;
@@ -251,3 +304,47 @@ export declare function createClientRespondPort(dsh: {
251
304
  }, options?: ApiProxyFaceOptions): {
252
305
  respond(rpcId: string, value: unknown): Promise<RespondOutcome>;
253
306
  };
307
+ export type SessionModelsOutcome = {
308
+ outcome: 'completed';
309
+ models: SessionModelsShape;
310
+ } | {
311
+ outcome: 'failed';
312
+ } | {
313
+ outcome: 'unknown';
314
+ };
315
+ /**
316
+ * The session's detached model directory: the live selection, whether the
317
+ * current route still serves, and the per-provider catalog groups the
318
+ * /model cascade browses.
319
+ */
320
+ export declare function sessionModels(dsh: DshApiProxyFace, request: {
321
+ sessionId: string;
322
+ }, options?: ApiProxyFaceOptions): Promise<SessionModelsOutcome>;
323
+ export type SelectModelOutcome = {
324
+ outcome: 'completed';
325
+ selected: ModelSelectionShape;
326
+ } | {
327
+ outcome: 'rejected';
328
+ reason: string;
329
+ } | {
330
+ outcome: 'unknown';
331
+ };
332
+ /**
333
+ * Select the complete model selection for one session (session.selectModel):
334
+ * the session switches immediately and the Host records the choice as the
335
+ * default for sessions that have not logged their own — the response only
336
+ * proves the session switch, so callers must not claim the persistence
337
+ * outcome (design.md §7).
338
+ */
339
+ export declare function selectSessionModel(dsh: DshApiProxyFace, request: {
340
+ sessionId: string;
341
+ provider: string;
342
+ model: string;
343
+ reasoningEffort?: string;
344
+ }, options?: ApiProxyFaceOptions): Promise<SelectModelOutcome>;
345
+ /**
346
+ * The /model surface over the real session RPCs: the per-session live
347
+ * directory (`session.models`) and the guarded selection mutation
348
+ * (`session.selectModel`) — the shapes model-control reasons about.
349
+ */
350
+ export declare function createModelPort(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): DshModelPort;
@@ -368,3 +368,82 @@ export function createClientRespondPort(dsh, options = {}) {
368
368
  },
369
369
  };
370
370
  }
371
+ /** Bounded window for the per-session model directory read. */
372
+ const MODELS_TIMEOUT_MS = 10_000;
373
+ /**
374
+ * The session's detached model directory: the live selection, whether the
375
+ * current route still serves, and the per-provider catalog groups the
376
+ * /model cascade browses.
377
+ */
378
+ export async function sessionModels(dsh, request, options = {}) {
379
+ const timeoutMs = options.timeoutMs ?? MODELS_TIMEOUT_MS;
380
+ let response;
381
+ try {
382
+ response = await withRpcTimeout(dsh.sessions.models(mintRequest({ sessionId: request.sessionId })), timeoutMs);
383
+ }
384
+ catch (cause) {
385
+ if (cause instanceof RpcTimeoutError) {
386
+ options.log?.('discord_models_timeout', { sessionId: request.sessionId });
387
+ return { outcome: 'unknown' };
388
+ }
389
+ options.log?.('discord_models_threw', { cause: String(cause), sessionId: request.sessionId });
390
+ return { outcome: 'unknown' };
391
+ }
392
+ const result = response?.result;
393
+ if (result === undefined || !result.ok) {
394
+ options.log?.('discord_models_malformed', {
395
+ sessionId: request.sessionId,
396
+ code: result?.ok === false ? result.error.code : 'malformed',
397
+ });
398
+ return { outcome: 'failed' };
399
+ }
400
+ return { outcome: 'completed', models: result.value };
401
+ }
402
+ /**
403
+ * Select the complete model selection for one session (session.selectModel):
404
+ * the session switches immediately and the Host records the choice as the
405
+ * default for sessions that have not logged their own — the response only
406
+ * proves the session switch, so callers must not claim the persistence
407
+ * outcome (design.md §7).
408
+ */
409
+ export async function selectSessionModel(dsh, request, options = {}) {
410
+ const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
411
+ let response;
412
+ try {
413
+ response = await withRpcTimeout(dsh.sessions.selectModel(mintRequest({
414
+ sessionId: request.sessionId,
415
+ provider: request.provider,
416
+ model: request.model,
417
+ ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
418
+ })), timeoutMs);
419
+ }
420
+ catch (cause) {
421
+ if (cause instanceof RpcTimeoutError) {
422
+ options.log?.('discord_model_select_timeout', { sessionId: request.sessionId });
423
+ return { outcome: 'unknown' };
424
+ }
425
+ options.log?.('discord_model_select_threw', { cause: String(cause), sessionId: request.sessionId });
426
+ return { outcome: 'unknown' };
427
+ }
428
+ const result = response?.result;
429
+ if (result === undefined) {
430
+ options.log?.('discord_model_select_malformed', { sessionId: request.sessionId });
431
+ return { outcome: 'unknown' };
432
+ }
433
+ if (result.ok) {
434
+ return { outcome: 'completed', selected: result.value.selected };
435
+ }
436
+ options.log?.('discord_model_select_rejected', { code: result.error.code, sessionId: request.sessionId });
437
+ return { outcome: 'rejected', reason: result.error.code };
438
+ }
439
+ /**
440
+ * The /model surface over the real session RPCs: the per-session live
441
+ * directory (`session.models`) and the guarded selection mutation
442
+ * (`session.selectModel`) — the shapes model-control reasons about.
443
+ */
444
+ export function createModelPort(dsh, options = {}) {
445
+ return {
446
+ models: sessionId => sessionModels(dsh, { sessionId }, options),
447
+ selectModel: request => selectSessionModel(dsh, request, options),
448
+ };
449
+ }
@@ -55,6 +55,15 @@ export interface AskWiringDeps {
55
55
  reason?: string;
56
56
  messageId?: string;
57
57
  }>;
58
+ /**
59
+ * Edit an already-posted control message (PATCH). Retirement greys the
60
+ * controls out on the ORIGINAL message — a POST would only add a new,
61
+ * empty message while the live buttons stayed behind (16.41).
62
+ */
63
+ editMessage(channelId: string, messageId: string, payload: unknown): Promise<{
64
+ stored: boolean;
65
+ reason?: string;
66
+ }>;
58
67
  }
59
68
  export declare function createAskWiring(deps: AskWiringDeps): {
60
69
  /** Retire a rendered control (clear components) once the ask is settled. */
@@ -14,8 +14,16 @@ import { renderQuestionControls } from './question-view.js';
14
14
  import { abandonUnrenderableQuestion } from './question-expiry.js';
15
15
  import { handleRemoteResolution } from './question-routing.js';
16
16
  export function createAskWiring(deps) {
17
- /** Discord message refs for rendered controls, keyed by ask id. */
17
+ /** Discord message refs + rendered rows for controls, keyed by ask id. */
18
18
  const controlMessages = new Map();
19
+ function disabledComponents(components) {
20
+ if (!Array.isArray(components))
21
+ return [];
22
+ return components.map(row => ({
23
+ ...row,
24
+ components: (row.components ?? []).map(control => ({ ...control, disabled: true })),
25
+ }));
26
+ }
19
27
  const disableControl = async (key) => {
20
28
  const target = controlMessages.get(key);
21
29
  if (target === undefined)
@@ -24,9 +32,11 @@ export function createAskWiring(deps) {
24
32
  // Retirement is best-effort: a failed disable must never fail the ask's
25
33
  // settled outcome — the registry TTL keeps stray clicks answerable.
26
34
  try {
27
- const posted = await deps.postMessage(target.channelId, { components: [] });
28
- if (!posted.stored)
29
- deps.log('discord_control_disable_failed', { key });
35
+ const patched = await deps.editMessage(target.channelId, target.messageId, {
36
+ components: disabledComponents(target.components),
37
+ });
38
+ if (!patched.stored)
39
+ deps.log('discord_control_disable_failed', { key, reason: patched.reason });
30
40
  }
31
41
  catch (cause) {
32
42
  deps.log('discord_control_disable_failed', { key, cause: String(cause) });
@@ -77,7 +87,7 @@ export function createAskWiring(deps) {
77
87
  void deps.postMessage(input.threadId, payload)
78
88
  .then(sent => {
79
89
  if (sent.stored && sent.messageId !== undefined) {
80
- controlMessages.set(input.approvalId, { channelId: input.threadId, messageId: sent.messageId });
90
+ controlMessages.set(input.approvalId, { channelId: input.threadId, messageId: sent.messageId, components: payload.components });
81
91
  }
82
92
  })
83
93
  .catch((cause) => { deps.log('discord_approval_render_failed', String(cause)); });
@@ -136,7 +146,7 @@ export function createAskWiring(deps) {
136
146
  abandonQuestion(sent.reason ?? 'post rejected');
137
147
  return;
138
148
  }
139
- controlMessages.set(input.rpcId, { channelId: input.threadId, messageId: sent.messageId });
149
+ controlMessages.set(input.rpcId, { channelId: input.threadId, messageId: sent.messageId, components: payload.components });
140
150
  })
141
151
  .catch((cause) => { abandonQuestion(String(cause)); });
142
152
  },
@@ -14,6 +14,7 @@ import type { WorkspaceResolver } from './project-bind.js';
14
14
  import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailOutcome } from '../dsh/api-proxy-face.js';
15
15
  import type { TurnTracker } from './turn-ownership.js';
16
16
  import { type DshApprovalRespondPort } from './approval-routing.js';
17
+ import { type DshModelPort } from './model-control.js';
17
18
  import type { QuestionInteractionOutcome } from './question-routing.js';
18
19
  import type { ApprovalStore } from './approval-store.js';
19
20
  import type { ChannelBinding } from '../state/records.js';
@@ -82,6 +83,13 @@ export interface InteractionRouterDeps {
82
83
  * fresh; task 16.37).
83
84
  */
84
85
  purgeChannelBinding: (guildId: string, channelId: string) => Promise<void>;
86
+ /** The session's live model directory + selection mutation (/model surface). */
87
+ model: DshModelPort;
88
+ /**
89
+ * Whether /model select stays Host-operator-only (default). Single-user
90
+ * deployments flip this so any authorized member can switch (16.42).
91
+ */
92
+ modelSelectOperatorOnly: () => boolean;
85
93
  log: (event: string, detail?: unknown) => void;
86
94
  warn: (event: string, detail?: unknown) => void;
87
95
  }
@@ -13,6 +13,33 @@ import { projectInfo } from './project-info.js';
13
13
  import { planSteer } from './steer-control.js';
14
14
  import { planStop } from './stop-control.js';
15
15
  import { handleApprovalClick } from './approval-routing.js';
16
+ import { applyModelSelection } from './model-control.js';
17
+ /** Discord interactive-component id for string select menus. */
18
+ const SELECT_MENU = 3;
19
+ /** `provider/model` — or `provider/model (effort)` when an effort is set. */
20
+ function modelSelectionLabel(selection) {
21
+ return selection.reasoningEffort === undefined
22
+ ? `${selection.provider}/${selection.model}`
23
+ : `${selection.provider}/${selection.model} (${selection.reasoningEffort})`;
24
+ }
25
+ function selectMenuRow(customId, placeholder, options) {
26
+ return [{
27
+ type: 1,
28
+ components: [{ type: SELECT_MENU, custom_id: customId, placeholder, options, max_values: 1 }],
29
+ }];
30
+ }
31
+ /** The ephemeral outcome line for a /model selection attempt. */
32
+ function renderModelApply(copy, result) {
33
+ switch (result.outcome) {
34
+ case 'applied': return copy.modelApplied(modelSelectionLabel(result.selected));
35
+ case 'rejected': return copy.modelSelectRejected(result.reason);
36
+ case 'unknown': return copy.modelSelectUnknown;
37
+ case 'refused':
38
+ return result.reason === 'not-host-operator' ? copy.modelSelectOperatorOnly
39
+ : result.reason === 'invalid-reasoning-effort' ? copy.modelInvalidReasoning
40
+ : copy.modelNotInCatalog;
41
+ }
42
+ }
16
43
  export function createInteractionRouter(deps) {
17
44
  /** Authorize one interaction against the live policy table. */
18
45
  function authorize(event) {
@@ -368,6 +395,94 @@ export function createInteractionRouter(deps) {
368
395
  }]);
369
396
  return;
370
397
  }
398
+ if (event.commandName === 'model') {
399
+ const options = event.data['options'];
400
+ const subcommand = Array.isArray(options) ? options[0] : undefined;
401
+ const subName = subcommand?.name;
402
+ const sessionId = deps.sessionForThread(event.guildId, event.channelId);
403
+ if (sessionId === undefined) {
404
+ await followUp(deps.copy.modelNeedsThread);
405
+ return;
406
+ }
407
+ if (subName === 'select') {
408
+ // Host-operator authority by default: the switch reaches the
409
+ // Host-wide default selection (design.md §7). A deployment may
410
+ // relax it for single-user use (16.42).
411
+ const requireOperator = deps.modelSelectOperatorOnly();
412
+ const decision = authorize(event);
413
+ if (!decision.allowed || (requireOperator && decision.level !== 'host-operator')) {
414
+ await followUp(deps.copy.modelSelectOperatorOnly);
415
+ return;
416
+ }
417
+ const wireOptions = Array.isArray(subcommand?.options) ? subcommand.options : [];
418
+ const typedModel = wireOptions.find(option => option.name === 'model')?.value;
419
+ const typedReasoning = wireOptions.find(option => option.name === 'reasoning')?.value;
420
+ if (typeof typedModel === 'string' && typedModel !== '') {
421
+ // Typed form: `provider/model` plus an optional reasoning
422
+ // effort, validated against the live catalog before mutation.
423
+ const separator = typedModel.indexOf('/');
424
+ if (separator <= 0 || separator === typedModel.length - 1) {
425
+ await followUp(deps.copy.modelTypedParseFailed);
426
+ return;
427
+ }
428
+ const result = await applyModelSelection(deps.model, {
429
+ decision,
430
+ sessionId,
431
+ provider: typedModel.slice(0, separator),
432
+ model: typedModel.slice(separator + 1),
433
+ requireHostOperator: requireOperator,
434
+ ...(typeof typedReasoning === 'string' && typedReasoning !== '' ? { reasoningEffort: typedReasoning } : {}),
435
+ });
436
+ await followUp(renderModelApply(deps.copy, result));
437
+ return;
438
+ }
439
+ // Interactive cascade, stage 1: the provider.
440
+ const directory = await deps.model.models(sessionId);
441
+ if (directory.outcome !== 'completed') {
442
+ await followUp(deps.copy.modelShowUnavailable);
443
+ return;
444
+ }
445
+ if (directory.models.groups.length === 0) {
446
+ await followUp(deps.copy.modelSelectNoModels);
447
+ return;
448
+ }
449
+ const failures = directory.models.failures.length > 0
450
+ ? deps.copy.modelShowFailures(directory.models.failures.map(failure => failure.name).join(', '))
451
+ : '';
452
+ const truncated = directory.models.groups.length > 25
453
+ ? deps.copy.modelCascadeTruncated(25, directory.models.groups.length)
454
+ : '';
455
+ const menuId = deps.registry.register({ kind: 'model', stage: 'provider', sessionId, expiresAtMs: Date.now() + 15 * 60 * 1000 });
456
+ await followUp(deps.copy.modelCascadeProviderHeader(modelSelectionLabel(directory.models.current), `${failures}${truncated}`), selectMenuRow(menuId, deps.copy.modelCascadeProviderPlaceholder, directory.models.groups.slice(0, 25).map(group => ({
457
+ label: group.name,
458
+ value: group.id,
459
+ description: `${String(group.models.length)} models`,
460
+ }))));
461
+ return;
462
+ }
463
+ // show (default): the member-readable live model directory.
464
+ const directory = await deps.model.models(sessionId);
465
+ if (directory.outcome !== 'completed') {
466
+ await followUp(deps.copy.modelShowUnavailable);
467
+ return;
468
+ }
469
+ const current = directory.models.current;
470
+ const selection = current.reasoningEffort === undefined
471
+ ? `${current.provider}/${current.model}`
472
+ : `${current.provider}/${current.model} (${current.reasoningEffort})`;
473
+ let content = deps.copy.modelShowHeader(selection, directory.models.groups.length);
474
+ if (current.reasoningEffort !== undefined)
475
+ content += deps.copy.modelShowReasoning(current.reasoningEffort);
476
+ if (!directory.models.routable)
477
+ content += deps.copy.modelShowNotRoutable;
478
+ if (directory.models.groups.length === 0)
479
+ content += deps.copy.modelShowNoGroups;
480
+ if (directory.models.failures.length > 0) {
481
+ content += deps.copy.modelShowFailures(directory.models.failures.map(failure => failure.name).join(', '));
482
+ }
483
+ await followUp(content);
484
+ return;
485
+ }
371
486
  // Terminal guard: a recognized interaction type with an unhandled
372
487
  // command name must still answer, never strand on the deferred ack.
373
488
  await followUp(deps.copy.unknownSubcommand);
@@ -476,6 +591,136 @@ export function createInteractionRouter(deps) {
476
591
  if (posted.outcome !== 'completed')
477
592
  deps.log('discord_followup_failed', posted.outcome);
478
593
  }
594
+ /** One cascade stage: ack, re-authorize, then advance or apply (16.35). */
595
+ async function routeModelComponent(event, interactionToken) {
596
+ const customId = event.data['custom_id'];
597
+ if (typeof customId !== 'string')
598
+ return;
599
+ const resolved = deps.registry.resolve(customId, Date.now());
600
+ const context = resolved.found ? resolved.context : undefined;
601
+ const rest = await deps.rest();
602
+ if (rest === undefined)
603
+ return;
604
+ // Ack BEFORE the catalog reads and the DSH round trip: component
605
+ // interactions must receive an initial response within 3s.
606
+ const acked = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
607
+ if (acked.outcome !== 'completed')
608
+ deps.log('discord_ack_failed', acked.outcome);
609
+ const followUp = async (content, components) => {
610
+ const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, {
611
+ content,
612
+ flags: OUTBOUND_EPHEMERAL_FLAGS,
613
+ ...(components === undefined ? {} : { components }),
614
+ });
615
+ if (posted.outcome !== 'completed') {
616
+ deps.log('discord_followup_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
617
+ }
618
+ };
619
+ if (context === undefined) {
620
+ await followUp(deps.copy.modelCascadeExpired);
621
+ return;
622
+ }
623
+ // Every stage re-checks Host-operator authority: a forwarded menu must
624
+ // not let another member drive — or enumerate — the selection surface.
625
+ const requireOperator = deps.modelSelectOperatorOnly();
626
+ const decision = authorize(event);
627
+ if (!decision.allowed || (requireOperator && decision.level !== 'host-operator')) {
628
+ await followUp(deps.copy.modelSelectOperatorOnly);
629
+ return;
630
+ }
631
+ const sessionId = typeof context['sessionId'] === 'string' ? context['sessionId'] : undefined;
632
+ const stage = context['stage'];
633
+ if (sessionId === undefined || typeof stage !== 'string') {
634
+ await followUp(deps.copy.modelCascadeExpired);
635
+ return;
636
+ }
637
+ const value = event.selectValues[0];
638
+ if (typeof value !== 'string' || value === '') {
639
+ await followUp(deps.copy.modelCascadeExpired);
640
+ return;
641
+ }
642
+ try {
643
+ if (stage === 'provider') {
644
+ const directory = await deps.model.models(sessionId);
645
+ const group = directory.outcome === 'completed'
646
+ ? directory.models.groups.find(candidate => candidate.id === value)
647
+ : undefined;
648
+ if (group === undefined || group.models.length === 0) {
649
+ await followUp(deps.copy.modelSelectNoModels);
650
+ return;
651
+ }
652
+ const truncated = group.models.length > 25 ? deps.copy.modelCascadeTruncated(25, group.models.length) : '';
653
+ const menuId = deps.registry.register({ kind: 'model', stage: 'model', sessionId, provider: group.id, expiresAtMs: Date.now() + 15 * 60 * 1000 });
654
+ await followUp(deps.copy.modelCascadeModelHeader(group.name) + truncated, selectMenuRow(menuId, deps.copy.modelCascadeModelPlaceholder, group.models.slice(0, 25).map(model => ({
655
+ label: model.name !== '' ? model.name : model.id,
656
+ value: model.id,
657
+ ...(model.description === undefined ? {} : { description: model.description.slice(0, 100) }),
658
+ }))));
659
+ return;
660
+ }
661
+ if (stage === 'model') {
662
+ const provider = typeof context['provider'] === 'string' ? context['provider'] : undefined;
663
+ if (provider === undefined) {
664
+ await followUp(deps.copy.modelCascadeExpired);
665
+ return;
666
+ }
667
+ const directory = await deps.model.models(sessionId);
668
+ const group = directory.outcome === 'completed'
669
+ ? directory.models.groups.find(candidate => candidate.id === provider)
670
+ : undefined;
671
+ const model = group?.models.find(candidate => candidate.id === value);
672
+ const efforts = model?.reasoning?.efforts ?? [];
673
+ if (efforts.length === 0) {
674
+ // No reasoning metadata: apply with the provider/default behavior.
675
+ const result = await applyModelSelection(deps.model, { decision, sessionId, provider, model: value, requireHostOperator: requireOperator });
676
+ await followUp(renderModelApply(deps.copy, result));
677
+ return;
678
+ }
679
+ const menuId = deps.registry.register({ kind: 'model', stage: 'reasoning', sessionId, provider, model: value, expiresAtMs: Date.now() + 15 * 60 * 1000 });
680
+ const defaultEffort = model?.reasoning?.defaultEffort;
681
+ const options = [
682
+ {
683
+ label: deps.copy.modelCascadeReasoningDefault,
684
+ value: '__default__',
685
+ description: (defaultEffort === undefined
686
+ ? deps.copy.modelCascadeReasoningDefaultHint
687
+ : `${deps.copy.modelCascadeReasoningDefaultHint} (${defaultEffort})`).slice(0, 100),
688
+ },
689
+ ...efforts.slice(0, 24).map(effort => ({
690
+ label: effort.name,
691
+ value: effort.id,
692
+ ...(effort.description === undefined ? {} : { description: effort.description.slice(0, 100) }),
693
+ })),
694
+ ];
695
+ const modelLabel = model?.name !== undefined && model.name !== '' ? model.name : value;
696
+ await followUp(deps.copy.modelCascadeReasoningHeader(modelLabel), selectMenuRow(menuId, deps.copy.modelCascadeReasoningPlaceholder, options));
697
+ return;
698
+ }
699
+ if (stage === 'reasoning') {
700
+ const provider = typeof context['provider'] === 'string' ? context['provider'] : undefined;
701
+ const model = typeof context['model'] === 'string' ? context['model'] : undefined;
702
+ if (provider === undefined || model === undefined) {
703
+ await followUp(deps.copy.modelCascadeExpired);
704
+ return;
705
+ }
706
+ const result = await applyModelSelection(deps.model, {
707
+ decision,
708
+ sessionId,
709
+ provider,
710
+ model,
711
+ requireHostOperator: requireOperator,
712
+ ...(value === '__default__' ? {} : { reasoningEffort: value }),
713
+ });
714
+ await followUp(renderModelApply(deps.copy, result));
715
+ return;
716
+ }
717
+ await followUp(deps.copy.modelCascadeExpired);
718
+ }
719
+ catch (cause) {
720
+ deps.warn('discord_model_cascade_failed', String(cause));
721
+ await deps.componentFollowUp(event.interactionId, interactionToken, deps.copy.commandFailed).catch(() => { });
722
+ }
723
+ }
479
724
  async function settleQuestionInteraction(event, interactionToken, outcome) {
480
725
  if (outcome.outcome === 'modal-requested') {
481
726
  const rest = await deps.rest();
@@ -578,6 +823,10 @@ export function createInteractionRouter(deps) {
578
823
  await routeGuildForGetComponent(event, interactionToken);
579
824
  return;
580
825
  }
826
+ if (bindContext?.['kind'] === 'model') {
827
+ await routeModelComponent(event, interactionToken);
828
+ return;
829
+ }
581
830
  // A not-found custom_id means the control was never registered or has
582
831
  // been retired by its TTL (approval/question registry entries stay
583
832
  // resolvable until expiry; claimed-vs-resolved state lives in the
@@ -1,24 +1,21 @@
1
1
  /**
2
2
  * `/model show` and the guarded `/model select` (design.md §7, tasks 10.2 +
3
- * 10.3). A successful `session.selectModel` changes the addressed session AND
4
- * attempts to persist that choice as the host default for future sessions, so
5
- * mutation is restricted to the explicit global Host-operator allowlist
6
- * Guild-local administrators are denied. The confirmation states BOTH facts
7
- * and nothing more: `applied-with-host-default`, `partial-session-only` (the
8
- * session changed but persistence did not land), or a plain rejection/unknown.
9
- * Catalog and reasoning-effort validation happen before any DSH call.
3
+ * 10.3 + 16.35). Selection goes through `session.selectModel`: the session
4
+ * switches immediately and the Host records the choice as the default for
5
+ * sessions that have not logged their own — the RPC response only proves the
6
+ * session switch, so the confirmation states the session change and the
7
+ * default request, never a persistence outcome. Because the switch reaches
8
+ * the Host-wide default, mutation is restricted to the explicit global
9
+ * Host-operator allowlist Guild-local administrators are denied. Provider/
10
+ * model/reasoning validation runs against the session's live catalog before
11
+ * any mutation.
10
12
  */
11
13
  import type { AccessDecision } from '../policy/authorization.js';
14
+ import type { ModelSelectionShape, SessionModelsShape } from '../dsh/api-proxy-face.js';
12
15
  export interface DshModelPort {
13
- catalog(): Promise<{
16
+ models(sessionId: string): Promise<{
14
17
  outcome: 'completed';
15
- providers: ReadonlyArray<{
16
- provider: string;
17
- models: ReadonlyArray<{
18
- id: string;
19
- reasonings: readonly string[];
20
- }>;
21
- }>;
18
+ models: SessionModelsShape;
22
19
  } | {
23
20
  outcome: 'failed';
24
21
  } | {
@@ -26,12 +23,12 @@ export interface DshModelPort {
26
23
  }>;
27
24
  selectModel(request: {
28
25
  sessionId: string;
29
- modelId: string;
30
- reasoning?: string;
26
+ provider: string;
27
+ model: string;
28
+ reasoningEffort?: string;
31
29
  }): Promise<{
32
30
  outcome: 'completed';
33
- sessionApplied: boolean;
34
- defaultPersisted: boolean;
31
+ selected: ModelSelectionShape;
35
32
  } | {
36
33
  outcome: 'rejected';
37
34
  reason: string;
@@ -39,41 +36,40 @@ export interface DshModelPort {
39
36
  outcome: 'unknown';
40
37
  }>;
41
38
  }
42
- export type ModelCatalogView = {
43
- outcome: 'ok';
44
- providers: ReadonlyArray<{
45
- provider: string;
46
- models: ReadonlyArray<{
47
- id: string;
48
- reasonings: readonly string[];
49
- }>;
50
- }>;
39
+ export type ModelsView = {
40
+ outcome: 'completed';
41
+ models: SessionModelsShape;
51
42
  } | {
52
43
  outcome: 'failed';
53
- reason: 'model-catalog-unavailable' | 'model-catalog-unknown';
44
+ } | {
45
+ outcome: 'unknown';
54
46
  };
55
- export declare function showModelCatalog(port: DshModelPort, _request: {
47
+ /** `/model show`: the session's live model directory (current + browsable groups). */
48
+ export declare function showModels(port: DshModelPort, request: {
56
49
  sessionId: string;
57
- }): Promise<ModelCatalogView>;
58
- export type ModelSelectResult = {
59
- outcome: 'applied-with-host-default';
60
- sessionApplied: boolean;
61
- defaultPersisted: boolean;
62
- } | {
63
- outcome: 'partial-session-only';
64
- sessionApplied: boolean;
65
- defaultPersisted: boolean;
50
+ }): Promise<ModelsView>;
51
+ export type ModelApplyResult = {
52
+ outcome: 'applied';
53
+ selected: ModelSelectionShape;
66
54
  } | {
67
55
  outcome: 'rejected';
56
+ reason: string;
68
57
  } | {
69
58
  outcome: 'unknown';
70
59
  } | {
71
60
  outcome: 'refused';
72
61
  reason: 'not-host-operator' | 'model-not-in-catalog' | 'invalid-reasoning-effort';
73
62
  };
74
- export declare function selectModel(port: DshModelPort, request: {
63
+ export declare function applyModelSelection(port: DshModelPort, request: {
75
64
  decision: AccessDecision;
76
65
  sessionId: string;
77
- modelId: string;
78
- reasoning?: string | undefined;
79
- }): Promise<ModelSelectResult>;
66
+ provider: string;
67
+ model: string;
68
+ reasoningEffort?: string | undefined;
69
+ /**
70
+ * Keep the Host-operator restriction (default). Single-user deployments
71
+ * may drop it so any authorized member of the allowlisted guild can
72
+ * switch — the switch still reaches the Host-wide default (16.42).
73
+ */
74
+ requireHostOperator?: boolean | undefined;
75
+ }): Promise<ModelApplyResult>;
@@ -1,51 +1,57 @@
1
1
  /**
2
2
  * `/model show` and the guarded `/model select` (design.md §7, tasks 10.2 +
3
- * 10.3). A successful `session.selectModel` changes the addressed session AND
4
- * attempts to persist that choice as the host default for future sessions, so
5
- * mutation is restricted to the explicit global Host-operator allowlist
6
- * Guild-local administrators are denied. The confirmation states BOTH facts
7
- * and nothing more: `applied-with-host-default`, `partial-session-only` (the
8
- * session changed but persistence did not land), or a plain rejection/unknown.
9
- * Catalog and reasoning-effort validation happen before any DSH call.
3
+ * 10.3 + 16.35). Selection goes through `session.selectModel`: the session
4
+ * switches immediately and the Host records the choice as the default for
5
+ * sessions that have not logged their own — the RPC response only proves the
6
+ * session switch, so the confirmation states the session change and the
7
+ * default request, never a persistence outcome. Because the switch reaches
8
+ * the Host-wide default, mutation is restricted to the explicit global
9
+ * Host-operator allowlist Guild-local administrators are denied. Provider/
10
+ * model/reasoning validation runs against the session's live catalog before
11
+ * any mutation.
10
12
  */
11
- export async function showModelCatalog(port, _request) {
12
- const catalog = await port.catalog();
13
- if (catalog.outcome === 'failed')
14
- return { outcome: 'failed', reason: 'model-catalog-unavailable' };
15
- if (catalog.outcome === 'unknown')
16
- return { outcome: 'failed', reason: 'model-catalog-unknown' };
17
- return { outcome: 'ok', providers: catalog.providers };
13
+ /** `/model show`: the session's live model directory (current + browsable groups). */
14
+ export async function showModels(port, request) {
15
+ return port.models(request.sessionId);
18
16
  }
19
- export async function selectModel(port, request) {
20
- // Host-operator authority ONLY: a Guild administrator is not enough,
21
- // because the selection attempts to persist a host-wide default.
22
- if (!request.decision.allowed || request.decision.level !== 'host-operator') {
17
+ export async function applyModelSelection(port, request) {
18
+ const requireOperator = request.requireHostOperator ?? true;
19
+ if (requireOperator) {
20
+ // Host-operator authority ONLY: a Guild administrator is not enough,
21
+ // because the switch reaches the Host-wide default selection.
22
+ if (!request.decision.allowed || request.decision.level !== 'host-operator') {
23
+ return { outcome: 'refused', reason: 'not-host-operator' };
24
+ }
25
+ }
26
+ if (!request.decision.allowed) {
23
27
  return { outcome: 'refused', reason: 'not-host-operator' };
24
28
  }
25
- const catalog = await port.catalog();
26
- if (catalog.outcome !== 'completed') {
29
+ const directory = await port.models(request.sessionId);
30
+ if (directory.outcome !== 'completed') {
31
+ return { outcome: 'refused', reason: 'model-not-in-catalog' };
32
+ }
33
+ const group = directory.models.groups.find(candidate => candidate.id === request.provider);
34
+ if (group === undefined) {
27
35
  return { outcome: 'refused', reason: 'model-not-in-catalog' };
28
36
  }
29
- const model = catalog.providers
30
- .flatMap(provider => provider.models)
31
- .find(candidate => candidate.id === request.modelId);
37
+ const model = group.models.find(candidate => candidate.id === request.model);
32
38
  if (model === undefined) {
33
39
  return { outcome: 'refused', reason: 'model-not-in-catalog' };
34
40
  }
35
- if (request.reasoning !== undefined && !model.reasonings.includes(request.reasoning)) {
36
- return { outcome: 'refused', reason: 'invalid-reasoning-effort' };
41
+ if (request.reasoningEffort !== undefined) {
42
+ const efforts = model.reasoning?.efforts ?? [];
43
+ if (!efforts.some(effort => effort.id === request.reasoningEffort)) {
44
+ return { outcome: 'refused', reason: 'invalid-reasoning-effort' };
45
+ }
37
46
  }
38
47
  const selected = await port.selectModel({
39
48
  sessionId: request.sessionId,
40
- modelId: request.modelId,
41
- ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
49
+ provider: request.provider,
50
+ model: request.model,
51
+ ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
42
52
  });
43
53
  if (selected.outcome !== 'completed') {
44
- return selected.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
45
- }
46
- // State exactly what DSH proved — both facts, never overstated.
47
- if (selected.sessionApplied && selected.defaultPersisted) {
48
- return { outcome: 'applied-with-host-default', sessionApplied: true, defaultPersisted: true };
54
+ return selected.outcome === 'rejected' ? { outcome: 'rejected', reason: selected.reason } : { outcome: 'unknown' };
49
55
  }
50
- return { outcome: 'partial-session-only', sessionApplied: selected.sessionApplied, defaultPersisted: selected.defaultPersisted };
56
+ return { outcome: 'applied', selected: selected.selected };
51
57
  }
package/lib/i18n.d.ts CHANGED
@@ -84,6 +84,31 @@ declare const zh: {
84
84
  unboundNoticeAdministrator: string;
85
85
  unboundNoticeMember: string;
86
86
  interruptedMarker: string;
87
+ modelNeedsThread: string;
88
+ modelShowUnavailable: string;
89
+ modelShowHeader: (sel: string, groups: number) => string;
90
+ modelShowReasoning: (effort: string) => string;
91
+ modelShowNotRoutable: string;
92
+ modelShowFailures: (names: string) => string;
93
+ modelShowNoGroups: string;
94
+ modelSelectOperatorOnly: string;
95
+ modelSelectNoModels: string;
96
+ modelCascadeProviderHeader: (current: string, extra: string) => string;
97
+ modelCascadeProviderPlaceholder: string;
98
+ modelCascadeModelHeader: (provider: string) => string;
99
+ modelCascadeModelPlaceholder: string;
100
+ modelCascadeReasoningHeader: (model: string) => string;
101
+ modelCascadeReasoningPlaceholder: string;
102
+ modelCascadeReasoningDefault: string;
103
+ modelCascadeReasoningDefaultHint: string;
104
+ modelCascadeTruncated: (shown: number, total: number) => string;
105
+ modelCascadeExpired: string;
106
+ modelApplied: (sel: string) => string;
107
+ modelSelectUnknown: string;
108
+ modelSelectRejected: (reason: string) => string;
109
+ modelNotInCatalog: string;
110
+ modelInvalidReasoning: string;
111
+ modelTypedParseFailed: string;
87
112
  approvalRequired: (label: string) => string;
88
113
  };
89
114
  export type CopyTable = typeof zh;
package/lib/i18n.js CHANGED
@@ -92,6 +92,32 @@ const zh = {
92
92
  unboundNoticeMember: '💡 此频道未绑定工作区;请工作区管理员运行 `/project bind`。',
93
93
  // ── stream renderer ──────────────────────────────────────────────────
94
94
  interruptedMarker: '*(已被中断)*',
95
+ // ── /model show / select ─────────────────────────────────────────────
96
+ modelNeedsThread: '⚠️ /model 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
97
+ modelShowUnavailable: '⚠️ 模型目录暂时不可用,请稍后重试。',
98
+ modelShowHeader: (sel, groups) => `**当前模型:** \`${sel}\`\n**可用 provider:** ${String(groups)}`,
99
+ modelShowReasoning: (effort) => `\n**推理强度:** \`${effort}\``,
100
+ modelShowNotRoutable: '\n⚠️ 当前 provider 暂时无法服务请求。',
101
+ modelShowFailures: (names) => `\n⚠️ 目录加载失败的 provider:${names}`,
102
+ modelShowNoGroups: '\n(当前没有 provider 提供模型)',
103
+ modelSelectOperatorOnly: '⚠️ 只有 Host 操作员可以切换模型(此操作会切换当前 Session 并更新 Host 默认)。',
104
+ modelSelectNoModels: '⚠️ 当前没有 provider 提供模型,无法选择。',
105
+ modelCascadeProviderHeader: (current, extra) => `当前模型:\`${current}\`${extra}\n请选择 provider:`,
106
+ modelCascadeProviderPlaceholder: '选择 provider',
107
+ modelCascadeModelHeader: (provider) => `Provider:**${provider}**\n请选择模型:`,
108
+ modelCascadeModelPlaceholder: '选择模型',
109
+ modelCascadeReasoningHeader: (model) => `模型:**${model}**\n请选择推理强度:`,
110
+ modelCascadeReasoningPlaceholder: '选择推理强度',
111
+ modelCascadeReasoningDefault: '跟随默认',
112
+ modelCascadeReasoningDefaultHint: '使用 provider/默认推理行为',
113
+ modelCascadeTruncated: (shown, total) => `(共 ${String(total)} 项,仅显示前 ${String(shown)} 项)`,
114
+ modelCascadeExpired: '⚠️ 该选择已过期,请重新运行 /model select。',
115
+ modelApplied: (sel) => `✅ 已应用到当前 Session:\`${sel}\`,并已请求 DSH 记为 Host 默认。`,
116
+ modelSelectUnknown: '⚠️ 选择结果未知(请求可能未送达),请用 /model show 确认。',
117
+ modelSelectRejected: (reason) => `⚠️ DSH 拒绝了此次选择:${reason}`,
118
+ modelNotInCatalog: '⚠️ 该 provider/模型不在当前会话的目录中。',
119
+ modelInvalidReasoning: '⚠️ 该推理强度对此模型无效。',
120
+ modelTypedParseFailed: '⚠️ 模型需按 `provider/model` 格式填写,或留空进入交互式选择。',
95
121
  // ── approval / question cards ────────────────────────────────────────
96
122
  approvalRequired: (label) => `Approval required — ${label}`,
97
123
  };
@@ -171,6 +197,32 @@ const en = {
171
197
  unboundNoticeAdministrator: '💡 This channel is not bound to a workspace. A workspace administrator can run `/project bind` to create and bind the project channel.',
172
198
  unboundNoticeMember: '💡 This channel is not bound to a workspace; ask a workspace administrator to run `/project bind`.',
173
199
  interruptedMarker: '*(interrupted)*',
200
+ // ── /model show / select ─────────────────────────────────────────────
201
+ modelNeedsThread: '⚠️ /model needs a thread bound to a Session (mention the bot in a project channel first).',
202
+ modelShowUnavailable: '⚠️ The model catalog is temporarily unavailable; try again later.',
203
+ modelShowHeader: (sel, groups) => `**Current model:** \`${sel}\`\n**Available providers:** ${String(groups)}`,
204
+ modelShowReasoning: effort => `\n**Reasoning effort:** \`${effort}\``,
205
+ modelShowNotRoutable: '\n⚠️ The current provider is not serving requests right now.',
206
+ modelShowFailures: names => `\n⚠️ Providers failing catalog lookup: ${names}`,
207
+ modelShowNoGroups: '\n(no providers currently advertise models)',
208
+ modelSelectOperatorOnly: '⚠️ Only Host operators can switch the model (it switches this Session and updates the Host default).',
209
+ modelSelectNoModels: '⚠️ No providers currently advertise models; nothing to select.',
210
+ modelCascadeProviderHeader: (current, extra) => `Current model: \`${current}\`${extra}\nSelect a provider:`,
211
+ modelCascadeProviderPlaceholder: 'Select a provider',
212
+ modelCascadeModelHeader: provider => `Provider: **${provider}**\nSelect a model:`,
213
+ modelCascadeModelPlaceholder: 'Select a model',
214
+ modelCascadeReasoningHeader: model => `Model: **${model}**\nSelect a reasoning effort:`,
215
+ modelCascadeReasoningPlaceholder: 'Select a reasoning effort',
216
+ modelCascadeReasoningDefault: 'Provider default',
217
+ modelCascadeReasoningDefaultHint: 'Use the provider/default reasoning behavior',
218
+ modelCascadeTruncated: (shown, total) => `(${String(total)} total, showing the first ${String(shown)})`,
219
+ modelCascadeExpired: '⚠️ This selection expired; run /model select again.',
220
+ modelApplied: sel => `✅ Applied to this session: \`${sel}\`. DSH has also been asked to record it as the Host default.`,
221
+ modelSelectUnknown: '⚠️ The selection outcome is unknown (the request may not have landed); check /model show.',
222
+ modelSelectRejected: reason => `⚠️ DSH rejected the selection: ${reason}`,
223
+ modelNotInCatalog: "⚠️ That provider/model is not in this session's catalog.",
224
+ modelInvalidReasoning: '⚠️ That reasoning effort is not valid for this model.',
225
+ modelTypedParseFailed: '⚠️ The model must be `provider/model`, or left empty for the interactive cascade.',
174
226
  // ── approval / question cards ────────────────────────────────────────
175
227
  approvalRequired: label => `Approval required — ${label}`,
176
228
  };
package/lib/index.js CHANGED
@@ -10,7 +10,7 @@ import { createRestThreadPort } from './discord/thread-port.js';
10
10
  import { createComponentRegistry } from './discord/components.js';
11
11
  import { buildCommandRegistrations } from './discord/commands.js';
12
12
  import { startDiscordAdapter } from './compose.js';
13
- import { createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy } from './dsh/api-proxy-face.js';
13
+ import { createModelPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy } from './dsh/api-proxy-face.js';
14
14
  import { createApprovalStore } from './features/approval-store.js';
15
15
  import { createAskWiring } from './features/ask-wiring.js';
16
16
  import { sweepExpiredApprovals } from './features/approval-expiry.js';
@@ -225,6 +225,21 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
225
225
  return { stored: false, reason: `HTTP ${String(sent.status)}: response missing message id` };
226
226
  return { stored: true, messageId: sent.body.id };
227
227
  },
228
+ editMessage: async (channelId, messageId, payload) => {
229
+ const rest = await sharedRest();
230
+ if (rest === undefined)
231
+ return { stored: false, reason: 'rest unavailable' };
232
+ const patched = await rest.request('PATCH', `/channels/${channelId}/messages/${messageId}`, payload);
233
+ if (patched.outcome !== 'completed') {
234
+ return {
235
+ stored: false,
236
+ reason: patched.outcome === 'rejected'
237
+ ? `HTTP ${String(patched.status)} ${patched.error.message}`
238
+ : patched.reason,
239
+ };
240
+ }
241
+ return { stored: true };
242
+ },
228
243
  });
229
244
  const componentFollowUp = async (_interactionId, interactionToken, content) => {
230
245
  const rest = await sharedRest();
@@ -685,6 +700,8 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
685
700
  await channelTable.delete(bindChannelKey(guildId, channelId));
686
701
  rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
687
702
  },
703
+ model: createModelPort(apiProxy, { log: rpcLog }),
704
+ modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
688
705
  log: rpcLog,
689
706
  warn: (event, detail) => {
690
707
  emitLog(ctx, 'warn', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
package/lib/settings.d.ts CHANGED
@@ -23,6 +23,13 @@ export interface DiscordSettings {
23
23
  questionTimeoutMs: number;
24
24
  /** Task-thread auto-archive; Discord supports exactly these four values. */
25
25
  threadAutoArchiveMinutes: ThreadAutoArchiveMinutes;
26
+ /**
27
+ * Restrict /model select to the explicit Host-operator allowlist (the
28
+ * switch reaches the Host-wide default). Defaults to false — single-user
29
+ * deployments let any authorized member switch (16.42); set true to
30
+ * re-tighten to the operator allowlist.
31
+ */
32
+ modelSelectOperatorOnly: boolean;
26
33
  }
27
34
  /** The archive durations Discord's API accepts (minutes). */
28
35
  export type ThreadAutoArchiveMinutes = 60 | 1440 | 4320 | 10080;
package/lib/settings.js CHANGED
@@ -20,6 +20,7 @@ export const DEFAULT_DISCORD_SETTINGS = Object.freeze({
20
20
  approvalTimeoutMs: 10 * 60_000,
21
21
  questionTimeoutMs: 30 * 60_000,
22
22
  threadAutoArchiveMinutes: 1440,
23
+ modelSelectOperatorOnly: false,
23
24
  });
24
25
  const discordIdList = z.array(z.string()).default([]);
25
26
  export const DiscordSettingsSchema = z.object({
@@ -41,6 +42,7 @@ export const DiscordSettingsSchema = z.object({
41
42
  typingIntervalMs: z.number().step(1).min(1_000).max(30_000).default(7_000),
42
43
  approvalTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(600_000),
43
44
  questionTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(1_800_000),
45
+ modelSelectOperatorOnly: z.boolean().default(false),
44
46
  });
45
47
  const DISCORD_SNOWFLAKE = /^\d{17,20}$/u;
46
48
  const ID_FIELDS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addozhang/dsh-discord",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Discord-first adapter for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "type": "module",