@addozhang/dsh-discord 0.6.2-rc.1 → 0.6.4-rc.1

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.
Files changed (49) hide show
  1. package/README.md +4 -2
  2. package/README.zh.md +4 -2
  3. package/lib/client/DiscordSettingsCard.d.ts +3 -0
  4. package/lib/client/card-form.d.ts +2 -0
  5. package/lib/client.js +20 -9
  6. package/lib/credential.d.ts +6 -1
  7. package/lib/credential.js +5 -1
  8. package/lib/discord/commands.d.ts +9 -4
  9. package/lib/discord/commands.js +10 -5
  10. package/lib/discord/rest.d.ts +16 -3
  11. package/lib/discord/rest.js +46 -7
  12. package/lib/discord/thread-port.js +5 -1
  13. package/lib/dsh/host-asks.js +12 -2
  14. package/lib/dsh/host-events.d.ts +11 -0
  15. package/lib/dsh/host-events.js +36 -8
  16. package/lib/dsh/host-face.js +11 -2
  17. package/lib/features/interaction-router.d.ts +5 -1
  18. package/lib/features/interaction-router.js +37 -2
  19. package/lib/features/prompt-submission.d.ts +8 -5
  20. package/lib/features/prompt-submission.js +19 -6
  21. package/lib/features/question-routing.d.ts +1 -1
  22. package/lib/features/question-routing.js +62 -40
  23. package/lib/features/question-store.d.ts +6 -0
  24. package/lib/features/question-store.js +12 -0
  25. package/lib/features/question-view.d.ts +8 -0
  26. package/lib/features/question-view.js +11 -1
  27. package/lib/features/reconcile-intents.d.ts +15 -0
  28. package/lib/features/reconcile-intents.js +20 -0
  29. package/lib/features/session-mainline.js +10 -1
  30. package/lib/features/single-flight.d.ts +13 -0
  31. package/lib/features/single-flight.js +36 -0
  32. package/lib/features/thread-creation.d.ts +4 -1
  33. package/lib/features/thread-creation.js +4 -0
  34. package/lib/gateway/gateway.js +27 -1
  35. package/lib/i18n.d.ts +1 -0
  36. package/lib/i18n.js +4 -2
  37. package/lib/index.js +205 -104
  38. package/lib/startup.js +5 -2
  39. package/lib/stream/finalizer.js +18 -3
  40. package/lib/stream/live.d.ts +8 -0
  41. package/lib/stream/live.js +100 -18
  42. package/lib/stream/markdown.d.ts +4 -1
  43. package/lib/stream/markdown.js +39 -4
  44. package/lib/stream/splitter.js +5 -0
  45. package/lib/stream/typing.d.ts +4 -2
  46. package/lib/stream/typing.js +15 -5
  47. package/lib/version.d.ts +2 -0
  48. package/lib/version.js +11 -0
  49. package/package.json +4 -3
package/README.md CHANGED
@@ -23,6 +23,7 @@ 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
+ - **Adapter status** — `/info` shows the adapter version, connection state, and project links (feedback, docs). Works in any channel.
26
27
  - **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.
27
28
  - **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.
28
29
  - **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).
@@ -121,7 +122,8 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
121
122
  | `/project bind` | any channel | bind the guild to a workspace (admin; provisions the home channel) |
122
123
  | `/project list` / `info` | any channel | list workspaces / inspect this channel's binding |
123
124
  | `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
124
- | `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
125
+ | `/steer`, `/stop` | session thread | steer or cancel the running turn — any member of the thread may act on the current turn (the thread is a deliberately shared surface: single-user and small-team deployments; approvals/questions stay stricter — asker or thread owner only) |
126
+ | `/info` | any channel | show the adapter version, connection state, and project links |
125
127
  | `/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) |
126
128
  | `/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 |
127
129
  | `/guild forget` | any channel | operator-only removal of adapter records |
@@ -148,7 +150,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
148
150
 
149
151
  ```sh
150
152
  pnpm install --ignore-scripts
151
- pnpm test # 729 tests incl. gateway/REST twin E2E
153
+ pnpm test # 782 tests incl. gateway/REST twin E2E
152
154
  pnpm typecheck
153
155
  pnpm lint
154
156
  pnpm build # lib + client bundle
package/README.zh.md CHANGED
@@ -23,6 +23,7 @@
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
+ - **适配器状态** — `/info` 显示适配器版本、连接状态与项目链接(反馈/文档)。任意频道可用。
26
27
  - **权限切换** — `/permission show` / `set` 读写会话权限预设(沙箱 + 审批捆绑档位),走宿主自己的 `/permission` 命令路径,journal 自动记审计;`danger-full-access` 需按钮二次确认。
27
28
  - **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
28
29
  - **安全设计** — 显式服务器白名单内的 deny-first 授权。提及抑制双保险:每条请求携带 `allowed_mentions`,外加 wire body 的字节级提及中和。DSH 提交至多一次,并做保留 unknown 的对账——结果不明的投递绝不盲目重发。绑定重启后持久;READY 扫描会重建被删除的 category/控制频道,而把被删除的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
@@ -121,7 +122,8 @@ dsh-discord:
121
122
  | `/project bind` | 任意频道 | 将 Guild 绑定到工作区(管理员;会创建主频道) |
122
123
  | `/project list` / `info` | 任意频道 | 列出工作区 / 查看当前频道绑定 |
123
124
  | `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
124
- | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
125
+ | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn——线程内任意成员可操作当前 Turn(有意为之的共享驾驶舱模型,面向单人与小团队场景;审批/提问保持更严的提问人-or-线程主人规则) |
126
+ | `/info` | 任意频道 | 显示适配器版本、连接状态与项目链接 |
125
127
  | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
126
128
  | `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现。线程会渲染会话历史——回答与工具摘要之外,用户输入以引用行回显(插件/系统注入永不回显)。适配器重启后线程只补齐错过部分:持久化的渲染水位抑制已投递过的历史,已绑定会话在重连时自动重订阅,因此从 web UI 发起的回合也会落进对应线程 |
127
129
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
@@ -149,7 +151,7 @@ dsh-discord:
149
151
 
150
152
  ```sh
151
153
  pnpm install --ignore-scripts
152
- pnpm test # 729 tests incl. gateway/REST twin E2E
154
+ pnpm test # 782 tests incl. gateway/REST twin E2E
153
155
  pnpm typecheck
154
156
  pnpm lint
155
157
  pnpm build # lib + client bundle
@@ -3,4 +3,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
3
3
  import type { DiscordCardFace } from './card-controller.js';
4
4
  /** Props the runtime binds for the Discord settings card. */
5
5
  export type DiscordSettingsCardProps = PropsRuntime<"settings.section"> & PropsLocale<'dsh-discord'> & InjectFace<DiscordCardFace>;
6
+ /** Scoped stylesheet, injected once per card mount. Neutral translucent
7
+ * tones keep the controls readable on both the Host's dark and light themes. */
8
+ export declare const CARD_CSS = "\n[data-dsh-discord-card] { display: flex; flex-direction: column; gap: 24px; padding: 4px 2px 0; }\n[data-dsh-discord-card-head] { display: flex; flex-direction: column; gap: 2px; text-align: left; }\n[data-dsh-discord-card-title] { font-size: 14px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }\n[data-dsh-discord-card-title] svg { opacity: .85; }\n[data-dsh-discord-card-description] { font-size: 12px; opacity: .55; }\n[data-dsh-discord-card] button[aria-expanded] { all: unset; cursor: pointer; display: block; width: 100%; box-sizing: border-box; }\n[data-dsh-discord-card] button[aria-expanded]:focus-visible { outline: 2px solid rgba(88, 101, 242, .8); border-radius: 8px; }\n[data-dsh-discord-badge] { display: inline-block; font-size: 11px; line-height: 18px; padding: 0 8px; border-radius: 999px; }\n[data-dsh-discord-badge=\"unsaved\"] { background: rgba(250, 204, 21, .16); color: #facc15; }\n[data-dsh-discord-status] { display: flex; align-items: center; gap: 8px; font-size: 13px; }\n[data-dsh-discord-status-dot] { width: 10px; height: 10px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }\n[data-dsh-discord-field] { display: flex; flex-direction: column; gap: 6px; }\n[data-dsh-discord-field-help] + [data-dsh-discord-field], [data-dsh-discord-token] { margin-top: 8px; }\n[data-dsh-discord-field-head] { display: flex; align-items: center; gap: 8px; }\n[data-dsh-discord-field-head] label { font-size: 13px; font-weight: 500; }\n[data-dsh-discord-badges] { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }\n[data-dsh-discord-badge=\"overridden\"] { background: rgba(250, 204, 21, .16); color: #facc15; }\n[data-dsh-discord-field-head] button { all: unset; cursor: pointer; font-size: 12px; padding: 1px 8px; border-radius: 6px; background: rgba(127, 127, 127, .18); }\n[data-dsh-discord-field-head] button:hover { background: rgba(127, 127, 127, .3); }\n[data-dsh-discord-field] textarea, [data-dsh-discord-field] select {\n width: 100%; box-sizing: border-box; color: inherit; font: inherit; font-size: 13px;\n background: rgba(127, 127, 127, .12); border: 1px solid rgba(127, 127, 127, .28);\n border-radius: 8px; padding: 8px 10px; resize: vertical;\n}\n[data-dsh-discord-field] textarea:focus-visible, [data-dsh-discord-field] select:focus-visible {\n outline: none; border-color: rgba(88, 101, 242, .9);\n}\n[data-dsh-discord-field] textarea[aria-invalid=\"true\"], [data-dsh-discord-field] select[aria-invalid=\"true\"] {\n border-color: rgba(242, 63, 67, .8);\n}\n[data-dsh-discord-field-note] { margin: 0; font-size: 12px; opacity: .55; }\n[data-dsh-discord-field-note=\"invalid\"] { opacity: 1; color: #f23f43; }\n[data-dsh-discord-field-help] { margin: -2px 0 0; font-size: 12px; opacity: .55; line-height: 1.6; }\n[data-dsh-discord-disconnect] { all: unset; cursor: pointer; margin-left: auto; font-size: 12px; padding: 2px 10px; border-radius: 6px; background: rgba(127, 127, 127, .18); }\n[data-dsh-discord-disconnect]:hover { background: rgba(242, 63, 67, .18); color: #f23f43; }\n[data-dsh-discord-token] { display: flex; flex-direction: column; gap: 8px; padding: 14px; border: 1px solid rgba(127, 127, 127, .28); border-radius: 10px; }\n[data-dsh-discord-token-row] { display: flex; gap: 10px; }\n[data-dsh-discord-token-row] input {\n flex: 1; min-width: 0; box-sizing: border-box; color: inherit; font: inherit; font-size: 13px;\n background: rgba(127, 127, 127, .12); border: 1px solid rgba(127, 127, 127, .28); border-radius: 8px; padding: 8px 10px;\n}\n[data-dsh-discord-token-row] input:focus-visible { outline: none; border-color: rgba(88, 101, 242, .9); }\n[data-dsh-discord-token-row] button { all: unset; cursor: pointer; font-size: 13px; padding: 7px 16px; border-radius: 8px; background: rgba(88, 101, 242, .9); color: #fff; white-space: nowrap; }\n[data-dsh-discord-token-row] button:disabled { opacity: .45; cursor: default; }\n[data-dsh-discord-token-error] { margin: 0; font-size: 12px; color: #f23f43; }\n[data-dsh-discord-card-footer] { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }\n[data-dsh-discord-card-footer] button { all: unset; cursor: pointer; font-size: 13px; padding: 6px 16px; border-radius: 8px; }\n[data-dsh-discord-card-footer] button[type=\"button\"]:first-child { background: rgba(127, 127, 127, .18); }\n[data-dsh-discord-card-footer] button:not(:disabled) { background: rgba(88, 101, 242, .9); color: #fff; }\n[data-dsh-discord-card-footer] button[type=\"button\"]:first-child:not(:disabled) { background: rgba(127, 127, 127, .18); color: inherit; }\n[data-dsh-discord-card-footer] button:disabled { opacity: .4; cursor: default; }\n[data-dsh-discord-card-footer] button:not(:disabled):hover { filter: brightness(1.12); }\n";
6
9
  export declare function DiscordSettingsCard(props: DiscordSettingsCardProps): import("react/jsx-runtime").JSX.Element | null;
@@ -88,6 +88,8 @@ export declare class DiscordCardForm {
88
88
  private saving;
89
89
  private failed;
90
90
  private status;
91
+ /** The one bound store every face shares (lazily created). */
92
+ private bound;
91
93
  constructor(scope: ConfigForm<DiscordSettings>);
92
94
  /**
93
95
  * Publish the Host's sanitized status view. Edits, saves, and failures
package/lib/client.js CHANGED
@@ -870,6 +870,8 @@ var DiscordCardForm = class {
870
870
  saving = false;
871
871
  failed = false;
872
872
  status = void 0;
873
+ /** The one bound store every face shares (lazily created). */
874
+ bound;
873
875
  /**
874
876
  * Publish the Host's sanitized status view. Edits, saves, and failures
875
877
  * never touch it — connection state is the Host's report, not form state.
@@ -880,11 +882,13 @@ var DiscordCardForm = class {
880
882
  }
881
883
  /** Publish the card state the renderer reads through its bound selector. */
882
884
  bind() {
883
- const store = createLocalSnapshotStore(this.project());
884
- this.listeners.add(() => {
885
- store.set(this.project());
886
- });
887
- return store;
885
+ if (this.bound === void 0) {
886
+ this.bound = createLocalSnapshotStore(this.project());
887
+ this.listeners.add(() => {
888
+ this.bound?.set(this.project());
889
+ });
890
+ }
891
+ return this.bound;
888
892
  }
889
893
  /** Build the edit, reset, save, and discard actions bound to this form. */
890
894
  actions() {
@@ -1004,11 +1008,17 @@ var DiscordCardForm = class {
1004
1008
  this.saving = true;
1005
1009
  this.failed = false;
1006
1010
  this.publish();
1011
+ const plannedDrafts = /* @__PURE__ */ new Map();
1012
+ for (const item of plan) plannedDrafts.set(item.field, this.staged.get(item.field));
1007
1013
  let landed = true;
1008
1014
  for (const write of writes) {
1009
1015
  landed = await write().catch(() => false) && landed;
1010
1016
  }
1011
- if (landed) this.staged.clear();
1017
+ if (landed) {
1018
+ for (const [field, snapshot] of plannedDrafts) {
1019
+ if (this.staged.get(field) === snapshot) this.staged.delete(field);
1020
+ }
1021
+ }
1012
1022
  this.saving = false;
1013
1023
  this.failed = !landed;
1014
1024
  this.publish();
@@ -1246,8 +1256,8 @@ var CARD_CSS = `
1246
1256
  [data-dsh-discord-card-title] { font-size: 14px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
1247
1257
  [data-dsh-discord-card-title] svg { opacity: .85; }
1248
1258
  [data-dsh-discord-card-description] { font-size: 12px; opacity: .55; }
1249
- button[aria-expanded] { all: unset; cursor: pointer; display: block; width: 100%; box-sizing: border-box; }
1250
- button[aria-expanded]:focus-visible { outline: 2px solid rgba(88, 101, 242, .8); border-radius: 8px; }
1259
+ [data-dsh-discord-card] button[aria-expanded] { all: unset; cursor: pointer; display: block; width: 100%; box-sizing: border-box; }
1260
+ [data-dsh-discord-card] button[aria-expanded]:focus-visible { outline: 2px solid rgba(88, 101, 242, .8); border-radius: 8px; }
1251
1261
  [data-dsh-discord-badge] { display: inline-block; font-size: 11px; line-height: 18px; padding: 0 8px; border-radius: 999px; }
1252
1262
  [data-dsh-discord-badge="unsaved"] { background: rgba(250, 204, 21, .16); color: #facc15; }
1253
1263
  [data-dsh-discord-status] { display: flex; align-items: center; gap: 8px; font-size: 13px; }
@@ -1342,7 +1352,8 @@ function TokenSetup(props) {
1342
1352
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: connecting || !props.tokenConfigured && token.trim() === "", onClick: submit, children: connecting ? t("discordTokenConnecting") : t("discordTokenConnect") })
1343
1353
  ] }),
1344
1354
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-field-help": "", children: props.tokenConfigured ? t("discordTokenReconnectHint") : t("discordTokenHelp") }),
1345
- error !== "" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-token-error": "", children: error })
1355
+ error !== "" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-token-error": "", children: error }),
1356
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-card-footer-link": "", style: { margin: "8px 0 0", fontSize: 12, opacity: 0.6 }, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("a", { href: "https://github.com/addozhang/dsh-discord", target: "_blank", rel: "noopener noreferrer", children: "\u2B50 Feedback & docs" }) })
1346
1357
  ] });
1347
1358
  }
1348
1359
  function DiscordSettingsCard(props) {
@@ -1,4 +1,9 @@
1
- export declare const DISCORD_BOT_TOKEN_REF: import("@deepseek-ai/dsh-credentials").CredentialRef;
1
+ /** Branded ref, inlined from @deepseek-ai/dsh-credentials (runtime value
2
+ * is the plain string — the brand is compile-time only; see domain.ts). */
3
+ export type DiscordCredentialRef = string & {
4
+ readonly __brand: 'DiscordCredentialRef';
5
+ };
6
+ export declare const DISCORD_BOT_TOKEN_REF: DiscordCredentialRef;
2
7
  export interface DiscordCredentialProvider {
3
8
  describe(ref: typeof DISCORD_BOT_TOKEN_REF): Promise<{
4
9
  configured: boolean;
package/lib/credential.js CHANGED
@@ -1,4 +1,8 @@
1
- import { credentialRef } from '@deepseek-ai/dsh-credentials';
1
+ function credentialRef(value) {
2
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value))
3
+ throw new TypeError(`credential ref "${value}" must be an uppercase identifier`);
4
+ return value;
5
+ }
2
6
  export const DISCORD_BOT_TOKEN_REF = credentialRef('DSH_DISCORD_BOT_TOKEN');
3
7
  export async function describeDiscordCredential(provider) {
4
8
  const result = await provider.describe(DISCORD_BOT_TOKEN_REF);
@@ -42,9 +42,14 @@ export declare const MILESTONE_ONE_COMMANDS: readonly AdapterCommand[];
42
42
  /**
43
43
  * Build the bulk-overwrite registration payload for
44
44
  * `PUT /applications/{application.id}/commands`.
45
- * `default_member_permissions: '0'` keeps every command visible: the
46
- * adapter's own deny-first RBAC authorizes at interaction time, because
47
- * Discord roles cannot express Workspace-administrator or Host-operator
48
- * authority.
45
+ * `default_member_permissions` is deliberately OMITTED: `'0'` is Discord's
46
+ * "disabled for everyone except server admins" sentinel, which hid the
47
+ * commands from ordinary members entirely — the opposite of the adapter's
48
+ * model, where every command is visible to the guild and the deny-first
49
+ * RBAC gate authorizes at interaction time (Discord roles cannot express
50
+ * Workspace-administrator or Host-operator authority anyway). Omitted
51
+ * means @everyone may invoke, subject to the guild's own role overrides.
52
+ * Wire change: verify once on a real machine with a plain member account
53
+ * (the digital twin does not model permission gating).
49
54
  */
50
55
  export declare function buildCommandRegistrations(): Array<Record<string, unknown>>;
@@ -27,6 +27,7 @@ export const MILESTONE_ONE_COMMANDS = [
27
27
  { name: 'list' },
28
28
  { name: 'remove', options: [{ name: 'item', required: true }] },
29
29
  ]),
30
+ leaf('info', 'Show the adapter version, connection state, and project links'),
30
31
  leaf('steer', 'Steer the running turn of this thread', [{ name: 'prompt', required: true }]),
31
32
  leaf('stop', 'Cancel the running turn of this thread'),
32
33
  grouped('model', 'Show or select the session model', [
@@ -68,10 +69,15 @@ function argumentToWire(option) {
68
69
  /**
69
70
  * Build the bulk-overwrite registration payload for
70
71
  * `PUT /applications/{application.id}/commands`.
71
- * `default_member_permissions: '0'` keeps every command visible: the
72
- * adapter's own deny-first RBAC authorizes at interaction time, because
73
- * Discord roles cannot express Workspace-administrator or Host-operator
74
- * authority.
72
+ * `default_member_permissions` is deliberately OMITTED: `'0'` is Discord's
73
+ * "disabled for everyone except server admins" sentinel, which hid the
74
+ * commands from ordinary members entirely — the opposite of the adapter's
75
+ * model, where every command is visible to the guild and the deny-first
76
+ * RBAC gate authorizes at interaction time (Discord roles cannot express
77
+ * Workspace-administrator or Host-operator authority anyway). Omitted
78
+ * means @everyone may invoke, subject to the guild's own role overrides.
79
+ * Wire change: verify once on a real machine with a plain member account
80
+ * (the digital twin does not model permission gating).
75
81
  */
76
82
  export function buildCommandRegistrations() {
77
83
  return MILESTONE_ONE_COMMANDS.map((command) => {
@@ -88,7 +94,6 @@ export function buildCommandRegistrations() {
88
94
  name: command.name,
89
95
  description: command.description,
90
96
  contexts: [DISCORD_GUILD_CONTEXT],
91
- default_member_permissions: '0',
92
97
  ...(options === undefined || options.length === 0 ? {} : { options }),
93
98
  };
94
99
  });
@@ -2,9 +2,14 @@
2
2
  * The typed Discord REST adapter. Every request resolves to exactly one of
3
3
  * three outcomes — `completed`, `rejected`, or `unknown` — because cross-system
4
4
  * delivery is never provably once: a refused request (4xx, exhausted 429) is
5
- * definitive, while an unobservable outcome (network failure, abort,
6
- * exhausted 5xx) may or may not have been applied and must be reconciled,
7
- * never blindly retried by callers.
5
+ * definitive, while an unobservable outcome (network failure, abort, 5xx)
6
+ * may or may not have been applied and must be reconciled, never blindly
7
+ * retried by callers. That holds inside the adapter too: transient failures
8
+ * are auto-retried only for idempotent methods (GET/PUT/DELETE/PATCH); a
9
+ * POST that hits 5xx or the network fails returns `unknown` on the first
10
+ * failure — the retry decision belongs to the caller's nonce
11
+ * reconciliation, because a re-sent POST whose first attempt landed would
12
+ * double-deliver with no error to show for it.
8
13
  */
9
14
  export type RestMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
10
15
  /** The untyped wire face injected for testability (fetch-shaped). */
@@ -46,6 +51,14 @@ export interface RestClientOptions {
46
51
  backoffBaseMs?: number;
47
52
  /** Cap for any single wait, so a hostile Retry-After cannot stall the adapter. */
48
53
  maxDelayMs?: number;
54
+ /**
55
+ * Ack budget applied automatically to `/interactions/` callback paths:
56
+ * Discord grants one callback per interaction inside a 3-second deadline,
57
+ * so a request parked behind a Retry-After sleep (or a 5xx backoff)
58
+ * would deliver an ack that can no longer matter — the budget aborts it
59
+ * to unknown early instead. 0 disables the budget.
60
+ */
61
+ interactionAckBudgetMs?: number;
49
62
  }
50
63
  export declare function createRestClient(config: RestClientConfig, options?: RestClientOptions, fetchLike?: FetchLike): {
51
64
  request<T>(method: RestMethod, path: string, body?: unknown, init?: {
@@ -2,18 +2,39 @@
2
2
  * The typed Discord REST adapter. Every request resolves to exactly one of
3
3
  * three outcomes — `completed`, `rejected`, or `unknown` — because cross-system
4
4
  * delivery is never provably once: a refused request (4xx, exhausted 429) is
5
- * definitive, while an unobservable outcome (network failure, abort,
6
- * exhausted 5xx) may or may not have been applied and must be reconciled,
7
- * never blindly retried by callers.
5
+ * definitive, while an unobservable outcome (network failure, abort, 5xx)
6
+ * may or may not have been applied and must be reconciled, never blindly
7
+ * retried by callers. That holds inside the adapter too: transient failures
8
+ * are auto-retried only for idempotent methods (GET/PUT/DELETE/PATCH); a
9
+ * POST that hits 5xx or the network fails returns `unknown` on the first
10
+ * failure — the retry decision belongs to the caller's nonce
11
+ * reconciliation, because a re-sent POST whose first attempt landed would
12
+ * double-deliver with no error to show for it.
8
13
  */
9
14
  import { randomUUID } from 'node:crypto';
10
15
  const DEFAULT_API_BASE = 'https://discord.com/api/v10';
11
16
  const DEFAULT_MAX_RETRIES = 3;
12
17
  const DEFAULT_BACKOFF_BASE_MS = 1_000;
13
18
  const DEFAULT_MAX_DELAY_MS = 30_000;
19
+ /** Discord's interaction ack deadline is 3s; leave margin for the call itself. */
20
+ const DEFAULT_INTERACTION_ACK_BUDGET_MS = 2_500;
14
21
  const sleep = (ms) => {
15
22
  return new Promise(resolve => { setTimeout(resolve, ms); });
16
23
  };
24
+ /** sleep() that also wakes when the signal aborts, so a deadline can cut a pending Retry-After wait. */
25
+ function sleepAbortable(ms, signal) {
26
+ if (signal === undefined)
27
+ return sleep(ms);
28
+ return new Promise(resolve => {
29
+ const done = () => {
30
+ signal.removeEventListener('abort', done);
31
+ clearTimeout(timer);
32
+ resolve();
33
+ };
34
+ const timer = setTimeout(done, ms);
35
+ signal.addEventListener('abort', done, { once: true });
36
+ });
37
+ }
17
38
  function parseDelayMs(response, fallbackMs, capMs) {
18
39
  const header = response.headers.get('retry-after');
19
40
  const seconds = header === null ? Number.NaN : Number.parseFloat(header);
@@ -37,6 +58,7 @@ export function createRestClient(config, options = {}, fetchLike) {
37
58
  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
38
59
  const backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
39
60
  const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
61
+ const interactionAckBudgetMs = options.interactionAckBudgetMs ?? DEFAULT_INTERACTION_ACK_BUDGET_MS;
40
62
  const doFetch = fetchLike ?? (async (request) => {
41
63
  const init = { method: request.method, headers: request.headers };
42
64
  if (request.body !== undefined)
@@ -77,8 +99,17 @@ export function createRestClient(config, options = {}, fetchLike) {
77
99
  }
78
100
  }
79
101
  async function request(method, path, body, init) {
102
+ // Interaction callbacks are deadline-bound: fold the ack budget into
103
+ // the effective signal so the retry loop's checks, the in-flight
104
+ // fetch, AND the Retry-After sleeps all honor it.
105
+ let signal = init?.signal;
106
+ if (interactionAckBudgetMs > 0 && path.startsWith('/interactions/')) {
107
+ signal = signal === undefined
108
+ ? AbortSignal.timeout(interactionAckBudgetMs)
109
+ : AbortSignal.any([signal, AbortSignal.timeout(interactionAckBudgetMs)]);
110
+ }
80
111
  for (let attemptNo = 0; attemptNo <= maxRetries; attemptNo += 1) {
81
- if (init?.signal?.aborted === true) {
112
+ if (signal?.aborted === true) {
82
113
  return { outcome: 'unknown', reason: 'aborted' };
83
114
  }
84
115
  const fetchRequest = {
@@ -90,7 +121,7 @@ export function createRestClient(config, options = {}, fetchLike) {
90
121
  ...(body === undefined ? {} : { 'content-type': 'application/json' }),
91
122
  },
92
123
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
93
- ...(init?.signal === undefined ? {} : { signal: init.signal }),
124
+ ...(signal === undefined ? {} : { signal }),
94
125
  };
95
126
  const outcome = await attempt(fetchRequest);
96
127
  if ('retry' in outcome) {
@@ -101,13 +132,21 @@ export function createRestClient(config, options = {}, fetchLike) {
101
132
  if (isFinalAttempt) {
102
133
  return { outcome: 'rejected', status: 429, error: { code: 'rate-limited', message: 'rate limit retries exhausted' } };
103
134
  }
104
- await sleep(outcome.delayMs);
135
+ await sleepAbortable(outcome.delayMs, signal);
105
136
  continue;
106
137
  }
138
+ // A 5xx or network failure may mean the request was already applied
139
+ // upstream. Auto-retrying a non-idempotent POST could then deliver
140
+ // the message twice and the retry's 200 would mask it — so POST
141
+ // surfaces unknown immediately and the caller's nonce reconciliation
142
+ // decides; only idempotent methods ride the bounded retry loop.
143
+ if (method !== 'GET' && method !== 'PUT' && method !== 'DELETE' && method !== 'PATCH') {
144
+ return { outcome: 'unknown', reason: 'network-unreachable' };
145
+ }
107
146
  if (isFinalAttempt) {
108
147
  return { outcome: 'unknown', reason: 'network-unreachable' };
109
148
  }
110
- await sleep(Math.min(backoffBaseMs * 2 ** attemptNo, maxDelayMs));
149
+ await sleepAbortable(Math.min(backoffBaseMs * 2 ** attemptNo, maxDelayMs), signal);
111
150
  continue;
112
151
  }
113
152
  return outcome;
@@ -34,8 +34,12 @@ export function createRestThreadPort(rest, options = {}) {
34
34
  // `threads/active` route does not exist in the Discord API (archived
35
35
  // listing is the only channel-level one), so recovery would 404 forever.
36
36
  const listed = await rest.request('GET', `/guilds/${request.guildId}/threads/active`);
37
+ // An unobservable listing is NOT a definitive absence (review A4):
38
+ // conflating the two lets crash-window recovery conclude a thread
39
+ // that exists was never created. Only a completed listing that
40
+ // scanned past the source message proves 'not-found'.
37
41
  if (listed.outcome !== 'completed')
38
- return { outcome: 'not-found' };
42
+ return { outcome: 'unknown' };
39
43
  const threads = Array.isArray(listed.body?.threads) ? listed.body.threads : [];
40
44
  for (const thread of threads.slice(0, MAX_THREADS_SCANNED)) {
41
45
  const oldest = await rest.request('GET', `/channels/${thread.id}/messages?after=0&limit=1`);
@@ -109,7 +109,13 @@ export function installAskServicePatches(approval, questions, deps) {
109
109
  pending.dispose();
110
110
  void pending.promise.then(() => {
111
111
  deps.onWaitState?.(threadId, false);
112
- void deps.askWiring.disableControl(approvalId);
112
+ void deps.askWiring.disableControl(approvalId).catch((cause) => {
113
+ deps.log('discord_ask_control_disable_failed', { key: approvalId, cause: String(cause) });
114
+ });
115
+ }).catch((cause) => {
116
+ // The ask itself has settled; a failing wait-state clear must not
117
+ // escape as an unhandled rejection.
118
+ deps.log('discord_ask_wait_clear_failed', { threadId, cause: String(cause) });
113
119
  });
114
120
  return pending.promise;
115
121
  };
@@ -142,7 +148,11 @@ export function installAskServicePatches(approval, questions, deps) {
142
148
  pending.dispose();
143
149
  void pending.promise.then(() => {
144
150
  deps.onWaitState?.(threadId, false);
145
- void deps.askWiring.disableControl(rpcId);
151
+ void deps.askWiring.disableControl(rpcId).catch((cause) => {
152
+ deps.log('discord_ask_control_disable_failed', { key: rpcId, cause: String(cause) });
153
+ });
154
+ }).catch((cause) => {
155
+ deps.log('discord_ask_wait_clear_failed', { threadId, cause: String(cause) });
146
156
  });
147
157
  return pending.promise;
148
158
  };
@@ -43,6 +43,14 @@ export interface TrackSeedOptions {
43
43
  export interface HostEventRouterOptions {
44
44
  log?: (event: string, detail?: unknown) => void;
45
45
  }
46
+ interface FrameQueue {
47
+ push(frame: unknown): void;
48
+ close(): void;
49
+ }
50
+ /** Build the push-side of the single consumer queue. */
51
+ export declare function createFrameQueue(): FrameQueue & {
52
+ iterate(signal: AbortSignal): AsyncIterable<unknown>;
53
+ };
46
54
  /**
47
55
  * The fan-in router. `track(sessionId)` is idempotent and safe to call from
48
56
  * every session-acquisition site (create, adopt, resume, prompt); each
@@ -51,4 +59,7 @@ export interface HostEventRouterOptions {
51
59
  export declare function createHostEventRouter(services: HostSessionFollowFace, options?: HostEventRouterOptions): {
52
60
  track(sessionId: string, seed?: TrackSeedOptions): void;
53
61
  stream(signal: AbortSignal): AsyncIterable<unknown>;
62
+ /** Abort every tracked follow loop, cancel pending re-arms, stop the router. */
63
+ dispose(): void;
54
64
  };
65
+ export {};
@@ -18,7 +18,7 @@
18
18
  * ask-wiring migration wires separately.
19
19
  */
20
20
  /** Build the push-side of the single consumer queue. */
21
- function createFrameQueue() {
21
+ export function createFrameQueue() {
22
22
  const buffered = [];
23
23
  let notify;
24
24
  let closed = false;
@@ -54,12 +54,18 @@ function createFrameQueue() {
54
54
  if (closed)
55
55
  return;
56
56
  await new Promise(resolve => {
57
- notify = resolve;
58
57
  const abort = () => {
59
58
  notify = undefined;
60
59
  resolve();
61
60
  };
62
61
  signal.addEventListener('abort', abort, { once: true });
62
+ notify = () => {
63
+ // The wake path must drop its abort listener: a streaming
64
+ // session parks here per drain, and a leaked listener per
65
+ // park accumulates into MaxListenersExceededWarning.
66
+ signal.removeEventListener('abort', abort);
67
+ resolve();
68
+ };
63
69
  });
64
70
  }
65
71
  }
@@ -112,8 +118,11 @@ export function createHostEventRouter(services, options = {}) {
112
118
  const watermark = new Map();
113
119
  /** Sessions whose NEXT opening snapshot is swallowed whole (legacy bindings). */
114
120
  const suppressOnce = new Set();
121
+ /** Pending re-arm timers: disposed routers must stop resubscribing. */
122
+ const rearmTimers = new Set();
115
123
  let consumer;
116
124
  let rootSignal;
125
+ let disposed = false;
117
126
  const startLoop = (sessionId, attempt = 0) => {
118
127
  const per = new AbortController();
119
128
  tracked.set(sessionId, per);
@@ -244,12 +253,16 @@ export function createHostEventRouter(services, options = {}) {
244
253
  // process restart replays the journal. Re-arm with backoff.
245
254
  if (!per.signal.aborted) {
246
255
  const delayMs = Math.min(30_000, 1_000 * 2 ** attempt);
247
- setTimeout(() => {
248
- if (!per.signal.aborted && !tracked.has(sessionId)) {
249
- log?.('discord_host_follow_resubscribed', { sessionId, delayMs });
250
- startLoop(sessionId, attempt + 1);
251
- }
256
+ const timer = setTimeout(() => {
257
+ rearmTimers.delete(timer);
258
+ if (disposed || per.signal.aborted || tracked.has(sessionId))
259
+ return;
260
+ log?.('discord_host_follow_resubscribed', { sessionId, delayMs });
261
+ startLoop(sessionId, attempt + 1);
252
262
  }, delayMs);
263
+ // A parked re-arm must never pin the host's event loop open.
264
+ timer.unref();
265
+ rearmTimers.add(timer);
253
266
  }
254
267
  }
255
268
  })();
@@ -293,7 +306,7 @@ export function createHostEventRouter(services, options = {}) {
293
306
  track(sessionId, seed) {
294
307
  if (sessionId === '' || tracked.has(sessionId))
295
308
  return;
296
- if (rootSignal?.aborted)
309
+ if (disposed || rootSignal?.aborted)
297
310
  return;
298
311
  if (seed !== undefined) {
299
312
  // Seed BEFORE the loop opens: the opening snapshot races the very
@@ -321,5 +334,20 @@ export function createHostEventRouter(services, options = {}) {
321
334
  startControlLoop(signal);
322
335
  return queue.iterate(signal);
323
336
  },
337
+ dispose() {
338
+ // Plugin teardown: the per-session follow loops run on their own
339
+ // controllers (the consumer signal only reaches the control loop),
340
+ // so nothing else stops them — without this, disposal leaves zombie
341
+ // subscriptions and a re-arm chain that resubscribes forever.
342
+ disposed = true;
343
+ rootSignal = undefined;
344
+ for (const [, per] of [...tracked.entries()])
345
+ per.abort();
346
+ tracked.clear();
347
+ for (const timer of rearmTimers)
348
+ clearTimeout(timer);
349
+ rearmTimers.clear();
350
+ consumer?.close();
351
+ },
324
352
  };
325
353
  }
@@ -489,8 +489,13 @@ async function readSessionSelection(dsh, sessionId, options) {
489
489
  const dispose = observation?.[Symbol.asyncDispose]
490
490
  ?? observation?.dispose;
491
491
  if (typeof dispose === 'function') {
492
+ // The asyncDispose convention returns a promise: a rejection there
493
+ // must be caught, or it escapes as an unhandled rejection that kills
494
+ // the host process.
492
495
  try {
493
- void dispose.call(observation);
496
+ Promise.resolve(dispose.call(observation)).catch((cause) => {
497
+ log?.('discord_session_selection_dispose_threw', { cause: String(cause) });
498
+ });
494
499
  }
495
500
  catch (cause) {
496
501
  log?.('discord_session_selection_dispose_threw', { cause: String(cause) });
@@ -656,8 +661,12 @@ async function readSessionPermission(dsh, sessionId, options) {
656
661
  const dispose = observation?.[Symbol.asyncDispose]
657
662
  ?? observation?.dispose;
658
663
  if (typeof dispose === 'function') {
664
+ // See readSessionSelection: the disposal promise's rejection is
665
+ // caught, never unhandled.
659
666
  try {
660
- void dispose.call(observation);
667
+ Promise.resolve(dispose.call(observation)).catch((cause) => {
668
+ log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
669
+ });
661
670
  }
662
671
  catch (cause) {
663
672
  log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
@@ -60,6 +60,10 @@ export interface InteractionRouterDeps {
60
60
  } | undefined>;
61
61
  /** Delete every adapter-owned record for one guild (DSH untouched). */
62
62
  forgetGuild: (guildId: string) => Promise<void>;
63
+ /** The adapter's own package version (from package.json at load). */
64
+ adapterVersion: () => string;
65
+ /** One-word connection condition for /info. */
66
+ adapterStatus: () => string;
63
67
  /** Ephemeral failure/guidance followup on a component interaction. */
64
68
  componentFollowUp: (interactionId: string, interactionToken: string, content: string) => Promise<void>;
65
69
  /** Retire a rendered control (remove its components on the source message). */
@@ -76,7 +80,7 @@ export interface InteractionRouterDeps {
76
80
  userId: string;
77
81
  threadId: string;
78
82
  text: string;
79
- }) => QuestionInteractionOutcome;
83
+ }) => Promise<QuestionInteractionOutcome>;
80
84
  rest: () => Promise<SharedRestClient | undefined>;
81
85
  /**
82
86
  * Delete one channel→Workspace binding row (bind precheck: a bound