@addozhang/dsh-discord 0.2.1 → 0.2.3

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
@@ -11,12 +11,18 @@ A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai):
11
11
 
12
12
  This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection']`). It mounts the Discord Gateway, command surface, stream renderer, and the settings card onto a DSH web profile; session state lives in DSH and durable adapter bindings live in the profile's storage domain.
13
13
 
14
+ <p align="center">
15
+ <img src="docs/images/discord-task-lifecycle.jpg" alt="One task lifecycle: the @mention anchors a thread, tool activity rows stream while the answer edits in place, and the final answer lands as Markdown tables" width="720">
16
+ </p>
17
+
14
18
  ## Features
15
19
 
16
20
  - **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (the author's message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention.
17
21
  - **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
- - **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.
22
+ - **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 settled controls grey out in place.
23
+
19
24
  - **Session control** — `/steer`, `/stop`, `/queue list|remove` with turn-ownership checks; `/project bind|list|info` for guild↔workspace binding; `/guild forget` for operator cleanup.
25
+ - **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
26
  - **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
27
  - **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
28
  - **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 +67,10 @@ All keys live in the `dsh-discord` settings namespace and can be set either from
61
67
  | `enabled` | `false` | Adapter master switch; the card's Connect starts it once a token is stored. |
62
68
  | `allowedGuildIds` | `[]` | Guild allowlist. Anything outside is ignored with zero adapter or DSH calls. |
63
69
  | `memberUserIds` / `memberRoleIds` | `[]` | Member-level authorization inside an allowed guild. |
64
- | `administratorUserIds` / `administratorRoleIds` | `[]` | Workspace-administrator level (`/project bind`, preset changes). |
70
+ | `administratorUserIds` / `administratorRoleIds` | `[]` | Workspace-administrator level (`/project bind`). |
65
71
  | `deniedUserIds` / `deniedRoleIds` | `[]` | Deny entries; they win over every grant above. |
66
- | `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`). |
72
+ | `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`; `/model select` when `modelSelectOperatorOnly` is enabled). |
73
+ | `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
74
  | `defaultVerbosity` | `essential-tools` | Tool-activity row granularity: `text-only`, `essential-tools`, or `full-tools`. |
68
75
  | `language` | `auto` | Bot-visible copy language: `auto` follows the DSH language preference (non-Chinese renders English), or pin `zh`/`en`. |
69
76
  | `streamUpdateIntervalMs` | `800` | Coalescing budget for stream edits (250–10000). |
@@ -80,13 +87,18 @@ dsh-discord:
80
87
 
81
88
  The settings card exposes the three high-frequency fields (guild allowlist, auto-archive, language) plus the connection and token surface; every other key is fully supported through `settings.yaml`. An invalid stored section preserves the last known-good configuration.
82
89
 
90
+ <p align="center">
91
+ <img src="docs/images/settings-card.png" alt="The Discord settings card: connection, guild allowlist, auto-archive, bot language" width="560">
92
+ </p>
93
+
83
94
  ## Setup
84
95
 
85
96
  1. Invite the bot to your guild with at least: View Channels, **Manage Channels** (the adapter provisions its category and workspace home channels), Send Messages, Create Public Threads, Send Messages in Threads, Attach Files, Read Message History.
86
97
  2. Boot the profile and open the web UI.
87
98
  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
99
  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.
100
+ 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.
101
+ 6. Pick the bot language and mention the bot in a bound channel to start a session.
90
102
 
91
103
  ## Commands
92
104
 
@@ -96,9 +108,9 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
96
108
  | `/project list` / `info` | any channel | list workspaces / inspect this channel's binding |
97
109
  | `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
98
110
  | `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
111
+ | `/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
112
  | `/guild forget` | any channel | operator-only removal of adapter records |
100
113
 
101
-
102
114
  ## Design notes
103
115
 
104
116
  - 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.
@@ -111,7 +123,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
111
123
  ## Known Limitations and Deferred Work
112
124
 
113
125
  - **`/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.
114
- - **`/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).
126
+ - **`/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).
115
127
  - **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
116
128
  - **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.
117
129
  - **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.
@@ -120,7 +132,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
120
132
 
121
133
  ```sh
122
134
  pnpm install --ignore-scripts
123
- pnpm test # 642 tests incl. gateway/REST twin E2E
135
+ pnpm test # 650 tests incl. gateway/REST twin E2E
124
136
  pnpm typecheck
125
137
  pnpm lint
126
138
  pnpm build # lib + client bundle
package/README.zh.md CHANGED
@@ -11,12 +11,18 @@
11
11
 
12
12
  本插件为 function/namespace 插件(`inject: ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection']`):将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile;会话状态保存在 DSH,适配器的持久绑定保存在 profile 的存储域中。
13
13
 
14
+ <p align="center">
15
+ <img src="docs/images/discord-task-lifecycle.jpg" alt="一次任务的完整生命周期:@提及锚定线程、工具活动行随回答流式编辑、最终以 Markdown 表格落地" width="720">
16
+ </p>
17
+
14
18
  ## 功能
15
19
 
16
20
  - **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。
17
21
  - **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
18
- - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,远端决议自动退役控件。
22
+ - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
23
+
19
24
  - **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 管理 Guild↔工作区绑定;`/guild forget` 供操作员清理。
25
+ - **模型切换** — `/model show` 读取会话的实时模型目录(当前选择、可服务状态、目录分组);`/model select` 走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放,可通过设置收紧为仅 Host 操作员),也可直接填写 `provider/model` 应用。
20
26
  - **设置卡片** — Token 引导(粘贴 + 连接;存入 Host 凭据服务,绝不写入设置或日志)、连接/断开、服务器白名单、线程自动归档、Bot 语言。
21
27
  - **双语文案** — 所有 Discord 可见文案提供中英双语;Bot 语言默认跟随 DSH 语言偏好,也可从卡片固定。
22
28
  - **安全设计** — 显式服务器白名单内的 deny-first 授权、每条 wire 请求携带 `allowed_mentions` 并做字节级提及中和、至多一次的 DSH 提交与保留 unknown 的对账、重启后持久的绑定,以及 READY 扫描:被删的 category/控制频道会重建,被删的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
@@ -61,9 +67,10 @@ dsh plugin --profile web rm @addozhang/dsh-discord # 卸载;先执行 /guild
61
67
  | `enabled` | `false` | 适配器总开关;卡片 Connect 在 Token 存入后启动。 |
62
68
  | `allowedGuildIds` | `[]` | 服务器白名单。白名单之外零响应、零 DSH 调用。 |
63
69
  | `memberUserIds` / `memberRoleIds` | `[]` | 白名单 Guild 内的成员级授权。 |
64
- | `administratorUserIds` / `administratorRoleIds` | `[]` | 工作区管理员级(`/project bind`、预设修改)。 |
70
+ | `administratorUserIds` / `administratorRoleIds` | `[]` | 工作区管理员级(`/project bind`)。 |
65
71
  | `deniedUserIds` / `deniedRoleIds` | `[]` | 拒绝名单;优先级高于上述一切授权。 |
66
- | `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`)。 |
72
+ | `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`;启用 `modelSelectOperatorOnly` 后也包括 `/model select`)。 |
73
+ | `modelSelectOperatorOnly` | `false` | 将 `/model select` 限制为 Host 操作员(仅 `settings.yaml`,卡片不展示)。默认 `false`:任何授权成员均可切换,且切换仍会更新 Host 默认。 |
67
74
  | `defaultVerbosity` | `essential-tools` | 工具活动行粒度:`text-only`、`essential-tools`、`full-tools`。 |
68
75
  | `language` | `auto` | Bot 可见文案语言:`auto` 跟随 DSH 语言偏好(非中文渲染英文),或固定 `zh`/`en`。 |
69
76
  | `streamUpdateIntervalMs` | `800` | 流式编辑合并间隔(250–10000)。 |
@@ -80,13 +87,18 @@ dsh-discord:
80
87
 
81
88
  设置卡片暴露三个高频项(服务器白名单、自动归档、语言)以及连接与 Token 面;其余键完全支持通过 `settings.yaml` 配置。非法的已存配置会保留最近一次有效配置。
82
89
 
90
+ <p align="center">
91
+ <img src="docs/images/settings-card.png" alt="Discord 设置卡片:连接状态、服务器白名单、归档、语言" width="560">
92
+ </p>
93
+
83
94
  ## 初始设置
84
95
 
85
96
  1. 以至少以下权限把 Bot 邀请到你的服务器:查看频道、**管理频道**(适配器要创建自己的分类和工作区频道)、发送消息、创建公共线程、在线程中发送消息、上传文件、读取消息历史。
86
97
  2. 启动 profile 并打开 Web 界面。
87
98
  3. 在 **Settings → Discord** 中粘贴 Bot Token(开发者门户 → 你的应用 → Bot → Reset Token)并点击 **Connect**。Token 由 Host 凭据服务保存——绝不写入设置、日志或客户端。
88
99
  4. 填写 **允许的服务器**(服务器 ID 获取方式:Discord 开发者模式 → 右键服务器 → 复制服务器 ID)。白名单之外的一切都会被忽略。
89
- 5. 选择 Bot 语言,然后在已绑定的频道 @机器人 开始会话。
100
+ 5. `/model select` 默认对任何授权成员开放(单人自用部署)。如需限制为 Host 操作员:在 `hostOperatorUserIds` 中加入其用户 ID,并把 `modelSelectOperatorOnly` 设为 `true`(`settings.yaml`)。`/guild forget` 始终需要 Host 操作员。
101
+ 6. 选择 Bot 语言,然后在已绑定的频道 @机器人 开始会话。
90
102
 
91
103
  ## 命令
92
104
 
@@ -96,6 +108,7 @@ dsh-discord:
96
108
  | `/project list` / `info` | 任意频道 | 列出工作区 / 查看当前频道绑定 |
97
109
  | `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
98
110
  | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
111
+ | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
99
112
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
100
113
 
101
114
 
@@ -111,7 +124,7 @@ dsh-discord:
111
124
  ## 已知限制与推迟项
112
125
 
113
126
  - **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
114
- - **`/model` 已注册但尚未接线** — 其控制模块(模型目录与受控选择)已实现并通过单元测试;交互式 provider → 模型 → thinking/reasoning 级联将随路由接线里程碑落地。`/preset`、`/skill`、`/host` 保持注销状态,同一次接线时回归(`/preset` 的会话线程守卫一并处理)。
127
+ - **`/preset`、`/skill`、`/host` 保持注销状态** — 控制模块已实现并通过单元测试,待路由接线时回归(`/preset` 的会话线程守卫一并处理)。
115
128
  - **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
116
129
  - **经 Kimaki 对齐后有意推迟**:reconcile-interactions 接线、ask 等待期暂停 typing、fail-closed 绑定/会话属主 store 接线、凭据轮换监听。
117
130
  - **已知张力**:流式编辑 250ms 下限与高负载下 Discord 编辑预算的冲突(429 自愈),以及 typing 缺少时长上限看门狗。
@@ -120,7 +133,7 @@ dsh-discord:
120
133
 
121
134
  ```sh
122
135
  pnpm install --ignore-scripts
123
- pnpm test # 642 tests incl. gateway/REST twin E2E
136
+ pnpm test # 650 tests incl. gateway/REST twin E2E
124
137
  pnpm typecheck
125
138
  pnpm lint
126
139
  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
+ }
@@ -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';
@@ -700,6 +700,8 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
700
700
  await channelTable.delete(bindChannelKey(guildId, channelId));
701
701
  rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
702
702
  },
703
+ model: createModelPort(apiProxy, { log: rpcLog }),
704
+ modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
703
705
  log: rpcLog,
704
706
  warn: (event, detail) => {
705
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.1",
3
+ "version": "0.2.3",
4
4
  "description": "Discord-first adapter for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "type": "module",