@xmanrui/dsh-im 1.2.0 → 1.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.
Files changed (30) hide show
  1. package/lib/index.js +166 -163
  2. package/package.json +1 -1
  3. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  4. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  5. package/plugin-src/host/channels/qq/production.mjs +3 -1
  6. package/plugin-src/host/channels/shared/production.mjs +3 -1
  7. package/plugin-src/host/channels/slack/production.mjs +3 -1
  8. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  9. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  10. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  11. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  12. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  13. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  14. package/src/channels/discord/discord-runtime.mjs +23 -0
  15. package/src/channels/feishu/bridge.mjs +18 -10
  16. package/src/channels/feishu/message-utils.mjs +47 -0
  17. package/src/channels/qq/qq-bridge.mjs +80 -28
  18. package/src/channels/shared/file-download.mjs +64 -0
  19. package/src/channels/shared/harness-client.mjs +45 -0
  20. package/src/channels/shared/inbound-file.mjs +206 -0
  21. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  22. package/src/channels/slack/slack-api.mjs +27 -4
  23. package/src/channels/slack/slack-runtime.mjs +55 -5
  24. package/src/channels/telegram/telegram-api.mjs +21 -6
  25. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  26. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  27. package/src/channels/weixin/weixin-api.mjs +45 -0
  28. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  29. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
  30. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -12,6 +12,10 @@ import {
12
12
  imagePromptUserMessage,
13
13
  promptContentForMessage,
14
14
  } from '../shared/image-prompt.mjs';
15
+ import {
16
+ hasInboundFiles,
17
+ inboundFileUserMessage,
18
+ } from '../shared/inbound-file.mjs';
15
19
  import {
16
20
  harnessAnswerForQuestion,
17
21
  harnessQuestionText,
@@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
91
95
  const HELP_TEXT = [
92
96
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
93
97
  '',
94
- '直接发送文字或图片即可继续当前会话。',
98
+ '直接发送文字、图片或文件即可继续当前会话。',
95
99
  '/new 开启一个全新会话',
96
100
  '/compact 压缩当前会话的较早上下文',
97
101
  '/workspace 工作区绝对路径 切换工作区',
@@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
431
435
  const processingReaction = this.#addReaction(messageId, 'OnIt');
432
436
  const commandMessage = extractInboundMessage(event, this.#client);
433
437
  const commandText = nonEmptyString(commandMessage.content) ?? '';
434
- const commandRunner = isControlCommand(commandText)
438
+ const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
435
439
  ? runControlCommand
436
440
  : (isModelCommand(commandText)
437
441
  ? runModelCommand
@@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
610
614
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
611
615
  await this.#send(
612
616
  event.message.chat_id,
613
- imagePromptUserMessage(error)
617
+ inboundFileUserMessage(error)
618
+ ?? imagePromptUserMessage(error)
614
619
  ?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
615
620
  ).catch(() => undefined);
616
621
  }
@@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
641
646
  {
642
647
  signal: this.#signal,
643
648
  hasImages: hasInboundImages(message),
649
+ hasFiles: hasInboundFiles(message),
644
650
  pendingInteraction: this.#pendingInteractions.has(key)
645
651
  || this.#approvals.hasPending(key),
646
652
  control: { owner: this, key },
@@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
671
677
  const message = extractInboundMessage(event, this.#client);
672
678
  const text = message.content;
673
679
  const hasImages = hasInboundImages(message);
674
- const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
675
- if (!text && !hasImages) {
676
- await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
680
+ const hasFiles = hasInboundFiles(message);
681
+ const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
682
+ if (!text && !hasImages && !hasFiles) {
683
+ await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
677
684
  return;
678
685
  }
679
686
 
@@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
1603
1610
  }
1604
1611
  }
1605
1612
 
1606
- #interactionAskOptions(event, key) {
1613
+ #interactionAskOptions(event, key, files) {
1607
1614
  return {
1608
1615
  timeoutMs: this.#replyTimeoutMs,
1609
1616
  signal: this.#signal,
@@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
1615
1622
  requiresMention: event.message.chat_type !== 'p2p',
1616
1623
  }),
1617
1624
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
1625
+ files,
1618
1626
  };
1619
1627
  }
1620
1628
 
@@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
1709
1717
  content,
1710
1718
  createOptions: { signal: this.#signal },
1711
1719
  existsOptions: { signal: this.#signal },
1712
- askOptions: this.#interactionAskOptions(event, key),
1720
+ askOptions: this.#interactionAskOptions(event, key, message.files),
1713
1721
  });
1714
1722
  let textReceipt;
1715
1723
  let textSendError = null;
@@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
1749
1757
  markdown: async (controller) => {
1750
1758
  promptStarted = true;
1751
1759
  const askOptions = {
1752
- ...this.#interactionAskOptions(event, key),
1760
+ ...this.#interactionAskOptions(event, key, message.files),
1753
1761
  onUpdate: async (update) => {
1754
1762
  await controller.setContent(this.#progressText(update));
1755
1763
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
@@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
1821
1829
  content,
1822
1830
  createOptions: { signal: this.#signal },
1823
1831
  existsOptions: { signal: this.#signal },
1824
- askOptions: this.#interactionAskOptions(event, key),
1832
+ askOptions: this.#interactionAskOptions(event, key, message.files),
1825
1833
  });
1826
1834
  let textReceipt;
1827
1835
  let textSendError = null;
@@ -125,6 +125,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
125
125
  }
126
126
  }
127
127
 
128
+ async function readStream(stream, { signal }) {
129
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
130
+ throw new Error('Feishu file download returned no readable stream');
131
+ }
132
+ signal?.throwIfAborted();
133
+ const abort = () => stream.destroy?.(
134
+ signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
135
+ );
136
+ signal?.addEventListener('abort', abort, { once: true });
137
+ const chunks = [];
138
+ let size = 0;
139
+ try {
140
+ for await (const chunk of stream) {
141
+ signal?.throwIfAborted();
142
+ const data = Buffer.from(chunk);
143
+ size += data.length;
144
+ chunks.push(data);
145
+ }
146
+ signal?.throwIfAborted();
147
+ return Buffer.concat(chunks, size);
148
+ } finally {
149
+ signal?.removeEventListener('abort', abort);
150
+ }
151
+ }
152
+
128
153
  function providerCode(value) {
129
154
  if (!value || typeof value !== 'object') return null;
130
155
  const code = value.code ?? value.error?.code;
@@ -232,6 +257,26 @@ function feishuImageSource(event, client, key) {
232
257
  };
233
258
  }
234
259
 
260
+ function feishuFileSource(event, client, file) {
261
+ const key = nonEmptyString(file?.file_key);
262
+ if (!key) return null;
263
+ return {
264
+ name: nonEmptyString(file?.file_name) ?? 'file',
265
+ async load({ signal } = {}) {
266
+ signal?.throwIfAborted();
267
+ const resource = await client?.im?.v1?.messageResource?.get?.({
268
+ path: {
269
+ message_id: event.message.message_id,
270
+ file_key: key,
271
+ },
272
+ params: { type: 'file' },
273
+ });
274
+ signal?.throwIfAborted();
275
+ return readStream(resource?.getReadableStream?.(), { signal });
276
+ },
277
+ };
278
+ }
279
+
235
280
  export function extractInboundMessage(event, client) {
236
281
  const messageType = event?.message?.message_type;
237
282
  const parsed = parsedMessageContent(event);
@@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
240
285
  ? nonEmptyString(parsed?.image_key)
241
286
  : null;
242
287
  const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
288
+ const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
243
289
  return {
244
290
  content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
245
291
  images: imageKeys.map((key) => feishuImageSource(event, client, key)),
292
+ files: file ? [file] : [],
246
293
  };
247
294
  }
248
295
 
@@ -26,6 +26,11 @@ import {
26
26
  imagePromptUserMessage,
27
27
  promptContentForMessage,
28
28
  } from '../shared/image-prompt.mjs';
29
+ import {
30
+ hasInboundFiles,
31
+ inboundFileUserMessage,
32
+ prefetchInboundFiles,
33
+ } from '../shared/inbound-file.mjs';
29
34
  import {
30
35
  materializeOutboundArtifact,
31
36
  releaseOutboundArtifact,
@@ -55,7 +60,7 @@ const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
55
60
  const HELP_TEXT = [
56
61
  'QQ 机器人已连接 DeepSeek Harness。',
57
62
  '',
58
- '直接发送文字或图片即可继续当前会话。',
63
+ '直接发送文字、图片或文件即可继续当前会话。',
59
64
  '/new 开启一个全新会话',
60
65
  '/compact 压缩当前会话的较早上下文',
61
66
  '/workspace 工作区绝对路径 切换工作区',
@@ -100,32 +105,63 @@ function hasQqImageAttachments(message) {
100
105
  && message.attachments.some(isQqImageAttachment);
101
106
  }
102
107
 
108
+ function hasQqFileAttachments(message) {
109
+ return Array.isArray(message?.attachments)
110
+ && message.attachments.some((attachment) => !isQqImageAttachment(attachment));
111
+ }
112
+
113
+ async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
114
+ const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
115
+ const response = await fetchImpl(new URL(normalizedUrl), {
116
+ method: 'GET',
117
+ signal,
118
+ redirect: 'follow',
119
+ });
120
+ if (!response?.ok) {
121
+ await response?.body?.cancel?.().catch?.(() => undefined);
122
+ throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
123
+ }
124
+ return Buffer.from(await response.arrayBuffer());
125
+ }
126
+
103
127
  /** Convert QQ's attachment metadata into lazily downloaded image references. */
104
128
  export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
105
129
  if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
106
130
  const images = [];
131
+ const files = [];
107
132
  for (const attachment of message?.attachments ?? []) {
108
- if (!isQqImageAttachment(attachment)) continue;
109
133
  const url = nonEmptyString(attachment?.url);
110
134
  const name = nonEmptyString(attachment?.filename) ?? undefined;
111
135
  const mediaType = attachmentMediaType(attachment);
112
136
  const declaredSize = Number(attachment?.size);
113
- images.push({
114
- ...(name ? { name } : {}),
115
- ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
137
+ if (isQqImageAttachment(attachment)) {
138
+ images.push({
139
+ ...(name ? { name } : {}),
140
+ ...(mediaType?.startsWith('image/') ? { mediaType } : {}),
141
+ ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
142
+ load: ({ signal, maxBytes }) => {
143
+ if (!url) throw new Error('QQ image attachment has no download URL');
144
+ return fetchImageBuffer(url, {
145
+ fetchImpl,
146
+ signal,
147
+ maxBytes,
148
+ allowedHosts: QQ_IMAGE_HOSTS,
149
+ });
150
+ },
151
+ });
152
+ continue;
153
+ }
154
+ files.push({
155
+ name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
156
+ ...(mediaType?.includes('/') ? { mediaType } : {}),
116
157
  ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
117
- load: ({ signal, maxBytes }) => {
118
- if (!url) throw new Error('QQ image attachment has no download URL');
119
- return fetchImageBuffer(url, {
120
- fetchImpl,
121
- signal,
122
- maxBytes,
123
- allowedHosts: QQ_IMAGE_HOSTS,
124
- });
158
+ load: ({ signal } = {}) => {
159
+ if (!url) throw new Error('QQ file attachment has no download URL');
160
+ return fetchQqFileBuffer(url, { fetchImpl, signal });
125
161
  },
126
162
  });
127
163
  }
128
- return { content: safeText(message), images };
164
+ return { content: safeText(message), images, files };
129
165
  }
130
166
 
131
167
  function nonEmptyString(value) {
@@ -213,6 +249,7 @@ function canClaimInteractionReply(message, pending) {
213
249
  && nonEmptyString(message?.senderId) === pending.actor
214
250
  && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
215
251
  && !hasQqImageAttachments(message)
252
+ && !hasQqFileAttachments(message)
216
253
  && nonEmptyString(safeText(message));
217
254
  }
218
255
 
@@ -303,7 +340,7 @@ export class QqHarnessBridge {
303
340
  }
304
341
  const pending = this.#pendingInteractions.get(key);
305
342
  const commandText = safeText(message);
306
- const commandRunner = isControlCommand(commandText)
343
+ const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
307
344
  ? runControlCommand
308
345
  : (isModelCommand(commandText)
309
346
  ? runModelCommand
@@ -336,7 +373,7 @@ export class QqHarnessBridge {
336
373
  key,
337
374
  actor: sender,
338
375
  messageId,
339
- text: hasQqImageAttachments(message) ? '' : safeText(message),
376
+ text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
340
377
  addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
341
378
  hasPendingQuestion: Boolean(pending),
342
379
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -391,10 +428,19 @@ export class QqHarnessBridge {
391
428
  releaseMessageId = true,
392
429
  alreadyRecorded = false,
393
430
  } = {}) {
431
+ const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
432
+ const addressed = message.kind !== 'group'
433
+ || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
434
+ const preparedMessage = allowed && addressed
435
+ ? prefetchInboundFiles(
436
+ qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
437
+ { signal: this.#signal },
438
+ )
439
+ : undefined;
394
440
  const previous = this.#queues.get(key) ?? Promise.resolve();
395
441
  const current = previous
396
442
  .catch(() => undefined)
397
- .then(() => this.#process(message, key, { alreadyRecorded }))
443
+ .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
398
444
  .finally(() => {
399
445
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
400
446
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -423,6 +469,7 @@ export class QqHarnessBridge {
423
469
  const result = await runner(text, this.#harness, this.#state, key, {
424
470
  signal: this.#signal,
425
471
  hasImages: hasQqImageAttachments(message),
472
+ hasFiles: hasQqFileAttachments(message),
426
473
  pendingInteraction: this.#pendingInteractions.has(key)
427
474
  || this.#approvals.hasPending(key),
428
475
  control: { owner: this, key },
@@ -520,7 +567,7 @@ export class QqHarnessBridge {
520
567
  };
521
568
  }
522
569
 
523
- async #process(message, key, { alreadyRecorded = false } = {}) {
570
+ async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
524
571
  if (this.#signal?.aborted) return;
525
572
  const messageId = nonEmptyString(message?.messageId);
526
573
  const sender = nonEmptyString(message?.senderId);
@@ -539,35 +586,37 @@ export class QqHarnessBridge {
539
586
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
540
587
 
541
588
  const target = message.replyTarget;
542
- const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
589
+ const promptMessage = preparedMessage
590
+ ?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
543
591
  const text = promptMessage.content;
544
592
  const hasImages = hasInboundImages(promptMessage);
593
+ const hasFiles = hasInboundFiles(promptMessage);
545
594
  let stream = null;
546
595
  try {
547
- if (!text && !hasImages) {
548
- await this.#bot.sendText(target, '目前支持文字和图片消息。');
596
+ if (!text && !hasImages && !hasFiles) {
597
+ await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
549
598
  await this.#state.markSeen(messageId);
550
599
  return;
551
600
  }
552
601
  const command = text.toLowerCase();
553
- if (!hasImages && command === '/help') {
602
+ if (!hasImages && !hasFiles && command === '/help') {
554
603
  await this.#bot.sendText(target, HELP_TEXT);
555
604
  await this.#state.markSeen(messageId);
556
605
  return;
557
606
  }
558
- if (!hasImages && command === '/status') {
607
+ if (!hasImages && !hasFiles && command === '/status') {
559
608
  await this.#harness.ensureRunning({ signal: this.#signal });
560
609
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
561
610
  await this.#state.markSeen(messageId);
562
611
  return;
563
612
  }
564
- if (!hasImages && command === '/new') {
613
+ if (!hasImages && !hasFiles && command === '/new') {
565
614
  await this.#state.clearSession(key);
566
615
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
567
616
  await this.#state.markSeen(messageId);
568
617
  return;
569
618
  }
570
- const workspaceCommand = hasImages
619
+ const workspaceCommand = hasImages || hasFiles
571
620
  ? null
572
621
  : await runWorkspaceCommand(text, this.#harness, key);
573
622
  if (workspaceCommand) {
@@ -577,7 +626,7 @@ export class QqHarnessBridge {
577
626
  await this.#state.markSeen(messageId);
578
627
  return;
579
628
  }
580
- const compactCommand = hasImages
629
+ const compactCommand = hasImages || hasFiles
581
630
  ? null
582
631
  : await runCompactCommand(
583
632
  text,
@@ -632,6 +681,7 @@ export class QqHarnessBridge {
632
681
  requiresMention: message.kind === 'group',
633
682
  }),
634
683
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
684
+ files: promptMessage.files,
635
685
  },
636
686
  }));
637
687
  } finally {
@@ -708,7 +758,9 @@ export class QqHarnessBridge {
708
758
  try {
709
759
  await this.#bot.sendText(
710
760
  target,
711
- imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。',
761
+ inboundFileUserMessage(error)
762
+ ?? imagePromptUserMessage(error)
763
+ ?? '消息处理失败,请稍后重试。',
712
764
  );
713
765
  await this.#state.markSeen(messageId);
714
766
  } catch (sendError) {
@@ -734,7 +786,7 @@ export class QqHarnessBridge {
734
786
 
735
787
  if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
736
788
  const text = nonEmptyString(safeText(message));
737
- if (!text || hasQqImageAttachments(message)) {
789
+ if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
738
790
  await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
739
791
  return;
740
792
  }
@@ -0,0 +1,64 @@
1
+ async function cancelResponseBody(response) {
2
+ try {
3
+ await response?.body?.cancel?.();
4
+ } catch {
5
+ // Preserve the original download failure.
6
+ }
7
+ }
8
+
9
+ function hostedByMessagingPlatform(target, allowedHosts) {
10
+ return !Array.isArray(allowedHosts) || allowedHosts.some((rule) => (
11
+ typeof rule === 'string'
12
+ && (target.hostname === rule
13
+ || (rule.startsWith('.')
14
+ && (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
15
+ ));
16
+ }
17
+
18
+ /**
19
+ * Open a channel-hosted ordinary file as a stream.
20
+ *
21
+ * This deliberately has no plugin-defined size, type, count, or download-time
22
+ * limit. The caller owns cancellation through its AbortSignal and the channel
23
+ * remains the authority for its own file limits.
24
+ */
25
+ export async function fetchFileStream(url, {
26
+ fetchImpl = fetch,
27
+ headers,
28
+ signal,
29
+ allowedHosts,
30
+ } = {}) {
31
+ const target = new URL(url);
32
+ if (target.protocol !== 'https:') throw new Error('File download URL must use HTTPS');
33
+ if (!hostedByMessagingPlatform(target, allowedHosts)) {
34
+ throw new Error('File download URL is not hosted by the messaging platform');
35
+ }
36
+
37
+ const response = await fetchImpl(target, {
38
+ method: 'GET',
39
+ headers,
40
+ signal,
41
+ redirect: 'manual',
42
+ });
43
+ if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
44
+ await cancelResponseBody(response);
45
+ const error = new Error(`File download redirect was blocked (HTTP ${response.status})`);
46
+ error.code = 'file-redirect-blocked';
47
+ throw error;
48
+ }
49
+ if (!response?.ok) {
50
+ await cancelResponseBody(response);
51
+ const error = new Error(`File download failed with HTTP ${response?.status ?? 'unknown'}`);
52
+ error.code = 'file-http-error';
53
+ error.status = response?.status;
54
+ throw error;
55
+ }
56
+ if (response.body?.[Symbol.asyncIterator]) return { stream: response.body };
57
+ if (typeof response.arrayBuffer === 'function') {
58
+ const data = Buffer.from(await response.arrayBuffer());
59
+ return {
60
+ stream: (async function* fileBody() { yield data; }()),
61
+ };
62
+ }
63
+ throw new Error('File download returned no readable body');
64
+ }
@@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
3
3
  import { isAbsolute } from 'node:path';
4
4
 
5
5
  import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
6
+ import {
7
+ appendInboundFilesToPrompt,
8
+ InboundFileError,
9
+ } from './inbound-file.mjs';
6
10
  import { outboundArtifactRegistry } from './semantic/artifact.mjs';
7
11
 
8
12
  // Every channel plugin runs in the same Host process. Sharing ownership by
@@ -466,6 +470,7 @@ export class HarnessClient {
466
470
  #commandExecutor;
467
471
  #controlExecutor;
468
472
  #sessionMaintenanceExecutor;
473
+ #fileIngressExecutor;
469
474
  #managedProcess = null;
470
475
  #interactionRegistry;
471
476
  #interactionOwnerships;
@@ -486,6 +491,7 @@ export class HarnessClient {
486
491
  commandExecutor,
487
492
  controlExecutor,
488
493
  sessionMaintenanceExecutor,
494
+ fileIngressExecutor,
489
495
  }) {
490
496
  if (typeof createWebSocket !== 'function') {
491
497
  throw new TypeError('createWebSocket must be a function');
@@ -509,6 +515,9 @@ export class HarnessClient {
509
515
  && typeof sessionMaintenanceExecutor !== 'function') {
510
516
  throw new TypeError('sessionMaintenanceExecutor must be a function');
511
517
  }
518
+ if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
519
+ throw new TypeError('fileIngressExecutor must be a function');
520
+ }
512
521
  this.#baseUrl = new URL(baseUrl);
513
522
  this.#workspace = workspace;
514
523
  // Keep an omitted preset absent so session.create resolves the Host's current default.
@@ -523,6 +532,7 @@ export class HarnessClient {
523
532
  this.#commandExecutor = commandExecutor;
524
533
  this.#controlExecutor = controlExecutor;
525
534
  this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
535
+ this.#fileIngressExecutor = fileIngressExecutor;
526
536
  this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
527
537
  this.#interactionOwnerships = this.#interactionRegistry.ownerships;
528
538
  this.#interactionClaims = this.#interactionRegistry.claims;
@@ -1034,6 +1044,7 @@ export class HarnessClient {
1034
1044
  ? options.onInteractionResolved
1035
1045
  : undefined;
1036
1046
  const control = normalizeControl(options.control);
1047
+ const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
1037
1048
  await this.ensureRunning({ signal });
1038
1049
  const before = await this.rpc(
1039
1050
  'session.history',
@@ -1075,6 +1086,9 @@ export class HarnessClient {
1075
1086
  let interactionTask = null;
1076
1087
  let artifactsDelivered = false;
1077
1088
  let deliveredArtifactCount = 0;
1089
+ let stagedInboundFiles = null;
1090
+ let promptAccepted = false;
1091
+ let turnFinished = false;
1078
1092
 
1079
1093
  const deliverArtifacts = async () => {
1080
1094
  if (!onArtifact || artifactsDelivered || tracker.turn === null) {
@@ -1103,6 +1117,30 @@ export class HarnessClient {
1103
1117
  const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
1104
1118
 
1105
1119
  try {
1120
+ if (inboundFiles.length > 0) {
1121
+ if (!this.#fileIngressExecutor) {
1122
+ throw new InboundFileError(
1123
+ 'inbound-file-ingress-unavailable',
1124
+ 'Harness file ingress is unavailable in this Host process.',
1125
+ );
1126
+ }
1127
+ const sessionList = await this.rpc(
1128
+ 'session.list',
1129
+ {},
1130
+ 30_000,
1131
+ { signal },
1132
+ );
1133
+ const sessionWorkspace = sessionList?.items?.find(
1134
+ (item) => item?.sessionId === sessionId,
1135
+ )?.cwd;
1136
+ stagedInboundFiles = await this.#fileIngressExecutor({
1137
+ sessionId,
1138
+ workspace: sessionWorkspace,
1139
+ files: inboundFiles,
1140
+ signal,
1141
+ });
1142
+ prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
1143
+ }
1106
1144
  if (interactionSignal) {
1107
1145
  let markOpen;
1108
1146
  const opened = new Promise((resolve) => { markOpen = resolve; });
@@ -1134,6 +1172,7 @@ export class HarnessClient {
1134
1172
  content,
1135
1173
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1136
1174
  }, 30_000, { rpcId: promptRpcId, signal });
1175
+ promptAccepted = true;
1137
1176
 
1138
1177
  try {
1139
1178
  const deadline = Date.now() + timeoutMs;
@@ -1159,6 +1198,7 @@ export class HarnessClient {
1159
1198
  }
1160
1199
  }
1161
1200
  if (!tracker.finished) continue;
1201
+ turnFinished = true;
1162
1202
  // An accepted /stop revokes attachment delivery even when Harness
1163
1203
  // preserved a useful partial text answer for the existing UX.
1164
1204
  const artifactCount = ownership?.stopRequested
@@ -1185,6 +1225,11 @@ export class HarnessClient {
1185
1225
  throw turnStoppedError();
1186
1226
  }
1187
1227
  } finally {
1228
+ if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
1229
+ await stagedInboundFiles.cleanup().catch((error) => {
1230
+ console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
1231
+ });
1232
+ }
1188
1233
  closeArtifactConsumer();
1189
1234
  if (ownership) {
1190
1235
  this.#unregisterControlOwnership(ownership);