@addozhang/dsh-discord 0.2.3 → 0.4.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
@@ -6,6 +6,7 @@
6
6
  [![CI](https://img.shields.io/github/actions/workflow/status/addozhang/dsh-discord/ci.yml?branch=main&label=CI)](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
7
7
  [![license](https://img.shields.io/npm/l/@addozhang/dsh-discord)](./LICENSE)
8
8
  [![node](https://img.shields.io/node/v/@addozhang/dsh-discord)](./package.json)
9
+ [![dshfind](https://dshfind.com/api/badge/addozhang/dsh-discord)](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
9
10
 
10
11
  A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai): run DSH sessions from a Discord guild — mention the bot to open a task thread, steer and stop turns, answer approvals and questions inline, and watch the answer stream in.
11
12
 
@@ -17,7 +18,7 @@ This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'setti
17
18
 
18
19
  ## Features
19
20
 
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.
21
+ - **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (the author's message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
21
22
  - **Stream rendering** — typing indicators, a single edited head message, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
22
23
  - **Approvals & questions** — DSH ask frames become Discord buttons, select menus, and a free-text modal; ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
23
24
 
@@ -109,6 +110,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
109
110
  | `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
110
111
  | `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
111
112
  | `/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) |
113
+ | `/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
114
  | `/guild forget` | any channel | operator-only removal of adapter records |
113
115
 
114
116
  ## Design notes
@@ -122,7 +124,6 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
122
124
 
123
125
  ## Known Limitations and Deferred Work
124
126
 
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
127
  - **`/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
128
  - **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
128
129
  - **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.
@@ -132,7 +133,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
132
133
 
133
134
  ```sh
134
135
  pnpm install --ignore-scripts
135
- pnpm test # 650 tests incl. gateway/REST twin E2E
136
+ pnpm test # 683 tests incl. gateway/REST twin E2E
136
137
  pnpm typecheck
137
138
  pnpm lint
138
139
  pnpm build # lib + client bundle
package/README.zh.md CHANGED
@@ -6,6 +6,7 @@
6
6
  [![CI](https://img.shields.io/github/actions/workflow/status/addozhang/dsh-discord/ci.yml?branch=main&label=CI)](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
7
7
  [![license](https://img.shields.io/npm/l/@addozhang/dsh-discord)](./LICENSE)
8
8
  [![node](https://img.shields.io/node/v/@addozhang/dsh-discord)](./package.json)
9
+ [![dshfind](https://dshfind.com/api/badge/addozhang/dsh-discord)](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
9
10
 
10
11
  [DeepSeek Harness](https://github.com/deepseek-ai) 的 Discord 优先适配器:在 Discord 服务器中运行 DSH 会话——@机器人即可开启任务线程、插话与停止运行、在线审批与回答问题,并实时观看回答流式输出。
11
12
 
@@ -17,7 +18,7 @@
17
18
 
18
19
  ## 功能
19
20
 
20
- - **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。
21
+ - **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
21
22
  - **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
22
23
  - **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
23
24
 
@@ -109,6 +110,7 @@ dsh-discord:
109
110
  | `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
110
111
  | `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
111
112
  | `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
113
+ | `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现 |
112
114
  | `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
113
115
 
114
116
 
@@ -123,7 +125,6 @@ dsh-discord:
123
125
 
124
126
  ## 已知限制与推迟项
125
127
 
126
- - **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
127
128
  - **`/preset`、`/skill`、`/host` 保持注销状态** — 控制模块已实现并通过单元测试,待路由接线时回归(`/preset` 的会话线程守卫一并处理)。
128
129
  - **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
129
130
  - **经 Kimaki 对齐后有意推迟**:reconcile-interactions 接线、ask 等待期暂停 typing、fail-closed 绑定/会话属主 store 接线、凭据轮换监听。
@@ -133,7 +134,7 @@ dsh-discord:
133
134
 
134
135
  ```sh
135
136
  pnpm install --ignore-scripts
136
- pnpm test # 650 tests incl. gateway/REST twin E2E
137
+ pnpm test # 683 tests incl. gateway/REST twin E2E
137
138
  pnpm typecheck
138
139
  pnpm lint
139
140
  pnpm build # lib + client bundle
package/lib/compose.d.ts CHANGED
@@ -31,6 +31,13 @@ export interface SessionMainlinePort {
31
31
  authorId: string;
32
32
  workspaceId: string;
33
33
  prompt: string;
34
+ /** Declared image attachments carried by the message (16.50). */
35
+ images: ReadonlyArray<{
36
+ url: string;
37
+ filename: string;
38
+ declaredSize: number;
39
+ contentType: string;
40
+ }>;
34
41
  }): Promise<{
35
42
  outcome: 'admitted';
36
43
  threadId: string;
@@ -47,6 +54,9 @@ export interface SessionMainlinePort {
47
54
  outcome: 'prompt-rejected';
48
55
  } | {
49
56
  outcome: 'prompt-unknown';
57
+ } | {
58
+ outcome: 'image-failed';
59
+ reason: string;
50
60
  }>;
51
61
  continueInThread(request: {
52
62
  applicationId: string;
@@ -55,6 +65,13 @@ export interface SessionMainlinePort {
55
65
  sessionId: string;
56
66
  messageId: string;
57
67
  prompt: string;
68
+ /** Declared image attachments carried by the message (16.50). */
69
+ images: ReadonlyArray<{
70
+ url: string;
71
+ filename: string;
72
+ declaredSize: number;
73
+ contentType: string;
74
+ }>;
58
75
  }): Promise<{
59
76
  outcome: 'queued';
60
77
  } | {
@@ -65,6 +82,9 @@ export interface SessionMainlinePort {
65
82
  outcome: 'rejected';
66
83
  } | {
67
84
  outcome: 'unknown';
85
+ } | {
86
+ outcome: 'image-failed';
87
+ reason: string;
68
88
  }>;
69
89
  }
70
90
  export interface BindingsProbe {
package/lib/compose.js CHANGED
@@ -18,10 +18,13 @@ export const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
18
18
  export function routeEvent(deps, event, decision) {
19
19
  if (event.kind === 'message') {
20
20
  const prompt = event.content.trim();
21
+ const images = event.images;
22
+ // Text and images are independent task carriers: either alone admits.
23
+ const hasTask = prompt !== '' || images.length > 0;
21
24
  // Adapter-owned thread: ordinary continuation, no mention required.
22
25
  const sessionId = deps.bindings.sessionForThread(event.guildId, event.channelId);
23
26
  if (sessionId !== undefined) {
24
- if (prompt === '')
27
+ if (!hasTask)
25
28
  return;
26
29
  const request = {
27
30
  applicationId: deps.applicationId(),
@@ -30,10 +33,11 @@ export function routeEvent(deps, event, decision) {
30
33
  sessionId,
31
34
  messageId: event.messageId,
32
35
  prompt,
36
+ images,
33
37
  };
34
38
  void deps.mainline.continueInThread(request)
35
39
  .then((result) => {
36
- if (result.outcome === 'rejected' || result.outcome === 'unknown') {
40
+ if (result.outcome === 'rejected' || result.outcome === 'unknown' || result.outcome === 'image-failed') {
37
41
  deps.logger?.warn('discord_continuation_not_queued', { ...result, messageId: event.messageId });
38
42
  }
39
43
  })
@@ -63,7 +67,7 @@ export function routeEvent(deps, event, decision) {
63
67
  }
64
68
  return;
65
69
  }
66
- if (prompt === '')
70
+ if (!hasTask)
67
71
  return;
68
72
  void deps.mainline.admitMention({
69
73
  applicationId: deps.applicationId(),
@@ -73,6 +77,7 @@ export function routeEvent(deps, event, decision) {
73
77
  authorId: event.authorId,
74
78
  workspaceId,
75
79
  prompt,
80
+ images,
76
81
  })
77
82
  .then((result) => {
78
83
  if (result.outcome !== 'admitted') {
@@ -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
  ]),
@@ -34,6 +34,10 @@ export interface DshApiProxyFace {
34
34
  content: Array<{
35
35
  type: 'text';
36
36
  text: string;
37
+ } | {
38
+ type: 'image';
39
+ mediaType: string;
40
+ data: string;
37
41
  }>;
38
42
  }>): Promise<RpcResponseShape<{
39
43
  accepted: true;
@@ -62,9 +66,7 @@ export interface DshApiProxyFace {
62
66
  list(request: RpcRequestShape<{
63
67
  cursor?: string;
64
68
  }>): Promise<RpcResponseShape<{
65
- items: Array<{
66
- sessionId: string;
67
- }>;
69
+ items: SessionSummaryShape[];
68
70
  }>>;
69
71
  models(request: RpcRequestShape<{
70
72
  sessionId: string;
@@ -79,6 +81,29 @@ export interface DshApiProxyFace {
79
81
  }>>;
80
82
  };
81
83
  }
84
+ /** Defensive read of the title projection in a list row's values. */
85
+ export interface SessionProjectionsShape {
86
+ values?: {
87
+ title?: unknown;
88
+ };
89
+ }
90
+ /** The per-session summary `sessions.list` returns (rc.2 rich rows). */
91
+ export interface SessionSummaryShape {
92
+ sessionId: string;
93
+ updatedAt: number;
94
+ running: boolean;
95
+ blank: boolean;
96
+ cwd?: string;
97
+ agentPreset?: string;
98
+ origin?: 'subagent';
99
+ projections?: SessionProjectionsShape;
100
+ }
101
+ /** The complete provider/model/reasoning selection (dsh-agent ModelSelection). */
102
+ export interface ModelSelectionShape {
103
+ provider: string;
104
+ model: string;
105
+ reasoningEffort?: string;
106
+ }
82
107
  /** One reasoning effort a model's adapter advertises (sessions.d.ts). */
83
108
  import type { DshModelPort } from '../features/model-control.js';
84
109
  export interface ModelReasoningEffortShape {
@@ -115,12 +140,6 @@ export interface SessionModelsShape {
115
140
  message: string;
116
141
  }>;
117
142
  }
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
143
  /** Signature-layer narrow request form (RpcId brand erased at this seam). */
125
144
  export interface RpcRequestShape<P> {
126
145
  rpcId: string;
@@ -200,10 +219,16 @@ export type PromptOutcome = {
200
219
  * the turn may or may not have been admitted, so callers must not resubmit.
201
220
  * `options.rpcId` pins the adapter-owned stable request id, which the Host
202
221
  * records on the durable `user/message` (`source.rpcId`) for reconciliation.
222
+ * Images (16.50) encode as ordered `image` parts after the text part — the
223
+ * rc.2 `session.prompt` content is parts-shaped at this seam.
203
224
  */
204
225
  export declare function promptSession(dsh: DshApiProxyFace, request: {
205
226
  sessionId: string;
206
227
  prompt: string;
228
+ images?: ReadonlyArray<{
229
+ mediaType: string;
230
+ base64: string;
231
+ }>;
207
232
  }, options?: ApiProxyFaceOptions & {
208
233
  rpcId?: string;
209
234
  }): Promise<PromptOutcome>;
@@ -246,6 +271,31 @@ export type SessionIdListOutcome = {
246
271
  };
247
272
  /** List durable Session ids (`session.list`, v1 returns everything). */
248
273
  export declare function listSessionIds(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): Promise<SessionIdListOutcome>;
274
+ /** A list row narrowed to what the /session resume surface renders. */
275
+ export interface SessionResumeRow {
276
+ sessionId: string;
277
+ title: string | undefined;
278
+ updatedAt: number;
279
+ running: boolean;
280
+ blank: boolean;
281
+ cwd: string | undefined;
282
+ origin: 'subagent' | undefined;
283
+ }
284
+ export type SessionSummariesOutcome = {
285
+ outcome: 'completed';
286
+ sessions: SessionResumeRow[];
287
+ } | {
288
+ outcome: 'failed';
289
+ } | {
290
+ outcome: 'unknown';
291
+ };
292
+ /**
293
+ * The rich `sessions.list` for the /session resume surface: titles ride each
294
+ * row's projection values (absence = the session has no title yet), blank
295
+ * sessions are flagged, and rows arrive updatedAt-descending. Defensive
296
+ * narrowing: the wire is untrusted, extra/missing fields never throw.
297
+ */
298
+ export declare function listSessionSummaries(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): Promise<SessionSummariesOutcome>;
249
299
  export type CancelOutcome = {
250
300
  outcome: 'accepted';
251
301
  } | {
@@ -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', {
@@ -162,6 +174,8 @@ export async function readWorkspaceDetail(dsh, reference, options = {}) {
162
174
  * the turn may or may not have been admitted, so callers must not resubmit.
163
175
  * `options.rpcId` pins the adapter-owned stable request id, which the Host
164
176
  * records on the durable `user/message` (`source.rpcId`) for reconciliation.
177
+ * Images (16.50) encode as ordered `image` parts after the text part — the
178
+ * rc.2 `session.prompt` content is parts-shaped at this seam.
165
179
  */
166
180
  export async function promptSession(dsh, request, options = {}) {
167
181
  return submitPromptTurn(dsh, { ...request, mode: 'queue' }, options);
@@ -181,7 +195,10 @@ async function submitPromptTurn(dsh, request, options) {
181
195
  response = await withRpcTimeout(dsh.sessions.prompt(mintRequest({
182
196
  sessionId: request.sessionId,
183
197
  mode: request.mode,
184
- content: [{ type: 'text', text: request.prompt }],
198
+ content: [
199
+ { type: 'text', text: request.prompt },
200
+ ...(request.images ?? []).map(image => ({ type: 'image', mediaType: image.mediaType, data: image.base64 })),
201
+ ],
185
202
  }, options.rpcId)), timeoutMs);
186
203
  }
187
204
  catch (cause) {
@@ -271,6 +288,55 @@ export async function listSessionIds(dsh, options = {}) {
271
288
  log?.('discord_session_list_rejected', { code: result.error.code });
272
289
  return { outcome: 'failed' };
273
290
  }
291
+ /**
292
+ * The rich `sessions.list` for the /session resume surface: titles ride each
293
+ * row's projection values (absence = the session has no title yet), blank
294
+ * sessions are flagged, and rows arrive updatedAt-descending. Defensive
295
+ * narrowing: the wire is untrusted, extra/missing fields never throw.
296
+ */
297
+ export async function listSessionSummaries(dsh, options = {}) {
298
+ const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
299
+ const log = options.log;
300
+ let response;
301
+ try {
302
+ response = await withRpcTimeout(dsh.sessions.list(mintRequest({})), timeoutMs);
303
+ }
304
+ catch (cause) {
305
+ if (cause instanceof RpcTimeoutError) {
306
+ log?.('discord_session_summaries_timeout', { timeoutMs });
307
+ return { outcome: 'unknown' };
308
+ }
309
+ log?.('discord_session_summaries_threw', { cause: String(cause) });
310
+ return { outcome: 'unknown' };
311
+ }
312
+ const result = response?.result;
313
+ if (result === undefined || !result.ok || !Array.isArray(result.value.items)) {
314
+ log?.('discord_session_summaries_malformed');
315
+ return { outcome: 'failed' };
316
+ }
317
+ const sessions = [];
318
+ // The wire is untrusted: narrow every row defensively before use.
319
+ const items = Array.isArray(result.value.items) ? result.value.items : [];
320
+ for (const item of items) {
321
+ if (typeof item !== 'object' || item === null)
322
+ continue;
323
+ const row = item;
324
+ if (typeof row.sessionId !== 'string' || row.sessionId === '')
325
+ continue;
326
+ const values = row.projections?.values;
327
+ const title = typeof values?.title === 'string' && values.title !== '' ? values.title : undefined;
328
+ sessions.push({
329
+ sessionId: row.sessionId,
330
+ title,
331
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
332
+ running: row.running ?? false,
333
+ blank: row.blank === true,
334
+ cwd: typeof row.cwd === 'string' ? row.cwd : undefined,
335
+ origin: row.origin === 'subagent' ? 'subagent' : undefined,
336
+ });
337
+ }
338
+ return { outcome: 'completed', sessions };
339
+ }
274
340
  /** Cancel the session's active turn (`session.cancel`); DSH preserves the pending inbox. */
275
341
  export async function cancelSessionViaProxy(dsh, request, options = {}) {
276
342
  const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
@@ -37,10 +37,21 @@ export interface ImageAttachment {
37
37
  declaredSize: number;
38
38
  contentType: string;
39
39
  }
40
+ /**
41
+ * The production download port (16.50): every fetch rides the safe boundary
42
+ * — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
43
+ * supported media types only.
44
+ */
45
+ export declare function createSafeImageDownloadPort(): ImageDownloadPort;
40
46
  export type ImageCollectionResult = {
41
47
  outcome: 'collected';
42
48
  images: number;
43
49
  totalBytes: number;
50
+ /** The downloaded images in attachment order (16.50): callers encode them for submission. */
51
+ downloaded: ReadonlyArray<{
52
+ mediaType: string;
53
+ body: Uint8Array;
54
+ }>;
44
55
  } | {
45
56
  outcome: 'too-large';
46
57
  reason: 'declared' | 'actual' | 'aggregate';
@@ -8,10 +8,22 @@
8
8
  * bounded in practice by Discord CDN attachment limits and the aggregate
9
9
  * cap, not by streaming. Refusals are plain values.
10
10
  */
11
+ import { createHttpFetchPort, fetchSafeImage } from './image-download.js';
11
12
  /** Per-image cap (design: bounded by DSH-advertised limits; adapter-capped). */
12
13
  export const MAX_IMAGE_BYTES = 8 * 1_024 * 1_024;
13
14
  /** Aggregate cap across all images of one message. */
14
15
  export const MAX_AGGREGATE_IMAGE_BYTES = 24 * 1_024 * 1_024;
16
+ /**
17
+ * The production download port (16.50): every fetch rides the safe boundary
18
+ * — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
19
+ * supported media types only.
20
+ */
21
+ export function createSafeImageDownloadPort() {
22
+ const http = createHttpFetchPort();
23
+ return {
24
+ download: request => fetchSafeImage(http, { url: request.url }),
25
+ };
26
+ }
15
27
  /**
16
28
  * Wrap the safe download boundary with byte-count enforcement: the body size
17
29
  * is checked against the remaining aggregate budget before it is accepted,
@@ -35,6 +47,7 @@ export function collectImages(port, request) {
35
47
  let remainingAggregate = MAX_AGGREGATE_IMAGE_BYTES;
36
48
  let images = 0;
37
49
  let totalBytes = 0;
50
+ const downloaded = [];
38
51
  const inner = port;
39
52
  async function run() {
40
53
  for (const attachment of request.attachments) {
@@ -50,30 +63,31 @@ export function collectImages(port, request) {
50
63
  const timeout = new Promise((resolve) => {
51
64
  timer = setTimeout(() => { resolve('timeout'); }, request.timeoutMs);
52
65
  });
53
- const downloaded = await Promise.race([
66
+ const downloadedImage = await Promise.race([
54
67
  bounded.download({ url: attachment.url }).then((result) => result),
55
68
  timeout,
56
69
  ]).finally(() => {
57
70
  if (timer !== undefined)
58
71
  clearTimeout(timer);
59
72
  });
60
- if (downloaded === 'timeout')
73
+ if (downloadedImage === 'timeout')
61
74
  return { outcome: 'timeout' };
62
- if (downloaded.outcome === 'downloaded') {
63
- if (downloaded.body.byteLength > MAX_IMAGE_BYTES) {
75
+ if (downloadedImage.outcome === 'downloaded') {
76
+ if (downloadedImage.body.byteLength > MAX_IMAGE_BYTES) {
64
77
  return { outcome: 'too-large', reason: 'actual' };
65
78
  }
66
79
  images += 1;
67
- totalBytes += downloaded.body.byteLength;
68
- remainingAggregate -= downloaded.body.byteLength;
80
+ totalBytes += downloadedImage.body.byteLength;
81
+ remainingAggregate -= downloadedImage.body.byteLength;
82
+ downloaded.push({ mediaType: downloadedImage.mediaType, body: downloadedImage.body });
69
83
  continue;
70
84
  }
71
- if (downloaded.outcome === 'http-error' && downloaded.status === 413) {
85
+ if (downloadedImage.outcome === 'http-error' && downloadedImage.status === 413) {
72
86
  return { outcome: 'too-large', reason: 'actual' };
73
87
  }
74
88
  return { outcome: 'download-failed' };
75
89
  }
76
- return { outcome: 'collected', images, totalBytes };
90
+ return { outcome: 'collected', images, totalBytes, downloaded };
77
91
  }
78
92
  return run();
79
93
  }
@@ -46,3 +46,9 @@ export type SafeImageResult = {
46
46
  export declare function fetchSafeImage(port: HttpFetchPort, request: {
47
47
  url: string;
48
48
  }): Promise<SafeImageResult>;
49
+ /**
50
+ * The production fetch boundary over the platform `fetch` (16.50). Redirects
51
+ * are NOT followed here — `fetchSafeImage` revalidates every hop itself —
52
+ * and the body is only read for a terminal 200 response.
53
+ */
54
+ export declare function createHttpFetchPort(): HttpFetchPort;
@@ -75,3 +75,24 @@ function isParseable(rawUrl) {
75
75
  export function fetchSafeImage(port, request) {
76
76
  return fetchOnce(port, MAX_REDIRECTS, request);
77
77
  }
78
+ /**
79
+ * The production fetch boundary over the platform `fetch` (16.50). Redirects
80
+ * are NOT followed here — `fetchSafeImage` revalidates every hop itself —
81
+ * and the body is only read for a terminal 200 response.
82
+ */
83
+ export function createHttpFetchPort() {
84
+ return {
85
+ async fetch(url) {
86
+ const response = await fetch(url, { redirect: 'manual' });
87
+ const location = response.headers.get('location');
88
+ const contentType = response.headers.get('content-type');
89
+ const body = response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : undefined;
90
+ return {
91
+ status: response.status,
92
+ contentType: contentType ?? undefined,
93
+ location: location ?? undefined,
94
+ body,
95
+ };
96
+ },
97
+ };
98
+ }
@@ -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).