@xmanrui/dsh-im 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.16.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.17.0)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -32,11 +32,14 @@ import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot
32
32
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
33
33
  import {
34
34
  MENU_PAGE_SIZE,
35
+ completionCard,
35
36
  menuCard,
36
37
  menuHelpText,
37
38
  sessionListCard,
39
+ watchListCard,
38
40
  workspaceListCard,
39
41
  } from './feishu-cards.mjs';
42
+ import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
40
43
 
41
44
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
42
45
  const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
@@ -44,6 +47,9 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
44
47
  const MENU_COMMAND = /^\/m(?:enu)?$/i;
45
48
  const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
46
49
  const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
50
+ const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
51
+ const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
52
+ const WATCHLIST_COMMAND = /^\/watchlist$/i;
47
53
  const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
48
54
  const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
49
55
  const NUMBER_REPLY = /^\d{1,2}$/;
@@ -83,9 +89,15 @@ const HELP_TEXT = [
83
89
  '/status 检查连接状态',
84
90
  '/repair 修复卡片按钮回调',
85
91
  '/m(或 /menu) 打开交互卡片菜单',
92
+ '/watch [Session ID 或序号] 关注会话,任务完成自动推送',
93
+ '/unwatch [Session ID 或序号] 取消关注',
94
+ '/watchlist 查看关注列表',
95
+ '/archived on|off 会话列表是否包含归档会话',
86
96
  '/help 显示本帮助',
87
97
  ].join('\n');
88
98
 
99
+ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
100
+
89
101
  /** Safe user-facing text for bind/workspace failures (no raw messages). */
90
102
  function safeErrorText(error) {
91
103
  switch (error?.code) {
@@ -106,6 +118,13 @@ function nonEmptyString(value) {
106
118
  return typeof value === 'string' && value.trim() ? value.trim() : null;
107
119
  }
108
120
 
121
+ function orderedHistoryEvents(history) {
122
+ return (Array.isArray(history?.events) ? history.events : [])
123
+ .map((entry) => entry?.event ?? entry)
124
+ .filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
125
+ .sort((left, right) => left.seq - right.seq);
126
+ }
127
+
109
128
  function senderOpenId(event) {
110
129
  return nonEmptyString(event?.sender?.sender_id?.open_id)
111
130
  ?? nonEmptyString(event?.sender?.sender_id?.user_id);
@@ -240,6 +259,12 @@ export class FeishuHarnessBridge {
240
259
  #menus = new Map();
241
260
  /** Interactive-card message id → route context for button callbacks. */
242
261
  #cardKeys = new Map();
262
+ /** The global event-mux watcher (one per bridge). */
263
+ #eventWatcher = null;
264
+ /** Serializes live completions and reconnect compensation. */
265
+ #eventTail = Promise.resolve();
266
+ /** Earliest completion that still needs delivery for each watch. */
267
+ #failedWatchSeqs = new Map();
243
268
 
244
269
  constructor({
245
270
  client,
@@ -295,6 +320,11 @@ export class FeishuHarnessBridge {
295
320
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
296
321
  this.#signal = signal;
297
322
  ensureStatus(this.#status);
323
+ // Persisted watches must resume at runtime start, not on the first
324
+ // message. Older hosts without the mux watcher simply skip this.
325
+ if (typeof this.#harness?.watchHarnessEvents === 'function') {
326
+ queueMicrotask(() => this.#ensureEventWatcher());
327
+ }
298
328
  }
299
329
 
300
330
  accept(event) {
@@ -519,6 +549,7 @@ export class FeishuHarnessBridge {
519
549
  )),
520
550
  ...this.#interactionTasks,
521
551
  ...this.#commandTasks,
552
+ this.#eventTail,
522
553
  ]);
523
554
  }
524
555
 
@@ -604,6 +635,36 @@ export class FeishuHarnessBridge {
604
635
  await this.#showWorkspaces({ chatId: event.message.chat_id, key });
605
636
  return;
606
637
  }
638
+ if (WATCH_COMMAND.test(commandText)) {
639
+ const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
640
+ await this.#runWatch(key, event.message.chat_id, target);
641
+ return;
642
+ }
643
+ if (UNWATCH_COMMAND.test(commandText)) {
644
+ const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
645
+ await this.#runUnwatch(key, event.message.chat_id, target);
646
+ return;
647
+ }
648
+ if (WATCHLIST_COMMAND.test(commandText)) {
649
+ await this.#showWatchList(key, event.message.chat_id);
650
+ return;
651
+ }
652
+ if (ARCHIVED_COMMAND.test(commandText)) {
653
+ const match = ARCHIVED_COMMAND.exec(commandText);
654
+ const value = match[1]?.toLowerCase();
655
+ if (value !== 'on' && value !== 'off') {
656
+ await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)');
657
+ return;
658
+ }
659
+ if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
660
+ await this.#state.setIncludeArchivedSessions(value === 'on');
661
+ }
662
+ await this.#send(
663
+ event.message.chat_id,
664
+ value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
665
+ );
666
+ return;
667
+ }
607
668
  if (NUMBER_REPLY.test(commandText)) {
608
669
  const menu = this.#takeMenu(key);
609
670
  if (menu) {
@@ -991,7 +1052,15 @@ export class FeishuHarnessBridge {
991
1052
  if (!action) return Promise.resolve();
992
1053
  const messageId = nonEmptyString(event?.context?.open_message_id);
993
1054
  const entry = messageId ? this.#cardKeys.get(messageId) : null;
994
- if (!entry) return Promise.resolve();
1055
+ if (!entry) {
1056
+ // The card predates this process (the in-memory mapping resets on
1057
+ // restart) or never came from us: nudge instead of staying silent.
1058
+ const chatId = nonEmptyString(event?.context?.open_chat_id);
1059
+ if (chatId) {
1060
+ this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
1061
+ }
1062
+ return Promise.resolve();
1063
+ }
995
1064
  // The promise is returned so tests (and future callers) can await the
996
1065
  // action; the runtime dispatcher ignores it.
997
1066
  return this.#handleCardAction(action, entry).catch((error) => {
@@ -1009,6 +1078,10 @@ export class FeishuHarnessBridge {
1009
1078
  await this.#showWorkspaces({ chatId, key });
1010
1079
  return;
1011
1080
  }
1081
+ if (action === 'watchlist') {
1082
+ await this.#showWatchList(key, chatId);
1083
+ return;
1084
+ }
1012
1085
  if (action === 'new') {
1013
1086
  await this.#state.clearSession(key);
1014
1087
  await this.#send(chatId, '已开启全新 Harness 会话。');
@@ -1029,6 +1102,14 @@ export class FeishuHarnessBridge {
1029
1102
  }
1030
1103
  if (action.startsWith('workspace:')) {
1031
1104
  await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
1105
+ return;
1106
+ }
1107
+ if (action.startsWith('unwatch:')) {
1108
+ await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
1109
+ return;
1110
+ }
1111
+ if (action.startsWith('watch:')) {
1112
+ await this.#runWatch(key, chatId, action.slice('watch:'.length));
1032
1113
  }
1033
1114
  }
1034
1115
 
@@ -1053,7 +1134,7 @@ export class FeishuHarnessBridge {
1053
1134
 
1054
1135
  async #handleMenuPick(menu, number, { chatId, key, event }) {
1055
1136
  if (menu.kind === 'menu') {
1056
- const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair'][number - 1];
1137
+ const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
1057
1138
  if (!action) {
1058
1139
  await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
1059
1140
  return;
@@ -1071,6 +1152,7 @@ export class FeishuHarnessBridge {
1071
1152
  await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
1072
1153
  return;
1073
1154
  }
1155
+ // The number label sits on the session (bind) button of the row.
1074
1156
  await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
1075
1157
  return;
1076
1158
  }
@@ -1081,9 +1163,26 @@ export class FeishuHarnessBridge {
1081
1163
  return;
1082
1164
  }
1083
1165
  await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
1166
+ return;
1167
+ }
1168
+ if (menu.kind === 'watches') {
1169
+ const entry = menu.entries[number - 1];
1170
+ if (!entry?.sessionId) {
1171
+ await this.#send(chatId, `关注列表只有 ${menu.entries.length} 个会话。`);
1172
+ return;
1173
+ }
1174
+ await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
1084
1175
  }
1085
1176
  }
1086
1177
 
1178
+ /** The sessions visible under the bot's archived policy. */
1179
+ #visibleSessions(sessions) {
1180
+ if (this.#state?.includesArchivedSessions?.() === false) {
1181
+ return sessions.filter((session) => session.archived !== true);
1182
+ }
1183
+ return sessions;
1184
+ }
1185
+
1087
1186
  async #showSessions({ chatId, key }, selector, page = 0) {
1088
1187
  try {
1089
1188
  const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
@@ -1092,7 +1191,7 @@ export class FeishuHarnessBridge {
1092
1191
  return;
1093
1192
  }
1094
1193
  const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
1095
- const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
1194
+ const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
1096
1195
  const workspace = listed?.workspace ?? resolved.workspace;
1097
1196
  if (sessions.length === 0) {
1098
1197
  await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
@@ -1100,16 +1199,24 @@ export class FeishuHarnessBridge {
1100
1199
  }
1101
1200
  const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
1102
1201
  const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
1202
+ const watchedSet = new Set(
1203
+ (this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
1204
+ );
1205
+ const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
1103
1206
  this.#rememberMenu(key, {
1104
1207
  kind: 'sessions',
1105
- sessions: sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE),
1106
- });
1107
- await this.#sendCard(chatId, sessionListCard(workspace, sessions, safePage, sessions.length), {
1108
- key,
1109
- // Keep the canonical selector result for later page callbacks. The
1110
- // list response's workspace is display data and is not authoritative.
1111
- sessionWorkspace: resolved.workspace,
1208
+ sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
1112
1209
  });
1210
+ await this.#sendCard(
1211
+ chatId,
1212
+ sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
1213
+ {
1214
+ key,
1215
+ // Keep the canonical selector result for later page callbacks. The
1216
+ // list response's workspace is display data and is not authoritative.
1217
+ sessionWorkspace: resolved.workspace,
1218
+ },
1219
+ );
1113
1220
  } catch (error) {
1114
1221
  this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
1115
1222
  await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
@@ -1171,6 +1278,256 @@ export class FeishuHarnessBridge {
1171
1278
  return messageId;
1172
1279
  }
1173
1280
 
1281
+ // ── Watches: read-only session tracking + completion pushes ─────────────
1282
+
1283
+ #ensureEventWatcher() {
1284
+ if (this.#eventWatcher) return;
1285
+ if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
1286
+ if (this.#signal?.aborted) return;
1287
+ const signal = this.#signal ?? new AbortController().signal;
1288
+ try {
1289
+ this.#eventWatcher = this.#harness.watchHarnessEvents({
1290
+ signal,
1291
+ onSessionEvent: (payload) => this.#onHarnessEvent(payload),
1292
+ onReconnect: () => {
1293
+ void this.#queueEventTask(() => this.#compensateMissedEvents());
1294
+ },
1295
+ });
1296
+ Promise.resolve(this.#eventWatcher).catch((error) => {
1297
+ if (!signal.aborted) {
1298
+ this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
1299
+ }
1300
+ });
1301
+ } catch (error) {
1302
+ this.#eventWatcher = null;
1303
+ this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
1304
+ }
1305
+ }
1306
+
1307
+ #queueEventTask(task) {
1308
+ const next = this.#eventTail.then(task, task).catch((error) => {
1309
+ if (!this.#signal?.aborted) {
1310
+ this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
1311
+ }
1312
+ });
1313
+ this.#eventTail = next;
1314
+ return next;
1315
+ }
1316
+
1317
+ /**
1318
+ * Resolve a /watch target READ-ONLY: a session id is validated against
1319
+ * the registered workspaces' listings, an index against the current
1320
+ * workspace. Nothing is bound and no workspace is switched.
1321
+ */
1322
+ async #resolveWatchTarget(target) {
1323
+ if (typeof target !== 'string' || target === '') {
1324
+ return { error: '用法:/watch <Session ID 或当前工作区序号>' };
1325
+ }
1326
+ const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
1327
+ const currentPath = typeof this.#harness?.currentWorkspace === 'function'
1328
+ ? this.#harness.currentWorkspace()
1329
+ : null;
1330
+ const listSessions = async (workspace) => {
1331
+ const listed = await this.#harness.listWorkspaceSessions(workspace);
1332
+ return Array.isArray(listed?.sessions) ? listed.sessions : [];
1333
+ };
1334
+ if (numeric !== null) {
1335
+ if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' };
1336
+ const sessions = this.#visibleSessions(await listSessions(currentPath));
1337
+ const session = sessions[numeric - 1];
1338
+ if (!session?.sessionId) {
1339
+ return { error: `当前工作区只有 ${sessions.length} 个会话。` };
1340
+ }
1341
+ return { sessionId: session.sessionId, title: session.title ?? '暂无标题' };
1342
+ }
1343
+ const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
1344
+ ? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
1345
+ : [];
1346
+ const paths = [currentPath, ...extraPaths].filter(Boolean);
1347
+ for (const workspace of paths) {
1348
+ const sessions = await listSessions(workspace);
1349
+ const session = sessions.find((candidate) => candidate.sessionId === target);
1350
+ if (session) return { sessionId: target, title: session.title ?? '暂无标题' };
1351
+ }
1352
+ return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
1353
+ }
1354
+
1355
+ async #latestSessionSeq(sessionId) {
1356
+ if (typeof this.#harness?.rpc !== 'function') return null;
1357
+ const history = await this.#harness.rpc(
1358
+ 'session.history',
1359
+ { sessionId, maxMessages: 20 },
1360
+ 30_000,
1361
+ { signal: this.#signal },
1362
+ );
1363
+ return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
1364
+ }
1365
+
1366
+ async #runWatch(key, chatId, target) {
1367
+ this.#ensureEventWatcher();
1368
+ if (typeof this.#state?.setWatch !== 'function') {
1369
+ await this.#send(chatId, '当前状态存储不支持关注。');
1370
+ return;
1371
+ }
1372
+ let resolved;
1373
+ try {
1374
+ resolved = await this.#resolveWatchTarget(target);
1375
+ } catch (error) {
1376
+ await this.#send(chatId, `无法解析会话:${safeErrorText(error)}`);
1377
+ return;
1378
+ }
1379
+ if (resolved.error) {
1380
+ await this.#send(chatId, resolved.error);
1381
+ return;
1382
+ }
1383
+ const existing = this.#state.watchEntries?.(key) ?? [];
1384
+ const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
1385
+ if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
1386
+ await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
1387
+ return;
1388
+ }
1389
+ try {
1390
+ const lastSeq = typeof existingEntry?.lastSeq === 'number'
1391
+ ? existingEntry.lastSeq
1392
+ : await this.#latestSessionSeq(resolved.sessionId);
1393
+ await this.#state.setWatch(key, {
1394
+ sessionId: resolved.sessionId,
1395
+ title: resolved.title,
1396
+ chatId,
1397
+ lastSeq,
1398
+ });
1399
+ await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
1400
+ await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
1401
+ } catch (error) {
1402
+ await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
1403
+ }
1404
+ }
1405
+
1406
+ async #runUnwatch(key, chatId, target) {
1407
+ if (typeof this.#state?.removeWatch !== 'function') return;
1408
+ const entries = this.#state.watchEntries?.(key) ?? [];
1409
+ const entry = typeof target === 'string' && /^\d{1,4}$/.test(target)
1410
+ ? entries[Number(target) - 1]
1411
+ : entries.find((candidate) => candidate.sessionId === target);
1412
+ if (!entry) {
1413
+ await this.#send(chatId, '关注列表里没有这个会话,回复 /watchlist 查看。');
1414
+ return;
1415
+ }
1416
+ try {
1417
+ await this.#state.removeWatch(key, entry.sessionId);
1418
+ this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
1419
+ await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
1420
+ } catch (error) {
1421
+ await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
1422
+ }
1423
+ }
1424
+
1425
+ async #showWatchList(key, chatId) {
1426
+ const entries = this.#state.watchEntries?.(key) ?? [];
1427
+ this.#rememberMenu(key, { kind: 'watches', entries });
1428
+ await this.#sendCard(chatId, watchListCard(entries), { key });
1429
+ }
1430
+
1431
+ /** Queue live turn completions behind any reconnect compensation. */
1432
+ #onHarnessEvent({ sessionId, event }) {
1433
+ if (this.#signal?.aborted
1434
+ || !sessionId
1435
+ || !event
1436
+ || typeof event !== 'object'
1437
+ || event.type !== 'turn/end'
1438
+ || !Number.isFinite(event.seq)) return;
1439
+ void this.#queueEventTask(async () => {
1440
+ const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
1441
+ .some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
1442
+ if (hasFailedDelivery) await this.#compensateSession(sessionId);
1443
+ await this.#deliverCompletion(sessionId, event);
1444
+ });
1445
+ }
1446
+
1447
+ async #deliverCompletion(sessionId, event) {
1448
+ if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
1449
+ const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
1450
+ for (const key of this.#state.keysWatching(sessionId)) {
1451
+ if (this.#signal?.aborted) return;
1452
+ const entry = this.#state.watchEntry?.(key, sessionId);
1453
+ const deliveryKey = `${key}\0${sessionId}`;
1454
+ let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
1455
+ if (typeof failedSeq === 'number'
1456
+ && typeof entry?.lastSeq === 'number'
1457
+ && entry.lastSeq >= failedSeq) {
1458
+ this.#failedWatchSeqs.delete(deliveryKey);
1459
+ failedSeq = undefined;
1460
+ }
1461
+ if (!entry?.chatId
1462
+ || (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
1463
+ || (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
1464
+ try {
1465
+ await this.#sendCard(
1466
+ entry.chatId,
1467
+ completionCard(sessionId, entry.title, reason),
1468
+ { key },
1469
+ );
1470
+ const current = this.#state.watchEntry?.(key, sessionId);
1471
+ if (!current
1472
+ || current.chatId !== entry.chatId
1473
+ || (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
1474
+ await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
1475
+ if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
1476
+ } catch (error) {
1477
+ this.#failedWatchSeqs.set(
1478
+ deliveryKey,
1479
+ typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
1480
+ );
1481
+ this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
1482
+ }
1483
+ }
1484
+ }
1485
+
1486
+ async #compensateSession(sessionId) {
1487
+ if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
1488
+ try {
1489
+ const history = await this.#harness.rpc(
1490
+ 'session.history',
1491
+ { sessionId, maxMessages: 20 },
1492
+ 30_000,
1493
+ { signal: this.#signal },
1494
+ );
1495
+ const events = orderedHistoryEvents(history);
1496
+ const latestSeq = events.at(-1)?.seq ?? -1;
1497
+ const keys = typeof this.#state?.keysWatching === 'function'
1498
+ ? this.#state.keysWatching(sessionId)
1499
+ : [];
1500
+
1501
+ // Watches created by older versions have no baseline. Establish one
1502
+ // without replaying completions that predate the watch.
1503
+ for (const key of keys) {
1504
+ const entry = this.#state.watchEntry?.(key, sessionId);
1505
+ if (entry && typeof entry.lastSeq !== 'number') {
1506
+ await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
1507
+ }
1508
+ }
1509
+
1510
+ for (const event of events) {
1511
+ if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
1512
+ }
1513
+ } catch (error) {
1514
+ if (!this.#signal?.aborted) {
1515
+ this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ /** Replay recent turn completions missed while the mux was disconnected. */
1521
+ async #compensateMissedEvents() {
1522
+ const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
1523
+ ? this.#state.watchedSessionIds()
1524
+ : [];
1525
+ for (const sessionId of sessionIds) {
1526
+ if (this.#signal?.aborted) return;
1527
+ await this.#compensateSession(sessionId);
1528
+ }
1529
+ }
1530
+
1174
1531
  #interactionAskOptions(event, key) {
1175
1532
  return {
1176
1533
  timeoutMs: this.#replyTimeoutMs,
@@ -4,10 +4,14 @@
4
4
  * `im.message.create` API expects as `content` for `msg_type: interactive`
5
5
  * (card schema 2.0; callback buttons live inside a column_set/column layout).
6
6
  *
7
- * Buttons carry a small `{ action }` callback behavior that
8
- * `card.action.trigger` events echo back (when the app subscribes that
9
- * callback); every button also carries a numeric label so the number-reply
10
- * fallback stays usable without button callbacks.
7
+ * The session list lays each row out as a `column_set` (fixed-width
8
+ * watch-toggle column + weighted session-button column), which is how V2
9
+ * expresses a row of buttons.
10
+ *
11
+ * Buttons carry a small `{ action }` value object that `card.action.trigger`
12
+ * events echo back (when the app subscribes that event); every numbered
13
+ * button also has a numeric label so the number-reply fallback stays usable
14
+ * without button callbacks.
11
15
  */
12
16
 
13
17
  export const MENU_PAGE_SIZE = 10;
@@ -39,6 +43,17 @@ function button(content, actionValue) {
39
43
  };
40
44
  }
41
45
 
46
+ /** The raw button element (without the full-width column_set wrapper). */
47
+ function buttonElement(content, actionValue) {
48
+ return {
49
+ tag: 'button',
50
+ text: plainText(content),
51
+ type: 'default',
52
+ width: 'fill',
53
+ behaviors: [{ type: 'callback', value: { action: actionValue } }],
54
+ };
55
+ }
56
+
42
57
  function safeTitle(value) {
43
58
  const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
44
59
  return title || '暂无标题';
@@ -65,6 +80,7 @@ export function menuCard() {
65
80
  // have card.action.trigger yet, so rendering it as a callback button would
66
81
  // send the user straight back to Feishu's broken callback setup popup.
67
82
  { tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
83
+ button('7 · 关注列表', 'watchlist'),
68
84
  ]);
69
85
  }
70
86
 
@@ -101,23 +117,43 @@ export function cardActionProbeCard(nonce) {
101
117
  }
102
118
 
103
119
  /**
104
- * One page of the workspace's sessions. Each row is a bind button; the
105
- * number label equals the reply-number for the same action (fallback).
120
+ * One page of the workspace's sessions. Each row is a `column_set` pair:
121
+ * the fixed-width watch toggle (`⭐关注` / `⭐取关` for already-watched
122
+ * sessions) followed by the session button that carries the page-local
123
+ * number label (reply-number fallback = bind). Archived sessions are marked
124
+ * in the label. `watchedSessionIds` is a Set-like of ids this conversation
125
+ * already watches.
106
126
  */
107
- export function sessionListCard(workspace, sessions, page, total) {
127
+ export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
108
128
  const start = page * MENU_PAGE_SIZE;
109
129
  const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
110
130
  const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
131
+ const watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
132
+ /** One row: fixed 90px watch toggle + the session button filling the rest. */
133
+ const row = (watchButton, sessionButton) => ({
134
+ tag: 'column_set',
135
+ flex_mode: 'none',
136
+ horizontal_spacing: 'default',
137
+ columns: [
138
+ { tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
139
+ { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
140
+ ],
141
+ });
111
142
  const elements = [
112
143
  { tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
113
- ...slice.map((session, offset) => button(
114
- `${offset + 1}. ${safeTitle(session.title)}`,
115
- `use:${session.sessionId}`,
116
- )),
144
+ ...slice.map((session, offset) => {
145
+ // Page-local numbering: number replies resolve against this page.
146
+ const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
147
+ const watching = watched(session.sessionId);
148
+ return row(
149
+ buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
150
+ buttonElement(label, `use:${session.sessionId}`),
151
+ );
152
+ }),
117
153
  ];
118
154
  if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
119
155
  if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
120
- elements.push({ tag: 'div', text: markdown('回复数字(1~N)同样可以绑定本页会话。') });
156
+ elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
121
157
  return cardWith('📂 会话列表', elements);
122
158
  }
123
159
 
@@ -146,10 +182,49 @@ export function menuHelpText() {
146
182
  '4 · /status 连接状态',
147
183
  '5 · /help 本帮助',
148
184
  '6 · /repair 修复卡片按钮(请回复数字 6)',
185
+ '7 · /watchlist 关注列表',
149
186
  '',
150
187
  '直接发送文字/图片即继续当前会话。',
151
188
  '/session ID 或序号 绑定已有会话',
189
+ '/watch ID 或序号 关注会话(完成后推送)',
152
190
  '/compact 压缩上下文',
153
191
  '/workspace 绝对路径 切换工作区',
154
192
  ].join('\n');
155
193
  }
194
+
195
+ /** The watch list for one conversation (unwatch buttons + reply fallback). */
196
+ export function watchListCard(entries) {
197
+ const elements = entries.length === 0
198
+ ? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
199
+ : [
200
+ { tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
201
+ ...entries.map((entry, index) => button(
202
+ `${index + 1}. ${safeTitle(entry.title)}`,
203
+ `unwatch:${entry.sessionId}`,
204
+ )),
205
+ ];
206
+ return cardWith('👁 关注列表', elements);
207
+ }
208
+
209
+ /**
210
+ * The completion push card. `title` is the session title, `reason` the
211
+ * turn-end kind (completed / stopped / aborted).
212
+ */
213
+ export function completionCard(sessionId, title, reason) {
214
+ const reasonText = reason === 'completed'
215
+ ? '已完成'
216
+ : reason === 'stopped'
217
+ ? '已停止'
218
+ : reason === 'aborted'
219
+ ? '已中止'
220
+ : reason === 'cancelled'
221
+ ? '已取消'
222
+ : '已结束';
223
+ return cardWith('✅ 任务完成', [
224
+ { tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
225
+ { tag: 'div', text: markdown(`**状态**:${reasonText}`) },
226
+ button('打开会话列表', 'sessions'),
227
+ button('工作区', 'workspaces'),
228
+ { tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
229
+ ]);
230
+ }