@addozhang/dsh-discord 0.5.0-rc.2 → 0.5.0-rc.4
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 +19 -3
- package/README.zh.md +19 -3
- package/cordis.patch.yml +1 -1
- package/lib/client/card-controller.d.ts +2 -2
- package/lib/client/card-form.d.ts +2 -2
- package/lib/client.js +5 -5
- package/lib/discord/commands.js +6 -0
- package/lib/dsh/host-face.d.ts +73 -0
- package/lib/dsh/host-face.js +124 -1
- package/lib/features/interaction-router.d.ts +8 -0
- package/lib/features/interaction-router.js +232 -2
- package/lib/features/permission-control.d.ts +88 -0
- package/lib/features/permission-control.js +59 -0
- package/lib/i18n.d.ts +26 -0
- package/lib/i18n.js +56 -0
- package/lib/index.d.ts +46 -2
- package/lib/index.js +29 -9
- package/lib/settings.d.ts +48 -2
- package/lib/settings.js +37 -25
- package/lib/startup.d.ts +1 -1
- package/lib/startup.js +16 -7
- package/lib/stream/live.d.ts +7 -0
- package/lib/stream/live.js +21 -0
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -23,18 +23,32 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
23
23
|
- **Approvals & questions** — DSH asks become 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.
|
|
24
24
|
- **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
|
|
25
25
|
- **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
|
|
26
|
+
- **Permission switching** — `/permission show` / `set` read and switch the session's permission preset (bundled sandbox + approval tiers) through the Host's own `/permission` command path, inheriting its journal audit pair; `danger-full-access` requires a button confirmation.
|
|
26
27
|
- **Settings card, bilingual out of the box** — token onboarding and connect/disconnect (stored in the Host credential service, never in settings or logs), guild allowlist, auto-archive, and language. Every Discord-visible string ships in Chinese and English; the bot follows the DSH language preference or a pinned choice.
|
|
27
28
|
- **Hardened by design** — deny-first authorization inside an explicit guild allowlist. Mentions are suppressed twice: `allowed_mentions` on every request, plus byte-level neutralization of the wire body. DSH submission is at-most-once with unknown-preserving reconciliation — an ambiguous delivery is never blindly resent. Bindings survive restarts, and the READY sweep rebuilds deleted category/control channels while treating a deleted workspace channel as user intent (the mapping retires; the workspace stays bindable).
|
|
28
29
|
|
|
29
30
|
## Requirements
|
|
30
31
|
|
|
31
|
-
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh)
|
|
32
|
+
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh), running a web profile. Match the dsh and adapter channels — see [Versions](#versions).
|
|
32
33
|
- Node.js `^22.19.0 || >=24`
|
|
33
34
|
- A Discord application with a bot user and the **MESSAGE CONTENT** privileged intent enabled (Developer Portal → your application → Bot → Privileged Gateway Intents)
|
|
34
35
|
|
|
36
|
+
## Versions
|
|
37
|
+
|
|
38
|
+
Two channels ship in parallel, each pinned to the dsh host surface it was verified against. Pick the adapter and the dsh CLI from the same row:
|
|
39
|
+
|
|
40
|
+
| Channel | Adapter | dsh CLI | Install |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| **stable** | `0.4.x` — npm `latest` | `0.1.5` — npm `latest` | `dsh plugin --profile web add @addozhang/dsh-discord` |
|
|
43
|
+
| **next** | `>=0.5.0-rc.4` — npm `next` | `0.1.7-alpha` — npm `alpha` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
|
|
44
|
+
|
|
45
|
+
- **The rows do not mix.** Adapter `>=0.5.0-rc.4` speaks the 0.1.7 profile-backed settings forms and controller services and will not load on older hosts (the startup probe refuses with one actionable diagnostic); `0.5.0-rc.1`–`rc.3` target dsh `0.1.6-alpha`; adapter `0.4.x` talks to the former `apiProxy` surface, which `0.1.6-alpha` removed. A mismatched pair fails at startup.
|
|
46
|
+
- To switch the dsh CLI line: `npm install -g @deepseek-ai/dsh` (stable) or `npm install -g @deepseek-ai/dsh@alpha` (next).
|
|
47
|
+
- Right after a `next` release, the `@next` spec can resolve to a stale cached version. If the installed copy looks old, install by exact version instead (e.g. `@addozhang/dsh-discord@0.5.0-rc.4`) and verify the `version` field inside the profile's `node_modules`.
|
|
48
|
+
|
|
35
49
|
## Install
|
|
36
50
|
|
|
37
|
-
Install with the dsh CLI — it installs the package into the profile and registers the bundle for you:
|
|
51
|
+
Install with the dsh CLI — it installs the package into the profile and registers the bundle for you. The stable channel is shown below; for the next channel use the `@next` command from the [Versions](#versions) table (`up` / `rm` take the same spec):
|
|
38
52
|
|
|
39
53
|
```sh
|
|
40
54
|
dsh plugin --profile web add @addozhang/dsh-discord
|
|
@@ -70,6 +84,7 @@ All keys live in the `dsh-discord` settings namespace and can be set either from
|
|
|
70
84
|
| `deniedUserIds` / `deniedRoleIds` | `[]` | Deny entries; they win over every grant above. |
|
|
71
85
|
| `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`; `/model select` when `modelSelectOperatorOnly` is enabled). |
|
|
72
86
|
| `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. |
|
|
87
|
+
| `permissionSelectOperatorOnly` | `true` | Restrict `/permission set` to Host operators (`settings.yaml` only). Default `true` — stricter than `/model` because `danger-full-access` disables both sandbox and approval; loosen to let any authorized member switch. |
|
|
73
88
|
| `defaultVerbosity` | `essential-tools` | Tool-activity row granularity: `text-only`, `essential-tools`, or `full-tools`. |
|
|
74
89
|
| `language` | `auto` | Bot-visible copy language: `auto` follows the DSH language preference (non-Chinese renders English), or pin `zh`/`en`. |
|
|
75
90
|
| `streamUpdateIntervalMs` | `800` | Coalescing budget for stream edits (250–10000). |
|
|
@@ -110,10 +125,11 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
110
125
|
| `/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) |
|
|
111
126
|
| `/session resume` | project channel | pick one of this workspace's past sessions (autocomplete: title and age, newest first) and resume it into a new thread of this channel; blank, already-bound, subagent, and archived sessions are never offered. The thread renders the session's history — answers and tool summaries plus the user's inputs as quoted echo lines (plugin/system injections are never echoed). After an adapter restart, threads catch up only what they missed: a persisted render watermark suppresses already-delivered history, and bound sessions re-subscribe on reconnect so turns prompted from the web UI still land in their thread |
|
|
112
127
|
| `/guild forget` | any channel | operator-only removal of adapter records |
|
|
128
|
+
| `/permission show` / `set` | session thread | read or switch the session's permission preset (view only / workspace edit / full access — bundled sandbox + approval tiers, catalog read live from the Host). `set danger-full-access` asks for a button confirmation first; presets autocomplete; Host operators only by default (`permissionSelectOperatorOnly` can loosen it). Every switch — from Discord or the web UI — renders one system line in the thread |
|
|
113
129
|
|
|
114
130
|
## Design notes
|
|
115
131
|
|
|
116
|
-
- The adapter is a function/namespace plugin (`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`) that mounts the Discord Gateway, command surface, stream renderer, and the settings card onto the DSH web profile.
|
|
132
|
+
- The adapter is a function/namespace plugin (`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection', 'commands', 'permissionPresets']`) that mounts the Discord Gateway, command surface, stream renderer, and the settings card onto the DSH web profile.
|
|
117
133
|
- 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.
|
|
118
134
|
- The publish workflow authenticates to npm via trusted publishing (OIDC) — no publish token is stored anywhere.
|
|
119
135
|
- The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
|
package/README.zh.md
CHANGED
|
@@ -23,18 +23,32 @@
|
|
|
23
23
|
- **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
24
24
|
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
|
|
25
25
|
- **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
|
|
26
|
+
- **权限切换** — `/permission show` / `set` 读写会话权限预设(沙箱 + 审批捆绑档位),走宿主自己的 `/permission` 命令路径,journal 自动记审计;`danger-full-access` 需按钮二次确认。
|
|
26
27
|
- **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
27
28
|
- **安全设计** — 显式服务器白名单内的 deny-first 授权。提及抑制双保险:每条请求携带 `allowed_mentions`,外加 wire body 的字节级提及中和。DSH 提交至多一次,并做保留 unknown 的对账——结果不明的投递绝不盲目重发。绑定重启后持久;READY 扫描会重建被删除的 category/控制频道,而把被删除的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
|
|
28
29
|
|
|
29
30
|
## 环境要求
|
|
30
31
|
|
|
31
|
-
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh)
|
|
32
|
+
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh)(web profile)。dsh 与适配器的通道需匹配——见[版本支持](#版本支持)。
|
|
32
33
|
- Node.js `^22.19.0 || >=24`
|
|
33
34
|
- 一个 Discord 应用(含 Bot 用户),并在开发者门户启用 **MESSAGE CONTENT** 特权 intent(Developer Portal → 你的应用 → Bot → Privileged Gateway Intents)
|
|
34
35
|
|
|
36
|
+
## 版本支持
|
|
37
|
+
|
|
38
|
+
两条通道并行发布,各自钉在经过真机验证的 dsh 宿主面上。适配器与 dsh CLI 请取同一行:
|
|
39
|
+
|
|
40
|
+
| 通道 | 适配器 | dsh CLI | 安装命令 |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| **stable** | `0.4.x` — npm `latest` | `0.1.5` — npm `latest` | `dsh plugin --profile web add @addozhang/dsh-discord` |
|
|
43
|
+
| **next** | `>=0.5.0-rc.4` — npm `next` | `0.1.7-alpha` — npm `alpha` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
|
|
44
|
+
|
|
45
|
+
- **两行不可混搭。** 适配器 `>=0.5.0-rc.4` 使用 0.1.7 的 profile-backed settings forms 与控制器服务,在更旧的宿主上无法加载(startup 探针会给出一条可操作的诊断后拒绝激活);`0.5.0-rc.1`–`rc.3` 对应 dsh `0.1.6-alpha`;适配器 `0.4.x` 使用旧版 `apiProxy` 宿主面,而该面已在 `0.1.6-alpha` 中移除。错配会在启动时失败。
|
|
46
|
+
- 切换 dsh CLI 通道:`npm install -g @deepseek-ai/dsh`(stable)或 `npm install -g @deepseek-ai/dsh@alpha`(next 线)。
|
|
47
|
+
- `next` 刚发版后,`@next` 规格可能解析到过期的缓存版本。若装到的版本偏旧,请改用精确版本号安装(如 `@addozhang/dsh-discord@0.5.0-rc.4`),并核对 profile `node_modules` 中的 `version` 字段。
|
|
48
|
+
|
|
35
49
|
## 安装
|
|
36
50
|
|
|
37
|
-
使用 dsh CLI 安装——它会自动把包装进 profile 并注册 bundle
|
|
51
|
+
使用 dsh CLI 安装——它会自动把包装进 profile 并注册 bundle。下面是 stable 通道;next 通道使用[版本支持](#版本支持)表中的 `@next` 命令(`up` / `rm` 同规格):
|
|
38
52
|
|
|
39
53
|
```sh
|
|
40
54
|
dsh plugin --profile web add @addozhang/dsh-discord
|
|
@@ -70,6 +84,7 @@ dsh plugin --profile web rm @addozhang/dsh-discord # 卸载;先执行 /guild
|
|
|
70
84
|
| `deniedUserIds` / `deniedRoleIds` | `[]` | 拒绝名单;优先级高于上述一切授权。 |
|
|
71
85
|
| `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`;启用 `modelSelectOperatorOnly` 后也包括 `/model select`)。 |
|
|
72
86
|
| `modelSelectOperatorOnly` | `false` | 将 `/model select` 限制为 Host 操作员(仅 `settings.yaml`,卡片不展示)。默认 `false`:任何授权成员均可切换,且切换仍会更新 Host 默认。 |
|
|
87
|
+
| `permissionSelectOperatorOnly` | `true` | 将 `/permission set` 限制为 Host 操作员(仅 `settings.yaml`)。默认 `true`——比 `/model` 更严,因为「完全权限」会同时解除沙箱与审批;可放开为任何授权成员可切换。 |
|
|
73
88
|
| `defaultVerbosity` | `essential-tools` | 工具活动行粒度:`text-only`、`essential-tools`、`full-tools`。 |
|
|
74
89
|
| `language` | `auto` | Bot 可见文案语言:`auto` 跟随 DSH 语言偏好(非中文渲染英文),或固定 `zh`/`en`。 |
|
|
75
90
|
| `streamUpdateIntervalMs` | `800` | 流式编辑合并间隔(250–10000)。 |
|
|
@@ -110,11 +125,12 @@ dsh-discord:
|
|
|
110
125
|
| `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
|
|
111
126
|
| `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现。线程会渲染会话历史——回答与工具摘要之外,用户输入以引用行回显(插件/系统注入永不回显)。适配器重启后线程只补齐错过部分:持久化的渲染水位抑制已投递过的历史,已绑定会话在重连时自动重订阅,因此从 web UI 发起的回合也会落进对应线程 |
|
|
112
127
|
| `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
|
|
128
|
+
| `/permission show` / `set` | 会话线程 | 查看或切换会话权限预设(仅可查看 / 工作区内修改 / 完全权限——沙箱与审批的捆绑档位,目录取自宿主实时配置)。`set danger-full-access` 先要求按钮确认;预设自动补全;默认仅 Host 操作员可切换(`permissionSelectOperatorOnly` 可放开)。任何入口(含 web UI)的切换都会在线程渲染一行系统消息 |
|
|
113
129
|
|
|
114
130
|
|
|
115
131
|
## 设计说明
|
|
116
132
|
|
|
117
|
-
- 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
|
|
133
|
+
- 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection', 'commands', 'permissionPresets']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
|
|
118
134
|
- 设置卡片是首次使用的引导面:Token 输入通过插件管理通道写入凭据服务的 `DSH_DISCORD_BOT_TOKEN` 引用,然后触发启动链。断开连接保留凭据;留空重连直接使用已存 Token。
|
|
119
135
|
- 发布工作流通过 npm trusted publishing (OIDC) 认证——任何地方都不保存发布凭证。
|
|
120
136
|
- 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
|
package/cordis.patch.yml
CHANGED
|
@@ -11,4 +11,4 @@
|
|
|
11
11
|
- insert:
|
|
12
12
|
- id: dsh-discord
|
|
13
13
|
name: '@addozhang/dsh-discord'
|
|
14
|
-
inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection]
|
|
14
|
+
inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection, commands, permissionPresets]
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* hands to the component: the edit actions plus the `discordCard` snapshot
|
|
5
5
|
* store the framework binds as the `useDiscordCard` selector hook.
|
|
6
6
|
*/
|
|
7
|
-
import type {
|
|
7
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
8
8
|
import type { AdapterStatusView } from '../features/adapter-status.js';
|
|
9
9
|
import type { DiscordSettings } from '../settings.js';
|
|
10
10
|
import { type CardManagement, type DiscordCardFace, type DiscordCardState } from './card-form.js';
|
|
11
11
|
export declare class DiscordCardController {
|
|
12
12
|
private readonly form;
|
|
13
13
|
private management;
|
|
14
|
-
constructor(
|
|
14
|
+
constructor(form: ConfigForm<DiscordSettings>);
|
|
15
15
|
/** Attach the token write/connect path once the RPC face is known. */
|
|
16
16
|
setManagement(management: CardManagement): void;
|
|
17
17
|
/** Publish the Host's sanitized connection status onto the card. */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* is not a list of Discord IDs, which blocks the save rather than dropping it.
|
|
8
8
|
*/
|
|
9
9
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
10
|
-
import type {
|
|
10
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
11
11
|
import type { AdapterStatusView } from '../features/adapter-status.js';
|
|
12
12
|
import type { DiscordSettings } from '../settings.js';
|
|
13
13
|
import { type AdapterStatusPresentation } from './settings-model.js';
|
|
@@ -88,7 +88,7 @@ export declare class DiscordCardForm {
|
|
|
88
88
|
private saving;
|
|
89
89
|
private failed;
|
|
90
90
|
private status;
|
|
91
|
-
constructor(scope:
|
|
91
|
+
constructor(scope: ConfigForm<DiscordSettings>);
|
|
92
92
|
/**
|
|
93
93
|
* Publish the Host's sanitized status view. Edits, saves, and failures
|
|
94
94
|
* never touch it — connection state is the Host's report, not form state.
|
package/lib/client.js
CHANGED
|
@@ -1022,8 +1022,8 @@ var DiscordCardForm = class {
|
|
|
1022
1022
|
var DiscordCardController = class {
|
|
1023
1023
|
form;
|
|
1024
1024
|
management;
|
|
1025
|
-
constructor(
|
|
1026
|
-
this.form = new DiscordCardForm(
|
|
1025
|
+
constructor(form) {
|
|
1026
|
+
this.form = new DiscordCardForm(form);
|
|
1027
1027
|
}
|
|
1028
1028
|
/** Attach the token write/connect path once the RPC face is known. */
|
|
1029
1029
|
setManagement(management) {
|
|
@@ -1469,7 +1469,7 @@ function DiscordSettingsCard(props) {
|
|
|
1469
1469
|
|
|
1470
1470
|
// src/client/index.ts
|
|
1471
1471
|
var name = "dsh-discord-client";
|
|
1472
|
-
var inject = ["
|
|
1472
|
+
var inject = ["configForms", "slots", "connection", "locale"];
|
|
1473
1473
|
var STATUS_POLL_MS = 3e4;
|
|
1474
1474
|
function isStatusView(value) {
|
|
1475
1475
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -1477,8 +1477,8 @@ function isStatusView(value) {
|
|
|
1477
1477
|
return typeof candidate["token"] === "string" && typeof candidate["connection"] === "string";
|
|
1478
1478
|
}
|
|
1479
1479
|
function apply(ctx) {
|
|
1480
|
-
const
|
|
1481
|
-
const controller = new DiscordCardController(
|
|
1480
|
+
const form = ctx.configForms.get(DISCORD_SETTINGS_NAMESPACE);
|
|
1481
|
+
const controller = new DiscordCardController(form);
|
|
1482
1482
|
ctx.locale.register("dsh-discord", {
|
|
1483
1483
|
en: DISCORD_CARD_LOCALE_EN,
|
|
1484
1484
|
zh: DISCORD_CARD_LOCALE_ZH
|
package/lib/discord/commands.js
CHANGED
|
@@ -44,6 +44,12 @@ export const MILESTONE_ONE_COMMANDS = [
|
|
|
44
44
|
grouped('guild', 'Guild-scoped adapter operations', [
|
|
45
45
|
{ name: 'forget' },
|
|
46
46
|
]),
|
|
47
|
+
grouped('permission', 'Show or switch the session permission preset', [
|
|
48
|
+
{ name: 'show' },
|
|
49
|
+
// The preset autocompletes from the live Host catalog (16.61);
|
|
50
|
+
// danger-full-access goes through a button confirmation first.
|
|
51
|
+
{ name: 'set', options: [{ name: 'preset', required: true, autocomplete: true }] },
|
|
52
|
+
]),
|
|
47
53
|
];
|
|
48
54
|
const DISCORD_APPLICATION_COMMAND = 1;
|
|
49
55
|
const DISCORD_SUBCOMMAND = 1;
|
package/lib/dsh/host-face.d.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { ProjectListPort } from '../features/project-list.js';
|
|
20
20
|
import type { WorkspaceResolver } from '../features/project-bind.js';
|
|
21
21
|
import type { DshModelPort } from '../features/model-control.js';
|
|
22
|
+
import type { DshPermissionPort } from '../features/permission-control.js';
|
|
22
23
|
/** The workspace rows the catalog port needs (subset of WorkspaceView). */
|
|
23
24
|
export interface WorkspaceCatalogEntry {
|
|
24
25
|
workspaceId: string;
|
|
@@ -93,6 +94,18 @@ export interface DshSessionControllerFace {
|
|
|
93
94
|
}, signal: AbortSignal): AsyncIterable<unknown>;
|
|
94
95
|
/** Host-wide live state stream (queue/jobs/projection frames over one baseline). */
|
|
95
96
|
control(signal: AbortSignal): AsyncIterable<unknown>;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve or resume one Session's live Agent (0.1.6 probe-verified shape
|
|
99
|
+
* `{agent} | {error}`); the agent object stays opaque — it exists only to
|
|
100
|
+
* feed `commands.execute` for the Host-native `/permission` command path.
|
|
101
|
+
*/
|
|
102
|
+
resolveAgent(sessionId: string): Promise<{
|
|
103
|
+
agent?: unknown;
|
|
104
|
+
error?: {
|
|
105
|
+
code?: string;
|
|
106
|
+
message?: string;
|
|
107
|
+
};
|
|
108
|
+
}>;
|
|
96
109
|
}
|
|
97
110
|
/**
|
|
98
111
|
* Narrow slice of the 0.1.6 `workspaceController` cordis service. The
|
|
@@ -118,6 +131,30 @@ export interface DshSessionQueryFace {
|
|
|
118
131
|
};
|
|
119
132
|
} & Partial<AsyncDisposable>>;
|
|
120
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Narrow slice of the `commands` cordis service (dsh-commands). `execute`
|
|
136
|
+
* runs one registered slash command against an exact agent WITHOUT sending
|
|
137
|
+
* it to the model, logging the command/run + command/done journal pair; the
|
|
138
|
+
* signal is owned by the caller. Signatures 2026-09-19 real-host verified.
|
|
139
|
+
*/
|
|
140
|
+
export interface DshCommandsFace {
|
|
141
|
+
execute(agent: unknown, line: string, submittedAttachments: readonly unknown[], signal: AbortSignal): Promise<{
|
|
142
|
+
commandId: string;
|
|
143
|
+
result?: {
|
|
144
|
+
kind?: unknown;
|
|
145
|
+
text?: unknown;
|
|
146
|
+
};
|
|
147
|
+
} | undefined>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Narrow slice of the `permissionPresets` cordis service
|
|
151
|
+
* (dsh-permission-presets): the deployment's configured preset table.
|
|
152
|
+
*/
|
|
153
|
+
export interface DshPermissionPresetsFace {
|
|
154
|
+
catalog(): Promise<{
|
|
155
|
+
options?: unknown;
|
|
156
|
+
}>;
|
|
157
|
+
}
|
|
121
158
|
/** The host-generation model catalog (`session/modelCatalog`, host-wide). */
|
|
122
159
|
export interface ModelCatalogWireShape {
|
|
123
160
|
default: ModelSelectionShape;
|
|
@@ -134,6 +171,8 @@ export interface DshHostFace {
|
|
|
134
171
|
session: DshSessionControllerFace;
|
|
135
172
|
workspace: DshWorkspaceControllerFace;
|
|
136
173
|
sessionQuery: DshSessionQueryFace;
|
|
174
|
+
commands: DshCommandsFace;
|
|
175
|
+
permissionPresets: DshPermissionPresetsFace;
|
|
137
176
|
}
|
|
138
177
|
/**
|
|
139
178
|
* Resolve the 0.1.6 controller services off the Cordis context. Throws one
|
|
@@ -416,3 +455,37 @@ export declare function selectSessionModel(dsh: DshHostFace, request: {
|
|
|
416
455
|
* reasons about.
|
|
417
456
|
*/
|
|
418
457
|
export declare function createModelPort(dsh: DshHostFace, options?: ApiProxyFaceOptions): DshModelPort;
|
|
458
|
+
/** One normalized preset row off the catalog's `options` array. */
|
|
459
|
+
export interface PermissionCatalogEntry {
|
|
460
|
+
value: string;
|
|
461
|
+
name?: string;
|
|
462
|
+
}
|
|
463
|
+
export type PermissionCatalogOutcome = {
|
|
464
|
+
outcome: 'completed';
|
|
465
|
+
entries: PermissionCatalogEntry[];
|
|
466
|
+
} | {
|
|
467
|
+
outcome: 'failed';
|
|
468
|
+
};
|
|
469
|
+
/** Read the deployment's preset table (`permissionPresets.catalog`). */
|
|
470
|
+
export declare function permissionCatalog(dsh: DshHostFace, options?: ApiProxyFaceOptions): Promise<PermissionCatalogOutcome>;
|
|
471
|
+
export type SwitchSessionPermissionOutcome = {
|
|
472
|
+
outcome: 'completed';
|
|
473
|
+
preset: string;
|
|
474
|
+
} | {
|
|
475
|
+
outcome: 'rejected';
|
|
476
|
+
reason: string;
|
|
477
|
+
} | {
|
|
478
|
+
outcome: 'unknown';
|
|
479
|
+
};
|
|
480
|
+
/**
|
|
481
|
+
* Switch the session's preset through the Host's OWN `/permission` command
|
|
482
|
+
* path (resolveAgent → commands.execute) — the same entry the web UI uses,
|
|
483
|
+
* inheriting its journal audit pair and admission checks. An execute throw
|
|
484
|
+
* is unknown (the command may have run): callers never retry blindly.
|
|
485
|
+
*/
|
|
486
|
+
export declare function switchSessionPermission(dsh: DshHostFace, request: {
|
|
487
|
+
sessionId: string;
|
|
488
|
+
preset: string;
|
|
489
|
+
}, options?: ApiProxyFaceOptions): Promise<SwitchSessionPermissionOutcome>;
|
|
490
|
+
/** The /permission surface over the controller, command, and preset services. */
|
|
491
|
+
export declare function createPermissionPort(dsh: DshHostFace, options?: ApiProxyFaceOptions): DshPermissionPort;
|
package/lib/dsh/host-face.js
CHANGED
|
@@ -24,7 +24,7 @@ import { parseWorkspaceReference } from '../policy/disclosure.js';
|
|
|
24
24
|
*/
|
|
25
25
|
export function resolveHostFace(ctx) {
|
|
26
26
|
const missing = [];
|
|
27
|
-
for (const name of ['sessionController', 'workspaceController', 'sessionQuery']) {
|
|
27
|
+
for (const name of ['sessionController', 'workspaceController', 'sessionQuery', 'commands', 'permissionPresets']) {
|
|
28
28
|
if (ctx.get(name) === undefined || ctx.get(name) === null)
|
|
29
29
|
missing.push(name);
|
|
30
30
|
}
|
|
@@ -35,6 +35,8 @@ export function resolveHostFace(ctx) {
|
|
|
35
35
|
session: ctx.get('sessionController'),
|
|
36
36
|
workspace: ctx.get('workspaceController'),
|
|
37
37
|
sessionQuery: ctx.get('sessionQuery'),
|
|
38
|
+
commands: ctx.get('commands'),
|
|
39
|
+
permissionPresets: ctx.get('permissionPresets'),
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
/** Raised when the Host did not answer within the bounded window. */
|
|
@@ -596,3 +598,124 @@ export function createModelPort(dsh, options = {}) {
|
|
|
596
598
|
selectModel: request => selectSessionModel(dsh, request, options),
|
|
597
599
|
};
|
|
598
600
|
}
|
|
601
|
+
/** Read the deployment's preset table (`permissionPresets.catalog`). */
|
|
602
|
+
export async function permissionCatalog(dsh, options = {}) {
|
|
603
|
+
const log = options.log;
|
|
604
|
+
try {
|
|
605
|
+
const catalog = await withRpcTimeout(dsh.permissionPresets.catalog(), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
|
|
606
|
+
if (!isRecord(catalog) || !Array.isArray(catalog['options'])) {
|
|
607
|
+
log?.('discord_permission_catalog_malformed', {});
|
|
608
|
+
return { outcome: 'failed' };
|
|
609
|
+
}
|
|
610
|
+
const entries = [];
|
|
611
|
+
for (const row of catalog['options']) {
|
|
612
|
+
if (!isRecord(row) || typeof row['value'] !== 'string')
|
|
613
|
+
continue;
|
|
614
|
+
entries.push({
|
|
615
|
+
value: row['value'],
|
|
616
|
+
...(typeof row['name'] === 'string' ? { name: row['name'] } : {}),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return { outcome: 'completed', entries };
|
|
620
|
+
}
|
|
621
|
+
catch (cause) {
|
|
622
|
+
if (cause instanceof RpcTimeoutError) {
|
|
623
|
+
log?.('discord_permission_catalog_timeout', {});
|
|
624
|
+
return { outcome: 'failed' };
|
|
625
|
+
}
|
|
626
|
+
log?.('discord_permission_catalog_failed', { cause: String(cause) });
|
|
627
|
+
return { outcome: 'failed' };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Read the session's current preset off the `permissions` projection view
|
|
632
|
+
* (`{currentValue}`, stateVersion 2). Any read failure resolves failed —
|
|
633
|
+
* show degrades to "current unknown", never a hard failure.
|
|
634
|
+
*/
|
|
635
|
+
async function readSessionPermission(dsh, sessionId, options) {
|
|
636
|
+
const log = options.log;
|
|
637
|
+
let observation;
|
|
638
|
+
try {
|
|
639
|
+
observation = await withRpcTimeout(dsh.sessionQuery.observeSession(sessionId), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
const projections = isRecord(observation) && isRecord(observation['projections'])
|
|
646
|
+
? observation['projections']
|
|
647
|
+
: undefined;
|
|
648
|
+
const values = projections !== undefined && isRecord(projections['values'])
|
|
649
|
+
? projections['values']
|
|
650
|
+
: undefined;
|
|
651
|
+
const permission = values !== undefined ? values['permissions'] : undefined;
|
|
652
|
+
const current = isRecord(permission) ? permission['currentValue'] : undefined;
|
|
653
|
+
return typeof current === 'string' ? current : undefined;
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
const dispose = observation?.[Symbol.asyncDispose]
|
|
657
|
+
?? observation?.dispose;
|
|
658
|
+
if (typeof dispose === 'function') {
|
|
659
|
+
try {
|
|
660
|
+
void dispose.call(observation);
|
|
661
|
+
}
|
|
662
|
+
catch (cause) {
|
|
663
|
+
log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Switch the session's preset through the Host's OWN `/permission` command
|
|
670
|
+
* path (resolveAgent → commands.execute) — the same entry the web UI uses,
|
|
671
|
+
* inheriting its journal audit pair and admission checks. An execute throw
|
|
672
|
+
* is unknown (the command may have run): callers never retry blindly.
|
|
673
|
+
*/
|
|
674
|
+
export async function switchSessionPermission(dsh, request, options = {}) {
|
|
675
|
+
const log = options.log;
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
try {
|
|
678
|
+
const resolved = await withRpcTimeout(dsh.session.resolveAgent(request.sessionId), options.timeoutMs ?? PROMPT_TIMEOUT_MS);
|
|
679
|
+
if (!isRecord(resolved) || resolved['agent'] === undefined) {
|
|
680
|
+
const message = isRecord(resolved['error']) && typeof resolved['error']['message'] === 'string'
|
|
681
|
+
? resolved['error']['message']
|
|
682
|
+
: 'session unavailable';
|
|
683
|
+
log?.('discord_permission_agent_unresolved', { sessionId: request.sessionId });
|
|
684
|
+
return { outcome: 'rejected', reason: message };
|
|
685
|
+
}
|
|
686
|
+
const agent = resolved['agent'];
|
|
687
|
+
const execution = await withRpcTimeout(dsh.commands.execute(agent, `/permission ${request.preset}`, [], controller.signal), options.timeoutMs ?? PROMPT_TIMEOUT_MS);
|
|
688
|
+
if (execution === undefined) {
|
|
689
|
+
// Unmatched command: this Host has no /permission registered.
|
|
690
|
+
log?.('discord_permission_command_missing', { sessionId: request.sessionId });
|
|
691
|
+
return { outcome: 'rejected', reason: 'the Host has no /permission command' };
|
|
692
|
+
}
|
|
693
|
+
const result = isRecord(execution) && isRecord(execution['result']) ? execution['result'] : undefined;
|
|
694
|
+
const kind = result !== undefined ? result['kind'] : undefined;
|
|
695
|
+
const text = result !== undefined && typeof result['text'] === 'string' ? result['text'] : '';
|
|
696
|
+
if (kind === 'success') {
|
|
697
|
+
return { outcome: 'completed', preset: request.preset };
|
|
698
|
+
}
|
|
699
|
+
log?.('discord_permission_command_error', { sessionId: request.sessionId, text });
|
|
700
|
+
return { outcome: 'rejected', reason: text === '' ? 'the Host rejected the switch' : text };
|
|
701
|
+
}
|
|
702
|
+
catch (cause) {
|
|
703
|
+
if (cause instanceof RpcTimeoutError) {
|
|
704
|
+
log?.('discord_permission_switch_timeout', { sessionId: request.sessionId });
|
|
705
|
+
return { outcome: 'unknown' };
|
|
706
|
+
}
|
|
707
|
+
log?.('discord_permission_switch_threw', { sessionId: request.sessionId, cause: String(cause) });
|
|
708
|
+
return { outcome: 'unknown' };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** The /permission surface over the controller, command, and preset services. */
|
|
712
|
+
export function createPermissionPort(dsh, options = {}) {
|
|
713
|
+
return {
|
|
714
|
+
catalog: () => permissionCatalog(dsh, options),
|
|
715
|
+
current: async (sessionId) => {
|
|
716
|
+
const preset = await readSessionPermission(dsh, sessionId, options);
|
|
717
|
+
return preset === undefined ? { outcome: 'failed' } : { outcome: 'completed', preset };
|
|
718
|
+
},
|
|
719
|
+
set: (sessionId, preset) => switchSessionPermission(dsh, { sessionId, preset }, options),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailO
|
|
|
15
15
|
import type { TurnTracker } from './turn-ownership.js';
|
|
16
16
|
import { type DshApprovalRespondPort } from './approval-routing.js';
|
|
17
17
|
import { type DshModelPort } from './model-control.js';
|
|
18
|
+
import { type DshPermissionPort } from './permission-control.js';
|
|
18
19
|
import type { QuestionInteractionOutcome } from './question-routing.js';
|
|
19
20
|
import type { ApprovalStore } from './approval-store.js';
|
|
20
21
|
import type { ChannelBinding } from '../state/records.js';
|
|
@@ -132,6 +133,13 @@ export interface InteractionRouterDeps {
|
|
|
132
133
|
* deployments flip this so any authorized member can switch (16.42).
|
|
133
134
|
*/
|
|
134
135
|
modelSelectOperatorOnly: () => boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Whether /permission set stays Host-operator-only (default true — the
|
|
138
|
+
* danger-full-access preset strips sandbox AND approval; 16.60).
|
|
139
|
+
*/
|
|
140
|
+
permissionSelectOperatorOnly: () => boolean;
|
|
141
|
+
/** The live permission-preset catalog + guarded switch (/permission surface). */
|
|
142
|
+
permission: DshPermissionPort;
|
|
135
143
|
log: (event: string, detail?: unknown) => void;
|
|
136
144
|
warn: (event: string, detail?: unknown) => void;
|
|
137
145
|
}
|