@xmanrui/dsh-im 4.34.2 → 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.
package/locale/en.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "meta": {
3
+ "title": "IM bots",
3
4
  "description": "Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH."
4
5
  }
5
6
  }
package/locale/zh.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "meta": {
3
+ "title": "IM 机器人",
3
4
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。"
4
5
  }
5
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.34.2",
3
+ "version": "4.35.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -286,8 +286,8 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
286
286
  if (typeof coreController.sendProactiveText !== 'function') {
287
287
  throw new TypeError('delivery controller cannot send proactive text');
288
288
  }
289
- await coreController.sendProactiveText(botId, normalized, text, options);
290
- return { sent: true };
289
+ const result = await coreController.sendProactiveText(botId, normalized, text, options);
290
+ return options.receipt === true ? result : { sent: true };
291
291
  },
292
292
  async setSessionSync(botId, targetId, enabled) {
293
293
  if (typeof workspaces.setDeliveryTargetSessionSync !== 'function') {
@@ -30,6 +30,7 @@ const DELIVERY_ERROR_CODES = new Set([
30
30
  'bot-not-connected',
31
31
  'target-rejected',
32
32
  'delivery-failed',
33
+ 'send-result-unknown',
33
34
  'session-sync-unavailable',
34
35
  'cancelled',
35
36
  ]);
@@ -314,10 +315,10 @@ export class DeliveryService {
314
315
  } catch (error) { throw publicOperationError(error); }
315
316
  }
316
317
 
317
- async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain' } = {}) {
318
+ async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
318
319
  const id = botIdOf(botId);
319
320
  const key = targetIdOf(targetId);
320
- if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format)
321
+ if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format) || typeof receipt !== 'boolean'
321
322
  || !/^[a-f0-9]{64}$/.test(expectedFingerprint ?? '') || !/^[a-f0-9]{64}$/.test(expectedTargetDigest ?? '')) {
322
323
  throw deliveryError('bad-request');
323
324
  }
@@ -341,14 +342,22 @@ export class DeliveryService {
341
342
  throw deliveryError('capability-unavailable');
342
343
  }
343
344
  if (account.account?.fingerprint !== expectedFingerprint) throw deliveryError('account-changed');
345
+ if (receipt && (!account.capabilities?.includes('proactive-receipt-checked') || target.kind !== 'group'))
346
+ throw deliveryError('capability-unavailable');
344
347
  const beforeSend = () => {
345
348
  cancellation(signal);
346
349
  this.#assertRegistered(registration);
347
350
  };
348
351
  beforeSend();
349
- await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
352
+ const result = await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
353
+ ...(receipt ? { receipt: true } : {}),
350
354
  ...(format === 'markdown' ? { format } : {}) });
351
- return { sent: true };
355
+ if (!receipt) return { sent: true };
356
+ if (result?.sent !== true || result.receipt?.version !== 1
357
+ || typeof result.receipt.messageId !== 'string' || !result.receipt.messageId || result.receipt.messageId.length > 512
358
+ || result.receipt.conversationId !== target.route.chatId)
359
+ throw deliveryError('send-result-unknown');
360
+ return { sent: true, receipt: { version: 1, messageId: result.receipt.messageId, conversationId: result.receipt.conversationId } };
352
361
  } catch (error) { throw publicOperationError(error); }
353
362
  }
354
363
 
@@ -92,6 +92,7 @@ export function createImHostPlugin(internals = {}) {
92
92
  if (typeof ctx?.provide === 'function') {
93
93
  ctx.provide('dshIm', Object.freeze({
94
94
  contractVersion: 1,
95
+ receiptVersion: 1,
95
96
  describeBot: (botId) => deliveryService.describeBot(botId),
96
97
  sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
97
98
  send: (botId, targetId, text, options) => (
@@ -3041,14 +3041,14 @@ export class FeishuHarnessBridge {
3041
3041
 
3042
3042
  async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
3043
3043
  try {
3044
- const { current, paths } = await workspacePathSnapshot(
3044
+ const { current, paths, titles } = await workspacePathSnapshot(
3045
3045
  this.#harness,
3046
- { signal: this.#cardDataSignal() },
3046
+ { signal: this.#cardDataSignal(), includeTitles: true },
3047
3047
  );
3048
3048
  this.#rememberMenu(key, { kind: 'workspaces', paths });
3049
3049
  await this.#sendCard(
3050
3050
  chatId,
3051
- workspaceListCard(paths, current),
3051
+ workspaceListCard(paths, current, titles),
3052
3052
  { key, updateMessageId, replyTo },
3053
3053
  );
3054
3054
  } catch (error) {
@@ -3239,7 +3239,7 @@ export class FeishuHarnessBridge {
3239
3239
  const dataSignal = this.#cardDataSignal();
3240
3240
  // Independent sections start together. Each one degrades on its own so a
3241
3241
  // slow preset/model RPC cannot force redundant session-list scans.
3242
- const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal })
3242
+ const workspaceTask = workspacePathSnapshot(this.#harness, { signal: dataSignal, includeTitles: true })
3243
3243
  .catch(() => {
3244
3244
  const current = typeof this.#harness.currentWorkspace === 'function'
3245
3245
  ? this.#harness.currentWorkspace()
@@ -3316,7 +3316,7 @@ export class FeishuHarnessBridge {
3316
3316
  const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
3317
3317
  this.#rememberMenu(key, { kind: 'menu', chatId });
3318
3318
  const menuContext = {
3319
- workspaces, currentWorkspace,
3319
+ workspaces, currentWorkspace, workspaceTitles: snapshot.titles,
3320
3320
  currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
3321
3321
  sessions, archiveVisible, presetCatalog, modelCatalog,
3322
3322
  newSessionAction: { id: randomUUID(), status: 'idle' },
@@ -14,6 +14,7 @@
14
14
  * without button callbacks.
15
15
  */
16
16
 
17
+ import { posix, win32 } from 'node:path';
17
18
  import { t } from '../shared/i18n.mjs';
18
19
 
19
20
  export const MENU_PAGE_SIZE = 10;
@@ -87,9 +88,56 @@ function initialIndex(options, currentValue) {
87
88
  return idx >= 0 ? idx + 1 : 0;
88
89
  }
89
90
 
91
+ function cleanTitle(value) {
92
+ return String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
93
+ }
94
+
90
95
  function safeTitle(value) {
91
- const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
92
- return title || t('暂无标题');
96
+ return cleanTitle(value) || t('暂无标题');
97
+ }
98
+
99
+ const WORKSPACE_LABEL_LENGTH = 22;
100
+
101
+ /** Display labels only: paths and their snapshot order remain the callback identity. */
102
+ function workspaceLabels(paths, titles = {}) {
103
+ const entries = paths.map((path) => {
104
+ const api = /^[a-z]:[\\/]|^\\\\/iu.test(path) ? win32 : posix;
105
+ const normalized = api.normalize(path);
106
+ const name = cleanTitle(api.basename(normalized) || api.parse(normalized).root);
107
+ const base = (typeof titles[path] === 'string' && cleanTitle(titles[path])) || name;
108
+ return {
109
+ api, base, label: base, parent: api.dirname(normalized),
110
+ context: base === name ? '' : api.basename(normalized),
111
+ };
112
+ });
113
+ const counts = () => {
114
+ const result = new Map();
115
+ for (const { label } of entries) result.set(label, (result.get(label) ?? 0) + 1);
116
+ return result;
117
+ };
118
+ // Grow only ambiguous path suffixes; stop at the root even if display text collides.
119
+ for (;;) {
120
+ const repeated = counts();
121
+ let changed = false;
122
+ for (const entry of entries) {
123
+ if (repeated.get(entry.label) < 2 || entry.parent === null) continue;
124
+ const { api, parent } = entry;
125
+ entry.context = api.join(api.basename(parent) || parent, entry.context);
126
+ entry.label = `${entry.base} · ${cleanTitle(entry.context)}`;
127
+ const next = api.dirname(parent);
128
+ entry.parent = next === parent ? null : next;
129
+ changed = true;
130
+ }
131
+ if (!changed) break;
132
+ }
133
+ for (const entry of entries) {
134
+ const chars = Array.from(entry.label);
135
+ if (chars.length > WORKSPACE_LABEL_LENGTH) {
136
+ entry.label = `${chars.slice(0, 11).join('')}…${chars.slice(-10).join('')}`;
137
+ }
138
+ }
139
+ const repeated = counts();
140
+ return entries.map(({ label }) => ({ label, numbered: repeated.get(label) > 1 }));
93
141
  }
94
142
 
95
143
  function cardWith(headerText, elements) {
@@ -117,6 +165,7 @@ function backButton() {
117
165
  * 4. 系统 → 状态 · 帮助
118
166
  * `ctx` bundle:
119
167
  * - workspaces: string[] (工作区下拉选项)
168
+ * - workspaceTitles: object (可选,按绝对路径索引的工作区名称)
120
169
  * - currentWorkspace: string|null (当前工作区,下拉高亮)
121
170
  * - currentSession: {id,title}|null (当前绑定会话,续写目标)
122
171
  * - sessions: {id,title}[] (最近会话,供会话下拉切换)
@@ -130,6 +179,7 @@ function backButton() {
130
179
  export function menuCard(ctx) {
131
180
  const {
132
181
  workspaces = [],
182
+ workspaceTitles = {},
133
183
  currentWorkspace = null,
134
184
  currentSession = null,
135
185
  sessions = [],
@@ -188,8 +238,9 @@ export function menuCard(ctx) {
188
238
 
189
239
  let workspaceDropdown = null;
190
240
  if (hasWorkspaces) {
191
- const wsOptions = workspaces.slice(0, 20).map((path) => ({
192
- text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
241
+ const labels = workspaceLabels(workspaces, workspaceTitles);
242
+ const wsOptions = workspaces.slice(0, 20).map((path, index) => ({
243
+ text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${labels[index].numbered ? `${index + 1}. ` : ''}${labels[index].label}` },
193
244
  value: path,
194
245
  }));
195
246
  workspaceDropdown = {
@@ -726,7 +777,8 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
726
777
  // ── Workspace list card (preserved, with back button) ─────────────────────
727
778
 
728
779
  /** The workspace list card (switch-workspace buttons + reply fallback). */
729
- export function workspaceListCard(paths, current) {
780
+ export function workspaceListCard(paths, current, titles = {}) {
781
+ const labels = workspaceLabels(paths, titles);
730
782
  const elements = paths.length === 0
731
783
  ? [
732
784
  { tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
@@ -735,7 +787,7 @@ export function workspaceListCard(paths, current) {
735
787
  : [
736
788
  { tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
737
789
  ...paths.map((path, index) => button(
738
- `${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
790
+ `${index + 1}. ${labels[index].label}${path === current ? t('(当前)') : ''}`,
739
791
  `workspace:${path}`,
740
792
  )),
741
793
  { tag: 'hr' },
@@ -675,7 +675,7 @@ export class FeishuRuntime {
675
675
  });
676
676
  }
677
677
 
678
- async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
678
+ async sendProactiveText(target, text, { signal, format = 'plain', receipt = false } = {}) {
679
679
  if (!this.#status.ready || !this.#client) {
680
680
  const error = new Error('飞书机器人尚未连接');
681
681
  error.code = 'bot-not-connected';
@@ -698,6 +698,8 @@ export class FeishuRuntime {
698
698
  error.code = 'bad-request';
699
699
  throw error;
700
700
  }
701
+ if (typeof receipt !== 'boolean' || (receipt && target.kind !== 'group'))
702
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
701
703
  signal?.throwIfAborted();
702
704
  // Use the same native Markdown element as chat, without opening a stream
703
705
  // or retrying as plain text after a possibly accepted delivery.
@@ -724,7 +726,12 @@ export class FeishuRuntime {
724
726
  error.code = 'target-rejected';
725
727
  throw error;
726
728
  }
727
- 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 } };
728
735
  }
729
736
 
730
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') {
@@ -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 = {}) {
@@ -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) {