@xmanrui/dsh-im 4.32.0 → 4.33.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.
@@ -1,8 +1,11 @@
1
+ import { addAbortSignal, Readable } from 'node:stream';
2
+ import { downloadFeishuResourceRanges } from './resource-download.mjs';
1
3
  import { imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
2
4
  import { ImagePromptError } from '../shared/image-prompt.mjs';
3
5
  import { t } from '../shared/i18n.mjs';
4
6
 
5
7
  const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
8
+ const FEISHU_RESOURCE_SIZE_LIMIT_CODE = 234037;
6
9
  const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
7
10
  const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
8
11
  const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
@@ -305,31 +308,6 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
305
308
  }
306
309
  }
307
310
 
308
- async function readStream(stream, { signal }) {
309
- if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
310
- throw new Error('Feishu file download returned no readable stream');
311
- }
312
- signal?.throwIfAborted();
313
- const abort = () => stream.destroy?.(
314
- signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
315
- );
316
- signal?.addEventListener('abort', abort, { once: true });
317
- const chunks = [];
318
- let size = 0;
319
- try {
320
- for await (const chunk of stream) {
321
- signal?.throwIfAborted();
322
- const data = Buffer.from(chunk);
323
- size += data.length;
324
- chunks.push(data);
325
- }
326
- signal?.throwIfAborted();
327
- return Buffer.concat(chunks, size);
328
- } finally {
329
- signal?.removeEventListener('abort', abort);
330
- }
331
- }
332
-
333
311
  function providerCode(value) {
334
312
  if (!value || typeof value !== 'object') return null;
335
313
  const code = value.code ?? value.error?.code;
@@ -375,7 +353,7 @@ async function feishuProviderCode(error, signal) {
375
353
  seen.add(value);
376
354
  const directCode = providerCode(value);
377
355
  const data = value.response?.data ?? value.data;
378
- if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
356
+ if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || directCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) {
379
357
  data?.destroy?.();
380
358
  return directCode;
381
359
  }
@@ -383,21 +361,22 @@ async function feishuProviderCode(error, signal) {
383
361
  const body = await readFeishuErrorBody(data, signal);
384
362
  try {
385
363
  const parsedCode = providerCode(JSON.parse(body));
386
- if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode;
364
+ if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || parsedCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) return parsedCode;
387
365
  } catch {
388
366
  // Non-JSON provider failures keep the generic image download message.
389
367
  }
390
368
  } else {
391
369
  const dataCode = providerCode(data);
392
- if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode;
370
+ if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || dataCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) return dataCode;
393
371
  }
394
372
  pending.push(value.cause);
395
373
  }
396
374
  return null;
397
375
  }
398
376
 
399
- async function feishuImageDownloadError(error, signal) {
400
- if (await feishuProviderCode(error, signal) !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
377
+ async function feishuImageDownloadError(error, signal, code = undefined) {
378
+ if (code === undefined) code = await feishuProviderCode(error, signal);
379
+ if (code !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
401
380
  return new ImagePromptError(
402
381
  'feishu-image-permission-required',
403
382
  'Feishu image download requires the im:message:readonly tenant scope',
@@ -406,33 +385,59 @@ async function feishuImageDownloadError(error, signal) {
406
385
  );
407
386
  }
408
387
 
409
- function feishuImageSource(event, client, key) {
410
- return {
411
- async load({ signal, maxBytes }) {
412
- signal?.throwIfAborted();
413
- let resource;
388
+ async function feishuResourceStream(event, client, key, type, { signal, maxBytes } = {}) {
389
+ signal?.throwIfAborted();
390
+ const path = { message_id: event.message.message_id, file_key: key };
391
+ let resource;
392
+ try {
393
+ resource = await client?.im?.v1?.messageResource?.get?.({ path, params: { type } });
394
+ } catch (error) {
395
+ if (signal?.aborted) {
396
+ error?.response?.data?.destroy?.();
397
+ signal.throwIfAborted();
398
+ }
399
+ const code = await feishuProviderCode(error, signal);
400
+ if (code !== FEISHU_RESOURCE_SIZE_LIMIT_CODE) {
401
+ throw type === 'image' ? await feishuImageDownloadError(error, signal, code) : error;
402
+ }
403
+ return Readable.from((async function* ranges() {
414
404
  try {
415
- resource = await client?.im?.v1?.messageResource?.get?.({
416
- path: {
417
- message_id: event.message.message_id,
418
- file_key: key,
419
- },
420
- params: { type: 'image' },
421
- });
405
+ yield* downloadFeishuResourceRanges(client, path, type, { signal, maxBytes });
422
406
  } catch (error) {
423
- throw await feishuImageDownloadError(error, signal);
424
- }
425
- signal?.throwIfAborted();
426
- const size = declaredSize(resource?.headers);
427
- if (size !== null && size > maxBytes) {
428
- resource?.getReadableStream?.().destroy?.();
429
- throw new ImagePromptError(
430
- 'image-too-large',
431
- `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
432
- imageDownloadLimitMessage(maxBytes),
433
- );
407
+ try {
408
+ throw type === 'image' ? await feishuImageDownloadError(error, signal) : error;
409
+ } finally {
410
+ error?.response?.data?.destroy?.();
411
+ }
434
412
  }
435
- return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
413
+ }()), { objectMode: false, signal });
414
+ }
415
+ const stream = resource?.getReadableStream?.();
416
+ try {
417
+ signal?.throwIfAborted();
418
+ const size = declaredSize(resource?.headers);
419
+ if (size !== null && size > maxBytes) {
420
+ throw new ImagePromptError(
421
+ 'image-too-large',
422
+ `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
423
+ imageDownloadLimitMessage(maxBytes),
424
+ );
425
+ }
426
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
427
+ throw new Error(`Feishu ${type} download returned no readable stream`);
428
+ }
429
+ return signal ? addAbortSignal(signal, stream) : stream;
430
+ } catch (error) {
431
+ stream?.destroy?.();
432
+ throw error;
433
+ }
434
+ }
435
+
436
+ function feishuImageSource(event, client, key) {
437
+ return {
438
+ async load({ signal, maxBytes }) {
439
+ const stream = await feishuResourceStream(event, client, key, 'image', { signal, maxBytes });
440
+ return readBoundedStream(stream, { signal, maxBytes });
436
441
  },
437
442
  };
438
443
  }
@@ -443,16 +448,8 @@ function feishuFileSource(event, client, file) {
443
448
  return {
444
449
  name: nonEmptyString(file?.file_name) ?? 'file',
445
450
  async load({ signal } = {}) {
446
- signal?.throwIfAborted();
447
- const resource = await client?.im?.v1?.messageResource?.get?.({
448
- path: {
449
- message_id: event.message.message_id,
450
- file_key: key,
451
- },
452
- params: { type: 'file' },
453
- });
454
- signal?.throwIfAborted();
455
- return readStream(resource?.getReadableStream?.(), { signal });
451
+ const stream = await feishuResourceStream(event, client, key, 'file', { signal });
452
+ return { stream };
456
453
  },
457
454
  };
458
455
  }
@@ -1,5 +1,5 @@
1
1
  import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { connectionTestMessage } from '../shared/connection-test.mjs';
4
4
  import { publicMessageFailure } from '../shared/message-failure.mjs';
5
5
  import { RegistrationManager } from './registration-manager.mjs';
@@ -563,10 +563,60 @@ export class MultiBotDshFeishuController {
563
563
  });
564
564
  }
565
565
 
566
+ async #deliveryAccount(config) {
567
+ let verified;
568
+ try {
569
+ const resolved = await this.#credentials.resolve(config.secretRef);
570
+ if (!resolved?.value) throw new Error('Account credentials unavailable');
571
+ verified = await this.#verifyApp({ appId: config.appId, appSecret: resolved.value, domain: config.domain });
572
+ } catch (cause) {
573
+ const error = new Error('Account credentials could not be verified', { cause });
574
+ error.code = 'account-unverified';
575
+ throw error;
576
+ }
577
+ if (!verified?.openId || verified.openId !== config.botOpenId) {
578
+ const error = new Error('Authenticated account identity changed');
579
+ error.code = 'account-changed';
580
+ throw error;
581
+ }
582
+ const fingerprint = createHash('sha256').update(JSON.stringify({
583
+ provider: 'feishu', domain: config.domain, appId: config.appId, botOpenId: verified.openId,
584
+ })).digest('hex');
585
+ return { fingerprint, ...(verified.name ? { name: verified.name } : {}) };
586
+ }
587
+
588
+ async describeDeliveryAccount(botId) {
589
+ this.#assertOpen('capability-unavailable');
590
+ return this.#withBotTransition(botId, async () => {
591
+ this.#assertOpen('capability-unavailable');
592
+ const config = this.#requireBot(botId);
593
+ const account = await this.#deliveryAccount(config);
594
+ this.#assertOpen('capability-unavailable');
595
+ return { version: 1, botId, channel: 'feishu', account,
596
+ connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
597
+ capabilities: ['proactive-text-checked'] };
598
+ });
599
+ }
600
+
566
601
  async sendProactiveText(botId, target, text, options = {}) {
567
- this.#assertOpen();
602
+ const checked = options.expectedFingerprint !== undefined;
603
+ this.#assertOpen(checked ? 'capability-unavailable' : undefined);
568
604
  return this.#withBotTransition(botId, async () => {
569
- this.#requireBot(botId);
605
+ this.#assertOpen(checked ? 'capability-unavailable' : undefined);
606
+ const config = this.#requireBot(botId);
607
+ if (checked) {
608
+ const account = await this.#deliveryAccount(config);
609
+ if (account.fingerprint !== options.expectedFingerprint) {
610
+ const error = new Error('Authenticated account identity changed');
611
+ error.code = 'account-changed';
612
+ throw error;
613
+ }
614
+ // Account verification yields; a Registration can be replaced or disposed
615
+ // before the SDK request starts. Recheck its owner at the effect boundary.
616
+ options.beforeSend?.();
617
+ this.#assertOpen('capability-unavailable');
618
+ options.signal?.throwIfAborted();
619
+ }
570
620
  const runtime = this.#runtimes.get(botId);
571
621
  if (!isConnected(connectionStatus(runtime))
572
622
  || typeof runtime.sendProactiveText !== 'function') {
@@ -1386,8 +1436,12 @@ export class MultiBotDshFeishuController {
1386
1436
  return config;
1387
1437
  }
1388
1438
 
1389
- #assertOpen() {
1390
- if (this.#closed) throw new Error('The Feishu controller is closed');
1439
+ #assertOpen(code) {
1440
+ if (this.#closed) {
1441
+ const error = new Error('The Feishu controller is closed');
1442
+ if (code) error.code = code;
1443
+ throw error;
1444
+ }
1391
1445
  }
1392
1446
 
1393
1447
  #serializeConfig(operation) {
@@ -0,0 +1,120 @@
1
+ import { ImagePromptError, imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
2
+
3
+ const RESOURCE_CHUNK_BYTES = 8 * 1024 * 1024;
4
+
5
+ function headerValue(headers, name) {
6
+ if (typeof headers?.get === 'function') return headers.get(name) ?? null;
7
+ const key = Object.keys(headers ?? {}).find((key) => key.toLowerCase() === name);
8
+ return key ? headers[key] : null;
9
+ }
10
+
11
+ function checkLimit(size, maxBytes) {
12
+ if (size > maxBytes) {
13
+ throw new ImagePromptError(
14
+ 'image-too-large',
15
+ `Feishu image exceeds ${maxBytes} bytes`,
16
+ imageDownloadLimitMessage(maxBytes),
17
+ );
18
+ }
19
+ }
20
+
21
+ function contentLength(headers) {
22
+ const value = headerValue(headers, 'content-length');
23
+ if (value === null || value === undefined) return null;
24
+ if (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) {
25
+ throw new Error('Invalid Feishu resource Content-Length');
26
+ }
27
+ return Number(value);
28
+ }
29
+
30
+ function contentRange(headers, start, end, total) {
31
+ const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(headerValue(headers, 'content-range') ?? '');
32
+ if (!match) throw new Error('Invalid Feishu resource Content-Range');
33
+ const [first, last, size] = match.slice(1).map(Number);
34
+ if (![first, last, size].every(Number.isSafeInteger)
35
+ || first !== start || last < first || last > end || last >= size
36
+ || (total !== null && size !== total)) {
37
+ throw new Error('Inconsistent Feishu resource Content-Range');
38
+ }
39
+ return { size, length: last - first + 1 };
40
+ }
41
+
42
+ /** Stream sequential ranges; never buffer an ordinary file or publish a partial success. */
43
+ export async function* downloadFeishuResourceRanges(client, path, type, { signal, maxBytes } = {}) {
44
+ let offset = 0;
45
+ let total = null;
46
+ do {
47
+ signal?.throwIfAborted();
48
+ const end = Math.min(
49
+ offset + RESOURCE_CHUNK_BYTES - 1,
50
+ (total ?? maxBytes ?? Number.MAX_SAFE_INTEGER) - 1,
51
+ );
52
+ let status;
53
+ const response = await client.request({
54
+ method: 'GET',
55
+ url: `open-apis/im/v1/messages/${encodeURIComponent(path.message_id)}/resources/${encodeURIComponent(path.file_key)}`,
56
+ params: { type },
57
+ headers: { Range: `bytes=${offset}-${end}`, 'Accept-Encoding': 'identity' },
58
+ responseType: 'stream',
59
+ $return_headers: true,
60
+ decompress: false,
61
+ signal,
62
+ // The SDK strips status from successful responses, including stream responses.
63
+ validateStatus(value) {
64
+ status = value;
65
+ return value >= 200 && value < 300;
66
+ },
67
+ });
68
+ const stream = response?.data;
69
+ const abort = () => stream?.destroy?.(signal.reason);
70
+ signal?.addEventListener('abort', abort, { once: true });
71
+ try {
72
+ signal?.throwIfAborted();
73
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
74
+ throw new Error('Feishu resource download returned no readable stream');
75
+ }
76
+ const encoding = headerValue(response.headers, 'content-encoding');
77
+ if (encoding && encoding !== 'identity') {
78
+ throw new Error('Unexpected Feishu resource Content-Encoding');
79
+ }
80
+ let length = contentLength(response.headers);
81
+ if (status === 206) {
82
+ const range = contentRange(response.headers, offset, end, total);
83
+ total = range.size;
84
+ checkLimit(total, maxBytes);
85
+ if (length !== null && length !== range.length) {
86
+ throw new Error('Feishu resource Content-Length disagrees with Content-Range');
87
+ }
88
+ length = range.length;
89
+ } else if (status === 200 && offset === 0) {
90
+ // A server may ignore the first Range. Read it once as a whole file.
91
+ if (headerValue(response.headers, 'content-range') !== null) {
92
+ throw new Error('Unexpected Feishu resource Content-Range on HTTP 200');
93
+ }
94
+ checkLimit(length, maxBytes);
95
+ } else {
96
+ throw new Error(`Unexpected Feishu resource range status: ${status}`);
97
+ }
98
+ let received = 0;
99
+ for await (const chunk of stream) {
100
+ signal?.throwIfAborted();
101
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
102
+ received += data.length;
103
+ checkLimit(offset + received, maxBytes);
104
+ if (length !== null && received > length) {
105
+ throw new Error('Feishu resource body exceeds the declared length');
106
+ }
107
+ yield data;
108
+ }
109
+ signal?.throwIfAborted();
110
+ if (length !== null && received !== length) {
111
+ throw new Error('Feishu resource body does not match the declared length');
112
+ }
113
+ if (status === 200) return;
114
+ offset += received;
115
+ } finally {
116
+ signal?.removeEventListener('abort', abort);
117
+ stream?.destroy?.();
118
+ }
119
+ } while (offset < total);
120
+ }
@@ -1729,9 +1729,10 @@ export class HarnessClient {
1729
1729
  this.#registerInteractionOwnership(sessionId, ownership);
1730
1730
  this.#registerControlOwnership(ownership);
1731
1731
  }
1732
- // This is resource ownership, not a feature Gate: it lets the Host retain
1733
- // this Turn's snapshots until the channel has polled and claimed them.
1734
- const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
1732
+ // Advertise delivery only when this request can actually claim its files.
1733
+ const closeArtifactConsumer = typeof onArtifact === 'function'
1734
+ ? outboundArtifactRegistry.openConsumer(sessionId, promptRpcId)
1735
+ : null;
1735
1736
 
1736
1737
  try {
1737
1738
  const basePrompt = prompt;
@@ -1929,7 +1930,7 @@ export class HarnessClient {
1929
1930
  }
1930
1931
  }
1931
1932
  }
1932
- closeArtifactConsumer();
1933
+ closeArtifactConsumer?.();
1933
1934
  if (ownership) {
1934
1935
  this.#unregisterControlOwnership(ownership);
1935
1936
  this.#unregisterInteractionOwnership(sessionId, ownership);
@@ -143,6 +143,8 @@ export default {
143
143
  '请输入补充指令后再提交。': 'Enter an instruction before submitting.',
144
144
  '操作过于频繁,请稍后再试。': 'Too many card actions are pending. Please try again shortly.',
145
145
  '卡片操作失败,请稍后重试。': 'The card action failed. Please try again later.',
146
+ '新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。':
147
+ 'Could not update the new-session card status. Send /m to reopen the menu.',
146
148
  '请先选择至少一个会话。': 'Select at least one session first.',
147
149
  '已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
148
150
  '已批量关注 {count} 个会话,另有 {failed} 个未成功。':
@@ -187,6 +189,11 @@ export default {
187
189
  '🤖 切换预设': '🤖 Switch preset',
188
190
  '🧠 切换模型': '🧠 Switch model',
189
191
  '🆕 新会话': '🆕 New session',
192
+ '正在开启…': 'Starting…',
193
+ '已执行:新会话': 'Done: new session',
194
+ '正在开启新会话…': 'Starting a new session…',
195
+ '已执行:新会话。发送消息即可开始。': 'Done: new session. Send a message to start.',
196
+ '新会话操作失败:{message}': 'New session action failed: {message}',
190
197
  '📋 会话/关注': '📋 Sessions / watches',
191
198
  '🗂 工作区列表': '🗂 Workspace list',
192
199
  '**任务控制**': '**Task controls**',
@@ -276,8 +276,8 @@ export class OutboundArtifactRegistry {
276
276
  }
277
277
 
278
278
  /**
279
- * Bind one channel request to the Turn it starts. This owns cleanup only:
280
- * it never changes tool visibility or decides whether a file may be sent.
279
+ * Bind one channel delivery consumer to the Turn its request starts.
280
+ * Only this consumer can retain files for delivery after the Turn ends.
281
281
  */
282
282
  openConsumer(sessionId, promptRpcId) {
283
283
  if (typeof sessionId !== 'string' || !sessionId
@@ -310,8 +310,24 @@ export class OutboundArtifactRegistry {
310
310
  };
311
311
  }
312
312
 
313
- /** Observe durable Session events solely to terminate unclaimed snapshots. */
314
- observeSessionEvent(session, event) {
313
+ /** The Host claims input before assembling the first step's prompt. */
314
+ observeClaimedMessage(session, message, turn) {
315
+ const sessionId = sessionIdOf(session);
316
+ if (!sessionId || !Number.isInteger(turn) || turn < 0
317
+ || this.#currentTurn(session) !== turn) return;
318
+ this.#bindConsumer(sessionId, message?.source?.rpcId, turn);
319
+ }
320
+
321
+ #bindConsumer(sessionId, rpcId, turn) {
322
+ if (typeof rpcId !== 'string' || !rpcId) return;
323
+ const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
324
+ if (!consumer || consumer.released) return;
325
+ consumer.turn = turn;
326
+ this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
327
+ }
328
+
329
+ /** Observe durable Session events to bind delivery ownership and clean up. */
330
+ observeSessionEvent(session, event, { logger } = {}) {
315
331
  const sessionId = sessionIdOf(session);
316
332
  if (!sessionId || !event || typeof event !== 'object') return;
317
333
  if (event.type === 'turn/start') {
@@ -321,21 +337,50 @@ export class OutboundArtifactRegistry {
321
337
  }
322
338
  if (event.type === 'user/message') {
323
339
  const rpcId = event.data?.source?.rpcId;
324
- const turn = this.#openTurns.get(sessionId) ?? currentTurn({ session });
340
+ const turn = this.#currentTurn(session);
325
341
  if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
326
342
  this.#openTurns.set(sessionId, turn);
327
- const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
328
- if (!consumer || consumer.released) return;
329
- consumer.turn = turn;
330
- this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
343
+ this.#bindConsumer(sessionId, rpcId, turn);
331
344
  return;
332
345
  }
333
346
  if (event.type !== 'turn/end') return;
334
347
  const turn = event.data?.turn;
335
348
  if (!Number.isInteger(turn) || turn < 0) return;
336
- if (this.#openTurns.get(sessionId) === turn) this.#openTurns.delete(sessionId);
349
+ // Retain the observed closed state so a stale Session snapshot cannot
350
+ // reopen a Turn while its consumer is still waiting to claim the files.
351
+ if (this.#currentTurn(session) === turn) this.#openTurns.set(sessionId, null);
352
+ if (!this.#consumerForTurn(sessionId, turn)) {
353
+ const keyForTurn = turnKey(sessionId, turn);
354
+ const artifactCount = (this.#turns.get(keyForTurn)?.size ?? 0)
355
+ + (this.#stagedTurns.get(keyForTurn)?.size ?? 0);
356
+ this.discard(sessionId, turn);
357
+ if (artifactCount > 0) {
358
+ logger?.warn?.('[dsh-im] discarded files without an active IM delivery consumer:', {
359
+ sessionId, turn, artifactCount,
360
+ });
361
+ }
362
+ }
363
+ }
364
+
365
+ #currentTurn(session) {
366
+ const sessionId = sessionIdOf(session);
367
+ return this.#openTurns.has(sessionId)
368
+ ? this.#openTurns.get(sessionId)
369
+ : currentTurn({ session });
370
+ }
371
+
372
+ #consumerForTurn(sessionId, turn) {
337
373
  const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
338
- if (!consumer || consumer.released) this.discard(sessionId, turn);
374
+ return consumer && !consumer.released && consumer.turn === turn
375
+ && this.#consumersByPrompt.get(promptKey(sessionId, consumer.promptRpcId)) === consumer
376
+ ? consumer : null;
377
+ }
378
+
379
+ /** Prompt guidance follows the active Turn's actual delivery owner. */
380
+ hasActiveConsumer(session) {
381
+ const turn = this.#currentTurn(session);
382
+ return Number.isInteger(turn)
383
+ && this.#consumerForTurn(sessionIdOf(session), turn) !== null;
339
384
  }
340
385
 
341
386
  /** A disposed Session cannot have another channel consumer claim its files. */
@@ -371,7 +416,7 @@ export class OutboundArtifactRegistry {
371
416
  const agent = exec?.agent;
372
417
  const sessionId = agent?.session?.header?.id;
373
418
  const workspace = agent?.session?.header?.cwd;
374
- const turn = this.#openTurns.get(sessionId) ?? currentTurn(agent);
419
+ const turn = this.#currentTurn(agent?.session);
375
420
  if (typeof sessionId !== 'string' || !sessionId
376
421
  || typeof workspace !== 'string' || !workspace || turn === null) {
377
422
  throw artifactError(
@@ -380,8 +425,25 @@ export class OutboundArtifactRegistry {
380
425
  );
381
426
  }
382
427
 
428
+ const consumer = this.#consumerForTurn(sessionId, turn);
429
+ const unavailable = () => artifactError(
430
+ 'artifact-consumer-required',
431
+ 'No active IM delivery consumer owns this turn. The file was not queued. Use the host present tool if available to return the file in this conversation.',
432
+ );
433
+ if (!consumer) throw unavailable();
434
+
383
435
  const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
384
436
  const { storagePath, ...snapshotMetadata } = snapshot;
437
+ // Copying and hashing yield to cancellation, disposal and Turn completion.
438
+ // Never leave a late snapshot behind after its delivery owner has gone.
439
+ try {
440
+ exec?.signal?.throwIfAborted();
441
+ if (this.#currentTurn(agent?.session) !== turn
442
+ || this.#consumerForTurn(sessionId, turn) !== consumer) throw unavailable();
443
+ } catch (error) {
444
+ await unlink(storagePath).catch(() => undefined);
445
+ throw error;
446
+ }
385
447
  const artifact = Object.freeze({
386
448
  kind: ARTIFACT_KIND,
387
449
  schemaVersion: 1,
@@ -578,7 +640,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
578
640
  };
579
641
  const definition = Object.freeze({
580
642
  name: OUTBOUND_ARTIFACT_TOOL,
581
- description: 'Register a readable file or generated image for delivery through the current conversation after this turn. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
643
+ description: 'Register a readable file or generated image for delivery through the current IM conversation after this turn. Requires an active IM delivery consumer; in Web, Desktop, or CLI conversations use the host present tool if available. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
582
644
  parameters: {
583
645
  type: 'object',
584
646
  additionalProperties: false,
@@ -643,20 +705,28 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
643
705
  return Object.freeze({ definition, onResult });
644
706
  }
645
707
 
646
- /** Register the file-return tool without a per-request Gate. */
708
+ /** Register the file-return tool with guidance scoped to an owned IM Turn. */
647
709
  export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
648
710
  if (typeof ctx?.tools?.register !== 'function'
649
- || typeof ctx?.systemPrompt?.section !== 'function'
711
+ || typeof ctx?.systemPrompt?.context !== 'function'
650
712
  || typeof ctx?.on !== 'function') return false;
713
+ const logger = typeof ctx.logger === 'function'
714
+ ? ctx.logger('dsh-im:outbound-artifact')
715
+ : (ctx.logger ?? console);
651
716
  const tool = createOutboundArtifactTool({ registry });
652
717
  ctx.tools.register(tool.definition);
653
718
  ctx.on('tools/result', tool.onResult);
654
- ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event));
719
+ ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
720
+ registry.observeClaimedMessage(agent?.session, message, turn);
721
+ }, { global: true });
722
+ ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event, { logger }));
655
723
  ctx.on('session/disposed', (session) => registry.disposeSession(session));
656
- ctx.systemPrompt.section({
724
+ ctx.systemPrompt.context({
657
725
  name: 'dsh-im:return-file',
658
726
  order: 115,
659
- text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery. This tool only registers the file; the channel uploads and sends it after your turn finishes. In your reply say the file is prepared or queued, never that it has already been sent or received. The channel reports delivery failures separately.`,
727
+ text: (assembly) => registry.hasActiveConsumer(assembly?.agent?.session)
728
+ ? `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery. This tool only registers the file; the channel uploads and sends it after your turn finishes. In your reply say the file is prepared or queued, never that it has already been sent or received. The channel reports delivery failures separately.`
729
+ : '',
660
730
  });
661
731
  return true;
662
732
  }