@addozhang/dsh-discord 0.2.2 → 0.3.0

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
@@ -11,11 +11,16 @@ A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai):
11
11
 
12
12
  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
13
 
14
+ <p align="center">
15
+ <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">
16
+ </p>
17
+
14
18
  ## Features
15
19
 
16
20
  - **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.
17
21
  - **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.
18
- - **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 remote resolution retires the controls.
22
+ - **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.
23
+
19
24
  - **Session control** — `/steer`, `/stop`, `/queue list|remove` with turn-ownership checks; `/project bind|list|info` for guild↔workspace binding; `/guild forget` for operator cleanup.
20
25
  - **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.
21
26
  - **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.
@@ -82,6 +87,10 @@ dsh-discord:
82
87
 
83
88
  The settings card exposes the three high-frequency fields (guild allowlist, auto-archive, language) plus the connection and token surface; every other key is fully supported through `settings.yaml`. An invalid stored section preserves the last known-good configuration.
84
89
 
90
+ <p align="center">
91
+ <img src="docs/images/settings-card.png" alt="The Discord settings card: connection, guild allowlist, auto-archive, bot language" width="560">
92
+ </p>
93
+
85
94
  ## Setup
86
95
 
87
96
  1. Invite the bot to your guild with at least: View Channels, **Manage Channels** (the adapter provisions its category and workspace home channels), Send Messages, Create Public Threads, Send Messages in Threads, Attach Files, Read Message History.
@@ -100,6 +109,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
100
109
  | `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
101
110
  | `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
102
111
  | `/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) |
112
+ | `/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 |
103
113
  | `/guild forget` | any channel | operator-only removal of adapter records |
104
114
 
105
115
  ## Design notes
@@ -113,7 +123,6 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
113
123
 
114
124
  ## Known Limitations and Deferred Work
115
125
 
116
- - **`/session new|resume` is not registered** — the selector and cold-adoption modules are implemented and unit-tested, but the Host RPC face cannot back them yet (`sessions.list` v1 returns bare ids; no `session.inspect`). They return with the next milestone.
117
126
  - **`/preset`, `/skill`, and `/host` stay deregistered** — their control modules are implemented and unit-tested and return when the router wires them (the `/preset` thread-context guard rides along).
118
127
  - **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
119
128
  - **Deferred after a Kimaki parity pass**: reconcile-interactions wiring, typing pause during ask waits, fail-closed binding/session-owner store wiring, and credential-rotation watching.
package/README.zh.md CHANGED
@@ -11,11 +11,16 @@
11
11
 
12
12
  本插件为 function/namespace 插件(`inject: ['apiProxy', 'credentials', 'settings', 'storageDomain', 'connection']`):将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile;会话状态保存在 DSH,适配器的持久绑定保存在 profile 的存储域中。
13
13
 
14
+ <p align="center">
15
+ <img src="docs/images/discord-task-lifecycle.jpg" alt="一次任务的完整生命周期:@提及锚定线程、工具活动行随回答流式编辑、最终以 Markdown 表格落地" width="720">
16
+ </p>
17
+
14
18
  ## 功能
15
19
 
16
20
  - **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。
17
21
  - **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
18
- - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,远端决议自动退役控件。
22
+ - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
23
+
19
24
  - **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 管理 Guild↔工作区绑定;`/guild forget` 供操作员清理。
20
25
  - **模型切换** — `/model show` 读取会话的实时模型目录(当前选择、可服务状态、目录分组);`/model select` 走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放,可通过设置收紧为仅 Host 操作员),也可直接填写 `provider/model` 应用。
21
26
  - **设置卡片** — Token 引导(粘贴 + 连接;存入 Host 凭据服务,绝不写入设置或日志)、连接/断开、服务器白名单、线程自动归档、Bot 语言。
@@ -82,6 +87,10 @@ dsh-discord:
82
87
 
83
88
  设置卡片暴露三个高频项(服务器白名单、自动归档、语言)以及连接与 Token 面;其余键完全支持通过 `settings.yaml` 配置。非法的已存配置会保留最近一次有效配置。
84
89
 
90
+ <p align="center">
91
+ <img src="docs/images/settings-card.png" alt="Discord 设置卡片:连接状态、服务器白名单、归档、语言" width="560">
92
+ </p>
93
+
85
94
  ## 初始设置
86
95
 
87
96
  1. 以至少以下权限把 Bot 邀请到你的服务器:查看频道、**管理频道**(适配器要创建自己的分类和工作区频道)、发送消息、创建公共线程、在线程中发送消息、上传文件、读取消息历史。
@@ -100,6 +109,7 @@ dsh-discord:
100
109
  | `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
101
110
  | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
102
111
  | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
112
+ | `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现 |
103
113
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
104
114
 
105
115
 
@@ -114,7 +124,6 @@ dsh-discord:
114
124
 
115
125
  ## 已知限制与推迟项
116
126
 
117
- - **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
118
127
  - **`/preset`、`/skill`、`/host` 保持注销状态** — 控制模块已实现并通过单元测试,待路由接线时回归(`/preset` 的会话线程守卫一并处理)。
119
128
  - **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
120
129
  - **经 Kimaki 对齐后有意推迟**:reconcile-interactions 接线、ask 等待期暂停 typing、fail-closed 绑定/会话属主 store 接线、凭据轮换监听。
@@ -35,6 +35,12 @@ export const MILESTONE_ONE_COMMANDS = [
35
35
  // → reasoning cascade (16.35); typing `provider/model` applies directly.
36
36
  { name: 'select', options: [{ name: 'model', required: false }, { name: 'reasoning', required: false }] },
37
37
  ]),
38
+ grouped('session', 'Resume a DSH session into a new thread', [
39
+ // Typing filters live candidates by session title (autocomplete);
40
+ // /session new is deliberately absent — the @mention IS the new-session
41
+ // path (design.md §13).
42
+ { name: 'resume', options: [{ name: 'session', required: true, autocomplete: true }] },
43
+ ]),
38
44
  grouped('guild', 'Guild-scoped adapter operations', [
39
45
  { name: 'forget' },
40
46
  ]),
@@ -62,9 +62,7 @@ export interface DshApiProxyFace {
62
62
  list(request: RpcRequestShape<{
63
63
  cursor?: string;
64
64
  }>): Promise<RpcResponseShape<{
65
- items: Array<{
66
- sessionId: string;
67
- }>;
65
+ items: SessionSummaryShape[];
68
66
  }>>;
69
67
  models(request: RpcRequestShape<{
70
68
  sessionId: string;
@@ -79,6 +77,29 @@ export interface DshApiProxyFace {
79
77
  }>>;
80
78
  };
81
79
  }
80
+ /** Defensive read of the title projection in a list row's values. */
81
+ export interface SessionProjectionsShape {
82
+ values?: {
83
+ title?: unknown;
84
+ };
85
+ }
86
+ /** The per-session summary `sessions.list` returns (rc.2 rich rows). */
87
+ export interface SessionSummaryShape {
88
+ sessionId: string;
89
+ updatedAt: number;
90
+ running: boolean;
91
+ blank: boolean;
92
+ cwd?: string;
93
+ agentPreset?: string;
94
+ origin?: 'subagent';
95
+ projections?: SessionProjectionsShape;
96
+ }
97
+ /** The complete provider/model/reasoning selection (dsh-agent ModelSelection). */
98
+ export interface ModelSelectionShape {
99
+ provider: string;
100
+ model: string;
101
+ reasoningEffort?: string;
102
+ }
82
103
  /** One reasoning effort a model's adapter advertises (sessions.d.ts). */
83
104
  import type { DshModelPort } from '../features/model-control.js';
84
105
  export interface ModelReasoningEffortShape {
@@ -115,12 +136,6 @@ export interface SessionModelsShape {
115
136
  message: string;
116
137
  }>;
117
138
  }
118
- /** The complete provider/model/reasoning selection (dsh-agent ModelSelection). */
119
- export interface ModelSelectionShape {
120
- provider: string;
121
- model: string;
122
- reasoningEffort?: string;
123
- }
124
139
  /** Signature-layer narrow request form (RpcId brand erased at this seam). */
125
140
  export interface RpcRequestShape<P> {
126
141
  rpcId: string;
@@ -246,6 +261,31 @@ export type SessionIdListOutcome = {
246
261
  };
247
262
  /** List durable Session ids (`session.list`, v1 returns everything). */
248
263
  export declare function listSessionIds(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): Promise<SessionIdListOutcome>;
264
+ /** A list row narrowed to what the /session resume surface renders. */
265
+ export interface SessionResumeRow {
266
+ sessionId: string;
267
+ title: string | undefined;
268
+ updatedAt: number;
269
+ running: boolean;
270
+ blank: boolean;
271
+ cwd: string | undefined;
272
+ origin: 'subagent' | undefined;
273
+ }
274
+ export type SessionSummariesOutcome = {
275
+ outcome: 'completed';
276
+ sessions: SessionResumeRow[];
277
+ } | {
278
+ outcome: 'failed';
279
+ } | {
280
+ outcome: 'unknown';
281
+ };
282
+ /**
283
+ * The rich `sessions.list` for the /session resume surface: titles ride each
284
+ * row's projection values (absence = the session has no title yet), blank
285
+ * sessions are flagged, and rows arrive updatedAt-descending. Defensive
286
+ * narrowing: the wire is untrusted, extra/missing fields never throw.
287
+ */
288
+ export declare function listSessionSummaries(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): Promise<SessionSummariesOutcome>;
249
289
  export type CancelOutcome = {
250
290
  outcome: 'accepted';
251
291
  } | {
@@ -101,7 +101,19 @@ export function createWorkspaceCatalogPort(dsh, options = {}) {
101
101
  workspaces: items.map(workspace => ({
102
102
  id: workspace.workspaceId,
103
103
  title: workspace.title,
104
+ // The registered path rides every workspace.* row (Host
105
+ // WorkspaceView); /session resume scopes candidates by it and
106
+ // /project autocomplete abbreviates it — dropping it here once
107
+ // silently emptied the resume list everywhere (16.46).
108
+ ...(typeof workspace.path === 'string' ? { path: workspace.path } : {}),
104
109
  })),
110
+ // The registry's archived set rides the workspace.list value
111
+ // (sessions.list rows carry NO archived marker): /session resume
112
+ // subtracts it — resuming an archived session dead-ends in a
113
+ // thread whose turns never run (16.49).
114
+ archivedSessionIds: Array.isArray(result.value.archivedSessionIds)
115
+ ? result.value.archivedSessionIds.filter((id) => typeof id === 'string')
116
+ : [],
105
117
  };
106
118
  }
107
119
  log?.('discord_workspace_list_rejected', {
@@ -271,6 +283,55 @@ export async function listSessionIds(dsh, options = {}) {
271
283
  log?.('discord_session_list_rejected', { code: result.error.code });
272
284
  return { outcome: 'failed' };
273
285
  }
286
+ /**
287
+ * The rich `sessions.list` for the /session resume surface: titles ride each
288
+ * row's projection values (absence = the session has no title yet), blank
289
+ * sessions are flagged, and rows arrive updatedAt-descending. Defensive
290
+ * narrowing: the wire is untrusted, extra/missing fields never throw.
291
+ */
292
+ export async function listSessionSummaries(dsh, options = {}) {
293
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
294
+ const log = options.log;
295
+ let response;
296
+ try {
297
+ response = await withRpcTimeout(dsh.sessions.list(mintRequest({})), timeoutMs);
298
+ }
299
+ catch (cause) {
300
+ if (cause instanceof RpcTimeoutError) {
301
+ log?.('discord_session_summaries_timeout', { timeoutMs });
302
+ return { outcome: 'unknown' };
303
+ }
304
+ log?.('discord_session_summaries_threw', { cause: String(cause) });
305
+ return { outcome: 'unknown' };
306
+ }
307
+ const result = response?.result;
308
+ if (result === undefined || !result.ok || !Array.isArray(result.value.items)) {
309
+ log?.('discord_session_summaries_malformed');
310
+ return { outcome: 'failed' };
311
+ }
312
+ const sessions = [];
313
+ // The wire is untrusted: narrow every row defensively before use.
314
+ const items = Array.isArray(result.value.items) ? result.value.items : [];
315
+ for (const item of items) {
316
+ if (typeof item !== 'object' || item === null)
317
+ continue;
318
+ const row = item;
319
+ if (typeof row.sessionId !== 'string' || row.sessionId === '')
320
+ continue;
321
+ const values = row.projections?.values;
322
+ const title = typeof values?.title === 'string' && values.title !== '' ? values.title : undefined;
323
+ sessions.push({
324
+ sessionId: row.sessionId,
325
+ title,
326
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
327
+ running: row.running ?? false,
328
+ blank: row.blank === true,
329
+ cwd: typeof row.cwd === 'string' ? row.cwd : undefined,
330
+ origin: row.origin === 'subagent' ? 'subagent' : undefined,
331
+ });
332
+ }
333
+ return { outcome: 'completed', sessions };
334
+ }
274
335
  /** Cancel the session's active turn (`session.cancel`); DSH preserves the pending inbox. */
275
336
  export async function cancelSessionViaProxy(dsh, request, options = {}) {
276
337
  const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
@@ -85,6 +85,48 @@ export interface InteractionRouterDeps {
85
85
  purgeChannelBinding: (guildId: string, channelId: string) => Promise<void>;
86
86
  /** The session's live model directory + selection mutation (/model surface). */
87
87
  model: DshModelPort;
88
+ /**
89
+ * /session resume autocomplete candidates: rich Host rows filtered to
90
+ * non-blank, unbound sessions, title-filtered, newest-first, capped at 25
91
+ * (16.44).
92
+ */
93
+ resumeCandidates: (query: string, workspacePath?: string, archivedSessionIds?: ReadonlySet<string>) => Promise<{
94
+ outcome: 'ok';
95
+ options: Array<{
96
+ label: string;
97
+ value: string;
98
+ description?: string;
99
+ }>;
100
+ } | {
101
+ outcome: 'unavailable';
102
+ }>;
103
+ /**
104
+ * Cold-adopt a session into a NEW thread of the bound project channel:
105
+ * anchor post → message-anchored thread creation → durable thread→session
106
+ * binding. Already-bound sessions resolve to their existing thread
107
+ * (16.44).
108
+ */
109
+ resumeSession: (input: {
110
+ sessionId: string;
111
+ workspaceId: string;
112
+ guildId: string;
113
+ parentChannelId: string;
114
+ actorId: string;
115
+ }) => Promise<{
116
+ outcome: 'started';
117
+ threadId: string;
118
+ } | {
119
+ outcome: 'already-bound';
120
+ threadId: string;
121
+ } | {
122
+ outcome: 'refused-control-channel';
123
+ } | {
124
+ outcome: 'refused-subagent';
125
+ } | {
126
+ outcome: 'refused-archived';
127
+ } | {
128
+ outcome: 'failed';
129
+ }>;
88
130
  /**
89
131
  * Whether /model select stays Host-operator-only (default). Single-user
90
132
  * deployments flip this so any authorized member can switch (16.42).
@@ -52,19 +52,41 @@ export function createInteractionRouter(deps) {
52
52
  });
53
53
  }
54
54
  async function routeAutocomplete(event, interactionToken) {
55
- if (event.commandName !== 'project')
55
+ if (event.commandName !== 'project' && event.commandName !== 'session')
56
56
  return;
57
57
  // Autocomplete has no deferred-ack form: whatever happens below, Discord
58
58
  // must receive exactly one type-8 answer, even if only empty choices.
59
59
  try {
60
60
  const decision = authorize(event);
61
61
  const choices = [];
62
- if (decision.allowed) {
62
+ const wireOptions = event.data['options'];
63
+ const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
64
+ const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
65
+ const query = typeof focused?.value === 'string' ? focused.value : '';
66
+ if (decision.allowed && event.commandName === 'session') {
67
+ // /session resume: live candidates scoped to the bound workspace.
68
+ try {
69
+ const binding = deps.channelBinding(event.guildId, event.channelId);
70
+ const catalog = await deps.catalogPort.listWorkspaces();
71
+ const workspacePath = binding !== undefined && catalog.outcome === 'completed'
72
+ ? catalog.workspaces.find(workspace => workspace.id === binding.workspaceId)?.path
73
+ : undefined;
74
+ const archivedSessionIds = catalog.outcome === 'completed'
75
+ ? new Set(catalog.archivedSessionIds)
76
+ : undefined;
77
+ const outcome = await deps.resumeCandidates(query, workspacePath, archivedSessionIds);
78
+ if (outcome.outcome === 'ok') {
79
+ choices.push(...outcome.options.slice(0, 25).map(option => (option.description === undefined
80
+ ? { name: option.label, value: option.value }
81
+ : { name: option.label, value: option.value, description: option.description })));
82
+ }
83
+ }
84
+ catch (cause) {
85
+ deps.warn('discord_autocomplete_sessions_failed', String(cause));
86
+ }
87
+ }
88
+ else if (decision.allowed && event.commandName === 'project') {
63
89
  try {
64
- const wireOptions = event.data['options'];
65
- const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
66
- const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
67
- const query = typeof focused?.value === 'string' ? focused.value : '';
68
90
  const catalog = await deps.catalogPort.listWorkspaces();
69
91
  if (catalog.outcome === 'completed') {
70
92
  choices.push(...workspaceAutocompleteChoices(catalog.workspaces, query).slice(0, 25));
@@ -483,6 +505,57 @@ export function createInteractionRouter(deps) {
483
505
  await followUp(content);
484
506
  return;
485
507
  }
508
+ if (event.commandName === 'session') {
509
+ // /session resume: cold-adopt an existing DSH session into a new
510
+ // thread of the bound project channel (16.44). /session new is
511
+ // deliberately absent — the @mention is the new-session path.
512
+ const options = event.data['options'];
513
+ const subcommand = Array.isArray(options) ? options[0] : undefined;
514
+ if (subcommand?.name !== 'resume') {
515
+ await followUp(deps.copy.unknownSubcommand);
516
+ return;
517
+ }
518
+ const binding = deps.channelBinding(event.guildId, event.channelId);
519
+ if (binding === undefined) {
520
+ await followUp(deps.copy.sessionResumeNeedsBoundChannel);
521
+ return;
522
+ }
523
+ const wireOptions = Array.isArray(subcommand.options) ? subcommand.options : [];
524
+ const sessionId = wireOptions.find(option => option.name === 'session')?.value;
525
+ if (typeof sessionId !== 'string' || sessionId === '') {
526
+ await followUp(deps.copy.sessionResumeFailed);
527
+ return;
528
+ }
529
+ const outcome = await deps.resumeSession({
530
+ sessionId,
531
+ workspaceId: binding.workspaceId,
532
+ guildId: event.guildId,
533
+ parentChannelId: event.channelId,
534
+ actorId: event.actorId,
535
+ });
536
+ if (outcome.outcome === 'refused-control-channel') {
537
+ await followUp(deps.copy.sessionResumeControlChannel);
538
+ return;
539
+ }
540
+ if (outcome.outcome === 'refused-subagent') {
541
+ await followUp(deps.copy.sessionResumeSubagent);
542
+ return;
543
+ }
544
+ if (outcome.outcome === 'refused-archived') {
545
+ await followUp(deps.copy.sessionResumeArchived);
546
+ return;
547
+ }
548
+ if (outcome.outcome === 'already-bound') {
549
+ await followUp(deps.copy.sessionResumeAlreadyBound(outcome.threadId));
550
+ return;
551
+ }
552
+ if (outcome.outcome === 'started') {
553
+ await followUp(deps.copy.sessionResumeStarted(outcome.threadId));
554
+ return;
555
+ }
556
+ await followUp(deps.copy.sessionResumeFailed);
557
+ return;
558
+ }
486
559
  // Terminal guard: a recognized interaction type with an unhandled
487
560
  // command name must still answer, never strand on the deferred ack.
488
561
  await followUp(deps.copy.unknownSubcommand);
@@ -22,6 +22,7 @@ export interface ProjectListPort {
22
22
  title: string;
23
23
  path?: string | undefined;
24
24
  }>;
25
+ archivedSessionIds: ReadonlyArray<string>;
25
26
  } | {
26
27
  outcome: 'failed';
27
28
  } | {
@@ -1,44 +1,78 @@
1
1
  /**
2
- * `/session resume` selector (design.md §13, task 9.1). Metadata only: the
3
- * current workspace's sessions, filtered by id/title substring never
4
- * full-text content search (disabled by default in the Web profile).
5
- * Untitled sessions fall back to their id; archived sessions carry an
6
- * explicit mark so the user can tell before adopting.
2
+ * `/session resume` candidate building (design.md §13, tasks 9.1 + 16.44).
3
+ * The Host's `sessions.list` returns rich rows (updatedAt desc, running,
4
+ * blank, cwd, title via projection values). Candidates exclude blank
5
+ * sessions and sessions this adapter already owns a thread for, filter by
6
+ * title/id substring, and sort newest-first Discord autocomplete caps at
7
+ * 25 choices, the caller slices.
7
8
  */
9
+ import type { SessionResumeRow } from '../dsh/api-proxy-face.js';
10
+ export type { SessionResumeRow };
11
+ import type { CopyTable } from '../i18n.js';
8
12
  import { type SelectorOption } from '../discord/selector.js';
9
13
  export interface SessionCatalogPort {
10
14
  listSessions(): Promise<{
11
15
  outcome: 'completed';
12
- sessions: ReadonlyArray<{
13
- sessionId: string;
14
- title: string | null;
15
- archived: boolean;
16
- }>;
16
+ sessions: ReadonlyArray<SessionResumeRow>;
17
17
  } | {
18
18
  outcome: 'failed';
19
19
  } | {
20
20
  outcome: 'unknown';
21
21
  }>;
22
22
  }
23
- export interface ResumeSelectorRequest {
24
- workspaceId: string;
25
- selectionId: string;
26
- query?: string | undefined;
27
- page?: number | undefined;
28
- }
29
- export type ResumeSelectorView = {
23
+ export type ResumeCandidatesPort = (query: string,
24
+ /**
25
+ * The bound Workspace's REGISTERED PATH, resolved by the router from the
26
+ * channel binding through the workspace catalog. Not a workspace id: the
27
+ * port must never re-resolve it through the catalog (an id/path swap here
28
+ * once answered `unavailable` in every channel — 16.45). `undefined`
29
+ * (unbound channel / catalog miss) is fail-closed.
30
+ */
31
+ workspacePath?: string,
32
+ /** The registry's archived set (16.49): archived sessions never resume. */
33
+ archivedSessionIds?: ReadonlySet<string>) => Promise<{
30
34
  outcome: 'ok';
31
- items: SelectorOption[];
32
- pageIndex: number;
33
- pageCount: number;
34
- hasPrev: boolean;
35
- hasNext: boolean;
36
- navValues: {
37
- prev?: string;
38
- next?: string;
39
- };
35
+ options: SelectorOption[];
40
36
  } | {
41
- outcome: 'failed';
42
- reason: 'session-catalog-unavailable' | 'session-catalog-unknown';
43
- };
44
- export declare function buildResumeSelector(port: SessionCatalogPort, request: ResumeSelectorRequest): Promise<ResumeSelectorView>;
37
+ outcome: 'unavailable';
38
+ }>;
39
+ /**
40
+ * Relative-time rendering for a candidate's label. Discord autocomplete
41
+ * choices carry no description field on the wire (name/value only), so the
42
+ * age rides the label — bilingual via the copy table (16.47).
43
+ */
44
+ export declare function relativeTime(copy: CopyTable, fromMs: number, nowMs: number): string;
45
+ /**
46
+ * Build the /session resume autocomplete candidates from one Host listing:
47
+ * blank sessions are hidden (nothing to resume), already-bound sessions are
48
+ * excluded (their thread is the live surface), the query filters over
49
+ * title/id case-insensitively, and rows sort newest-first. Uncapped here —
50
+ * the caller applies Discord's 25-choice ceiling. Same-titled candidates
51
+ * get a short-id suffix: choices have no description on the wire, so
52
+ * identical labels would be indistinguishable (16.47).
53
+ */
54
+ export declare function buildResumeCandidates(sessions: ReadonlyArray<SessionResumeRow>, options: {
55
+ /** Registered path of the bound Workspace; only its sessions are offered. */
56
+ workspacePath: string | undefined;
57
+ boundSessionIds?: ReadonlySet<string>;
58
+ /** Registry's archived set: archived sessions never resume (16.49). */
59
+ archivedSessionIds?: ReadonlySet<string>;
60
+ query?: string | undefined;
61
+ nowMs?: number;
62
+ copy: CopyTable;
63
+ }): SelectorOption[];
64
+ /** Discard candidates: prefix matches first, then substring, capped. */
65
+ export declare function filterResumeCandidates(candidates: ReadonlyArray<SelectorOption>, query: string, limit?: number): SelectorOption[];
66
+ /**
67
+ * The composition-root wiring for the router's `resumeCandidates` dep
68
+ * (16.44): one Host listing per call, filtered by the handed-in workspace
69
+ * path against the live thread-ownership set. Listing failure is logged at
70
+ * the face (`…failed`/`…timeout`) and degrades to `unavailable` — Discord
71
+ * autocomplete then answers empty choices, never an unanswered interaction.
72
+ */
73
+ export declare function createResumeCandidatesPort(deps: {
74
+ listSessions: SessionCatalogPort['listSessions'];
75
+ /** Sessions this adapter already owns a thread for, re-read per call. */
76
+ boundSessionIds: () => ReadonlySet<string>;
77
+ copy: CopyTable;
78
+ }): ResumeCandidatesPort;
@@ -1,40 +1,113 @@
1
1
  /**
2
- * `/session resume` selector (design.md §13, task 9.1). Metadata only: the
3
- * current workspace's sessions, filtered by id/title substring never
4
- * full-text content search (disabled by default in the Web profile).
5
- * Untitled sessions fall back to their id; archived sessions carry an
6
- * explicit mark so the user can tell before adopting.
2
+ * `/session resume` candidate building (design.md §13, tasks 9.1 + 16.44).
3
+ * The Host's `sessions.list` returns rich rows (updatedAt desc, running,
4
+ * blank, cwd, title via projection values). Candidates exclude blank
5
+ * sessions and sessions this adapter already owns a thread for, filter by
6
+ * title/id substring, and sort newest-first Discord autocomplete caps at
7
+ * 25 choices, the caller slices.
7
8
  */
8
- import { filterAutocomplete, paginateSelector } from '../discord/selector.js';
9
- import { safeTitle } from '../policy/disclosure.js';
10
- export async function buildResumeSelector(port, request) {
11
- const catalog = await port.listSessions();
12
- if (catalog.outcome === 'failed') {
13
- return { outcome: 'failed', reason: 'session-catalog-unavailable' };
14
- }
15
- if (catalog.outcome === 'unknown') {
16
- return { outcome: 'failed', reason: 'session-catalog-unknown' };
17
- }
18
- const selectable = catalog.sessions.map(session => ({
19
- label: session.archived
20
- ? `[archived] ${session.title === null ? session.sessionId : safeTitle(session.title)}`
21
- : session.title === null
22
- ? session.sessionId
23
- : safeTitle(session.title),
24
- value: `sess:${session.sessionId.replace('sess-', '')}`,
9
+ import { filterAutocomplete } from '../discord/selector.js';
10
+ /**
11
+ * Relative-time rendering for a candidate's label. Discord autocomplete
12
+ * choices carry no description field on the wire (name/value only), so the
13
+ * age rides the label — bilingual via the copy table (16.47).
14
+ */
15
+ export function relativeTime(copy, fromMs, nowMs) {
16
+ const delta = Math.max(0, nowMs - fromMs);
17
+ const minutes = Math.floor(delta / 60_000);
18
+ if (minutes < 1)
19
+ return copy.sessionCandidateJustNow;
20
+ if (minutes < 60)
21
+ return copy.sessionCandidateMinutesAgo(minutes);
22
+ const hours = Math.floor(minutes / 60);
23
+ if (hours < 24)
24
+ return copy.sessionCandidateHoursAgo(hours);
25
+ const days = Math.floor(hours / 24);
26
+ return copy.sessionCandidateDaysAgo(days);
27
+ }
28
+ /** Short display form of a session id (first 8 chars). */
29
+ function shortId(sessionId) {
30
+ return sessionId.slice(0, 8);
31
+ }
32
+ /** Discord rejects the whole autocomplete answer when a choice name exceeds 100. */
33
+ const DISCORD_CHOICE_NAME_MAX = 100;
34
+ function candidateLabel(copy, session, nowMs) {
35
+ const parts = [session.title ?? shortId(session.sessionId), relativeTime(copy, session.updatedAt, nowMs)];
36
+ if (session.running)
37
+ parts.push(copy.sessionCandidateRunning);
38
+ const label = parts.join(' · ');
39
+ return label.length <= DISCORD_CHOICE_NAME_MAX ? label : label.slice(0, DISCORD_CHOICE_NAME_MAX);
40
+ }
41
+ /**
42
+ * Build the /session resume autocomplete candidates from one Host listing:
43
+ * blank sessions are hidden (nothing to resume), already-bound sessions are
44
+ * excluded (their thread is the live surface), the query filters over
45
+ * title/id case-insensitively, and rows sort newest-first. Uncapped here —
46
+ * the caller applies Discord's 25-choice ceiling. Same-titled candidates
47
+ * get a short-id suffix: choices have no description on the wire, so
48
+ * identical labels would be indistinguishable (16.47).
49
+ */
50
+ export function buildResumeCandidates(sessions, options) {
51
+ const query = (options.query ?? '').trim().toLowerCase();
52
+ const nowMs = options.nowMs ?? Date.now();
53
+ const bound = options.boundSessionIds ?? new Set();
54
+ const archived = options.archivedSessionIds ?? new Set();
55
+ const copy = options.copy;
56
+ const rows = sessions
57
+ .filter(session => !session.blank && !bound.has(session.sessionId))
58
+ // Subagent sessions are never resumable as top-level threads (the spec's
59
+ // "Subagent Session selected" refusal) — offering them only produces
60
+ // selections that must fail (16.48).
61
+ .filter(session => session.origin !== 'subagent')
62
+ // Archived sessions adopt fine but never run a turn — the user waits in
63
+ // a dead thread. `workspace.list` carries the registry's archived set;
64
+ // sessions.list rows have no archived marker (16.49).
65
+ .filter(session => !archived.has(session.sessionId))
66
+ // Workspace scoping (16.44): a session belongs to the channel's Workspace
67
+ // only when its recorded cwd is that Workspace's registered path.
68
+ // Sessions with no recorded cwd cannot be attributed and are never
69
+ // offered.
70
+ .filter(session => options.workspacePath === undefined || session.cwd === options.workspacePath)
71
+ .sort((a, b) => b.updatedAt - a.updatedAt);
72
+ const options_ = rows.map(session => ({
73
+ label: candidateLabel(copy, session, nowMs),
74
+ value: session.sessionId,
25
75
  }));
26
- const query = request.query ?? '';
27
- const matches = query.trim() === ''
28
- ? selectable
29
- : filterAutocomplete(selectable, query.trim(), Number.POSITIVE_INFINITY);
30
- const page = paginateSelector(matches, request.page ?? 0, `${request.selectionId}:page`);
31
- return {
32
- outcome: 'ok',
33
- items: page.items,
34
- pageIndex: page.pageIndex,
35
- pageCount: page.pageCount,
36
- hasPrev: page.hasPrev,
37
- hasNext: page.hasNext,
38
- navValues: page.navValues,
76
+ const counts = new Map();
77
+ for (const option of options_)
78
+ counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
79
+ const disambiguated = options_.map(option => (counts.get(option.label) ?? 0) > 1
80
+ ? { label: `${option.label} · ${shortId(option.value)}`.slice(0, DISCORD_CHOICE_NAME_MAX), value: option.value }
81
+ : option);
82
+ if (query === '')
83
+ return disambiguated;
84
+ return disambiguated.filter(option => option.label.toLowerCase().includes(query) || option.value.toLowerCase().includes(query));
85
+ }
86
+ /** Discard candidates: prefix matches first, then substring, capped. */
87
+ export function filterResumeCandidates(candidates, query, limit = 25) {
88
+ return filterAutocomplete(candidates, query, limit);
89
+ }
90
+ /**
91
+ * The composition-root wiring for the router's `resumeCandidates` dep
92
+ * (16.44): one Host listing per call, filtered by the handed-in workspace
93
+ * path against the live thread-ownership set. Listing failure is logged at
94
+ * the face (`…failed`/`…timeout`) and degrades to `unavailable` — Discord
95
+ * autocomplete then answers empty choices, never an unanswered interaction.
96
+ */
97
+ export function createResumeCandidatesPort(deps) {
98
+ return async (query, workspacePath, archivedSessionIds) => {
99
+ if (workspacePath === undefined)
100
+ return { outcome: 'unavailable' };
101
+ const summaries = await deps.listSessions();
102
+ if (summaries.outcome !== 'completed')
103
+ return { outcome: 'unavailable' };
104
+ const candidates = buildResumeCandidates(summaries.sessions, {
105
+ workspacePath,
106
+ boundSessionIds: deps.boundSessionIds(),
107
+ ...(archivedSessionIds === undefined ? {} : { archivedSessionIds }),
108
+ query,
109
+ copy: deps.copy,
110
+ });
111
+ return { outcome: 'ok', options: filterResumeCandidates(candidates, query, 25) };
39
112
  };
40
113
  }
package/lib/i18n.d.ts CHANGED
@@ -109,6 +109,19 @@ declare const zh: {
109
109
  modelNotInCatalog: string;
110
110
  modelInvalidReasoning: string;
111
111
  modelTypedParseFailed: string;
112
+ sessionResumeNeedsBoundChannel: string;
113
+ sessionResumeStarted: (threadId: string) => string;
114
+ sessionResumeAlreadyBound: (threadId: string) => string;
115
+ sessionResumeFailed: string;
116
+ sessionResumeAnchor: (title: string) => string;
117
+ sessionResumeControlChannel: string;
118
+ sessionResumeSubagent: string;
119
+ sessionResumeArchived: string;
120
+ sessionCandidateJustNow: string;
121
+ sessionCandidateMinutesAgo: (minutes: number) => string;
122
+ sessionCandidateHoursAgo: (hours: number) => string;
123
+ sessionCandidateDaysAgo: (days: number) => string;
124
+ sessionCandidateRunning: string;
112
125
  approvalRequired: (label: string) => string;
113
126
  };
114
127
  export type CopyTable = typeof zh;
package/lib/i18n.js CHANGED
@@ -118,6 +118,22 @@ const zh = {
118
118
  modelNotInCatalog: '⚠️ 该 provider/模型不在当前会话的目录中。',
119
119
  modelInvalidReasoning: '⚠️ 该推理强度对此模型无效。',
120
120
  modelTypedParseFailed: '⚠️ 模型需按 `provider/model` 格式填写,或留空进入交互式选择。',
121
+ // ── /session resume ──────────────────────────────────────────────────
122
+ sessionResumeNeedsBoundChannel: '⚠️ /session resume 需要在已绑定工作区的项目频道中使用。',
123
+ sessionResumeStarted: (threadId) => `✅ 会话已恢复到 <#${threadId}>——历史在 Web 界面查看,线程内直接续聊。`,
124
+ sessionResumeAlreadyBound: (threadId) => `该会话已在 <#${threadId}> 中。`,
125
+ sessionResumeFailed: '⚠️ 会话恢复失败,请稍后重试。',
126
+ sessionResumeAnchor: (title) => `📌 恢复会话:${title}`,
127
+ sessionResumeControlChannel: 'general 是控制频道,不承载会话——请到工作区频道使用 /session resume。',
128
+ sessionResumeSubagent: '⚠️ 这是 subagent 会话,不能恢复为顶层线程——可在 Web 界面查看。',
129
+ sessionResumeArchived: '⚠️ 该会话已归档,恢复后不会有任何运行——请在 Web 界面先取消归档。',
130
+ // Discord autocomplete choices carry no description field, so the age and
131
+ // running marker ride the candidate label (16.47).
132
+ sessionCandidateJustNow: '刚刚',
133
+ sessionCandidateMinutesAgo: (minutes) => `${String(minutes)} 分钟前`,
134
+ sessionCandidateHoursAgo: (hours) => `${String(hours)} 小时前`,
135
+ sessionCandidateDaysAgo: (days) => `${String(days)} 天前`,
136
+ sessionCandidateRunning: '运行中',
121
137
  // ── approval / question cards ────────────────────────────────────────
122
138
  approvalRequired: (label) => `Approval required — ${label}`,
123
139
  };
@@ -223,6 +239,20 @@ const en = {
223
239
  modelNotInCatalog: "⚠️ That provider/model is not in this session's catalog.",
224
240
  modelInvalidReasoning: '⚠️ That reasoning effort is not valid for this model.',
225
241
  modelTypedParseFailed: '⚠️ The model must be `provider/model`, or left empty for the interactive cascade.',
242
+ // ── /session resume ──────────────────────────────────────────────────
243
+ sessionResumeNeedsBoundChannel: '⚠️ /session resume must run in a bound project channel.',
244
+ sessionResumeStarted: threadId => `✅ Session resumed into <#${threadId}> — full history lives in the web UI; continue in the thread.`,
245
+ sessionResumeAlreadyBound: threadId => `This session already lives in <#${threadId}>.`,
246
+ sessionResumeFailed: '⚠️ Resuming the session failed; try again later.',
247
+ sessionResumeAnchor: title => `📌 Resumed session: ${title}`,
248
+ sessionResumeControlChannel: 'general is the control channel and carries no sessions — use /session resume in a workspace channel.',
249
+ sessionResumeSubagent: '⚠️ That is a subagent session — it cannot be resumed as a top-level thread; view it in the web UI.',
250
+ sessionResumeArchived: '⚠️ That session is archived — a resumed one never runs; unarchive it in the web UI first.',
251
+ sessionCandidateJustNow: 'just now',
252
+ sessionCandidateMinutesAgo: minutes => `${String(minutes)}m ago`,
253
+ sessionCandidateHoursAgo: hours => `${String(hours)}h ago`,
254
+ sessionCandidateDaysAgo: days => `${String(days)}d ago`,
255
+ sessionCandidateRunning: 'running',
226
256
  // ── approval / question cards ────────────────────────────────────────
227
257
  approvalRequired: label => `Approval required — ${label}`,
228
258
  };
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_DISCORD_SETTINGS, DiscordSettingsSchema, installDiscordSettings, normalizeDiscordSettings, } from './settings.js';
2
2
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
3
3
  import { installCancellationRoot } from './lifecycle.js';
4
- import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EPHEMERAL_FLAGS } from './policy/disclosure.js';
4
+ import { ALLOWED_MENTIONS_NONE, DISCORD_SUPPRESS_NOTIFICATIONS_FLAG, OUTBOUND_EPHEMERAL_FLAGS, safeTitle } from './policy/disclosure.js';
5
5
  import { validateHostCapabilities } from './startup.js';
6
6
  import { createAdapterStatusTracker, installAdapterStatusRpc, } from './features/adapter-status.js';
7
7
  import { DISCORD_BOT_TOKEN_REF, describeDiscordCredential, resolveDiscordBotToken } from './credential.js';
@@ -22,12 +22,13 @@ import { createInteractionRouter } from './features/interaction-router.js';
22
22
  import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
23
23
  import { planBindingReconciliation } from './features/reconcile-bindings.js';
24
24
  import { guildKeysToForget, sweepExpired } from './state/retention.js';
25
- import { listSessionIds, createClientRespondPort } from './dsh/api-proxy-face.js';
25
+ import { listSessionIds, listSessionSummaries, createClientRespondPort } from './dsh/api-proxy-face.js';
26
26
  import { createBindingStore } from './state/bindings.js';
27
27
  import { createIntentStore } from './state/intents.js';
28
28
  import { createTurnTracker } from './features/turn-ownership.js';
29
29
  import { createThreadCreationFlow } from './features/thread-creation.js';
30
30
  import { createSessionCreationFlow } from './features/session-creation.js';
31
+ import { createResumeCandidatesPort } from './features/session-resume.js';
31
32
  import { createPromptSubmissionFlow } from './features/prompt-submission.js';
32
33
  import { createSessionMainline, requestIdFor } from './features/session-mainline.js';
33
34
  import { startLiveRender } from './stream/live.js';
@@ -702,6 +703,91 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
702
703
  },
703
704
  model: createModelPort(apiProxy, { log: rpcLog }),
704
705
  modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
706
+ resumeCandidates: createResumeCandidatesPort({
707
+ listSessions: () => listSessionSummaries(apiProxy, { log: rpcLog }),
708
+ boundSessionIds: () => {
709
+ const bound = new Set();
710
+ for (const [, record] of threadTable.entries())
711
+ bound.add(record.sessionId);
712
+ return bound;
713
+ },
714
+ copy,
715
+ }),
716
+ resumeSession: async ({ sessionId, workspaceId, guildId, parentChannelId, actorId }) => {
717
+ const rest = await sharedRest();
718
+ if (rest === undefined)
719
+ return { outcome: 'failed' };
720
+ // threadBindings is the Discord-ownership record: a session with a
721
+ // binding already lives in a thread (16.44; the owners-store wiring
722
+ // stays deferred per 16.28).
723
+ const existingThread = threadForSession(sessionId);
724
+ if (existingThread !== undefined)
725
+ return { outcome: 'already-bound', threadId: existingThread };
726
+ // The control channel never carries sessions: refuse explicitly so
727
+ // the user is pointed at the workspace channel (16.44 rev).
728
+ const listedChannels = await rest.request('GET', `/guilds/${guildId}/channels`);
729
+ if (listedChannels.outcome === 'completed') {
730
+ const category = listedChannels.body.find(channel => channel.type === 4 && channel.name.toLowerCase() === CATEGORY_NAME.toLowerCase());
731
+ const control = category !== undefined
732
+ ? listedChannels.body.find(channel => channel.type === 0 && channel.parent_id === category.id && channel.name.toLowerCase() === 'general')
733
+ : undefined;
734
+ if (control !== undefined && control.id === parentChannelId) {
735
+ return { outcome: 'refused-control-channel' };
736
+ }
737
+ }
738
+ const summaries = await listSessionSummaries(apiProxy, { log: rpcLog });
739
+ if (summaries.outcome !== 'completed')
740
+ return { outcome: 'failed' };
741
+ const summary = summaries.sessions.find(candidate => candidate.sessionId === sessionId);
742
+ if (summary === undefined || summary.blank)
743
+ return { outcome: 'failed' };
744
+ // Spec scenario "Subagent Session selected": a subagent spawn is
745
+ // never a top-level writable thread, even if handed in directly
746
+ // (the candidates already hide it — 16.48).
747
+ if (summary.origin === 'subagent')
748
+ return { outcome: 'refused-subagent' };
749
+ // Same defense for archived sessions (16.49): adoption would
750
+ // succeed and the thread would never see a turn.
751
+ const catalog = await catalogPort.listWorkspaces();
752
+ if (catalog.outcome === 'completed'
753
+ && catalog.archivedSessionIds.includes(sessionId)) {
754
+ return { outcome: 'refused-archived' };
755
+ }
756
+ const title = summary.title ?? `Resume ${sessionId.slice(0, 8)}`;
757
+ // Anchor post: a durable marker the resume thread hangs off.
758
+ const anchor = await rest.request('POST', `/channels/${parentChannelId}/messages`, {
759
+ content: copy.sessionResumeAnchor(safeTitle(title)),
760
+ flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
761
+ allowed_mentions: ALLOWED_MENTIONS_NONE,
762
+ });
763
+ if (anchor.outcome === 'rejected') {
764
+ rpcLog('discord_session_resume_anchor_failed', { sessionId, status: `HTTP ${String(anchor.status)}` });
765
+ return { outcome: 'failed' };
766
+ }
767
+ if (anchor.outcome === 'unknown' || typeof anchor.body.id !== 'string') {
768
+ rpcLog('discord_session_resume_anchor_unknown', { sessionId });
769
+ return { outcome: 'failed' };
770
+ }
771
+ const thread = await rest.request('POST', `/channels/${parentChannelId}/messages/${anchor.body.id}/threads`, {
772
+ name: safeTitle(title),
773
+ });
774
+ if (thread.outcome === 'rejected') {
775
+ rpcLog('discord_session_resume_thread_failed', { sessionId, status: `HTTP ${String(thread.status)}` });
776
+ return { outcome: 'failed' };
777
+ }
778
+ if (thread.outcome === 'unknown' || typeof thread.body.id !== 'string') {
779
+ rpcLog('discord_session_resume_thread_unknown', { sessionId });
780
+ return { outcome: 'failed' };
781
+ }
782
+ await threadBindingStore.bind(threadBindingKey({ applicationId: applicationIdRef.current, guildId, threadId: thread.body.id }), {
783
+ sessionId,
784
+ workspaceId,
785
+ createdBy: actorId,
786
+ createdAtMs: Date.now(),
787
+ });
788
+ rpcLog('discord_session_resumed', { sessionId, threadId: thread.body.id, title });
789
+ return { outcome: 'started', threadId: thread.body.id };
790
+ },
705
791
  log: rpcLog,
706
792
  warn: (event, detail) => {
707
793
  emitLog(ctx, 'warn', { event, detail: typeof detail === 'string' ? detail : JSON.stringify(detail ?? null) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addozhang/dsh-discord",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Discord-first adapter for DeepSeek Harness",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -91,5 +91,16 @@
91
91
  "bugs": {
92
92
  "url": "https://github.com/addozhang/dsh-discord/issues"
93
93
  },
94
- "homepage": "https://github.com/addozhang/dsh-discord#readme"
94
+ "homepage": "https://github.com/addozhang/dsh-discord#readme",
95
+ "keywords": [
96
+ "discord",
97
+ "deepseek",
98
+ "deepseek-harness",
99
+ "dsh",
100
+ "dsh-plugin",
101
+ "bot",
102
+ "ai-agent",
103
+ "claude-code",
104
+ "coding-agent"
105
+ ]
95
106
  }