@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 CHANGED
@@ -8,9 +8,9 @@
8
8
  [![node](https://img.shields.io/node/v/@addozhang/dsh-discord)](./package.json)
9
9
  [![dshfind](https://dshfind.com/api/badge/addozhang/dsh-discord)](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 a Discord guild — mention the bot to open a task thread, steer and stop turns, answer approvals and questions inline, and watch the answer stream in.
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
- 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
+ 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 (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. 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 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.
23
- - **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.
24
-
25
- - **Session control** — `/steer`, `/stop`, `/queue list|remove` with turn-ownership checks; `/project bind|list|info` for guild↔workspace binding; `/guild forget` for operator cleanup.
26
- - **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.
27
- - **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.
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.1-rc.2` or newer, running a web profile
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. Under the hood `dsh plugin` is a thin pnpm forwarder into the profile directory; after installing, it reconciles the profile's `dsh.profile.bundles` layer list and appends every dependency that declares a `dsh.bundle` patch — nothing to edit by hand.
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 rc.2 Host wires no plugin log exporter and exposes no log-level switch — `logger.debug` output is unobservable — and should be dropped once the Host grows one.
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
  [![node](https://img.shields.io/node/v/@addozhang/dsh-discord)](./package.json)
9
9
  [![dshfind](https://dshfind.com/api/badge/addozhang/dsh-discord)](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
- 本插件为 function/namespace 插件(`inject: ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection']`):将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile;会话状态保存在 DSH,适配器的持久绑定保存在 profile 的存储域中。
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 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
23
- - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
24
-
25
- - **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 管理 Guild↔工作区绑定;`/guild forget` 供操作员清理。
26
- - **模型切换**`/model show` 读取会话的实时模型目录(当前选择、可服务状态、目录分组);`/model select` 走交互式 provider 模型 → 推理强度级联(默认对所有授权成员开放,可通过设置收紧为仅 Host 操作员),也可直接填写 `provider/model` 应用。
27
- - **设置卡片**Token 引导(粘贴 + 连接;存入 Host 凭据服务,绝不写入设置或日志)、连接/断开、服务器白名单、线程自动归档、Bot 语言。
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.1-rc.2` 或更新(web profile
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` 底层是指向 profile 目录的 pnpm 薄转发;安装完成后它会自动对账 profile `dsh.profile.bundles` 层列表,把所有声明了 `dsh.bundle` patch 的依赖追加进去——无需手动编辑。
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(默认静默)。存在原因:rc.2 Host 未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
123
+ - 链路级 trace:启动前设置 `DSH_DISCORD_TRACE=1` 可将 mux 帧、丢弃点与投递结果输出到 stderr(默认静默)。存在原因:Host 在该面上未为插件日志接线任何 exporter,也没有日志级别开关——`logger.debug` 输出不可见;Host 提供等价机制后应移除。
125
124
 
126
125
  ## 已知限制与推迟项
127
126
 
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: [apiProxy, credentials, settings, storageDomain, connection]
14
+ inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection]
@@ -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-runtime/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 { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
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';
@@ -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 { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
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: ClientContext): void;
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-runtime/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 apiProxy,
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 apiProxy respond face lives.
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 apiProxy,
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
+ };