@xmanrui/dsh-im 4.34.1 → 4.35.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 (33) hide show
  1. package/README.en.md +5 -1
  2. package/README.md +5 -1
  3. package/lib/client.js +306 -201
  4. package/lib/index.js +291 -290
  5. package/locale/en.json +1 -0
  6. package/locale/zh.json +1 -0
  7. package/package.json +1 -1
  8. package/plugin-src/client/bot-settings-tabs.js +114 -0
  9. package/plugin-src/client/delivery-settings.js +2 -16
  10. package/plugin-src/client/i18n.js +2 -0
  11. package/plugin-src/client/styles.js +8 -3
  12. package/plugin-src/host/channels/feishu/production.mjs +2 -1
  13. package/plugin-src/host/channels/qq/production.mjs +2 -1
  14. package/plugin-src/host/channels/shared/access-policy-production.mjs +13 -0
  15. package/plugin-src/host/delivery-adapter.mjs +2 -2
  16. package/plugin-src/host/delivery-service.mjs +13 -4
  17. package/plugin-src/host/index.mjs +1 -0
  18. package/plugin-src/host/modern-harness-api.mjs +2 -1
  19. package/src/channels/feishu/bridge.mjs +34 -18
  20. package/src/channels/feishu/feishu-cards.mjs +58 -6
  21. package/src/channels/feishu/feishu-channel.mjs +11 -3
  22. package/src/channels/feishu/feishu-runtime.mjs +19 -4
  23. package/src/channels/feishu/multi-bot-controller.mjs +1 -1
  24. package/src/channels/qq/qq-bridge.mjs +8 -8
  25. package/src/channels/qq/qq-mention.mjs +37 -0
  26. package/src/channels/qq/qq-runtime.mjs +11 -3
  27. package/src/channels/shared/bot-workspace-store.mjs +1 -0
  28. package/src/channels/shared/connection-error.mjs +10 -4
  29. package/src/channels/shared/harness-client.mjs +9 -3
  30. package/src/channels/shared/i18n-en/shared-a.mjs +4 -0
  31. package/src/channels/shared/message-failure.mjs +26 -4
  32. package/src/channels/shared/text-harness-bridge.mjs +2 -2
  33. package/src/channels/shared/workspace-command.mjs +28 -16
@@ -80,12 +80,13 @@ function abortReason(signal) {
80
80
  }
81
81
 
82
82
  function operationTimeout(stage) {
83
- const error = new Error(`Feishu file ${stage} timed out.`);
83
+ const error = new Error(`Feishu ${stage} timed out.`);
84
84
  error.code = 'provider-timeout';
85
85
  return error;
86
86
  }
87
87
 
88
- function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
88
+ // Bound the caller's wait even when the SDK does not honor cancellation.
89
+ export function waitForFeishuOperation(operation, { signal, timeoutMs, stage = 'request' }) {
89
90
  signal?.throwIfAborted();
90
91
  const deadline = new AbortController();
91
92
  const operationSignal = signal
@@ -108,7 +109,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
108
109
  const timer = setTimeout(() => deadline.abort(), timeoutMs);
109
110
  operationSignal.addEventListener('abort', onAbort, { once: true });
110
111
 
111
- Promise.resolve().then(() => operation(operationSignal)).then(
112
+ Promise.resolve().then(() => {
113
+ operationSignal.throwIfAborted();
114
+ return operation(operationSignal);
115
+ }).then(
112
116
  (value) => finish(resolve, value),
113
117
  (error) => finish(reject, error),
114
118
  );
@@ -116,6 +120,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
116
120
  });
117
121
  }
118
122
 
123
+ function waitForFileOperation(operation, { stage, ...options }) {
124
+ return waitForFeishuOperation(operation, { ...options, stage: `file ${stage}` });
125
+ }
126
+
119
127
  function deliveryUuid(file, chatId, messageType) {
120
128
  const seed = `${file.deliveryKey}\u0000${chatId}`;
121
129
  const digest = createHash('sha256')
@@ -2,7 +2,7 @@ import { createConnectionDiagnostics, atConnectionStage } from '../shared/connec
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { FeishuHarnessBridge } from './bridge.mjs';
4
4
  import { cardActionProbeCard } from './feishu-cards.mjs';
5
- import { VerifiedFeishuChannel } from './feishu-channel.mjs';
5
+ import { VerifiedFeishuChannel, waitForFeishuOperation } from './feishu-channel.mjs';
6
6
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
7
7
  import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
8
  import { createVoice } from './voice.mjs';
@@ -367,6 +367,7 @@ export class FeishuRuntime {
367
367
  voice: this.#voice,
368
368
  sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
369
369
  repair: this.#repair,
370
+ requestTimeoutMs: this.#requestTimeoutMs,
370
371
  replyTimeoutMs: this.#replyTimeoutMs,
371
372
  // Interaction cards (approval/question buttons) are on by default.
372
373
  // Set DSH_IM_INTERACTION_CARDS=0 to fall back to plain-text replies.
@@ -674,7 +675,7 @@ export class FeishuRuntime {
674
675
  });
675
676
  }
676
677
 
677
- async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
678
+ async sendProactiveText(target, text, { signal, format = 'plain', receipt = false } = {}) {
678
679
  if (!this.#status.ready || !this.#client) {
679
680
  const error = new Error('飞书机器人尚未连接');
680
681
  error.code = 'bot-not-connected';
@@ -697,26 +698,40 @@ export class FeishuRuntime {
697
698
  error.code = 'bad-request';
698
699
  throw error;
699
700
  }
701
+ if (typeof receipt !== 'boolean' || (receipt && target.kind !== 'group'))
702
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
700
703
  signal?.throwIfAborted();
701
704
  // Use the same native Markdown element as chat, without opening a stream
702
705
  // or retrying as plain text after a possibly accepted delivery.
703
706
  const content = format === 'markdown'
704
707
  ? { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } }
705
708
  : { text };
706
- const response = await this.#client.im.v1.message.create({
709
+ const activeSignal = signal
710
+ ? AbortSignal.any([signal, this.#abortController.signal])
711
+ : this.#abortController.signal;
712
+ const response = await waitForFeishuOperation(() => this.#client.im.v1.message.create({
707
713
  params: { receive_id_type: receiveIdType },
708
714
  data: {
709
715
  receive_id: receiveId,
710
716
  msg_type: format === 'markdown' ? 'interactive' : 'text',
711
717
  content: JSON.stringify(content),
712
718
  },
719
+ }), {
720
+ signal: activeSignal,
721
+ timeoutMs: this.#requestTimeoutMs,
722
+ stage: `proactive text send (${this.#requestTimeoutMs}ms)`,
713
723
  });
714
724
  if (response?.code && response.code !== 0) {
715
725
  const error = new Error(`Feishu proactive delivery failed: ${response.msg || response.code}`);
716
726
  error.code = 'target-rejected';
717
727
  throw error;
718
728
  }
719
- return { sent: true };
729
+ if (!receipt) return { sent: true };
730
+ const messageId = response?.data?.message_id;
731
+ const conversationId = response?.data?.chat_id;
732
+ if (typeof messageId !== 'string' || !messageId || messageId.length > 512 || conversationId !== receiveId)
733
+ throw Object.assign(new Error('send-result-unknown'), { code: 'send-result-unknown' });
734
+ return { sent: true, receipt: { version: 1, messageId, conversationId } };
720
735
  }
721
736
 
722
737
  /**
@@ -594,7 +594,7 @@ export class MultiBotDshFeishuController {
594
594
  this.#assertOpen('capability-unavailable');
595
595
  return { version: 1, botId, channel: 'feishu', account,
596
596
  connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
597
- capabilities: ['proactive-text-checked'] };
597
+ capabilities: ['proactive-text-checked', 'proactive-receipt-checked'] };
598
598
  });
599
599
  }
600
600
 
@@ -75,6 +75,7 @@ import {
75
75
  } from './qq-menu.mjs';
76
76
  import { withSessionBindingLock } from '../shared/session-binding-lock.mjs';
77
77
  import { t } from '../shared/i18n.mjs';
78
+ import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
78
79
 
79
80
  function interactionResolvedText() {
80
81
  return t('这个问题已在其他客户端处理,无需再次回答。');
@@ -408,7 +409,7 @@ async function sendQqFile(
408
409
  function canClaimInteractionReply(message, pending) {
409
410
  return pending.questions[pending.index]
410
411
  && nonEmptyString(message?.senderId) === pending.actor
411
- && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
412
+ && isQqMessageAddressed(message)
412
413
  && !hasQqImageAttachments(message)
413
414
  && !hasQqFileAttachments(message)
414
415
  && nonEmptyString(safeText(message));
@@ -506,9 +507,9 @@ export class QqHarnessBridge {
506
507
  || !['c2c', 'group'].includes(message?.kind)
507
508
  || this.#state.hasSeen(messageId)
508
509
  || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
510
+ message = normalizeQqMentions(message, new Set([this.#bot.appId]));
509
511
  const key = conversationKey(message);
510
- const addressed = message.kind !== 'group'
511
- || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
512
+ const addressed = isQqMessageAddressed(message);
512
513
  const commandText = safeText(message);
513
514
  const menuTextOnly = !hasQqImageAttachments(message) && !hasQqFileAttachments(message)
514
515
  && !qqReplyReference(message);
@@ -634,7 +635,7 @@ export class QqHarnessBridge {
634
635
  actor: sender,
635
636
  messageId,
636
637
  text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
637
- addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
638
+ addressed,
638
639
  hasPendingQuestion: Boolean(pending),
639
640
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
640
641
  ? pending.queue
@@ -689,8 +690,7 @@ export class QqHarnessBridge {
689
690
  alreadyRecorded = false,
690
691
  batchSubmission = null,
691
692
  } = {}) {
692
- const addressed = message.kind !== 'group'
693
- || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
693
+ const addressed = isQqMessageAddressed(message);
694
694
  const preparedMessage = addressed
695
695
  ? prefetchInboundFiles(
696
696
  qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
@@ -984,7 +984,7 @@ export class QqHarnessBridge {
984
984
  await this.#state.markSeen(messageId);
985
985
  messageRecorded = true;
986
986
  };
987
- if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
987
+ if (!isQqMessageAddressed(message)) return;
988
988
 
989
989
  const target = message.replyTarget;
990
990
  const promptMessage = preparedMessage
@@ -1245,7 +1245,7 @@ export class QqHarnessBridge {
1245
1245
  this.#status.messagesReceived += 1;
1246
1246
  this.#status.lastMessageAt = new Date().toISOString();
1247
1247
 
1248
- if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
1248
+ if (!isQqMessageAddressed(message)) return;
1249
1249
  const text = nonEmptyString(safeText(message));
1250
1250
  if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
1251
1251
  await this.#bot.sendText(message.replyTarget, t('请用文字回答当前问题。'));
@@ -0,0 +1,37 @@
1
+ // Keep the decision after the SDK sanitizes the mention out of the content.
2
+ const addressedToBot = Symbol('qq.addressedToBot');
3
+
4
+ function selfMentions(message) {
5
+ return Array.isArray(message?.mentions)
6
+ ? message.mentions.filter((mention) => mention?.is_you === true)
7
+ : [];
8
+ }
9
+
10
+ export function isQqMessageAddressed(message) {
11
+ return Boolean(message) && (message.kind !== 'group'
12
+ || message[addressedToBot] === true
13
+ || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE'
14
+ || selfMentions(message).length > 0);
15
+ }
16
+
17
+ /** botIds belongs to one runtime; only platform-marked self mentions add IDs. */
18
+ export function normalizeQqMentions(message, botIds = new Set()) {
19
+ if (message?.kind !== 'group') return message;
20
+ for (const mention of selfMentions(message)) {
21
+ for (const id of [mention.id, mention.user_openid, mention.member_openid]) {
22
+ if (typeof id === 'string' && id.trim()) botIds.add(id.trim());
23
+ }
24
+ }
25
+ const marker = /<@!?([^<>\s]+)>/gu;
26
+ const content = typeof message.content === 'string' ? message.content : '';
27
+ // raw.content is still the current message, never the quoted message snapshot.
28
+ const rawContent = typeof message.raw?.content === 'string' ? message.raw.content : '';
29
+ const mentioned = isQqMessageAddressed(message)
30
+ || [...`${content}\n${rawContent}`.matchAll(marker)].some((match) => botIds.has(match[1]));
31
+ // Match the SDK sanitizer's in-place contract: later sender enrichment must
32
+ // remain visible to the existing queued message and source-context capture.
33
+ message[addressedToBot] = mentioned;
34
+ message.content = content.replace(/<@!?([^<>\s]+)>[ \t]*/gu,
35
+ (match, id) => botIds.has(id) ? '' : match).trim();
36
+ return message;
37
+ }
@@ -8,6 +8,7 @@ import {
8
8
  import { t } from '../shared/i18n.mjs';
9
9
  import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
10
10
  import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
11
+ import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
11
12
 
12
13
  function timeoutError() {
13
14
  const error = new Error('QQ WebSocket did not become ready in time');
@@ -178,6 +179,11 @@ export class QqRuntime {
178
179
  replyTimeoutMs: this.#replyTimeoutMs,
179
180
  signal: controller.signal,
180
181
  });
182
+ const botMentionIds = new Set([this.#config.appId]);
183
+ bot.use(async (ctx, next) => {
184
+ ctx.message = normalizeQqMentions(ctx.message, botMentionIds);
185
+ await next();
186
+ });
181
187
  // QQ delivers emoji/face messages as opaque `<faceType=..,faceId="..",ext="..">`
182
188
  // tags. Parse them into readable text so the Harness sees what the sender
183
189
  // actually meant instead of an unusable markup fragment.
@@ -186,8 +192,7 @@ export class QqRuntime {
186
192
  keepAlive: true,
187
193
  predicate: (ctx) => {
188
194
  const message = ctx?.message;
189
- if (!message || (message.kind === 'group'
190
- && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE')) return false;
195
+ if (!isQqMessageAddressed(message)) return false;
191
196
  if (!this.#accessPolicy) {
192
197
  return message.kind === 'group'
193
198
  || this.#config.ownerUserOpenid === '*'
@@ -207,7 +212,10 @@ export class QqRuntime {
207
212
  readyResolve = resolve;
208
213
  readyReject = reject;
209
214
  });
210
- const onReady = () => {
215
+ const onReady = (data) => {
216
+ if (typeof data?.user?.id === 'string' && data.user.id.trim()) {
217
+ botMentionIds.add(data.user.id.trim());
218
+ }
211
219
  const now = Date.now();
212
220
  this.#status.ready = true;
213
221
  this.#status.qqConnectionState = 'connected';
@@ -1709,6 +1709,7 @@ export function createBotWorkspaceScope(
1709
1709
  };
1710
1710
  }
1711
1711
  if ((property === 'listWorkspaces'
1712
+ || property === 'listWorkspaceEntries'
1712
1713
  || property === 'listWorkspaceSessions'
1713
1714
  || property === 'listModels')
1714
1715
  && typeof target[property] === 'function') {
@@ -6,7 +6,7 @@ import { t } from './i18n.mjs';
6
6
  const reported = new WeakMap();
7
7
  const lastReport = new WeakMap();
8
8
  const stages = new WeakMap();
9
- const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
9
+ const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
10
10
  const STANDARD_NAMES = new Set(['Error', 'TypeError', 'SyntaxError', 'AggregateError', 'DOMException', 'AxiosError', 'TimeoutError', 'AbortError']);
11
11
  const KNOWN_MESSAGES = {
12
12
  'discord-401': 'Discord Bot Token 无效,请重新填写。',
@@ -37,18 +37,24 @@ export function extractConnectionEvidence(error) {
37
37
  const { chain, truncated } = connectionErrorChain(error);
38
38
  const errors = chain.map(e => ({
39
39
  type: STANDARD_NAMES.has(e.name) ? e.name : 'Error',
40
+ // Host RPC adapters carry safe diagnostic facts without the original Error graph.
40
41
  ...normalizeDiagnosticDetails({
41
- reason: OWNED_REASONS[e.code] ?? e.code ?? (e.name === 'TimeoutError' ? 'timeout' : e instanceof SyntaxError ? 'invalid-json' : undefined),
42
+ reason: e.details?.reason, reasons: e.details?.reasons,
43
+ httpStatus: e.details?.httpStatus, providerCode: e.details?.providerCode,
44
+ }),
45
+ ...normalizeDiagnosticDetails({
46
+ // DOMException TimeoutError has a numeric legacy code; its name is authoritative.
47
+ reason: OWNED_REASONS[e.code] ?? (e.name === 'TimeoutError' ? 'timeout' : e.code ?? (e instanceof SyntaxError ? 'invalid-json' : undefined)),
42
48
  httpStatus: e.status ?? e.statusCode ?? e.response?.status,
43
49
  providerCode: e.providerCode,
44
50
  }),
45
51
  hasCause: Boolean(e.cause && typeof e.cause === 'object'),
46
52
  hasErrors: Array.isArray(e.errors),
47
53
  }));
48
- const allReasons = [...new Set(errors.map(e => e.reason).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
54
+ const allReasons = [...new Set(errors.flatMap(e => e.reason === 'multiple-causes' ? e.reasons ?? [] : [e.reason]).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
49
55
  const precise = allReasons.filter(r => !['timeout', 'http-error', 'provider-rejected', 'invalid-response'].includes(r));
50
56
  const reasons = precise.length ? precise : allReasons;
51
- const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] })[field]).find(v => v !== undefined);
57
+ const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
52
58
  return {
53
59
  chain, errors,
54
60
  details: normalizeDiagnosticDetails({
@@ -242,10 +242,12 @@ function turnStoppedError() {
242
242
  return error;
243
243
  }
244
244
 
245
- function workspacePaths(value) {
245
+ function workspaceEntries(value) {
246
246
  if (!Array.isArray(value?.items)) return [];
247
247
  return value.items.flatMap((item) => (
248
- typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
248
+ typeof item?.path === 'string' && isAbsolute(item.path)
249
+ ? [{ path: item.path, title: typeof item.title === 'string' ? item.title : '' }]
250
+ : []
249
251
  ));
250
252
  }
251
253
 
@@ -1081,8 +1083,12 @@ export class HarnessClient {
1081
1083
  }
1082
1084
 
1083
1085
  async listWorkspaces(options = {}) {
1086
+ return (await this.listWorkspaceEntries(options)).map((item) => item.path);
1087
+ }
1088
+
1089
+ async listWorkspaceEntries(options = {}) {
1084
1090
  await this.ensureRunning(options);
1085
- return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
1091
+ return workspaceEntries(await this.rpc('workspace.list', {}, 30_000, options));
1086
1092
  }
1087
1093
 
1088
1094
  async listWorkspaceSessions(workspacePath, options = {}) {
@@ -104,6 +104,10 @@ export default {
104
104
  'This message contains an image or file that cannot be processed. Adjust it and send it again.',
105
105
  '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
106
106
  'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
107
+ '请求超时,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。':
108
+ 'The request timed out and its outcome is unconfirmed. Check the task status or chat history before submitting it again.',
109
+ '网络请求失败,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。':
110
+ 'The network request failed and its outcome is unconfirmed. Check the task status or chat history before submitting it again.',
107
111
  '错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
108
112
  '{label}机器人': '{label} bot',
109
113
  '目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
@@ -1,4 +1,4 @@
1
- import { extractConnectionEvidence } from './connection-error.mjs';
1
+ import { connectionHint, extractConnectionEvidence } from './connection-error.mjs';
2
2
  import { diagnosticFields } from './diagnostic-details.mjs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
@@ -90,6 +90,10 @@ const FAILURE_MESSAGES = Object.freeze({
90
90
  '回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
91
91
  INPUT_INVALID:
92
92
  '当前消息包含无法处理的图片或文件。请调整后重新发送。',
93
+ REQUEST_TIMEOUT:
94
+ '请求超时,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。',
95
+ NETWORK_ERROR:
96
+ '网络请求失败,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。',
93
97
  INTERNAL_UNKNOWN:
94
98
  '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
95
99
  });
@@ -156,16 +160,30 @@ function failureCode(error) {
156
160
  return 'CHANNEL_RATE_LIMIT';
157
161
  }
158
162
  if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
159
- .includes(code)) {
163
+ .includes(code) || error?.deliveryOutcome === 'unknown') {
160
164
  return 'CHANNEL_DELIVERY_UNCERTAIN';
161
165
  }
162
166
  if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
163
- || ['network-error', 'timeout'].includes(code)) {
167
+ || code === 'network-error') {
164
168
  return 'CHANNEL_DELIVERY';
165
169
  }
166
170
  return 'INTERNAL_UNKNOWN';
167
171
  }
168
172
 
173
+ // Only refine the unknown fallback, using the same allowlisted evidence as the UI.
174
+ // Mixed unrelated causes and cancellation do not establish a network diagnosis.
175
+ function transportFailureCode(error, details) {
176
+ if (error?.name === 'AbortError' || error?.code === 'cancelled') return 'INTERNAL_UNKNOWN';
177
+ const reasons = details.reason === 'multiple-causes' ? details.reasons : [details.reason];
178
+ const timeout = reason => reason === 'timeout' || reason === 'ETIMEDOUT' || /_TIMEOUT$/u.test(reason);
179
+ if (reasons?.length && reasons.every(timeout)) return 'REQUEST_TIMEOUT';
180
+ const network = reason => timeout(reason) || [
181
+ 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET',
182
+ 'ENETUNREACH', 'EHOSTUNREACH', 'ERR_NETWORK_ACCESS_DENIED', 'ERR_PROXY_CONNECTION_FAILED',
183
+ ].includes(reason) || /CERT|TLS|SSL|ISSUER|SIGNATURE/u.test(reason);
184
+ return reasons?.length && reasons.every(network) ? 'NETWORK_ERROR' : 'INTERNAL_UNKNOWN';
185
+ }
186
+
169
187
  function safeReferenceId(value) {
170
188
  return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
171
189
  ? value
@@ -185,13 +203,17 @@ export function classifyMessageFailure(error, {
185
203
  } = {}) {
186
204
  const safeReason = safeFailureReason(reason);
187
205
  const classifiedCode = failureCode(error);
188
- const code = classifiedCode === 'INTERNAL_UNKNOWN'
206
+ const businessCode = classifiedCode === 'INTERNAL_UNKNOWN'
189
207
  && safeReason
190
208
  && typeof userMessage === 'string'
191
209
  && userMessage.trim()
192
210
  ? 'INPUT_INVALID'
193
211
  : classifiedCode;
194
212
  const details = extractConnectionEvidence(error).details;
213
+ const code = businessCode === 'INTERNAL_UNKNOWN'
214
+ ? transportFailureCode(error, details)
215
+ : businessCode;
216
+ if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
195
217
  return Object.freeze({
196
218
  ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
197
219
  code,
@@ -936,12 +936,12 @@ export class TextHarnessBridge {
936
936
  const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
937
937
  if (textReceipt?.deliveryOutcome === 'failed') {
938
938
  textDeliveryError = channelDeliveryFailure(
939
- new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
939
+ Object.assign(new Error('Final text delivery failed'), { code: textReceipt.reason }),
940
940
  { uncertain: false },
941
941
  );
942
942
  } else if (finalDeliveryUnknown) {
943
943
  textDeliveryError = channelDeliveryFailure(
944
- new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
944
+ Object.assign(new Error('Final text delivery outcome is unknown'), { code: textReceipt.reason }),
945
945
  );
946
946
  }
947
947
  // A failed final text must not discard an already registered result file.
@@ -62,17 +62,19 @@ export function parseSessionListArgument(value) {
62
62
  return { selector: '', limit };
63
63
  }
64
64
 
65
+ async function existingWorkspacePath(value) {
66
+ const workspace = normalizedWorkspacePath(value);
67
+ if (!workspace) return null;
68
+ try {
69
+ if (!(await stat(workspace)).isDirectory()) return null;
70
+ return normalizedWorkspacePath(await realpath(workspace));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
65
76
  async function existingWorkspacePaths(values) {
66
- const checked = await Promise.all(values.map(async (value) => {
67
- const workspace = normalizedWorkspacePath(value);
68
- if (!workspace) return null;
69
- try {
70
- if (!(await stat(workspace)).isDirectory()) return null;
71
- return normalizedWorkspacePath(await realpath(workspace));
72
- } catch {
73
- return null;
74
- }
75
- }));
77
+ const checked = await Promise.all(values.map(existingWorkspacePath));
76
78
  return [...new Set(checked.filter(Boolean))];
77
79
  }
78
80
 
@@ -109,16 +111,26 @@ async function selectedWorkspacePath(value) {
109
111
  }
110
112
  }
111
113
 
112
- export async function workspacePathSnapshot(harness, options = {}) {
113
- const listed = await harness.listWorkspaces(options);
114
+ export async function workspacePathSnapshot(harness, { includeTitles = false, ...options } = {}) {
115
+ const withEntries = includeTitles && typeof harness.listWorkspaceEntries === 'function';
116
+ const listed = await (withEntries ? harness.listWorkspaceEntries(options) : harness.listWorkspaces(options));
114
117
  const currentValue = typeof harness?.currentWorkspace === 'function'
115
118
  ? harness.currentWorkspace()
116
119
  : null;
117
- const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
118
- const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
119
- const paths = [...new Set([...(current ? [current] : []), ...registered])];
120
+ const current = await existingWorkspacePath(currentValue);
121
+ const entries = await Promise.all((Array.isArray(listed) ? listed : []).map(async (item) => ({
122
+ path: await existingWorkspacePath(withEntries ? item?.path : item),
123
+ title: withEntries ? safeDisplayText(item?.title) : '',
124
+ })));
125
+ const paths = [...new Set([...(current ? [current] : []), ...entries.map((item) => item.path).filter(Boolean)])];
126
+ const titles = {};
127
+ if (includeTitles) {
128
+ for (const item of entries) {
129
+ if (item.path && item.title && !titles[item.path]) titles[item.path] = item.title;
130
+ }
131
+ }
120
132
  harness.assertWorkspaceScope?.();
121
- return { current: current ?? null, paths };
133
+ return { current: current ?? null, paths, ...(includeTitles ? { titles } : {}) };
122
134
  }
123
135
 
124
136
  export function splitWorkspaceCommandMessage(message) {