@xmanrui/dsh-im 4.32.0 → 4.34.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
+ }
@@ -49,6 +49,8 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
49
49
  { command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
50
50
  { command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
51
51
  { command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
52
+ { command: 'permissionlist', icon: 'skill_outlined', default: '按序号列出权限档位', en_us: 'List numbered permission presets' },
53
+ { command: 'permission', icon: 'skill_outlined', default: '查看或切换当前会话权限', en_us: 'Show or switch current session permissions' },
52
54
  { command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
53
55
  { command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
54
56
  { command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
@@ -1,3 +1,4 @@
1
+ import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
1
2
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
3
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
3
4
  import { runCompactCommand } from '../shared/compact-command.mjs';
@@ -115,6 +116,9 @@ function helpText() {
115
116
  t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
116
117
  t('纯数字 ID:/preset id:<ID>'),
117
118
  t('/preset --default 跟随 Host 默认'),
119
+ t('/permissionlist 按序号列出权限档位'),
120
+ t('/permission [序号或完整ID] 查看或切换当前会话权限'),
121
+ t('纯数字 ID:/permission id:<ID>'),
118
122
  t('/stop 停止当前任务'),
119
123
  t('/steer 补充指令 纠偏当前任务'),
120
124
  t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
@@ -523,7 +527,7 @@ export class QqHarnessBridge {
523
527
  text: commandText,
524
528
  hasImages: hasQqImageAttachments(message),
525
529
  hasFiles: hasQqFileAttachments(message),
526
- ...(explicitMenu || numericMenu ? { isCommand: true } : {}),
530
+ ...(explicitMenu || numericMenu || isPermissionCommand(commandText) ? { isCommand: true } : {}),
527
531
  })
528
532
  : senderAllowed(message, this.#ownerUserOpenid)
529
533
  ? { allowed: true, reason: 'legacy-owner' }
@@ -594,6 +598,7 @@ export class QqHarnessBridge {
594
598
  const commandRunner = explicitMenu || numericMenu
595
599
  ? () => this.#runMenuCommand(message, menuInput, key)
596
600
  : isHistoryCommand(commandText) ? runHistoryCommand
601
+ : isPermissionCommand(commandText) ? runPermissionCommand
597
602
  : hasQqFileAttachments(message) ? null : isControlCommand(commandText)
598
603
  ? runControlCommand
599
604
  : (isModelCommand(commandText)
@@ -2002,6 +2002,11 @@ export function createBotWorkspaceScope(
2002
2002
  models(...args) {
2003
2003
  return invokeCurrentSession('getSessionModels', args, 'model listing');
2004
2004
  },
2005
+ ...(typeof target.getSessionPermissions === 'function' ? {
2006
+ permissions(...args) {
2007
+ return invokeCurrentSession('getSessionPermissions', args, 'permission listing');
2008
+ },
2009
+ } : {}),
2005
2010
  readHistory(...args) {
2006
2011
  return invokeCurrentSession('readSessionHistory', args, 'history read');
2007
2012
  },
@@ -0,0 +1,33 @@
1
+ export const COMMAND_LIST_TTL_MS = 15 * 60_000;
2
+ export const COMMAND_LIST_MAX_ENTRIES = 256;
3
+
4
+ /** Short-lived lists scoped to a bot's state and conversation, never persisted. */
5
+ export function createCommandListSnapshots() {
6
+ const states = new WeakMap();
7
+ return {
8
+ save(state, key, value) {
9
+ if ((!state || typeof state !== 'object') && typeof state !== 'function') return;
10
+ let entries = states.get(state);
11
+ if (!entries) states.set(state, entries = new Map());
12
+ const now = Date.now();
13
+ for (const [entryKey, entry] of entries) {
14
+ if (entry.expiresAt <= now) entries.delete(entryKey);
15
+ }
16
+ entries.delete(key);
17
+ entries.set(key, { value, expiresAt: now + COMMAND_LIST_TTL_MS });
18
+ while (entries.size > COMMAND_LIST_MAX_ENTRIES) entries.delete(entries.keys().next().value);
19
+ },
20
+ load(state, key) {
21
+ const entries = states.get(state);
22
+ const entry = entries?.get(key);
23
+ if (!entry) return null;
24
+ if (entry.expiresAt <= Date.now()) {
25
+ entries.delete(key);
26
+ return null;
27
+ }
28
+ entries.delete(key);
29
+ entries.set(key, entry);
30
+ return entry.value;
31
+ },
32
+ };
33
+ }
@@ -1139,6 +1139,12 @@ export class HarnessClient {
1139
1139
  return validateModelCatalog(value, 'session.models', { session: true });
1140
1140
  }
1141
1141
 
1142
+ async getSessionPermissions(sessionId, options = {}) {
1143
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
1144
+ await this.ensureRunning(options);
1145
+ return this.rpc('session.permissions', { sessionId }, 30_000, options);
1146
+ }
1147
+
1142
1148
  async selectSessionModel(sessionId, selection, options = {}) {
1143
1149
  if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
1144
1150
  if (!validModelSelection(selection)) {
@@ -1729,9 +1735,10 @@ export class HarnessClient {
1729
1735
  this.#registerInteractionOwnership(sessionId, ownership);
1730
1736
  this.#registerControlOwnership(ownership);
1731
1737
  }
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);
1738
+ // Advertise delivery only when this request can actually claim its files.
1739
+ const closeArtifactConsumer = typeof onArtifact === 'function'
1740
+ ? outboundArtifactRegistry.openConsumer(sessionId, promptRpcId)
1741
+ : null;
1735
1742
 
1736
1743
  try {
1737
1744
  const basePrompt = prompt;
@@ -1929,7 +1936,7 @@ export class HarnessClient {
1929
1936
  }
1930
1937
  }
1931
1938
  }
1932
- closeArtifactConsumer();
1939
+ closeArtifactConsumer?.();
1933
1940
  if (ownership) {
1934
1941
  this.#unregisterControlOwnership(ownership);
1935
1942
  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**',
@@ -0,0 +1,30 @@
1
+ export default {
2
+ '用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。': 'Usage: /permission to show current permissions; /permissionlist to list presets; /permission <number or full ID> to switch; use /permission id:<ID> for numeric IDs.',
3
+ '仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。': 'Applies only to the current session; sandbox and approval policies change together according to the Host preset.',
4
+ '请先执行 /permissionlist,再按列表序号选择权限档位。': 'Run /permissionlist first, then select a permission preset by its number.',
5
+ '完全文件访问,不再请求审批。': 'Full file access without approval prompts.',
6
+ '可写工作区和允许的临时目录;扩大访问范围需要审批。': 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
7
+ '当前聊天绑定的会话已不存在,请先绑定或建立会话。': 'The bound session no longer exists. Bind or start a session first.',
8
+ '当前 Host 暂不支持从机器人查看或切换权限档位。': 'This Host does not support viewing or switching permission presets from the bot.',
9
+ '未能确认权限切换结果,请执行 /permission 核对当前档位。': 'Could not confirm the permission change. Run /permission to check the current preset.',
10
+ '暂时无法获取权限档位,请稍后重试。': 'Could not read permission presets. Please try again later.',
11
+ '权限命令仅支持纯文字,请移除图片或文件后重试。': 'Permission commands require plain text. Remove images or files and try again.',
12
+ '当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。': 'This chat has no bound session. Send a regular message to start one, or use /session to bind an existing session.',
13
+ '当前会话已变化,请重新执行 /permissionlist。': 'The current session changed. Run /permissionlist again.',
14
+ '当前会话权限:{id}': 'Current session permissions: {id}',
15
+ ' ✓ 当前': ' ✓ Current',
16
+ '当前没有可用权限档位。': 'No permission presets are currently available.',
17
+ '切换:/permission <序号或完整ID>': 'Switch: /permission <number or full ID>',
18
+ '查看可用档位与序号:/permissionlist': 'Show available presets and numbers: /permissionlist',
19
+ '权限档位序号无效,请重新执行 /permissionlist。': 'Invalid permission preset number. Run /permissionlist again.',
20
+ '当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。': 'This Host has not registered /permission. Check that permission presets are enabled.',
21
+ '该权限档位不存在或已不可用,请重新执行 /permissionlist。': 'That permission preset no longer exists or is unavailable. Run /permissionlist again.',
22
+ '权限切换失败。': 'Permission change failed.',
23
+ '当前会话权限已切换为:{id}': 'Current session permissions changed to: {id}',
24
+ '恢复工作区权限:/permission workspace-write(需 Host 支持该档位)': 'Restore workspace permissions: /permission workspace-write (if supported by the Host)',
25
+ '/permissionlist 按序号列出权限档位': '/permissionlist List numbered permission presets',
26
+ '/permission [序号或完整ID] 查看或切换当前会话权限': '/permission [number or full ID] Show or switch current session permissions',
27
+ '纯数字 ID:/permission id:<ID>': 'Numeric IDs: /permission id:<ID>',
28
+ '`/permissionlist` — 按序号列出权限档位': '`/permissionlist` — List numbered permission presets',
29
+ '`/permission [序号/ID]` — 查看或切换当前会话权限': '`/permission [number/ID]` — Show or switch current session permissions',
30
+ };
@@ -1,4 +1,5 @@
1
1
  import imageInput from './i18n-en/image-input.mjs';
2
+ import permission from './i18n-en/permission.mjs';
2
3
  import diagnostics from './i18n-en/diagnostics.mjs';
3
4
  // English translations for host-side user-facing text.
4
5
  // Keys are the exact Chinese literals passed to t() in src/channels/**.
@@ -26,6 +27,7 @@ export const EN = Object.freeze(Object.assign(
26
27
  {},
27
28
  diagnostics,
28
29
  imageInput,
30
+ permission,
29
31
  sharedA,
30
32
  sharedB,
31
33
  sharedC,