botmux 2.45.0 → 2.46.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.
Files changed (59) hide show
  1. package/dist/core/command-handler.d.ts.map +1 -1
  2. package/dist/core/command-handler.js +471 -2
  3. package/dist/core/command-handler.js.map +1 -1
  4. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  5. package/dist/core/dashboard-ipc-server.js +114 -2
  6. package/dist/core/dashboard-ipc-server.js.map +1 -1
  7. package/dist/core/session-discovery.d.ts.map +1 -1
  8. package/dist/core/session-discovery.js +16 -4
  9. package/dist/core/session-discovery.js.map +1 -1
  10. package/dist/core/session-manager.d.ts +1 -1
  11. package/dist/core/session-manager.d.ts.map +1 -1
  12. package/dist/core/session-manager.js +10 -4
  13. package/dist/core/session-manager.js.map +1 -1
  14. package/dist/core/worker-pool.d.ts +105 -0
  15. package/dist/core/worker-pool.d.ts.map +1 -1
  16. package/dist/core/worker-pool.js +284 -4
  17. package/dist/core/worker-pool.js.map +1 -1
  18. package/dist/daemon.d.ts.map +1 -1
  19. package/dist/daemon.js +7 -1
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/dashboard/web/team-federation.d.ts.map +1 -1
  22. package/dist/dashboard/web/team-federation.js +17 -2
  23. package/dist/dashboard/web/team-federation.js.map +1 -1
  24. package/dist/dashboard-web/app.js +73 -73
  25. package/dist/i18n/en.d.ts.map +1 -1
  26. package/dist/i18n/en.js +61 -0
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/zh.d.ts.map +1 -1
  29. package/dist/i18n/zh.js +61 -0
  30. package/dist/i18n/zh.js.map +1 -1
  31. package/dist/im/lark/card-builder.d.ts +82 -0
  32. package/dist/im/lark/card-builder.d.ts.map +1 -1
  33. package/dist/im/lark/card-builder.js +315 -0
  34. package/dist/im/lark/card-builder.js.map +1 -1
  35. package/dist/im/lark/card-handler.d.ts.map +1 -1
  36. package/dist/im/lark/card-handler.js +197 -2
  37. package/dist/im/lark/card-handler.js.map +1 -1
  38. package/dist/im/lark/client.d.ts +24 -0
  39. package/dist/im/lark/client.d.ts.map +1 -1
  40. package/dist/im/lark/client.js +83 -0
  41. package/dist/im/lark/client.js.map +1 -1
  42. package/dist/im/lark/message-parser.d.ts.map +1 -1
  43. package/dist/im/lark/message-parser.js +1 -0
  44. package/dist/im/lark/message-parser.js.map +1 -1
  45. package/dist/services/relay-picker.d.ts +22 -0
  46. package/dist/services/relay-picker.d.ts.map +1 -0
  47. package/dist/services/relay-picker.js +62 -0
  48. package/dist/services/relay-picker.js.map +1 -0
  49. package/dist/setup/ensure-tmux.d.ts +0 -22
  50. package/dist/setup/ensure-tmux.d.ts.map +1 -1
  51. package/dist/setup/ensure-tmux.js +25 -1
  52. package/dist/setup/ensure-tmux.js.map +1 -1
  53. package/dist/types.d.ts +14 -0
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/utils/daemon-discovery.d.ts +11 -0
  56. package/dist/utils/daemon-discovery.d.ts.map +1 -0
  57. package/dist/utils/daemon-discovery.js +59 -0
  58. package/dist/utils/daemon-discovery.js.map +1 -0
  59. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"command-handler.d.ts","sourceRoot":"","sources":["../../src/core/command-handler.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAkD,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAM/G,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,aAAa,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAKhD,eAAO,MAAM,eAAe,aAA6J,CAAC;AAE1L;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,aAO/B,CAAC;AAIH,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAMD,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAAE,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA0C9C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKpF;AAED;;;;mCAImC;AACnC,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,sBAAsB,GAAG,IAAI,CAqB1F;AA8DD,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC3C,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACzG,cAAc,EAAE,MAAM,MAAM,CAAC;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,gCAAgC,EAAE,WAAW,EAAE,CAAC,CAAC;CACnF;AAoOD,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,kBAAkB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAwtBf;AAID,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,aAAa,EACjB,IAAI,EAAE,kBAAkB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
1
+ {"version":3,"file":"command-handler.d.ts","sourceRoot":"","sources":["../../src/core/command-handler.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAkD,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAM/G,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,aAAa,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAKhD,eAAO,MAAM,eAAe,aAAuK,CAAC;AAEpM;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,aAO/B,CAAC;AAIH,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAMD,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAAE,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA0C9C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKpF;AAED;;;;mCAImC;AACnC,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,sBAAsB,GAAG,IAAI,CAqB1F;AA8DD,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC3C,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACzG,cAAc,EAAE,MAAM,MAAM,CAAC;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,gCAAgC,EAAE,WAAW,EAAE,CAAC,CAAC;CACnF;AAoOD,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,kBAAkB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAsqCf;AAID,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,aAAa,EACjB,IAAI,EAAE,kBAAkB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
@@ -12,7 +12,7 @@ import * as scheduler from './scheduler.js';
12
12
  import { scanMultipleProjects, describeProjectDir } from '../services/project-scanner.js';
13
13
  import { buildRepoSelectCard, buildAdoptSelectCard, buildSessionClosedCard, getCliDisplayName } from '../im/lark/card-builder.js';
14
14
  import { createCliAdapterSync } from '../adapters/cli/registry.js';
15
- import { deleteMessage, listChatBotMembers, resolveUserUnionId, getChatModeStrict } from '../im/lark/client.js';
15
+ import { deleteMessage, sendMessage, listChatBotMembers, resolveUserUnionId, getChatModeStrict } from '../im/lark/client.js';
16
16
  import { claimPairing } from '../services/pairing-store.js';
17
17
  import { logger } from '../utils/logger.js';
18
18
  import { killWorker, forkWorker, forkAdoptWorker, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience } from './worker-pool.js';
@@ -27,7 +27,7 @@ import { getBotCapability, setBotCapability, clearBotCapability } from '../servi
27
27
  import { sessionKey, sessionAnchorId } from './types.js';
28
28
  import { t, localeForBot } from '../i18n/index.js';
29
29
  // ─── Exported constants ──────────────────────────────────────────────────────
30
- export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/pair', '/login', '/adopt', '/oncall', '/group', '/g', '/card']);
30
+ export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/pair', '/login', '/adopt', '/oncall', '/group', '/g', '/relay', '/card']);
31
31
  /**
32
32
  * Slash commands that are forwarded verbatim to the underlying CLI (e.g.
33
33
  * Claude Code's `/compact`, `/model`, `/usage`). The daemon does NOT handle
@@ -1022,6 +1022,475 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1022
1022
  }
1023
1023
  break;
1024
1024
  }
1025
+ /**
1026
+ * `/relay --create <群名> @bot [@bot...]` — create a new chat, invite
1027
+ * the @-mentioned bots, then migrate every bot's session in this
1028
+ * thread (including the leader's) into the new chat.
1029
+ *
1030
+ * Two-path command:
1031
+ * • `--create` (PR2) — implemented below; creates a new chat.
1032
+ * • no flag (PR3) — picker card listing user's relayable sessions
1033
+ * in OTHER chats so the user can pull one into
1034
+ * the current chat. Stubbed for now.
1035
+ *
1036
+ * Leader election is `mentions[0]` (identical to /group). The leader
1037
+ * is the only daemon that:
1038
+ * 1. Creates the new chat (createGroupWithBots)
1039
+ * 2. Sends the M1 announcement message (its message_id becomes the
1040
+ * shared rootMessageId for all relayed sessions — multi-bot
1041
+ * sessions co-anchor on the same root via different larkAppIds)
1042
+ * 3. Transfers its own session (if any) via local transferSession()
1043
+ * 4. POSTs /api/sessions/migrate-to-chat to every peer daemon to
1044
+ * ask them to transfer their own session at the same anchor
1045
+ * 5. Aggregates results into a single reply in the source thread
1046
+ *
1047
+ * Owner-only: only the source session's `ownerOpenId` may invoke. Peers
1048
+ * enforce the same check independently inside the migrate endpoint.
1049
+ *
1050
+ * Failure mode: best-effort, no rollback. Peers that timeout / fail /
1051
+ * are offline simply appear in the report as "skipped". The new chat
1052
+ * and any successful transfers stand.
1053
+ */
1054
+ case '/relay': {
1055
+ const argsLine = message.content.replace(/^\/relay\s*/i, '').trim();
1056
+ if (!/^--create\b/i.test(argsLine)) {
1057
+ // ── Pull picker ───────────────────────────────────────────────────
1058
+ // /relay (no flag) lives in the *target* chat — list the operator's
1059
+ // own active sessions in OTHER chats so they can pull one in.
1060
+ //
1061
+ // Filter:
1062
+ // • same bot (this larkAppId)
1063
+ // • session is active (has a worker / appears in activeSessions)
1064
+ // • session NOT in the current chat (can't relay to yourself)
1065
+ // • operator IS the session owner (owner-only access)
1066
+ //
1067
+ // The button's `target_chat_id` / `target_root_id` are the chat we're
1068
+ // pulling INTO (the chat hosting this command). card-handler uses
1069
+ // them to invoke transferSession after sending the M1 announcement.
1070
+ const operatorOpenId = message.senderId;
1071
+ if (!operatorOpenId) {
1072
+ await sessionReply(rootId, t('cmd.relay.no_sender', undefined, loc));
1073
+ break;
1074
+ }
1075
+ const myAppId = larkAppId ?? ds?.larkAppId;
1076
+ if (!myAppId) {
1077
+ await sessionReply(rootId, t('cmd.group.no_bot', undefined, loc));
1078
+ break;
1079
+ }
1080
+ const targetChatId = ds?.chatId;
1081
+ if (!targetChatId) {
1082
+ await sessionReply(rootId, t('cmd.relay.no_session', undefined, loc));
1083
+ break;
1084
+ }
1085
+ // ── Chat-type guard ───────────────────────────────────────────────
1086
+ // Picker mode only makes sense in regular group chats. p2p (1:1 with
1087
+ // bot) has no relay concept — there's no other participant to
1088
+ // collaborate with — and topic chats route per-thread, so a chat-
1089
+ // scope session pulled in would have no thread anchor.
1090
+ //
1091
+ // p2p is detectable from `ds.chatType` locally (cheap). Topic vs
1092
+ // regular group is NOT captured in chatType — both record 'group'
1093
+ // — so we hit the Lark API (getChatNameAndMode) to resolve the
1094
+ // mode. One API call per /relay invocation; picker is user-
1095
+ // triggered so latency is acceptable.
1096
+ if (ds?.chatType === 'p2p') {
1097
+ await sessionReply(rootId, t('cmd.relay.picker_p2p_unsupported', undefined, loc));
1098
+ break;
1099
+ }
1100
+ {
1101
+ const { getChatNameAndMode } = await import('../im/lark/client.js');
1102
+ const info = await getChatNameAndMode(myAppId, targetChatId).catch(() => null);
1103
+ if (info?.mode === 'p2p') {
1104
+ await sessionReply(rootId, t('cmd.relay.picker_p2p_unsupported', undefined, loc));
1105
+ break;
1106
+ }
1107
+ if (info?.mode === 'topic') {
1108
+ await sessionReply(rootId, t('cmd.relay.picker_topic_unsupported', undefined, loc));
1109
+ break;
1110
+ }
1111
+ }
1112
+ // ── Existing-session guard ────────────────────────────────────────
1113
+ // If this bot already runs a real session in the target chat, pulling
1114
+ // another session in would collide on sessionKey(targetChatId, larkAppId)
1115
+ // — Map.set would silently overwrite, orphaning the existing worker.
1116
+ // Refuse upfront with an actionable message.
1117
+ //
1118
+ // Scratch sessions (the placeholder a `/relay` typed in a fresh chat
1119
+ // gets routed through) are filtered by `!!c.worker` — they have no
1120
+ // worker process. We do NOT exclude `ds` by sessionId: when `/relay`
1121
+ // rides an EXISTING real session (daemon.ts:2034's "existing-session
1122
+ // DAEMON_COMMANDS" path skips the scratch and binds `ds` to the
1123
+ // chat's real session), `ds` itself IS the conflict — excluding it
1124
+ // would let the picker render and the user pick a remote session
1125
+ // that the eventual transferSession would have to refuse anyway.
1126
+ const conflict = [...activeSessions.values()].find(c => c.larkAppId === myAppId
1127
+ && c.chatId === targetChatId
1128
+ // chat-scope only: thread-scope sessions (e.g. a `/t` force-topic
1129
+ // session in a regular group) live at a different sessionKey
1130
+ // anchor (rootMessageId), so they don't collide on transfer.
1131
+ // transferSession's own pre-flight (worker-pool.ts) and card-
1132
+ // handler's confirm both filter the same way; align here so the
1133
+ // picker doesn't false-positive a thread-scope live session.
1134
+ && c.scope === 'chat'
1135
+ && !!c.worker // real running session, not a placeholder
1136
+ );
1137
+ if (conflict) {
1138
+ await sessionReply(rootId, t('cmd.relay.target_has_session', { title: conflict.session.title || conflict.session.sessionId.substring(0, 8) }, loc));
1139
+ break;
1140
+ }
1141
+ // Shared candidate-collection logic — used here at initial render
1142
+ // and again in card-handler when the user clicks a card to switch
1143
+ // selection (the card re-render needs the same filtered list).
1144
+ // Filters out: other bots / current chat / non-owned / adopt
1145
+ // sessions. Resolves friendly chat names + modes in parallel.
1146
+ const { collectRelayPickerEntries } = await import('../services/relay-picker.js');
1147
+ const entries = await collectRelayPickerEntries(activeSessions, myAppId, targetChatId, operatorOpenId);
1148
+ const { buildRelayPickerCard } = await import('../im/lark/card-builder.js');
1149
+ const card = buildRelayPickerCard(entries, targetChatId, rootId, operatorOpenId, loc);
1150
+ await sessionReply(rootId, card, 'interactive');
1151
+ break;
1152
+ }
1153
+ const afterFlag = argsLine.replace(/^--create\s*/i, '').trim();
1154
+ const creatorAppId = larkAppId ?? ds?.larkAppId;
1155
+ if (!creatorAppId) {
1156
+ await sessionReply(rootId, t('cmd.group.no_bot', undefined, loc));
1157
+ break;
1158
+ }
1159
+ const senderOpenId = message.senderId;
1160
+ // Cross-app stable identity — peer daemons can't compare against
1161
+ // leader's open_id directly because the same user has a different
1162
+ // open_id in each bot's namespace. union_id is shared per tenant.
1163
+ // We pass it through the migrate-to-chat HTTP body; peers compare
1164
+ // against their session's `ownerUnionId` (with fallback to
1165
+ // open_id for sessions persisted before this field existed).
1166
+ const senderUnionId = message.senderUnionId;
1167
+ if (!senderOpenId) {
1168
+ await sessionReply(rootId, t('cmd.relay.no_sender', undefined, loc));
1169
+ break;
1170
+ }
1171
+ // `--create` must be invoked inside an existing thread — the source
1172
+ // anchor for peer transfers comes from `ds`. (Picker mode in PR3 is
1173
+ // allowed without a session.)
1174
+ if (!ds) {
1175
+ await sessionReply(rootId, t('cmd.relay.no_session', undefined, loc));
1176
+ break;
1177
+ }
1178
+ // Front-loaded guards — transferSession refuses adoptedFrom /
1179
+ // pendingRepo too, but only after createGroupWithBots has already
1180
+ // built a new chat. Failing here keeps relay clean and avoids
1181
+ // orphan-chat garbage when the operation can't possibly succeed.
1182
+ if (ds.session.adoptedFrom) {
1183
+ await sessionReply(rootId, t('cmd.relay.adopt_not_relayable', undefined, loc));
1184
+ break;
1185
+ }
1186
+ if (ds.pendingRepo) {
1187
+ await sessionReply(rootId, t('cmd.relay.not_started_yet', undefined, loc));
1188
+ break;
1189
+ }
1190
+ // ── Mention parsing & leader election (mirror of /group) ───────────
1191
+ const mentions = message.mentions ?? [];
1192
+ const knownBotNames = globalKnownBotNames();
1193
+ if (knownBotNames.size === 0 && mentions.some(m => !!m.name)) {
1194
+ logger.warn(`[${logTag}] /relay --create: global bot registry empty; cannot elect a creator`);
1195
+ await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
1196
+ break;
1197
+ }
1198
+ const botMentions = mentions.filter(m => m.name && knownBotNames.has(m.name.toLowerCase()));
1199
+ if (botMentions.length === 0) {
1200
+ await sessionReply(rootId, t('cmd.relay.no_mentions', undefined, loc));
1201
+ break;
1202
+ }
1203
+ // Am I `mentions[0]`?
1204
+ const firstBot = botMentions[0];
1205
+ const myOpenId = getBotOpenId(creatorAppId);
1206
+ const myName = getBot(creatorAppId).botName?.toLowerCase();
1207
+ const myNameAmbiguous = !!myName
1208
+ && botMentions.filter(m => m.name?.toLowerCase() === myName).length > 1;
1209
+ const iAmFirstBot = (!!myOpenId && firstBot.openId === myOpenId) ||
1210
+ (!myOpenId && !!myName && !myNameAmbiguous && firstBot.name?.toLowerCase() === myName);
1211
+ if (!iAmFirstBot) {
1212
+ logger.info(`[${logTag}] /relay --create: not the first @-mentioned bot, staying silent`);
1213
+ break;
1214
+ }
1215
+ // Owner-only — only the source session owner may relay this session.
1216
+ if (ds.session.ownerOpenId && ds.session.ownerOpenId !== senderOpenId) {
1217
+ await sessionReply(rootId, t('cmd.relay.not_owner', undefined, loc));
1218
+ break;
1219
+ }
1220
+ // ── Resolve @-bots to larkAppIds via the source chat's bot roster ──
1221
+ const sourceChatId = ds.chatId;
1222
+ let members = [];
1223
+ try {
1224
+ members = await listChatBotMembers(creatorAppId, sourceChatId);
1225
+ }
1226
+ catch (e) {
1227
+ logger.warn(`[${logTag}] /relay --create: failed to list source chat members: ${e?.message ?? e}`);
1228
+ }
1229
+ const memberByOpenId = new Map(members.map(m => [m.openId, m]));
1230
+ const appIdToName = new Map();
1231
+ for (const m of members) {
1232
+ if (m.larkAppId && m.displayName)
1233
+ appIdToName.set(m.larkAppId, m.displayName);
1234
+ }
1235
+ const mentionedBotAppIds = [];
1236
+ const seenApp = new Set();
1237
+ let unresolved;
1238
+ for (const bm of botMentions) {
1239
+ const mem = bm.openId ? memberByOpenId.get(bm.openId) : undefined;
1240
+ if (!mem || !mem.larkAppId) {
1241
+ unresolved = bm.name;
1242
+ break;
1243
+ }
1244
+ if (!seenApp.has(mem.larkAppId)) {
1245
+ seenApp.add(mem.larkAppId);
1246
+ mentionedBotAppIds.push(mem.larkAppId);
1247
+ }
1248
+ }
1249
+ if (unresolved) {
1250
+ logger.warn(`[${logTag}] /relay --create: unresolved bot "${unresolved}"`);
1251
+ await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
1252
+ break;
1253
+ }
1254
+ // ── Group name extraction (mirror of /group) ───────────────────────
1255
+ let rawArgs = afterFlag;
1256
+ for (const m of mentions) {
1257
+ if (m.name)
1258
+ rawArgs = rawArgs.split(`@${m.name}`).join(' ');
1259
+ }
1260
+ const firstLine = rawArgs.split(/\r?\n/).map(s => s.trim()).find(Boolean) ?? '';
1261
+ const MAX_NAME = 50;
1262
+ let groupName;
1263
+ if (firstLine) {
1264
+ groupName = firstLine.length > MAX_NAME ? firstLine.slice(0, MAX_NAME) + '…' : firstLine;
1265
+ }
1266
+ else {
1267
+ const now = new Date();
1268
+ const ts = `${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
1269
+ groupName = t('cmd.relay.empty_group_name', { ts }, loc);
1270
+ }
1271
+ // ── Create the new chat ────────────────────────────────────────────
1272
+ const nameOf = (id) => appIdToName.get(id) ?? botDisplayName(id);
1273
+ let newChatId;
1274
+ let inviteLink;
1275
+ try {
1276
+ const { createGroupWithBots } = await import('../services/group-creator.js');
1277
+ const result = await createGroupWithBots({
1278
+ creatorLarkAppId: creatorAppId,
1279
+ larkAppIds: mentionedBotAppIds,
1280
+ name: groupName,
1281
+ userOpenIds: [senderOpenId],
1282
+ transferOwnerTo: senderOpenId,
1283
+ });
1284
+ newChatId = result.chatId;
1285
+ const applink = `https://applink.feishu.cn/client/chat/open?openChatId=${encodeURIComponent(result.chatId)}`;
1286
+ inviteLink = result.shareLink ?? applink;
1287
+ }
1288
+ catch (err) {
1289
+ logger.error(`[${logTag}] /relay --create: createGroup failed: ${err?.message ?? err}`);
1290
+ await sessionReply(rootId, t('cmd.relay.failed', { error: err?.message ?? String(err) }, loc));
1291
+ break;
1292
+ }
1293
+ // Snapshot the pre-transfer source anchor — peers locate their own
1294
+ // session by this value, and `transferSession()` will overwrite
1295
+ // `ds.session.rootMessageId` once it runs. Must capture BEFORE the
1296
+ // leader transfer call (caught in review).
1297
+ const sourceAnchor = ds.session.rootMessageId;
1298
+ // ── M1 deferred: post the announcement AFTER all transfers settle ──
1299
+ // Previous flow sent an optimistic "已接力" M1 before running any
1300
+ // transfer. When leader/peers later failed, that M1 was a lie — and
1301
+ // the --create path had no orphan-cleanup (picker path did).
1302
+ //
1303
+ // New flow: pass `newChatId` as a placeholder for targetRootMessageId
1304
+ // into transferSession. Chat-scope routing ignores rootMessageId
1305
+ // (worker-pool transferSession only stores it for audit/UX), so the
1306
+ // placeholder doesn't break routing. Once all outcomes are in, we
1307
+ // post the real M1 with success/failure breakdown, then patch the
1308
+ // leader's session.rootMessageId to that final M1 id. Peer sessions
1309
+ // keep newChatId as a cosmetic placeholder — fixing them would
1310
+ // require another round-trip; chat-scope doesn't actually care.
1311
+ const placeholderRootMessageId = newChatId;
1312
+ // Resolve friendly source-chat label for the M1 body — falls back to
1313
+ // raw chatId if Lark can't return a name. Mirrors picker-path
1314
+ // (card-handler.ts:341) so the message reads the same in both UX
1315
+ // entry points.
1316
+ const { getChatName } = await import('../im/lark/client.js');
1317
+ const sourceLabel = (await getChatName(creatorAppId, sourceChatId).catch(() => null)) ?? sourceChatId;
1318
+ // ── Step 1: leader transfers its own session (if any) ───────────────
1319
+ // Empty-leader handling: daemon auto-creates a placeholder ds for any
1320
+ // DAEMON_COMMAND (worker:null + hasHistory:false). If the user typed
1321
+ // `/relay --create` in a chat where they never actually chatted with
1322
+ // the bot, ds IS that placeholder — there's no real session to
1323
+ // migrate. Pre-Codex-review we'd happily transferSession the empty
1324
+ // shell and report "已就绪:leader" as a lie. Now we detect this,
1325
+ // skip transferSession, mark leader as `no_session`, and close the
1326
+ // scratch so it doesn't linger as a ghost.
1327
+ //
1328
+ // The new chat is still created (createGroupWithBots already ran
1329
+ // above) — that itself is a valuable product outcome since the
1330
+ // mentioned bots were invited. Peers continue through their normal
1331
+ // path; the final M1 template adapts to "all_fresh" when no bot
1332
+ // actually had a session to bring along.
1333
+ const reportLines = [];
1334
+ const leaderName = nameOf(creatorAppId);
1335
+ const successBotNames = [];
1336
+ const failedBotNames = [];
1337
+ // Use the persisted-marker predicate, not runtime ds.hasHistory:
1338
+ // restoreActiveSessions sets hasHistory:true UNCONDITIONALLY on
1339
+ // restart (session-manager.ts:618), so a scratch that survives a
1340
+ // restart comes back with hasHistory:true and would defeat a
1341
+ // naive `!!ds.worker || ds.hasHistory` check. cliId / lastCliInput
1342
+ // are only written after a real worker started the CLI, so they
1343
+ // survive restart correctly.
1344
+ const { isRelayableRealSession } = await import('./worker-pool.js');
1345
+ const leaderHasRealSession = isRelayableRealSession(ds);
1346
+ if (leaderHasRealSession) {
1347
+ const { transferSession } = await import('./worker-pool.js');
1348
+ // Target chat was just built by createGroupWithBots — by
1349
+ // construction a regular group.
1350
+ const leaderResult = await transferSession(ds.session.sessionId, newChatId, placeholderRootMessageId, 'group');
1351
+ if (!leaderResult.ok) {
1352
+ // Real session, real failure (worker busy / unsupported target
1353
+ // / tmux issue). Abort the entire --create flow — the new chat
1354
+ // exists but is empty of any migrated session; we don't post
1355
+ // an M1 because there's nothing to announce.
1356
+ reportLines.push(t('cmd.relay.report_leader_failed', { bot: leaderName, error: leaderResult.error }, loc));
1357
+ await sessionReply(rootId, t('cmd.relay.created', { name: groupName, link: inviteLink, report: reportLines.join('\n') }, loc));
1358
+ break;
1359
+ }
1360
+ reportLines.push(t('cmd.relay.report_leader_ok', { bot: leaderName }, loc));
1361
+ successBotNames.push(leaderName);
1362
+ }
1363
+ else {
1364
+ // Empty leader: no real session to migrate.
1365
+ reportLines.push(t('cmd.relay.report_leader_no_session', { bot: leaderName }, loc));
1366
+ failedBotNames.push(leaderName);
1367
+ // Close the daemon-command scratch so it doesn't linger as a
1368
+ // ghost active row at the source anchor (same hygiene that
1369
+ // transferSession's pre-flight applies to target-chat scratches).
1370
+ const { closeSession } = await import('./worker-pool.js');
1371
+ await closeSession(ds.session.sessionId).catch(err => {
1372
+ logger.warn(`[${logTag}] /relay --create: failed to close empty-leader scratch: ${err instanceof Error ? err.message : err}`);
1373
+ });
1374
+ }
1375
+ // ── Step 2: coordinate peer daemons (parallel) ─────────────────────
1376
+ const { findOnlineDaemon } = await import('../utils/daemon-discovery.js');
1377
+ const peerAppIds = mentionedBotAppIds.filter(id => id !== creatorAppId);
1378
+ const peerOutcomes = await Promise.all(peerAppIds.map(async (peerAppId) => {
1379
+ const botName = nameOf(peerAppId);
1380
+ const daemon = findOnlineDaemon(peerAppId);
1381
+ if (!daemon)
1382
+ return { peerAppId, botName, status: 'offline' };
1383
+ try {
1384
+ const ctrl = new AbortController();
1385
+ const tt = setTimeout(() => ctrl.abort(), 5000);
1386
+ const res = await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/sessions/migrate-to-chat`, {
1387
+ method: 'POST',
1388
+ headers: { 'content-type': 'application/json' },
1389
+ body: JSON.stringify({
1390
+ sourceAnchor,
1391
+ targetChatId: newChatId,
1392
+ targetRootMessageId: placeholderRootMessageId,
1393
+ requesterLarkAppId: creatorAppId,
1394
+ requestingUserOpenId: senderOpenId,
1395
+ // union_id is cross-app stable within a tenant — peer
1396
+ // compares against its own session.ownerUnionId rather
1397
+ // than translating open_ids per bot. Optional for
1398
+ // backward compat with daemons older than this commit.
1399
+ requestingUserUnionId: senderUnionId,
1400
+ }),
1401
+ signal: ctrl.signal,
1402
+ }).finally(() => clearTimeout(tt));
1403
+ const body = await res.json().catch(() => ({}));
1404
+ if (res.ok && body.ok)
1405
+ return { peerAppId, botName, status: 'ok' };
1406
+ if (body.error === 'no_session_at_anchor')
1407
+ return { peerAppId, botName, status: 'no_session' };
1408
+ if (body.error === 'not_session_owner')
1409
+ return { peerAppId, botName, status: 'not_owner' };
1410
+ if (body.error === 'worker_busy')
1411
+ return { peerAppId, botName, status: 'busy' };
1412
+ return { peerAppId, botName, status: 'failed', error: body.error ?? `http_${res.status}` };
1413
+ }
1414
+ catch (err) {
1415
+ const reason = err?.name === 'AbortError' ? 'busy' : 'failed';
1416
+ return { peerAppId, botName, status: reason, error: err?.message ?? String(err) };
1417
+ }
1418
+ }));
1419
+ // Bucket peer outcomes for the final M1 (success / failure) AND extend the
1420
+ // source-chat report with per-peer detail. Leader was already bucketed
1421
+ // above (real-success → successBotNames; real-fail or empty-leader →
1422
+ // failedBotNames), so we only iterate peers here.
1423
+ for (const r of peerOutcomes) {
1424
+ if (r.status === 'ok') {
1425
+ successBotNames.push(r.botName);
1426
+ reportLines.push(t('cmd.relay.report_peer_ok', { bot: r.botName }, loc));
1427
+ }
1428
+ else {
1429
+ failedBotNames.push(r.botName);
1430
+ switch (r.status) {
1431
+ case 'no_session':
1432
+ reportLines.push(t('cmd.relay.report_peer_no_session', { bot: r.botName }, loc));
1433
+ break;
1434
+ case 'not_owner':
1435
+ reportLines.push(t('cmd.relay.report_peer_not_owner', { bot: r.botName }, loc));
1436
+ break;
1437
+ case 'offline':
1438
+ reportLines.push(t('cmd.relay.report_peer_offline', { bot: r.botName }, loc));
1439
+ break;
1440
+ case 'busy':
1441
+ reportLines.push(t('cmd.relay.report_peer_busy', { bot: r.botName }, loc));
1442
+ break;
1443
+ case 'failed':
1444
+ reportLines.push(t('cmd.relay.report_peer_failed', { bot: r.botName, error: r.error ?? 'unknown' }, loc));
1445
+ break;
1446
+ }
1447
+ }
1448
+ }
1449
+ // ── Step 3: post the real M1 with status breakdown ─────────────────
1450
+ // Three templates:
1451
+ // - all_ok : every bot migrated cleanly
1452
+ // - partial : some migrated, some didn't (failed list explains)
1453
+ // - all_fresh : nobody had a session to migrate (group's still
1454
+ // useful — bots were invited; user just @s to start)
1455
+ // Pass the raw text — sendMessage wraps `'text'` msgType bodies into
1456
+ // { text: content } itself.
1457
+ let finalM1Text;
1458
+ if (successBotNames.length === 0) {
1459
+ finalM1Text = t('cmd.relay.m1_final_all_fresh', { sourceChat: sourceLabel }, loc);
1460
+ }
1461
+ else if (failedBotNames.length === 0) {
1462
+ finalM1Text = t('cmd.relay.m1_final_all_ok', {
1463
+ sourceChat: sourceLabel,
1464
+ successBots: successBotNames.join('、'),
1465
+ }, loc);
1466
+ }
1467
+ else {
1468
+ finalM1Text = t('cmd.relay.m1_final_partial', {
1469
+ sourceChat: sourceLabel,
1470
+ successBots: successBotNames.join('、'),
1471
+ failedBots: failedBotNames.join('、'),
1472
+ }, loc);
1473
+ }
1474
+ try {
1475
+ const finalM1Id = await sendMessage(creatorAppId, newChatId, finalM1Text, 'text');
1476
+ // Patch the leader's session.rootMessageId to the real M1 id, but
1477
+ // only if the leader was actually transferred — for the empty-
1478
+ // leader / all_fresh path, ds was either closed or never moved,
1479
+ // so we don't touch it (would write to a closed/stale record).
1480
+ if (leaderHasRealSession && successBotNames.includes(leaderName)) {
1481
+ ds.session.rootMessageId = finalM1Id;
1482
+ sessionStore.updateSession(ds.session);
1483
+ }
1484
+ }
1485
+ catch (err) {
1486
+ // Non-fatal: transfers already succeeded. The source-chat report
1487
+ // (sessionReply below) is the user's authoritative status.
1488
+ logger.warn(`[${logTag}] /relay --create: final M1 send failed: ${err?.message ?? err}`);
1489
+ }
1490
+ await sessionReply(rootId, t('cmd.relay.created', { name: groupName, link: inviteLink, report: reportLines.join('\n') }, loc));
1491
+ logger.info(`[${logTag}] /relay --create completed: chat=${newChatId} leader=${creatorAppId} peers=[${peerAppIds.join(',')}]`);
1492
+ break;
1493
+ }
1025
1494
  case '/card': {
1026
1495
  if (!ds) {
1027
1496
  await sessionReply(rootId, t('cmd.no_active_session', undefined, loc));