@addozhang/dsh-discord 0.4.0 → 0.5.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -14
- package/README.zh.md +12 -13
- package/cordis.patch.yml +1 -1
- package/lib/client/card-controller.d.ts +1 -1
- package/lib/client/card-form.d.ts +2 -1
- package/lib/client/index.d.ts +2 -2
- package/lib/client/slot-contract.d.ts +7 -0
- package/lib/client/snapshot-store.d.ts +1 -1
- package/lib/compose.d.ts +2 -2
- package/lib/compose.js +1 -1
- package/lib/dsh/host-asks.d.ts +96 -0
- package/lib/dsh/host-asks.js +151 -0
- package/lib/dsh/host-events.d.ts +42 -0
- package/lib/dsh/host-events.js +279 -0
- package/lib/dsh/host-face.d.ts +418 -0
- package/lib/dsh/host-face.js +598 -0
- package/lib/features/adapter-status.d.ts +15 -2
- package/lib/features/adapter-status.js +100 -4
- package/lib/features/approval-store.d.ts +1 -1
- package/lib/features/interaction-router.d.ts +2 -2
- package/lib/features/model-control.d.ts +1 -1
- package/lib/features/project-list.d.ts +1 -1
- package/lib/features/project-list.js +1 -1
- package/lib/features/session-resume.d.ts +1 -1
- package/lib/i18n.d.ts +5 -0
- package/lib/i18n.js +14 -0
- package/lib/index.js +121 -67
- package/lib/settings.js +5 -2
- package/lib/startup.d.ts +1 -1
- package/lib/startup.js +14 -7
- package/lib/stream/live.d.ts +13 -36
- package/lib/stream/live.js +157 -86
- package/lib/stream/tool-view.d.ts +12 -2
- package/lib/stream/tool-view.js +32 -2
- package/package.json +10 -12
- package/lib/dsh/api-proxy-face.d.ts +0 -400
- package/lib/dsh/api-proxy-face.js +0 -515
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
[](./package.json)
|
|
9
9
|
[](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
|
|
10
10
|
|
|
11
|
-
A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai): run DSH sessions from
|
|
11
|
+
A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai): run DSH sessions from your Discord guild — mention the bot with a task, a thread opens, the answer streams in, and approvals and questions arrive as buttons you can answer from your phone.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
No extra process: the adapter is a DSH plugin that mounts straight onto your `dsh web` profile. Session state stays in DSH; durable bindings live in the profile's storage domain.
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<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">
|
|
@@ -18,19 +18,17 @@ This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'setti
|
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
|
|
21
|
-
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (
|
|
22
|
-
- **Stream rendering** — typing
|
|
23
|
-
- **Approvals & questions** — DSH
|
|
24
|
-
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
- **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.
|
|
29
|
-
- **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).
|
|
21
|
+
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (your 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. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
|
|
22
|
+
- **Stream rendering** — typing indicator, one head message edited in place, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
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
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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).
|
|
30
28
|
|
|
31
29
|
## Requirements
|
|
32
30
|
|
|
33
|
-
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.
|
|
31
|
+
- The [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.6-alpha.1` or a compatible newer release, running a web profile. Adapter `0.5.x` uses the 0.1.6 controller services and is not compatible with the former `apiProxy` host surface.
|
|
34
32
|
- Node.js `^22.19.0 || >=24`
|
|
35
33
|
- A Discord application with a bot user and the **MESSAGE CONTENT** privileged intent enabled (Developer Portal → your application → Bot → Privileged Gateway Intents)
|
|
36
34
|
|
|
@@ -42,7 +40,7 @@ Install with the dsh CLI — it installs the package into the profile and regist
|
|
|
42
40
|
dsh plugin --profile web add @addozhang/dsh-discord
|
|
43
41
|
```
|
|
44
42
|
|
|
45
|
-
Then restart `dsh web` and refresh the browser.
|
|
43
|
+
Then restart `dsh web` and refresh the browser. `dsh plugin` reconciles the profile's bundle list for you — nothing to edit by hand.
|
|
46
44
|
|
|
47
45
|
Upgrade and removal use the same command:
|
|
48
46
|
|
|
@@ -115,12 +113,13 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
115
113
|
|
|
116
114
|
## Design notes
|
|
117
115
|
|
|
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.
|
|
118
117
|
- 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.
|
|
119
118
|
- The publish workflow authenticates to npm via trusted publishing (OIDC) — no publish token is stored anywhere.
|
|
120
119
|
- The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
|
|
121
120
|
- A credential probe falls back to `resolve()` because the Host's `describe()` misses env-sourced values — a connected adapter never reads as unconfigured.
|
|
122
121
|
- Adapter logging is default-quiet: flow records ride the Host's debug level and failure-shaped events escalate to warn — nothing prints into the DSH process at the default level.
|
|
123
|
-
- Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the
|
|
122
|
+
- Wire-level live-path tracing: set `DSH_DISCORD_TRACE=1` before booting to emit mux frames, drop points, and delivery outcomes to stderr (default silent). It exists because the Host wires no plugin log exporter and exposes no log-level switch at this surface — `logger.debug` output is unobservable — and should be dropped once the Host grows one.
|
|
124
123
|
|
|
125
124
|
## Known Limitations and Deferred Work
|
|
126
125
|
|
package/README.zh.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
[](./package.json)
|
|
9
9
|
[](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
|
|
10
10
|
|
|
11
|
-
[DeepSeek Harness](https://github.com/deepseek-ai) 的 Discord 优先适配器:在 Discord 服务器中运行 DSH
|
|
11
|
+
[DeepSeek Harness](https://github.com/deepseek-ai) 的 Discord 优先适配器:在 Discord 服务器中运行 DSH 会话——@机器人下一个任务,线程即开,回答流式输出,审批与提问以按钮形式送到手机上,随手即可回应。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
零额外进程:适配器是 DSH 插件,直接挂载到你的 `dsh web` profile。会话状态保存在 DSH,持久绑定保存在 profile 的存储域中。
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<img src="docs/images/discord-task-lifecycle.jpg" alt="一次任务的完整生命周期:@提及锚定线程、工具活动行随回答流式编辑、最终以 Markdown 表格落地" width="720">
|
|
@@ -19,18 +19,16 @@
|
|
|
19
19
|
## 功能
|
|
20
20
|
|
|
21
21
|
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
|
|
22
|
-
- **流式渲染** — typing
|
|
23
|
-
- **审批与提问** — DSH ask
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- **双语文案** — 所有 Discord 可见文案提供中英双语;Bot 语言默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
29
|
-
- **安全设计** — 显式服务器白名单内的 deny-first 授权、每条 wire 请求携带 `allowed_mentions` 并做字节级提及中和、至多一次的 DSH 提交与保留 unknown 的对账、重启后持久的绑定,以及 READY 扫描:被删的 category/控制频道会重建,被删的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
|
|
22
|
+
- **流式渲染** — typing 指示、单条头消息原地编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
23
|
+
- **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
24
|
+
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
|
|
25
|
+
- **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
|
|
26
|
+
- **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
27
|
+
- **安全设计** — 显式服务器白名单内的 deny-first 授权。提及抑制双保险:每条请求携带 `allowed_mentions`,外加 wire body 的字节级提及中和。DSH 提交至多一次,并做保留 unknown 的对账——结果不明的投递绝不盲目重发。绑定重启后持久;READY 扫描会重建被删除的 category/控制频道,而把被删除的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
|
|
30
28
|
|
|
31
29
|
## 环境要求
|
|
32
30
|
|
|
33
|
-
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.
|
|
31
|
+
- [dsh CLI](https://www.npmjs.com/package/@deepseek-ai/dsh) `0.1.6-alpha.1` 或兼容的更新版本(web profile)。适配器 `0.5.x` 使用 0.1.6 的控制器服务,与旧版 `apiProxy` 宿主面不兼容。
|
|
34
32
|
- Node.js `^22.19.0 || >=24`
|
|
35
33
|
- 一个 Discord 应用(含 Bot 用户),并在开发者门户启用 **MESSAGE CONTENT** 特权 intent(Developer Portal → 你的应用 → Bot → Privileged Gateway Intents)
|
|
36
34
|
|
|
@@ -42,7 +40,7 @@
|
|
|
42
40
|
dsh plugin --profile web add @addozhang/dsh-discord
|
|
43
41
|
```
|
|
44
42
|
|
|
45
|
-
然后重启 `dsh web` 并刷新浏览器。`dsh plugin`
|
|
43
|
+
然后重启 `dsh web` 并刷新浏览器。`dsh plugin` 会自动对账 profile 的 bundle 列表——无需手动编辑。
|
|
46
44
|
|
|
47
45
|
升级与卸载使用同一条命令:
|
|
48
46
|
|
|
@@ -116,12 +114,13 @@ dsh-discord:
|
|
|
116
114
|
|
|
117
115
|
## 设计说明
|
|
118
116
|
|
|
117
|
+
- 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
|
|
119
118
|
- 设置卡片是首次使用的引导面:Token 输入通过插件管理通道写入凭据服务的 `DSH_DISCORD_BOT_TOKEN` 引用,然后触发启动链。断开连接保留凭据;留空重连直接使用已存 Token。
|
|
120
119
|
- 发布工作流通过 npm trusted publishing (OIDC) 认证——任何地方都不保存发布凭证。
|
|
121
120
|
- 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
|
|
122
121
|
- 凭据探测会回退到 `resolve()`:Host 的 `describe()` 不识别环境变量来源的值——已连接的适配器不会被误报为未配置。
|
|
123
122
|
- 适配器日志默认静默:流程记录走 Host 的 debug 级别,失败形态的事件升到 warn——默认级别下不会向 DSH 进程打印任何内容。
|
|
124
|
-
- 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:
|
|
123
|
+
- 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:Host 在该面上未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
|
|
125
124
|
|
|
126
125
|
## 已知限制与推迟项
|
|
127
126
|
|
package/cordis.patch.yml
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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 { SettingsScope } from '@deepseek-ai/dsh-client-
|
|
7
|
+
import type { SettingsScope } 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';
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* not value comparison, marks an override. A field is invalid when its draft
|
|
7
7
|
* is not a list of Discord IDs, which blocks the save rather than dropping it.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
10
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
10
11
|
import type { AdapterStatusView } from '../features/adapter-status.js';
|
|
11
12
|
import type { DiscordSettings } from '../settings.js';
|
|
12
13
|
import { type AdapterStatusPresentation } from './settings-model.js';
|
package/lib/client/index.d.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* namespace scope on its own fiber, owns the card controller, and registers
|
|
4
4
|
* the settings card into the Plugins section's tab list until unload.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
7
7
|
/** Stable Cordis plugin name for diagnostics. */
|
|
8
8
|
export declare const name = "dsh-discord-client";
|
|
9
9
|
/** Client services the card needs: settings transport, slot registry, plugin RPC channel, and locale. */
|
|
10
10
|
export declare const inject: string[];
|
|
11
11
|
/** Mount the Discord settings card into the Plugins section. */
|
|
12
|
-
export declare function apply(ctx:
|
|
12
|
+
export declare function apply(ctx: Context): void;
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* locale namespace the Discord card's copy lives in. Importing it is what
|
|
6
6
|
* merges the declarations into this program.
|
|
7
7
|
*/
|
|
8
|
+
declare module '@deepseek-ai/cordis' {
|
|
9
|
+
interface Context {
|
|
10
|
+
locale: {
|
|
11
|
+
register(namespace: string, dictionary: Record<string, Record<string, string>>): unknown;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
8
15
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
9
16
|
interface LocaleNamespaceMap {
|
|
10
17
|
'dsh-discord': keyof DiscordPluginsLocale;
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
* factory, so the card implements the same interface locally and imports the
|
|
5
5
|
* contract type only.
|
|
6
6
|
*/
|
|
7
|
-
import type { SnapshotStore } from '@deepseek-ai/dsh-client-
|
|
7
|
+
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
8
8
|
/** Create an immutable-snapshot store with synchronous notification. */
|
|
9
9
|
export declare function createLocalSnapshotStore<T>(initial: T): SnapshotStore<T>;
|
package/lib/compose.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The adapter composition root (review C1). Assembles the runtime the
|
|
3
3
|
* module-level features could not wire themselves into: bot token → Gateway
|
|
4
|
-
* → authorized ingress → business router → Discord REST / DSH
|
|
4
|
+
* → authorized ingress → business router → Discord REST / DSH host face,
|
|
5
5
|
* plus the settings-card status feed. Everything injected is a port;
|
|
6
6
|
* everything started here is torn down by the returned disposer.
|
|
7
7
|
*
|
|
@@ -115,7 +115,7 @@ export interface CompositionDeps {
|
|
|
115
115
|
status: AdapterStatusTracker;
|
|
116
116
|
/**
|
|
117
117
|
* Interaction (commands/components/modals) routing, wired by the Host
|
|
118
|
-
* composition where the typed
|
|
118
|
+
* composition where the typed settle port lives.
|
|
119
119
|
*/
|
|
120
120
|
routeInteraction?: (event: NormalizedInboundEvent, interactionToken?: string) => void | Promise<void>;
|
|
121
121
|
/**
|
package/lib/compose.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The adapter composition root (review C1). Assembles the runtime the
|
|
3
3
|
* module-level features could not wire themselves into: bot token → Gateway
|
|
4
|
-
* → authorized ingress → business router → Discord REST / DSH
|
|
4
|
+
* → authorized ingress → business router → Discord REST / DSH host face,
|
|
5
5
|
* plus the settings-card status feed. Everything injected is a port;
|
|
6
6
|
* everything started here is torn down by the returned disposer.
|
|
7
7
|
*
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 0.1.6 ask carrier. The composed-answerer waterfalls dispatch inside
|
|
3
|
+
* the base tree's context chain, and no registration surface reachable from
|
|
4
|
+
* an external plugin (own-tree listeners, global hooks, the shared events
|
|
5
|
+
* service, a bridge plugin on the approval service's own context) is
|
|
6
|
+
* enumerated by that dispatch — only in-tree plugins like the web
|
|
7
|
+
* forwarder receive it (verified empirically on the real host,
|
|
8
|
+
* 2026-09-18). The supported reach for an adapter is therefore the service
|
|
9
|
+
* boundary: this module wraps `ApprovalService.request` and
|
|
10
|
+
* `UserQuestionService.ask`, and for sessions bound to a Discord thread
|
|
11
|
+
* renders the ask through the adapter's ask-wiring (buttons, ownership,
|
|
12
|
+
* expiry) and resolves with the user's answer; every other ask passes
|
|
13
|
+
* through to the original path (the web UI keeps working unchanged).
|
|
14
|
+
*/
|
|
15
|
+
/** Narrow approval-request shape the service receives. */
|
|
16
|
+
export interface HostApprovalRequest {
|
|
17
|
+
agent?: {
|
|
18
|
+
id?: string;
|
|
19
|
+
} | undefined;
|
|
20
|
+
toolName?: string;
|
|
21
|
+
callId?: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
/** Narrow user-question request shape the service receives. */
|
|
26
|
+
export interface HostQuestionRequest {
|
|
27
|
+
agent?: {
|
|
28
|
+
id?: string;
|
|
29
|
+
} | undefined;
|
|
30
|
+
questions?: ReadonlyArray<Record<string, unknown>>;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
/** The approval outcome vocabulary the service normalizes onto. */
|
|
34
|
+
export type HostApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
|
|
35
|
+
/** The answer payload the questions service resolves with. */
|
|
36
|
+
export interface HostQuestionAnswer {
|
|
37
|
+
answers: Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
selected: string[];
|
|
40
|
+
custom?: string;
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
/** Narrow approval-service face: the public ask entry we wrap. */
|
|
44
|
+
export interface HostApprovalServiceFace {
|
|
45
|
+
request(req: HostApprovalRequest): Promise<HostApprovalOutcome>;
|
|
46
|
+
}
|
|
47
|
+
/** Narrow user-questions-service face: the public ask entry we wrap. */
|
|
48
|
+
export interface HostQuestionServiceFace {
|
|
49
|
+
ask(req: HostQuestionRequest): Promise<HostQuestionAnswer>;
|
|
50
|
+
}
|
|
51
|
+
export interface HostAskDeps {
|
|
52
|
+
threadForSession(sessionId: string): string | undefined;
|
|
53
|
+
askWiring: {
|
|
54
|
+
onApprovalRequested(input: {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
threadId: string;
|
|
57
|
+
rpcId: string;
|
|
58
|
+
approvalId: string;
|
|
59
|
+
toolName: string;
|
|
60
|
+
reason?: string | undefined;
|
|
61
|
+
expiresAtMs: number;
|
|
62
|
+
}): void;
|
|
63
|
+
onQuestionRequested(input: {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
threadId: string;
|
|
66
|
+
rpcId: string;
|
|
67
|
+
expiresAtMs: number;
|
|
68
|
+
questions: ReadonlyArray<Record<string, unknown>>;
|
|
69
|
+
}): void;
|
|
70
|
+
disableControl(key: string): Promise<void>;
|
|
71
|
+
};
|
|
72
|
+
approvalTimeoutMs(): number;
|
|
73
|
+
questionTimeoutMs(): number;
|
|
74
|
+
nowMs(): number;
|
|
75
|
+
log(event: string, detail?: unknown): void;
|
|
76
|
+
/**
|
|
77
|
+
* Claim/settle steering for the live progress line (turn-progress spec):
|
|
78
|
+
* called with `true` when a thread-bound ask is claimed, `false` when it
|
|
79
|
+
* settles (answered, cancelled, or expired).
|
|
80
|
+
*/
|
|
81
|
+
onWaitState?(threadId: string, waiting: boolean): void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Wrap both ask services. Thread-bound sessions get the Discord ask flow;
|
|
85
|
+
* everything else passes through untouched. Returns the settle ports the
|
|
86
|
+
* click pipeline answers through, plus a dispose() the plugin teardown
|
|
87
|
+
* calls to restore the original entries.
|
|
88
|
+
*/
|
|
89
|
+
export declare function installAskServicePatches(approval: HostApprovalServiceFace, questions: HostQuestionServiceFace | undefined, deps: HostAskDeps): {
|
|
90
|
+
/** Settle one claimed approval ask; false when no pending ask owns the id. */
|
|
91
|
+
settleApproval(approvalId: string, outcome: HostApprovalOutcome): boolean;
|
|
92
|
+
/** Settle one claimed question ask; false when no pending ask owns the id. */
|
|
93
|
+
settleQuestion(rpcId: string, answer: HostQuestionAnswer): boolean;
|
|
94
|
+
/** Restore both services to their original entries. */
|
|
95
|
+
dispose(): void;
|
|
96
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 0.1.6 ask carrier. The composed-answerer waterfalls dispatch inside
|
|
3
|
+
* the base tree's context chain, and no registration surface reachable from
|
|
4
|
+
* an external plugin (own-tree listeners, global hooks, the shared events
|
|
5
|
+
* service, a bridge plugin on the approval service's own context) is
|
|
6
|
+
* enumerated by that dispatch — only in-tree plugins like the web
|
|
7
|
+
* forwarder receive it (verified empirically on the real host,
|
|
8
|
+
* 2026-09-18). The supported reach for an adapter is therefore the service
|
|
9
|
+
* boundary: this module wraps `ApprovalService.request` and
|
|
10
|
+
* `UserQuestionService.ask`, and for sessions bound to a Discord thread
|
|
11
|
+
* renders the ask through the adapter's ask-wiring (buttons, ownership,
|
|
12
|
+
* expiry) and resolves with the user's answer; every other ask passes
|
|
13
|
+
* through to the original path (the web UI keeps working unchanged).
|
|
14
|
+
*/
|
|
15
|
+
/** One claimed ask: a settle promise raced against the ask's abort signal. */
|
|
16
|
+
class PendingAsk {
|
|
17
|
+
owner;
|
|
18
|
+
key;
|
|
19
|
+
settle = () => { };
|
|
20
|
+
promise;
|
|
21
|
+
cancel;
|
|
22
|
+
constructor(owner, key, signal, cancelled) {
|
|
23
|
+
this.owner = owner;
|
|
24
|
+
this.key = key;
|
|
25
|
+
this.promise = new Promise(resolve => {
|
|
26
|
+
this.settle = value => {
|
|
27
|
+
if (this.owner.get(this.key) !== this)
|
|
28
|
+
return;
|
|
29
|
+
this.owner.delete(this.key);
|
|
30
|
+
resolve(value);
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
this.cancel = () => { this.settle(cancelled()); };
|
|
34
|
+
signal?.addEventListener('abort', this.cancel, { once: true });
|
|
35
|
+
// An already-aborted signal never fires its 'abort' event: settle now or
|
|
36
|
+
// the claim would hang past the host's own cancellation.
|
|
37
|
+
if (signal?.aborted === true)
|
|
38
|
+
this.cancel();
|
|
39
|
+
}
|
|
40
|
+
/** Resolve the ask; a no-op when it already settled. */
|
|
41
|
+
resolve(value) {
|
|
42
|
+
if (this.owner.get(this.key) !== this)
|
|
43
|
+
return false;
|
|
44
|
+
this.settle(value);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
dispose() {
|
|
48
|
+
this.cancel();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Wrap both ask services. Thread-bound sessions get the Discord ask flow;
|
|
53
|
+
* everything else passes through untouched. Returns the settle ports the
|
|
54
|
+
* click pipeline answers through, plus a dispose() the plugin teardown
|
|
55
|
+
* calls to restore the original entries.
|
|
56
|
+
*/
|
|
57
|
+
export function installAskServicePatches(approval, questions, deps) {
|
|
58
|
+
const pendingApprovals = new Map();
|
|
59
|
+
const pendingQuestions = new Map();
|
|
60
|
+
// Capture the current entries (own or prototype) before patching; dispose
|
|
61
|
+
// restores exactly these.
|
|
62
|
+
const originalRequest = approval.request.bind(approval);
|
|
63
|
+
const originalAsk = questions !== undefined ? questions.ask.bind(questions) : undefined;
|
|
64
|
+
const patch = {
|
|
65
|
+
settleApproval(approvalId, outcome) {
|
|
66
|
+
return pendingApprovals.get(approvalId)?.resolve(outcome) ?? false;
|
|
67
|
+
},
|
|
68
|
+
settleQuestion(rpcId, answer) {
|
|
69
|
+
return pendingQuestions.get(rpcId)?.resolve(answer) ?? false;
|
|
70
|
+
},
|
|
71
|
+
dispose() {
|
|
72
|
+
approval.request = originalRequest;
|
|
73
|
+
if (questions !== undefined && originalAsk !== undefined) {
|
|
74
|
+
questions.ask = originalAsk;
|
|
75
|
+
}
|
|
76
|
+
for (const [, pending] of [...pendingApprovals])
|
|
77
|
+
pending.dispose();
|
|
78
|
+
pendingApprovals.clear();
|
|
79
|
+
for (const [, pending] of [...pendingQuestions])
|
|
80
|
+
pending.dispose();
|
|
81
|
+
pendingQuestions.clear();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
approval.request = async (req) => {
|
|
85
|
+
const agentId = req.agent?.id;
|
|
86
|
+
const sessionId = agentId !== undefined && agentId !== '' ? agentId : undefined;
|
|
87
|
+
const threadId = sessionId !== undefined ? deps.threadForSession(sessionId) : undefined;
|
|
88
|
+
if (sessionId === undefined || threadId === undefined) {
|
|
89
|
+
deps.log('discord_ask_passthrough', { sessionId: sessionId ?? '', kind: 'approval' });
|
|
90
|
+
return originalRequest(req);
|
|
91
|
+
}
|
|
92
|
+
const approvalId = crypto.randomUUID();
|
|
93
|
+
deps.log('discord_approval_claimed', { approvalId, sessionId, threadId, toolName: req.toolName });
|
|
94
|
+
deps.onWaitState?.(threadId, true);
|
|
95
|
+
deps.askWiring.onApprovalRequested({
|
|
96
|
+
sessionId,
|
|
97
|
+
threadId,
|
|
98
|
+
// The settle port keys by approvalId; the rpcId field is vestigial in
|
|
99
|
+
// this model (no wire echo exists).
|
|
100
|
+
rpcId: approvalId,
|
|
101
|
+
approvalId,
|
|
102
|
+
toolName: typeof req.toolName === 'string' ? req.toolName : '',
|
|
103
|
+
reason: typeof req.reason === 'string' ? req.reason : undefined,
|
|
104
|
+
expiresAtMs: deps.nowMs() + deps.approvalTimeoutMs(),
|
|
105
|
+
});
|
|
106
|
+
const pending = new PendingAsk(pendingApprovals, approvalId, req.signal, () => 'cancelled');
|
|
107
|
+
pendingApprovals.set(approvalId, pending);
|
|
108
|
+
if (req.signal?.aborted === true)
|
|
109
|
+
pending.dispose();
|
|
110
|
+
void pending.promise.then(() => {
|
|
111
|
+
deps.onWaitState?.(threadId, false);
|
|
112
|
+
void deps.askWiring.disableControl(approvalId);
|
|
113
|
+
});
|
|
114
|
+
return pending.promise;
|
|
115
|
+
};
|
|
116
|
+
if (questions !== undefined && originalAsk !== undefined) {
|
|
117
|
+
questions.ask = async (req) => {
|
|
118
|
+
const agentId = req.agent?.id;
|
|
119
|
+
const sessionId = agentId !== undefined && agentId !== '' ? agentId : undefined;
|
|
120
|
+
const threadId = sessionId !== undefined ? deps.threadForSession(sessionId) : undefined;
|
|
121
|
+
if (sessionId === undefined || threadId === undefined) {
|
|
122
|
+
deps.log('discord_ask_passthrough', { sessionId: sessionId ?? '', kind: 'question' });
|
|
123
|
+
return originalAsk(req);
|
|
124
|
+
}
|
|
125
|
+
const rows = Array.isArray(req.questions) ? req.questions : [];
|
|
126
|
+
if (rows.length === 0)
|
|
127
|
+
return originalAsk(req);
|
|
128
|
+
const rpcId = crypto.randomUUID();
|
|
129
|
+
deps.log('discord_question_claimed', { rpcId, sessionId, threadId, questions: rows.length });
|
|
130
|
+
deps.onWaitState?.(threadId, true);
|
|
131
|
+
deps.askWiring.onQuestionRequested({
|
|
132
|
+
sessionId,
|
|
133
|
+
threadId,
|
|
134
|
+
rpcId,
|
|
135
|
+
expiresAtMs: deps.nowMs() + deps.questionTimeoutMs(),
|
|
136
|
+
questions: rows,
|
|
137
|
+
});
|
|
138
|
+
const cancelledAnswer = { answers: [] };
|
|
139
|
+
const pending = new PendingAsk(pendingQuestions, rpcId, req.signal, () => cancelledAnswer);
|
|
140
|
+
pendingQuestions.set(rpcId, pending);
|
|
141
|
+
if (req.signal?.aborted === true)
|
|
142
|
+
pending.dispose();
|
|
143
|
+
void pending.promise.then(() => {
|
|
144
|
+
deps.onWaitState?.(threadId, false);
|
|
145
|
+
void deps.askWiring.disableControl(rpcId);
|
|
146
|
+
});
|
|
147
|
+
return pending.promise;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return patch;
|
|
151
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 0.1.6 host event bridge: fans the per-session `session/follow`
|
|
3
|
+
* journals plus the host-wide `session/control` stream into the single
|
|
4
|
+
* frame stream the live renderer consumes (the rc.2 `apiProxy.events.mux`
|
|
5
|
+
* global stream no longer exists).
|
|
6
|
+
*
|
|
7
|
+
* Frame vocabulary is preserved verbatim (`session/event`,
|
|
8
|
+
* `session/subscribed`, `session/queue`), so `src/stream/live.ts` and its
|
|
9
|
+
* tests are untouched by the host-side rebase. Two deliberate exclusions:
|
|
10
|
+
*
|
|
11
|
+
* - `snapshot` opening windows ARE translated through the same seq
|
|
12
|
+
* watermark as live events: the turn often completes between prompt
|
|
13
|
+
* admission and our follow subscription, so the opening window is the
|
|
14
|
+
* ONLY carrier of those records. The watermark (last delivered seq per
|
|
15
|
+
* session) keeps re-subscription replays idempotent.
|
|
16
|
+
* - approval/question request frames are NOT synthesized here: the 0.1.6
|
|
17
|
+
* host routes asks through the composed-approval model, which the
|
|
18
|
+
* ask-wiring migration wires separately.
|
|
19
|
+
*/
|
|
20
|
+
/** Narrow follow face over one durable session journal. */
|
|
21
|
+
export interface HostSessionFollowFace {
|
|
22
|
+
follow(request: {
|
|
23
|
+
address: {
|
|
24
|
+
kind: 'session';
|
|
25
|
+
sessionId: string;
|
|
26
|
+
};
|
|
27
|
+
assistantStream?: true;
|
|
28
|
+
}, signal: AbortSignal): AsyncIterable<unknown>;
|
|
29
|
+
control(signal: AbortSignal): AsyncIterable<unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface HostEventRouterOptions {
|
|
32
|
+
log?: (event: string, detail?: unknown) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The fan-in router. `track(sessionId)` is idempotent and safe to call from
|
|
36
|
+
* every session-acquisition site (create, adopt, resume, prompt); each
|
|
37
|
+
* tracked session owns one follow loop whose frames join the shared queue.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createHostEventRouter(services: HostSessionFollowFace, options?: HostEventRouterOptions): {
|
|
40
|
+
track(sessionId: string): void;
|
|
41
|
+
stream(signal: AbortSignal): AsyncIterable<unknown>;
|
|
42
|
+
};
|