@addozhang/dsh-discord 0.2.3 → 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
@@ -109,6 +109,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
109
109
  | `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
110
110
  | `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
111
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 |
112
113
  | `/guild forget` | any channel | operator-only removal of adapter records |
113
114
 
114
115
  ## Design notes
@@ -122,7 +123,6 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
122
123
 
123
124
  ## Known Limitations and Deferred Work
124
125
 
125
- - **`/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.
126
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).
127
127
  - **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
128
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
@@ -109,6 +109,7 @@ dsh-discord:
109
109
  | `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
110
110
  | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
111
111
  | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
112
+ | `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现 |
112
113
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
113
114
 
114
115
 
@@ -123,7 +124,6 @@ dsh-discord:
123
124
 
124
125
  ## 已知限制与推迟项
125
126
 
126
- - **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
127
127
  - **`/preset`、`/skill`、`/host` 保持注销状态** — 控制模块已实现并通过单元测试,待路由接线时回归(`/preset` 的会话线程守卫一并处理)。
128
128
  - **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
129
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.3",
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
  }