dsh-caduceus 0.1.1 → 0.5.1

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/README.md CHANGED
@@ -78,6 +78,18 @@ onlyBuiltDependencies:
78
78
  | `personaAutoSync` | `true` | Sync SOUL into `$DSH_HOME/AGENTS.md` at startup |
79
79
  | `agentPreset` | `standard` | dsh agent preset mounted for new sessions |
80
80
  | `logLevel` | `info` | File log level: `info` / `debug` |
81
+ | `approvalCard` | `true` | Send interactive approval cards (`allow once` / `reject`) |
82
+ | `questionCard` | `true` | Send interactive question cards; single-select ≤4 options get buttons |
83
+ | `interactiveTimeoutMs` | `300000` | How long an approval/question card waits before it expires |
84
+ | `approvalPolicy` | `ask` | Session approval policy: `ask` (card confirmation, default) or `never` |
85
+ | `reactions` | `true` | Emoji reactions on messages: `OnIt` while running, `DONE`/`CROSS_MARK` at turn end |
86
+ | `reactionOnStart` | `OnIt` | Reaction added when a user message is accepted |
87
+ | `reactionOnDone` | `DONE` | Reaction added when a turn completes |
88
+ | `reactionOnFail` | `CROSS_MARK` | Reaction added when a turn errors/interrupts (falls back to `THINKING`) |
89
+ | `gateRequireBotIdentity` | `true` | Group @ gate fails closed when the bot open id is unknown |
90
+ | `images` | `true` | Accept image messages and attach them to the agent prompt |
91
+ | `files` | `true` | Accept file messages (durable attachment or saved to cwd) |
92
+ | `maxAttachmentBytes` | `20971520` | Per-attachment download limit (20 MiB) |
81
93
 
82
94
  ## Commands
83
95
 
package/README.zh.md CHANGED
@@ -77,6 +77,18 @@ onlyBuiltDependencies:
77
77
  | `personaAutoSync` | `true` | 启动时把 SOUL 同步到 `$DSH_HOME/AGENTS.md` |
78
78
  | `agentPreset` | `standard` | 新会话挂载的 dsh agent preset |
79
79
  | `logLevel` | `info` | 文件日志级别:`info` / `debug` |
80
+ | `approvalCard` | `true` | 发交互审批卡(允许一次 / 拒绝) |
81
+ | `questionCard` | `true` | 发交互选项卡;单选且 ≤4 个选项时显示按钮 |
82
+ | `interactiveTimeoutMs` | `300000` | 审批/选项卡等待多久后过期 |
83
+ | `approvalPolicy` | `ask` | 会话审批策略:`ask`(弹卡确认,默认)或 `never` |
84
+ | `reactions` | `true` | 消息表情反馈:处理中 `OnIt`,结束 `DONE`/`CROSS_MARK` |
85
+ | `reactionOnStart` | `OnIt` | 收到用户消息时添加的表情 |
86
+ | `reactionOnDone` | `DONE` | 回合完成时添加的表情 |
87
+ | `reactionOnFail` | `CROSS_MARK` | 回合出错/中断时添加的表情(失败退回 `THINKING`) |
88
+ | `gateRequireBotIdentity` | `true` | bot open id 未知时群 @ 门禁 fail-closed(false 恢复旧放行) |
89
+ | `images` | `true` | 接收图片消息并作为附件交给 agent |
90
+ | `files` | `true` | 接收文件消息(附件服务或落到 cwd) |
91
+ | `maxAttachmentBytes` | `20971520` | 单个附件下载上限(20 MiB) |
80
92
 
81
93
  ## 命令
82
94
 
@@ -19,6 +19,15 @@ export declare class Bridge {
19
19
  handleSessionEvent(session: {
20
20
  readonly id: string;
21
21
  }, event: StatusEventLike): Promise<void>;
22
+ /**
23
+ * Send an interactive card the same way process cards go out: reply into the
24
+ * originating topic/thread when the session still has a live scope. Falls
25
+ * back to a chat send only when the scope state is gone.
26
+ */
27
+ sendInteractiveCard(sessionId: string, card: FeishuCard): Promise<{
28
+ readonly messageId: string;
29
+ readonly threadId?: string;
30
+ }>;
22
31
  }
23
32
  /** Build the process card for one snapshot (exported for render tests). */
24
33
  export declare function progressCardForSnapshot(snapshot: StatusSnapshot, sessionTitle: string | undefined, recentEvent: string, tail: string, now: number, presetMissing?: boolean): FeishuCard;
@@ -17,6 +17,8 @@
17
17
  * @module dsh-caduceus/bridge/bridge
18
18
  */
19
19
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
20
22
  import { planSlashLine } from '../commands/dispatch.js';
21
23
  import { helpText } from '../commands/parse.js';
22
24
  import { evaluateGate, logGate } from '../gate.js';
@@ -27,7 +29,7 @@ import { writeOwnership } from '../ownership.js';
27
29
  import { finalCard, finalCardPlainText, progressCard, } from '../render/cards.js';
28
30
  import { CardStream, systemClock } from '../render/stream.js';
29
31
  import { mintSessionId, scopeKey, SCOPE_KEY_SEPARATOR } from '../session-key.js';
30
- import { StatusTracker, shouldNotifyTerminal, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
32
+ import { StatusTracker, shouldNotifyTerminal, stateForTurnEnd, recentEventText, statusLabel, statusTemplate, statusText, terminalNotice, } from '../status.js';
31
33
  import { ScopeQueue } from './scope-queue.js';
32
34
  function errorMessage(error) {
33
35
  return error instanceof Error ? error.message : String(error);
@@ -81,6 +83,8 @@ export class Bridge {
81
83
  #selfCheck;
82
84
  #agentCtxBySession = new Map();
83
85
  #toolsCheckedSessions = new Set();
86
+ #approvalMissingWarned = false;
87
+ #botIdentityErrorLogged = false;
84
88
  /** Last fail-id set already notified into the conversation. */
85
89
  #selfCheckNotifiedSignature;
86
90
  /** Session ids whose preset mount failed (shown as `(preset 未挂载)`). */
@@ -88,6 +92,7 @@ export class Bridge {
88
92
  #sessionTitleProvider;
89
93
  #queue = new ScopeQueue();
90
94
  #states = new Map();
95
+ #chatIdBySession = new Map();
91
96
  #scopeBySession = new Map();
92
97
  constructor(options) {
93
98
  this.#options = options;
@@ -117,6 +122,12 @@ export class Bridge {
117
122
  if (sessionId !== undefined)
118
123
  this.#scopeBySession.set(sessionId, key);
119
124
  }
125
+ if (options.interactive !== undefined && options.rootCtx !== undefined) {
126
+ options.interactive.registerRoot(options.rootCtx, {
127
+ ownsSession: (sessionId) => this.ownsSession(sessionId),
128
+ contextFor: (sessionId) => this.#interactiveContext(sessionId),
129
+ });
130
+ }
120
131
  }
121
132
  /** True when a session id belongs to a scope this bridge renders. */
122
133
  ownsSession(sessionId) {
@@ -124,16 +135,35 @@ export class Bridge {
124
135
  }
125
136
  /** Process one inbound message (serialized per scope). */
126
137
  async handleInbound(message) {
138
+ if (message.unsupportedType !== undefined) {
139
+ const state = this.#stateFor(message);
140
+ this.#logger.info(`unsupported message type: ${message.unsupportedType}`);
141
+ await this.#replyText(state, '这类消息暂不支持。');
142
+ await this.#safeAddReaction(message.messageId, 'CROSS_MARK');
143
+ return;
144
+ }
145
+ // Bot identity may have failed at startup; retry on the next group message
146
+ // before deciding, so a late bot/v3/info success restores normal gating.
147
+ if (message.chatType === 'group' && (this.#transport.getBotOpenId?.() ?? '').length === 0) {
148
+ try {
149
+ await this.#transport.refreshBotOpenId?.();
150
+ }
151
+ catch (error) {
152
+ this.#logger.warn(`bot identity refresh failed: ${errorMessage(error)}`);
153
+ }
154
+ }
155
+ const botOpenId = this.#transport.getBotOpenId?.();
127
156
  const decision = evaluateGate({
128
157
  chatType: message.chatType,
129
158
  chatId: message.chatId,
130
159
  ...(message.threadId === undefined ? {} : { threadId: message.threadId }),
131
160
  senderOpenId: message.senderOpenId,
132
- text: message.text,
161
+ text: message.text.trim().length === 0 && (message.attachments?.length ?? 0) > 0
162
+ ? '[attachment]'
163
+ : message.text,
133
164
  ...(message.mentions === undefined ? {} : { mentions: message.mentions }),
134
- ...(this.#transport.getBotOpenId?.() === undefined
135
- ? {}
136
- : { botOpenId: this.#transport.getBotOpenId?.() }),
165
+ ...(botOpenId === undefined ? {} : { botOpenId }),
166
+ requireBotIdentity: this.#config.gateRequireBotIdentity !== false,
137
167
  allowedChats: this.#config.allowedChats,
138
168
  allowedUsers: this.#config.allowedUsers,
139
169
  });
@@ -142,6 +172,12 @@ export class Bridge {
142
172
  ...(message.threadId === undefined ? {} : { threadId: message.threadId }),
143
173
  senderOpenId: message.senderOpenId,
144
174
  });
175
+ if (!decision.accept && decision.reason === 'bot-identity-unknown') {
176
+ if (!this.#botIdentityErrorLogged) {
177
+ this.#botIdentityErrorLogged = true;
178
+ this.#logger.error('gate: bot identity unknown; group @ messages are rejected until resolved');
179
+ }
180
+ }
145
181
  if (!decision.accept)
146
182
  return;
147
183
  await this.#queue.run(scopeKey(message.chatType, message.chatId, message.threadId, message.senderOpenId, this.#config.groupSessionsPerUser), () => this.#processInbound(message));
@@ -218,6 +254,7 @@ export class Bridge {
218
254
  async #processInbound(message) {
219
255
  const state = this.#stateFor(message);
220
256
  state.lastInboundMessageId = message.messageId;
257
+ state.ownerOpenId = message.senderOpenId;
221
258
  // Startup-stage failures have no session to notify yet; the first message
222
259
  // with a session catches up here (once per fail combination).
223
260
  await this.#maybeNotifySelfCheck(state);
@@ -226,7 +263,12 @@ export class Bridge {
226
263
  await this.#handleSlash(state, message.text);
227
264
  return;
228
265
  }
229
- await this.#deliverTurn(state, message.text);
266
+ state.reactionQueue.push(message.messageId);
267
+ this.#startReaction(state, message.messageId);
268
+ await this.#deliverTurn(state, {
269
+ text: message.text,
270
+ ...(message.attachments === undefined ? {} : { attachments: message.attachments }),
271
+ });
230
272
  }
231
273
  async #handleSlash(state, raw) {
232
274
  const plan = planSlashLine(raw);
@@ -382,14 +424,136 @@ export class Bridge {
382
424
  }
383
425
  }
384
426
  }
385
- async #deliverTurn(state, text) {
427
+ async #prepareAttachments(state, input) {
428
+ const attachments = input.attachments ?? [];
429
+ if (attachments.length === 0) {
430
+ return { content: [{ type: 'text', text: input.text }] };
431
+ }
432
+ const maxBytes = this.#config.maxAttachmentBytes ?? 20 * 1024 * 1024;
433
+ const imagesEnabled = this.#config.images !== false;
434
+ const filesEnabled = this.#config.files !== false;
435
+ const kept = [];
436
+ let imageCount = 0;
437
+ let truncatedImages = 0;
438
+ let ignored = 0;
439
+ for (const attachment of attachments) {
440
+ if (attachment.kind === 'image') {
441
+ if (!imagesEnabled) {
442
+ ignored += 1;
443
+ continue;
444
+ }
445
+ if (imageCount >= 9) {
446
+ truncatedImages += 1;
447
+ continue;
448
+ }
449
+ imageCount += 1;
450
+ kept.push(attachment);
451
+ }
452
+ else if (!filesEnabled) {
453
+ ignored += 1;
454
+ }
455
+ else {
456
+ kept.push(attachment);
457
+ }
458
+ }
459
+ if (ignored > 0) {
460
+ await this.#replyText(state, '当前未启用图片/文件消息,已忽略部分附件。');
461
+ }
462
+ let annotated = input.text;
463
+ if (truncatedImages > 0) {
464
+ annotated += `(图片超过 9 张,已截断 ${truncatedImages} 张)`;
465
+ }
466
+ const store = this.#options.attachmentProvider?.();
467
+ const parts = [];
468
+ for (const attachment of kept) {
469
+ let downloaded;
470
+ try {
471
+ downloaded = await this.#transport.downloadResource(state.lastInboundMessageId ?? '', attachment.fileKey, attachment.kind, maxBytes);
472
+ }
473
+ catch (error) {
474
+ const tooLarge = errorMessage(error).includes('too large');
475
+ await this.#replyText(state, tooLarge ? '文件太大,已跳过。' : '附件下载失败,已跳过。');
476
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
477
+ return undefined;
478
+ }
479
+ if (downloaded.bytes.length > maxBytes) {
480
+ await this.#replyText(state, '文件太大,已跳过。');
481
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
482
+ return undefined;
483
+ }
484
+ const base64 = Buffer.from(downloaded.bytes).toString('base64');
485
+ if (attachment.kind === 'image') {
486
+ if (store === undefined) {
487
+ await this.#replyText(state, '当前无法处理图片(attachments 服务不可用)。');
488
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
489
+ return undefined;
490
+ }
491
+ parts.push({
492
+ type: 'image',
493
+ mediaType: imageMediaType(downloaded.contentType),
494
+ data: base64,
495
+ ...(attachment.fileName === undefined ? {} : { name: attachment.fileName }),
496
+ });
497
+ continue;
498
+ }
499
+ const fileName = attachment.fileName ?? attachment.fileKey;
500
+ let admitted = false;
501
+ if (store !== undefined) {
502
+ try {
503
+ const ref = await store.admitEncodedFile({ data: base64, name: fileName });
504
+ parts.push({ type: 'file', attachment: ref });
505
+ admitted = true;
506
+ }
507
+ catch {
508
+ admitted = false;
509
+ }
510
+ }
511
+ if (!admitted) {
512
+ try {
513
+ const dir = this.#config.defaultCwd;
514
+ mkdirSync(dir, { recursive: true });
515
+ const target = join(dir, `${Date.now()}-${fileName.replace(/[^\w.-]+/g, '_')}`);
516
+ writeFileSync(target, downloaded.bytes);
517
+ annotated += `(文件已保存:${target})`;
518
+ }
519
+ catch (error) {
520
+ this.#logger.warn(`attachment file save failed: ${errorMessage(error)}`);
521
+ await this.#replyText(state, '附件保存失败,已跳过。');
522
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
523
+ return undefined;
524
+ }
525
+ }
526
+ }
527
+ if (parts.length === 0)
528
+ return { content: [{ type: 'text', text: annotated }] };
529
+ if (store !== undefined) {
530
+ try {
531
+ const admitted = await store.admitPromptContent([
532
+ { type: 'text', text: annotated },
533
+ ...parts,
534
+ ]);
535
+ return { content: admitted };
536
+ }
537
+ catch (error) {
538
+ this.#logger.warn(`attachment admission failed: ${errorMessage(error)}`);
539
+ await this.#replyText(state, '附件处理失败,已跳过。');
540
+ await this.#safeAddReaction(state.lastInboundMessageId ?? '', 'CROSS_MARK');
541
+ return undefined;
542
+ }
543
+ }
544
+ return { content: [{ type: 'text', text: annotated }, ...parts] };
545
+ }
546
+ async #deliverTurn(state, input) {
547
+ const prepared = await this.#prepareAttachments(state, input);
548
+ if (prepared === undefined)
549
+ return;
386
550
  const resolved = await this.#tryResolve(state);
387
551
  if (resolved === undefined) {
388
552
  await this.#replyText(state, '无法创建会话(agents 服务不可用)。');
389
553
  return;
390
554
  }
391
555
  const message = createUserMessage({
392
- content: [{ type: 'text', text }],
556
+ content: prepared.content,
393
557
  source: { kind: 'user' },
394
558
  });
395
559
  const agent = resolved.agent;
@@ -412,11 +576,17 @@ export class Bridge {
412
576
  }
413
577
  async #settleTurn(state, event) {
414
578
  state.running = false;
579
+ const kind = turnEndReasonKind(event.data);
580
+ const finalState = stateForTurnEnd(kind);
581
+ const finalStateLabel = statusLabel(finalState);
582
+ // The tracker is usually already terminal (apply ran before this method),
583
+ // but re-apply to guarantee /status never disagrees with the final card.
584
+ state.status.apply(event);
415
585
  const snapshot = state.status.get();
416
586
  const body = state.lastOutput.trim().length > 0
417
587
  ? state.lastOutput
418
- : `本轮已结束(${statusLabel(snapshot.state)})。`;
419
- const finalInput = this.#finalCardInput(state, snapshot, body);
588
+ : `本轮已结束(${finalStateLabel})。`;
589
+ const finalInput = this.#finalCardInput(state, snapshot, body, finalState, finalStateLabel);
420
590
  const stream = state.progress;
421
591
  const messageId = stream?.messageId;
422
592
  if (stream !== undefined) {
@@ -441,19 +611,20 @@ export class Bridge {
441
611
  await this.#finalFallback(state, finalInput, error);
442
612
  }
443
613
  }
444
- const kind = turnEndReasonKind(event.data);
445
614
  if (shouldNotifyTerminal(kind)) {
446
615
  const notice = terminalNotice(kind, snapshot.errorMessage);
447
616
  if (notice !== undefined)
448
617
  await this.#replyText(state, notice);
449
618
  }
619
+ await this.#finishReactions(state, kind);
450
620
  state.lastOutput = '';
451
621
  }
452
- #finalCardInput(state, snapshot, body) {
622
+ #finalCardInput(state, snapshot, body, finalState, finalStateLabel) {
453
623
  const sessionTitle = this.#titleOf(state)?.trim() ?? state.title;
454
624
  return {
455
625
  title: '飞书会话 · 过程',
456
- stateLabel: statusLabel(snapshot.state),
626
+ // Event-derived, never the (possibly stale) tracker snapshot.
627
+ stateLabel: finalStateLabel,
457
628
  steps: snapshot.steps,
458
629
  tools: snapshot.tools,
459
630
  ...(sessionTitle === undefined ? {} : { sessionTitle }),
@@ -461,7 +632,7 @@ export class Bridge {
461
632
  ...(snapshot.startedAt === undefined ? {} : { startedAt: snapshot.startedAt }),
462
633
  ...(snapshot.endedAt === undefined ? {} : { endedAt: snapshot.endedAt }),
463
634
  body,
464
- template: statusTemplate(snapshot.state),
635
+ template: statusTemplate(finalState),
465
636
  };
466
637
  }
467
638
  /** The ONLY fallback path: card edit failed, so send the same content as text once. */
@@ -698,11 +869,20 @@ export class Bridge {
698
869
  async #switchSession(state, target, verb = '已恢复会话') {
699
870
  const current = state.sessionId ?? this.#sessionMap.get(state.key);
700
871
  try {
701
- const { agent } = await this.#agents.resume({ resumeSessionId: target });
872
+ const persistedPreset = this.#sessionPresetLookup?.(target);
873
+ const { agent } = await this.#agents.resume({
874
+ resumeSessionId: target,
875
+ setup: async (agentCtx) => {
876
+ this.#agentCtxBySession.set(target, agentCtx);
877
+ if (persistedPreset === undefined)
878
+ await this.#mountPreset(target, agentCtx);
879
+ },
880
+ });
702
881
  if (current !== undefined && current !== target) {
703
882
  state.previousSessionId = current;
704
883
  state.previousSessionAt = this.#lastActivityAt(state) ?? this.#now();
705
884
  }
885
+ this.#applyApprovalPolicy(agent);
706
886
  this.#sessionMap.set(state.key, target);
707
887
  state.sessionId = target;
708
888
  state.pendingCreate = false;
@@ -775,6 +955,45 @@ export class Bridge {
775
955
  this.#agentPresetsCache = service;
776
956
  return service;
777
957
  }
958
+ /**
959
+ * Apply the configured session approval policy to a freshly created/resumed
960
+ * agent. Missing service warns once; setPolicy errors never block the turn.
961
+ */
962
+ #applyApprovalPolicy(agent) {
963
+ const policy = this.#options.approvalPolicy ?? 'ask';
964
+ this.#logger.info(`agent approval policy=${policy}`);
965
+ const provider = this.#options.approvalProvider;
966
+ if (provider === undefined)
967
+ return;
968
+ const service = provider();
969
+ if (service === undefined) {
970
+ if (!this.#approvalMissingWarned) {
971
+ this.#approvalMissingWarned = true;
972
+ this.#logger.warn('approval service unavailable; session approval policy not applied');
973
+ }
974
+ return;
975
+ }
976
+ try {
977
+ service.setPolicy(agent, policy);
978
+ }
979
+ catch (error) {
980
+ this.#logger.warn(`agent approval policy=${policy} failed: ${errorMessage(error)}`);
981
+ }
982
+ }
983
+ #interactiveContext(sessionId) {
984
+ const key = this.#scopeBySession.get(sessionId);
985
+ if (key === undefined)
986
+ return undefined;
987
+ const state = this.#states.get(key);
988
+ if (state === undefined || state.ownerOpenId === undefined)
989
+ return undefined;
990
+ return {
991
+ scopeKey: state.key,
992
+ chatId: state.chatId,
993
+ ownerOpenId: state.ownerOpenId,
994
+ sessionId,
995
+ };
996
+ }
778
997
  /**
779
998
  * Mount the configured agent preset in the unpublished agent scope.
780
999
  * Idempotent for resumes: a context that already reports a composed preset is
@@ -827,6 +1046,7 @@ export class Bridge {
827
1046
  catch (error) {
828
1047
  this.#logger.warn(`ownership write failed for ${sessionId}: ${errorMessage(error)}`);
829
1048
  }
1049
+ this.#applyApprovalPolicy(agent);
830
1050
  await this.#runSelfCheckStageB(state, sessionId);
831
1051
  return { agent };
832
1052
  }
@@ -1020,6 +1240,7 @@ export class Bridge {
1020
1240
  },
1021
1241
  });
1022
1242
  this.#bindSession(state, String(agent.session.id));
1243
+ this.#applyApprovalPolicy(agent);
1023
1244
  await this.#runSelfCheckStageB(state, sessionId);
1024
1245
  return { agent, sessionId, cwd };
1025
1246
  }
@@ -1045,8 +1266,77 @@ export class Bridge {
1045
1266
  this.#scopeBySession.delete(id);
1046
1267
  }
1047
1268
  this.#scopeBySession.set(sessionId, state.key);
1269
+ this.#chatIdBySession.set(sessionId, state.chatId);
1048
1270
  state.sessionId = sessionId;
1049
1271
  }
1272
+ /**
1273
+ * Send an interactive card the same way process cards go out: reply into the
1274
+ * originating topic/thread when the session still has a live scope. Falls
1275
+ * back to a chat send only when the scope state is gone.
1276
+ */
1277
+ async sendInteractiveCard(sessionId, card) {
1278
+ const key = this.#scopeBySession.get(sessionId);
1279
+ const state = key === undefined ? undefined : this.#states.get(key);
1280
+ if (state === undefined) {
1281
+ const chatId = this.#chatIdBySession.get(sessionId) ?? '';
1282
+ this.#logger.warn(`interactive: no scope state for ${sessionId}; falling back to chat send`);
1283
+ return this.#transport.sendCard(chatId, card);
1284
+ }
1285
+ const sent = await this.#sendNewCard(state, card);
1286
+ return {
1287
+ ...sent,
1288
+ ...(state.threadId === undefined ? {} : { threadId: state.threadId }),
1289
+ };
1290
+ }
1291
+ #startReaction(state, messageId) {
1292
+ if (this.#config.reactions === false)
1293
+ return;
1294
+ const promise = this.#safeAddReaction(messageId, this.#config.reactionOnStart ?? 'OnIt');
1295
+ state.reactionStarts.set(messageId, promise);
1296
+ void promise;
1297
+ }
1298
+ /** Remove the start reaction, then add the terminal reaction (best effort). */
1299
+ async #finishReactions(state, turnKind) {
1300
+ if (this.#config.reactions === false)
1301
+ return;
1302
+ const messageId = state.reactionQueue.shift();
1303
+ if (messageId === undefined)
1304
+ return;
1305
+ const start = state.reactionStarts.get(messageId);
1306
+ state.reactionStarts.delete(messageId);
1307
+ const reactionId = start === undefined ? undefined : await start;
1308
+ if (reactionId !== undefined)
1309
+ await this.#safeRemoveReaction(messageId, reactionId);
1310
+ const completed = turnKind === 'completed';
1311
+ const emoji = completed
1312
+ ? (this.#config.reactionOnDone ?? 'DONE')
1313
+ : (this.#config.reactionOnFail ?? 'CROSS_MARK');
1314
+ const added = await this.#safeAddReaction(messageId, emoji);
1315
+ if (!completed && added === undefined && emoji !== 'THINKING') {
1316
+ await this.#safeAddReaction(messageId, 'THINKING');
1317
+ }
1318
+ }
1319
+ async #safeAddReaction(messageId, emojiType) {
1320
+ try {
1321
+ return await this.#transport.addReaction(messageId, emojiType);
1322
+ }
1323
+ catch (error) {
1324
+ this.#logReactionSkipped(error);
1325
+ return undefined;
1326
+ }
1327
+ }
1328
+ async #safeRemoveReaction(messageId, reactionId) {
1329
+ try {
1330
+ await this.#transport.removeReaction(messageId, reactionId);
1331
+ }
1332
+ catch (error) {
1333
+ this.#logReactionSkipped(error);
1334
+ }
1335
+ }
1336
+ #logReactionSkipped(error) {
1337
+ const code = error.code;
1338
+ this.#logger.debug(`reaction skipped: ${typeof code === 'number' ? code : 'unknown'} ${errorMessage(error)}`);
1339
+ }
1050
1340
  #stateFor(message) {
1051
1341
  const key = scopeKey(message.chatType, message.chatId, message.threadId, message.senderOpenId, this.#config.groupSessionsPerUser);
1052
1342
  const existing = this.#states.get(key);
@@ -1061,6 +1351,8 @@ export class Bridge {
1061
1351
  running: false,
1062
1352
  status: new StatusTracker(),
1063
1353
  lastOutput: '',
1354
+ reactionQueue: [],
1355
+ reactionStarts: new Map(),
1064
1356
  };
1065
1357
  this.#states.set(key, created);
1066
1358
  return created;
@@ -1080,7 +1372,7 @@ export class Bridge {
1080
1372
  }
1081
1373
  async #sendNewCard(state, card) {
1082
1374
  if (state.lastInboundMessageId !== undefined) {
1083
- return this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'card', card }, { threadId: state.threadId });
1375
+ return this.#transport.reply(state.chatId, state.lastInboundMessageId, { kind: 'card', card }, state.threadId === undefined ? {} : { threadId: state.threadId });
1084
1376
  }
1085
1377
  return this.#transport.sendCard(state.chatId, card);
1086
1378
  }
@@ -1125,3 +1417,14 @@ export function progressCardForSnapshot(snapshot, sessionTitle, recentEvent, tai
1125
1417
  now,
1126
1418
  });
1127
1419
  }
1420
+ /** Normalize a resource content-type to one of dsh's accepted image types. */
1421
+ function imageMediaType(contentType) {
1422
+ const normalized = (contentType ?? '').split(';')[0]?.trim().toLowerCase();
1423
+ if (normalized === 'image/png' ||
1424
+ normalized === 'image/jpeg' ||
1425
+ normalized === 'image/webp' ||
1426
+ normalized === 'image/gif') {
1427
+ return normalized;
1428
+ }
1429
+ return 'image/jpeg';
1430
+ }
@@ -13,10 +13,25 @@ import type { HarnessCommandResult } from '../commands/harness.js';
13
13
  import type { LoggerLike } from '../logger.js';
14
14
  import type { Clock } from '../render/stream.js';
15
15
  import type { DoctorInputs, DoctorSessionInfo } from '../doctor.js';
16
+ import type { InteractiveRuntime } from '../interactive/runtime.js';
16
17
  import type { SessionStore } from '../session-key.js';
17
18
  import type { Transport } from '../transport/types.js';
18
19
  /** Busy-turn delivery policy (design doc §6 L3). */
19
20
  export type BusyMode = 'queue' | 'steer' | 'interrupt';
21
+ /** Session approval policy understood by dsh's approval service. */
22
+ export type ApprovalPolicy = 'ask' | 'never';
23
+ /** The slice of dsh's attachment service the bridge needs. */
24
+ export interface AttachmentStoreLike {
25
+ admitPromptContent(content: readonly unknown[]): Promise<readonly unknown[]>;
26
+ admitEncodedFile(input: {
27
+ readonly data: string;
28
+ readonly name?: string;
29
+ }): Promise<unknown>;
30
+ }
31
+ /** The slice of dsh's approval service the bridge needs. */
32
+ export interface ApprovalServiceLike {
33
+ setPolicy(agent: AgentLike, policy: ApprovalPolicy): void;
34
+ }
20
35
  /** Structural subset of a dsh session. */
21
36
  export interface SessionLike {
22
37
  readonly id: string;
@@ -86,6 +101,19 @@ export interface BridgeConfig {
86
101
  readonly groupSessionsPerUser: boolean;
87
102
  /** Agent preset mounted for caduceus sessions (default `standard`). */
88
103
  readonly agentPreset?: string;
104
+ /** Emoji reactions enabled (default true). */
105
+ readonly reactions?: boolean;
106
+ readonly reactionOnStart?: string;
107
+ readonly reactionOnDone?: string;
108
+ readonly reactionOnFail?: string;
109
+ /** Default true: group @ gate fails closed when bot open id is unknown. */
110
+ readonly gateRequireBotIdentity?: boolean;
111
+ /** Process image attachments (default true). */
112
+ readonly images?: boolean;
113
+ /** Process file attachments (default true). */
114
+ readonly files?: boolean;
115
+ /** Per-attachment byte cap (default 20 MiB). */
116
+ readonly maxAttachmentBytes?: number;
89
117
  }
90
118
  /** Everything the bridge needs; unit tests provide fakes for every seam. */
91
119
  export interface BridgeOptions {
@@ -111,6 +139,16 @@ export interface BridgeOptions {
111
139
  readonly persona?: (action: 'show' | 'sync') => Promise<PersonaCommandResult>;
112
140
  /** Time source for elapsed rendering; defaults to the clock. */
113
141
  readonly now?: () => number;
142
+ /** Interactive approval/question cards (wired by the gateway). */
143
+ readonly interactive?: InteractiveRuntime;
144
+ /** Plugin root Cordis context; answerers register here once, not per agent. */
145
+ readonly rootCtx?: unknown;
146
+ /** Lazily resolve dsh's attachment service (image admission / file refs). */
147
+ readonly attachmentProvider?: () => AttachmentStoreLike | undefined;
148
+ /** Lazily resolve dsh's approval service (agent-scoped setPolicy). */
149
+ readonly approvalProvider?: () => ApprovalServiceLike | undefined;
150
+ /** Desired session approval policy (default `ask`). */
151
+ readonly approvalPolicy?: ApprovalPolicy;
114
152
  /** `/doctor` data collector (wired by the gateway; not available in tests by default). */
115
153
  readonly doctorInputs?: (session: DoctorSessionInfo) => DoctorInputs | Promise<DoctorInputs>;
116
154
  /** Optional self-check controller (`/status`, `/selfcheck`, stage B). */
@@ -1,14 +1,3 @@
1
- /**
2
- * Slash-command parsing for the Feishu surface. Pure: no I/O, no side effects.
3
- *
4
- * Eleven commands are owned by dsh-caduceus: `/new`, `/sessions`, `/rename`,
5
- * `/resume`, `/adopt`, `/stop`, `/status`, `/persona`, `/help`. Everything else — unknown slash
6
- * commands such as `/compact` and all ordinary user text — comes back as
7
- * `{ kind: 'passthrough', raw }` so the caller can forward it unchanged to the
8
- * dsh command registry or the agent (design doc §6 L3).
9
- *
10
- * @module dsh-caduceus/commands/parse
11
- */
12
1
  /** Commands dsh-caduceus owns. Keep in sync with README's command table. */
13
2
  export declare const COMMAND_NAMES: readonly ["new", "sessions", "rename", "resume", "adopt", "selfcheck", "doctor", "stop", "status", "persona", "help"];
14
3
  export type CommandName = (typeof COMMAND_NAMES)[number];
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * @module dsh-caduceus/commands/parse
11
11
  */
12
+ import { stripLeadingMentions } from '../mentions.js';
12
13
  /** Commands dsh-caduceus owns. Keep in sync with README's command table. */
13
14
  export const COMMAND_NAMES = ['new', 'sessions', 'rename', 'resume', 'adopt', 'selfcheck', 'doctor', 'stop', 'status', 'persona', 'help'];
14
15
  /** Command metadata; `helpText()` and the README command table both derive from it. */
@@ -37,7 +38,10 @@ function usageResult(command, reason) {
37
38
  * never rewrites a user's text.
38
39
  */
39
40
  export function parseCommand(raw) {
40
- const trimmed = raw.trim();
41
+ // A topic/group message may arrive as "@_user_1 /stop" (Feishu's mention
42
+ // placeholder). Strip only the leading mentions before command detection;
43
+ // passthrough still forwards the ORIGINAL bytes.
44
+ const trimmed = stripLeadingMentions(raw).trim();
41
45
  if (!trimmed.startsWith('/'))
42
46
  return { kind: 'passthrough', raw };
43
47
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);