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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
20
20
 
21
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
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.
23
+ - **Approvals & questions (dual surface)** — DSH asks become buttons, select menus, and a free-text modal, mirrored to the web/desktop panel at the same time: the first surface to answer wins and the other auto-retires (Discord carries the ask alone when no web client is connected). 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
26
  - **Adapter status** — `/info` shows the adapter version, connection state, and project links (feedback, docs). Works in any channel.
@@ -40,11 +40,12 @@ Two channels ship in parallel, each pinned to the dsh host surface it was verifi
40
40
 
41
41
  | Channel | Adapter | dsh CLI | Install |
42
42
  |---|---|---|---|
43
- | **next (0.2.1 line)** | `>=0.6.1-rc.1` — npm `next` | `0.2.1-rc.2` — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
44
- | **stable (0.1.7 line)** | `0.5.0` — npm `latest` | `0.1.7-rc.1` — exact version | `dsh plugin --profile web add @addozhang/dsh-discord` |
43
+ | **stable (0.2.1 line)** | `0.6.4` — npm `latest` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord` |
44
+ | **next (0.2.1 line)** | `>=0.6.1-rc.1` — npm `next` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
45
+ | Legacy (0.1.7 line) | `0.5.0` — exact version | `0.1.7-rc.1` — exact version | `dsh plugin --profile web add @addozhang/dsh-discord@0.5.0` |
45
46
 
46
47
  - **The rows do not mix.** Adapter `0.6.1+` carries runtime dependencies (`dsh-storage-domain`/`dsh-credentials`) pinned to the dsh `0.2.1` line — on any other host line the exact-version compatibility gate disables them (the storageDomain service never arrives and the plugin stays inactive); adapter `0.6.0-rc.1` is the `0.2.0-rc.2` pairing (both sides are exact-version installs now that the dist-tags moved to 0.2.1), and adapter `0.5.0` pins the `0.1.7` line symmetrically. Adapter `>=0.5.0-rc.4` (including `0.5.0`) speaks the 0.1.7 profile-backed settings forms and controller services and will not load on older hosts (the startup probe refuses with one actionable diagnostic); `0.5.0-rc.1`–`rc.3` target dsh `0.1.6-alpha`; adapter `0.4.x` talks to the former `apiProxy` surface, which `0.1.6-alpha` removed — it remains the pairing for the dsh `0.1.5` stable line, reachable by exact version (e.g. `@addozhang/dsh-discord@0.4.0`). A mismatched pair fails at startup.
47
- - To switch the dsh CLI line: `npm install -g @deepseek-ai/dsh@latest` (0.2.1-rc.2, pairs with adapter 0.6.1-rc.1) or `npm install -g @deepseek-ai/dsh@0.1.7-rc.1` (pairs with adapter 0.5.0).
48
+ - To switch the dsh CLI line: `npm install -g @deepseek-ai/dsh@latest` (0.2.1-rc.x, pairs with adapter 0.6.1+) or `npm install -g @deepseek-ai/dsh@0.1.7-rc.1` (pairs with adapter 0.5.0).
48
49
  - Right after a `next` release, the `@next` spec can resolve to a stale cached version. If the installed copy looks old, install by exact version instead (e.g. `@addozhang/dsh-discord@0.5.0-rc.4`) and verify the `version` field inside the profile's `node_modules`.
49
50
 
50
51
  ## Install
package/README.zh.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  - **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
22
22
  - **流式渲染** — typing 指示、单条头消息原地编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
23
- - **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
23
+ - **审批与提问(双面)** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗,并同时镜像到 web/桌面端:两端先答先得,另一端的控件与卡片自动失效(无 web 客户端时 Discord 单独承载)。所有权强制校验(提问者——或后续 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
26
  - **适配器状态** — `/info` 显示适配器版本、连接状态与项目链接(反馈/文档)。任意频道可用。
@@ -40,11 +40,12 @@
40
40
 
41
41
  | 通道 | 适配器 | dsh CLI | 安装命令 |
42
42
  |---|---|---|---|
43
- | **next(0.2.1 线)** | `>=0.6.1-rc.1` — npm `next` | `0.2.1-rc.2` — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
44
- | **stable(0.1.7 线)** | `0.5.0` — npm `latest` | `0.1.7-rc.1` — 精确版本号 | `dsh plugin --profile web add @addozhang/dsh-discord` |
43
+ | **stable(0.2.1 线)** | `0.6.4` — npm `latest` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord` |
44
+ | **next(0.2.1 线)** | `>=0.6.1-rc.1` — npm `next` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
45
+ | 旧线(0.1.7) | `0.5.0` — 精确版本号 | `0.1.7-rc.1` — 精确版本号 | `dsh plugin --profile web add @addozhang/dsh-discord@0.5.0` |
45
46
 
46
47
  - **两行不可混搭。** 适配器 `0.6.1+` 携带的运行时依赖(`dsh-storage-domain`/`dsh-credentials`)钉在 dsh `0.2.1` 线——装到其它宿主线会被宿主的精确版本兼容门禁用(storageDomain 服务缺失,插件保持未激活);适配器 `0.6.0-rc.1` 是 `0.2.0-rc.2` 的配对(dist-tags 已移到 0.2.1,两侧都只能精确版本号安装),适配器 `0.5.0` 同理反向钉在 `0.1.7` 线。适配器 `>=0.5.0-rc.4`(含 `0.5.0`)使用 0.1.7 的 profile-backed settings forms 与控制器服务,在更旧的宿主上无法加载(startup 探针会给出一条可操作的诊断后拒绝激活);`0.5.0-rc.1`–`rc.3` 对应 dsh `0.1.6-alpha`;适配器 `0.4.x` 使用旧版 `apiProxy` 宿主面(该面已在 `0.1.6-alpha` 中移除)——它仍是 dsh `0.1.5` stable 线的配对,可用精确版本号安装(如 `@addozhang/dsh-discord@0.4.0`)。错配会在启动时失败。
47
- - 切换 dsh CLI 通道:`npm install -g @deepseek-ai/dsh@latest`(0.2.1-rc.2,配适配器 0.6.1-rc.1)或 `npm install -g @deepseek-ai/dsh@0.1.7-rc.1`(配适配器 0.5.0)。
48
+ - 切换 dsh CLI 通道:`npm install -g @deepseek-ai/dsh@latest`(0.2.1-rc.x,配适配器 0.6.1+)或 `npm install -g @deepseek-ai/dsh@0.1.7-rc.1`(配适配器 0.5.0)。
48
49
  - `next` 刚发版后,`@next` 规格可能解析到过期的缓存版本。若装到的版本偏旧,请改用精确版本号安装(如 `@addozhang/dsh-discord@0.5.0-rc.4`),并核对 profile `node_modules` 中的 `version` 字段。
49
50
 
50
51
  ## 安装
package/lib/client.js CHANGED
@@ -1352,8 +1352,7 @@ function TokenSetup(props) {
1352
1352
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", disabled: connecting || !props.tokenConfigured && token.trim() === "", onClick: submit, children: connecting ? t("discordTokenConnecting") : t("discordTokenConnect") })
1353
1353
  ] }),
1354
1354
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-field-help": "", children: props.tokenConfigured ? t("discordTokenReconnectHint") : t("discordTokenHelp") }),
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" }) })
1355
+ error !== "" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-token-error": "", children: error })
1357
1356
  ] });
1358
1357
  }
1359
1358
  function DiscordSettingsCard(props) {
@@ -1375,7 +1374,7 @@ function DiscordSettingsCard(props) {
1375
1374
  { value: "auto", label: t("discordLanguageAuto") },
1376
1375
  ...LANGUAGE_CHOICES
1377
1376
  ];
1378
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1377
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_jsx_runtime4.Fragment, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1379
1378
  PluginCard,
1380
1379
  {
1381
1380
  copy,
@@ -1472,10 +1471,11 @@ function DiscordSettingsCard(props) {
1472
1471
  props.resetField(LANGUAGE_FIELD);
1473
1472
  }
1474
1473
  }
1475
- )
1474
+ ),
1475
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { "data-dsh-discord-card-footer-link": "", style: { margin: "6px 2px 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" }) })
1476
1476
  ]
1477
1477
  }
1478
- );
1478
+ ) });
1479
1479
  }
1480
1480
 
1481
1481
  // src/client/index.ts
@@ -11,6 +11,14 @@
11
11
  * renders the ask through the adapter's ask-wiring (buttons, ownership,
12
12
  * expiry) and resolves with the user's answer; every other ask passes
13
13
  * through to the original path (the web UI keeps working unchanged).
14
+ *
15
+ * Dual surface (16.105, 2026-10-30): a claimed ask is ALSO forwarded to
16
+ * the original entry under a relayed abort signal, so a connected web
17
+ * client renders the same card. The two surfaces race and the first
18
+ * decisive answer wins: a web answer settles the claimed ask and retires
19
+ * the Discord controls; a Discord answer aborts the relay and the web wait
20
+ * closes. The pre-binding passthrough direction stays single-surface (the
21
+ * 2026-10-26 decision on issue #11 — status quo).
14
22
  */
15
23
  /** Narrow approval-request shape the service receives. */
16
24
  export interface HostApprovalRequest {
@@ -68,6 +76,18 @@ export interface HostAskDeps {
68
76
  questions: ReadonlyArray<Record<string, unknown>>;
69
77
  }): void;
70
78
  disableControl(key: string): Promise<void>;
79
+ /** Record a web-surface resolution and retire the Discord controls. */
80
+ onApprovalResolved(input: {
81
+ sessionId: string;
82
+ approvalId: string;
83
+ outcome?: string | undefined;
84
+ }): void;
85
+ /** Record a web-surface resolution and retire the Discord controls. */
86
+ onQuestionResolved(input: {
87
+ sessionId: string;
88
+ questionRpcId: string;
89
+ outcome: 'answered' | 'cancelled';
90
+ }): void;
71
91
  };
72
92
  approvalTimeoutMs(): number;
73
93
  questionTimeoutMs(): number;
@@ -11,6 +11,14 @@
11
11
  * renders the ask through the adapter's ask-wiring (buttons, ownership,
12
12
  * expiry) and resolves with the user's answer; every other ask passes
13
13
  * through to the original path (the web UI keeps working unchanged).
14
+ *
15
+ * Dual surface (16.105, 2026-10-30): a claimed ask is ALSO forwarded to
16
+ * the original entry under a relayed abort signal, so a connected web
17
+ * client renders the same card. The two surfaces race and the first
18
+ * decisive answer wins: a web answer settles the claimed ask and retires
19
+ * the Discord controls; a Discord answer aborts the relay and the web wait
20
+ * closes. The pre-binding passthrough direction stays single-surface (the
21
+ * 2026-10-26 decision on issue #11 — status quo).
14
22
  */
15
23
  /** One claimed ask: a settle promise raced against the ask's abort signal. */
16
24
  class PendingAsk {
@@ -48,6 +56,36 @@ class PendingAsk {
48
56
  this.cancel();
49
57
  }
50
58
  }
59
+ /**
60
+ * Relays the host's abort signal into an owned controller so the losing
61
+ * ask surface's wait can be closed independently: the web leg receives
62
+ * `relay.signal` instead of the host's own signal, and a Discord answer
63
+ * aborts it to settle the web card without touching the host's signal.
64
+ */
65
+ class SignalRelay {
66
+ controller = new AbortController();
67
+ constructor(source) {
68
+ if (source === undefined)
69
+ return;
70
+ if (source.aborted) {
71
+ this.controller.abort(source.reason);
72
+ return;
73
+ }
74
+ source.addEventListener('abort', () => { this.controller.abort(source.reason); }, { once: true });
75
+ }
76
+ get signal() {
77
+ return this.controller.signal;
78
+ }
79
+ /** Close the relayed wait (a no-op when the source signal already did). */
80
+ abort(reason) {
81
+ this.controller.abort(reason);
82
+ }
83
+ }
84
+ /** The question waterfall's "no connected answerer" rejection shape. */
85
+ function isNoProvider(cause) {
86
+ return typeof cause === 'object' && cause !== null
87
+ && cause.code === 'NO_PROVIDER';
88
+ }
51
89
  /**
52
90
  * Wrap both ask services. Thread-bound sessions get the Discord ask flow;
53
91
  * everything else passes through untouched. Returns the settle ports the
@@ -57,6 +95,8 @@ class PendingAsk {
57
95
  export function installAskServicePatches(approval, questions, deps) {
58
96
  const pendingApprovals = new Map();
59
97
  const pendingQuestions = new Map();
98
+ /** Live web-surface waits for claimed asks, keyed by ask id. */
99
+ const webRelays = new Map();
60
100
  // Capture the current entries (own or prototype) before patching; dispose
61
101
  // restores exactly these.
62
102
  const originalRequest = approval.request.bind(approval);
@@ -73,6 +113,11 @@ export function installAskServicePatches(approval, questions, deps) {
73
113
  if (questions !== undefined && originalAsk !== undefined) {
74
114
  questions.ask = originalAsk;
75
115
  }
116
+ // Close every live web wait before cancelling the Discord surface:
117
+ // the relayed abort lets the original entries settle their cards.
118
+ for (const [, relay] of [...webRelays])
119
+ relay.abort();
120
+ webRelays.clear();
76
121
  for (const [, pending] of [...pendingApprovals])
77
122
  pending.dispose();
78
123
  pendingApprovals.clear();
@@ -105,9 +150,40 @@ export function installAskServicePatches(approval, questions, deps) {
105
150
  });
106
151
  const pending = new PendingAsk(pendingApprovals, approvalId, req.signal, () => 'cancelled');
107
152
  pendingApprovals.set(approvalId, pending);
153
+ // Dual surface (16.105): the web surface races the Discord buttons
154
+ // through the original entry under a relayed signal. The first
155
+ // decisive outcome wins; 'unavailable' (no connected answerer, or an
156
+ // answerer failure the host normalizes fail-closed) and 'cancelled'
157
+ // (an abort racing the decision) never decide for the user.
158
+ const relay = new SignalRelay(req.signal);
159
+ webRelays.set(approvalId, relay);
160
+ void Promise.resolve()
161
+ .then(() => originalRequest({ ...req, signal: relay.signal }))
162
+ .then(outcome => {
163
+ if (outcome !== 'allowed-once' && outcome !== 'rejected') {
164
+ deps.log('discord_ask_dual_web_ignored', { kind: 'approval', approvalId, outcome });
165
+ return;
166
+ }
167
+ if (!pending.resolve(outcome))
168
+ return;
169
+ deps.log('discord_ask_dual_web_won', { kind: 'approval', approvalId, outcome });
170
+ deps.askWiring.onApprovalResolved({ sessionId, approvalId, outcome });
171
+ })
172
+ .catch((cause) => {
173
+ // The real entry normalizes answerer failures to 'unavailable';
174
+ // a rejection here means the relay closed the wait (the Discord
175
+ // surface won) or the host aborted — both already settled.
176
+ if (relay.signal.aborted)
177
+ return;
178
+ deps.log('discord_ask_dual_web_failed', { kind: 'approval', approvalId, cause: String(cause) });
179
+ });
108
180
  if (req.signal?.aborted === true)
109
181
  pending.dispose();
110
182
  void pending.promise.then(() => {
183
+ webRelays.delete(approvalId);
184
+ // Close the web wait when the Discord surface settled first (a
185
+ // no-op when the web answer already won or the host aborted).
186
+ relay.abort();
111
187
  deps.onWaitState?.(threadId, false);
112
188
  void deps.askWiring.disableControl(approvalId).catch((cause) => {
113
189
  deps.log('discord_ask_control_disable_failed', { key: approvalId, cause: String(cause) });
@@ -144,9 +220,36 @@ export function installAskServicePatches(approval, questions, deps) {
144
220
  const cancelledAnswer = { answers: [] };
145
221
  const pending = new PendingAsk(pendingQuestions, rpcId, req.signal, () => cancelledAnswer);
146
222
  pendingQuestions.set(rpcId, pending);
223
+ // Dual surface (16.105): mirror the batch to the web surface under
224
+ // the relayed signal; the first complete answer batch wins.
225
+ const relay = new SignalRelay(req.signal);
226
+ webRelays.set(rpcId, relay);
227
+ void Promise.resolve()
228
+ .then(() => originalAsk({ ...req, signal: relay.signal }))
229
+ .then(answer => {
230
+ if (!pending.resolve(answer))
231
+ return;
232
+ deps.log('discord_ask_dual_web_won', { kind: 'question', rpcId });
233
+ deps.askWiring.onQuestionResolved({ sessionId, questionRpcId: rpcId, outcome: 'answered' });
234
+ })
235
+ .catch((cause) => {
236
+ // NO_PROVIDER = no connected web client: the Discord surface
237
+ // carries the ask alone (the pre-dual-surface behavior).
238
+ if (isNoProvider(cause)) {
239
+ deps.log('discord_ask_dual_web_absent', { kind: 'question', rpcId });
240
+ return;
241
+ }
242
+ if (relay.signal.aborted)
243
+ return;
244
+ deps.log('discord_ask_dual_web_failed', { kind: 'question', rpcId, cause: String(cause) });
245
+ });
147
246
  if (req.signal?.aborted === true)
148
247
  pending.dispose();
149
248
  void pending.promise.then(() => {
249
+ webRelays.delete(rpcId);
250
+ // Close the web wait when the Discord surface settled first (a
251
+ // no-op when the web answer already won or the host aborted).
252
+ relay.abort();
150
253
  deps.onWaitState?.(threadId, false);
151
254
  void deps.askWiring.disableControl(rpcId).catch((cause) => {
152
255
  deps.log('discord_ask_control_disable_failed', { key: rpcId, cause: String(cause) });
@@ -73,3 +73,32 @@ export interface BindingReconciliationPlan {
73
73
  }
74
74
  /** Plan one reconciliation pass. Pure: callers decide how actions are applied. */
75
75
  export declare function planBindingReconciliation(input: BindingReconciliationInput): BindingReconciliationPlan;
76
+ /**
77
+ * One direct channel probe for a thread binding the guild's active-thread
78
+ * listing did not account for (16.104). The shapes mirror the REST client's
79
+ * outcome vocabulary; `completed` needs no body — existence is the verdict.
80
+ */
81
+ export type ThreadChannelProbeOutcome = {
82
+ outcome: 'completed';
83
+ } | {
84
+ outcome: 'rejected';
85
+ status: number;
86
+ } | {
87
+ outcome: 'unknown';
88
+ };
89
+ export type ThreadChannelProbe = (threadId: string) => Promise<ThreadChannelProbeOutcome>;
90
+ /**
91
+ * Refine facts for thread bindings absent from `/guilds/{id}/threads/active`.
92
+ * That listing drops both deleted threads and Discord-archived ones, so
93
+ * absence alone proves nothing (the pre-16.104 planner kept such threads
94
+ * forever — deleting a bound channel orphans every thread binding under it,
95
+ * permanently excluding those sessions from `/session resume`). One
96
+ * `GET /channels/{threadId}` each settles it: 200 → `ok` (alive, including
97
+ * Discord-archived — the thread remains the binding's live surface and a
98
+ * post un-archives it), 404 → `missing` (verified deletion → retire),
99
+ * anything else → `unknown` (keep-blocked, exactly the inconclusive
100
+ * posture the reconciliation spec mandates). Probes run sequentially; a
101
+ * retired binding leaves the table, so the probe set only ever shrinks to
102
+ * the genuinely alive or persistently unreachable.
103
+ */
104
+ export declare function refineUnlistedThreadFacts(threadIds: readonly string[], probe: ThreadChannelProbe): Promise<Record<string, 'ok' | 'missing' | 'unknown'>>;
@@ -52,3 +52,30 @@ export function planBindingReconciliation(input) {
52
52
  });
53
53
  return { channelActions, threadActions };
54
54
  }
55
+ /**
56
+ * Refine facts for thread bindings absent from `/guilds/{id}/threads/active`.
57
+ * That listing drops both deleted threads and Discord-archived ones, so
58
+ * absence alone proves nothing (the pre-16.104 planner kept such threads
59
+ * forever — deleting a bound channel orphans every thread binding under it,
60
+ * permanently excluding those sessions from `/session resume`). One
61
+ * `GET /channels/{threadId}` each settles it: 200 → `ok` (alive, including
62
+ * Discord-archived — the thread remains the binding's live surface and a
63
+ * post un-archives it), 404 → `missing` (verified deletion → retire),
64
+ * anything else → `unknown` (keep-blocked, exactly the inconclusive
65
+ * posture the reconciliation spec mandates). Probes run sequentially; a
66
+ * retired binding leaves the table, so the probe set only ever shrinks to
67
+ * the genuinely alive or persistently unreachable.
68
+ */
69
+ export async function refineUnlistedThreadFacts(threadIds, probe) {
70
+ const facts = {};
71
+ for (const threadId of threadIds) {
72
+ const probed = await probe(threadId);
73
+ if (probed.outcome === 'completed')
74
+ facts[threadId] = 'ok';
75
+ else if (probed.outcome === 'rejected' && probed.status === 404)
76
+ facts[threadId] = 'missing';
77
+ else
78
+ facts[threadId] = 'unknown';
79
+ }
80
+ return facts;
81
+ }
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@ import { handleSelectInput, handleModalSubmit } from './features/question-routin
21
21
  import { createCopy, permissionPresetLabel } from './i18n.js';
22
22
  import { createInteractionRouter } from './features/interaction-router.js';
23
23
  import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
24
- import { planBindingReconciliation } from './features/reconcile-bindings.js';
24
+ import { planBindingReconciliation, refineUnlistedThreadFacts } from './features/reconcile-bindings.js';
25
25
  import { guildKeysToForget, sweepExpired } from './state/retention.js';
26
26
  import { staleUncertainIntents } from './features/reconcile-intents.js';
27
27
  import { createHostEventRouter } from './dsh/host-events.js';
@@ -673,6 +673,28 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
673
673
  if (facts[channelId] === undefined)
674
674
  facts[channelId] = 'missing';
675
675
  }
676
+ // The active-thread listing drops Discord-archived threads too,
677
+ // so an unlisted thread binding is *absent evidence*, not
678
+ // deletion — one direct channel probe each settles it (16.104):
679
+ // 404 retires the orphan (deleting a bound channel kills every
680
+ // thread under it), 200 keeps it (archived threads are still the
681
+ // binding's live surface), anything else stays unverified.
682
+ const unlistedThreads = [...threadKeyById.keys()].filter(threadId => facts[threadId] === undefined);
683
+ if (unlistedThreads.length > 0) {
684
+ const refined = await refineUnlistedThreadFacts(unlistedThreads, async (threadId) => {
685
+ const probed = await rest.request('GET', `/channels/${threadId}`);
686
+ rpcLog('discord_reconcile_thread_probe', {
687
+ threadId,
688
+ outcome: probed.outcome === 'rejected' ? `http-${String(probed.status)}` : probed.outcome,
689
+ });
690
+ if (probed.outcome === 'completed')
691
+ return { outcome: 'completed' };
692
+ if (probed.outcome === 'rejected')
693
+ return { outcome: 'rejected', status: probed.status };
694
+ return { outcome: 'unknown' };
695
+ });
696
+ Object.assign(facts, refined);
697
+ }
676
698
  }
677
699
  // Default surface (16.34/16.37): the adapter's category and the
678
700
  // general control channel are ensured on every READY. Bound home
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addozhang/dsh-discord",
3
- "version": "0.6.4-rc.1",
3
+ "version": "0.6.4",
4
4
  "description": "Discord adapter for DeepSeek Harness · DeepSeek Harness 的 Discord 适配器",
5
5
  "license": "MIT",
6
6
  "type": "module",